From 43aacb4f39148eb43aebcd2692e3710fdbb80914 Mon Sep 17 00:00:00 2001 From: Mradul Singh Date: Fri, 13 Nov 2026 19:55:36 +0530 Subject: [PATCH 1/2] Count terminal scrollback lines from the last line with output wsh termscrollback --start/--end and the term_get_scrollback AI tool count lines from the bottom (0 = most recent line), but the bottom was buffer.length, which includes the empty rows below the cursor that xterm keeps for an unfilled screen. On a new terminal, --start 0 --end 20 read only those empty rows and returned 0 lines. Count from the cursor row instead, or from the last non-blank row when output was drawn below the cursor. Fixes #3587 --- frontend/app/view/term/term-wsh.tsx | 4 +-- frontend/app/view/term/termutil.test.ts | 44 +++++++++++++++++++++++++ frontend/app/view/term/termutil.ts | 18 ++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 frontend/app/view/term/termutil.test.ts diff --git a/frontend/app/view/term/term-wsh.tsx b/frontend/app/view/term/term-wsh.tsx index c1b4aa5478..b5f1b390e9 100644 --- a/frontend/app/view/term/term-wsh.tsx +++ b/frontend/app/view/term/term-wsh.tsx @@ -7,7 +7,7 @@ import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; import { RpcApi } from "@/app/store/wshclientapi"; import { makeFeBlockRouteId } from "@/app/store/wshrouter"; import { TermViewModel } from "@/app/view/term/term-model"; -import { bufferLinesToText } from "@/app/view/term/termutil"; +import { bufferContentLength, bufferLinesToText } from "@/app/view/term/termutil"; import { isBlank } from "@/util/util"; import debug from "debug"; @@ -120,7 +120,7 @@ export class TermWshClient extends WshClient { } const buffer = termWrap.terminal.buffer.active; - const totalLines = buffer.length; + const totalLines = bufferContentLength(buffer); if (data.lastcommand) { if (globalStore.get(termWrap.shellIntegrationStatusAtom) == null) { diff --git a/frontend/app/view/term/termutil.test.ts b/frontend/app/view/term/termutil.test.ts new file mode 100644 index 0000000000..0370817625 --- /dev/null +++ b/frontend/app/view/term/termutil.test.ts @@ -0,0 +1,44 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import type * as TermTypes from "@xterm/xterm"; +import { describe, expect, it } from "vitest"; +import { bufferContentLength, bufferLinesToText } from "./termutil"; + +function makeBuffer(rows: string[], cursorRow: number, baseY = 0): TermTypes.IBuffer { + return { + length: rows.length, + baseY, + cursorY: cursorRow - baseY, + getLine: (i: number) => + i < 0 || i >= rows.length ? undefined : { isWrapped: false, translateToString: () => rows[i] }, + } as unknown as TermTypes.IBuffer; +} + +function linesFromBottom(buffer: TermTypes.IBuffer, start: number, end: number): string[] { + const total = bufferContentLength(buffer); + return bufferLinesToText(buffer, total - Math.min(total, end), total - start); +} + +describe("bufferContentLength", () => { + it("stops at the cursor row on a screen that is not full yet", () => { + const rows = ["PS> echo one", "one", "PS> echo two", "two", "PS> ", ...Array(25).fill("")]; + const buffer = makeBuffer(rows, 4); + expect(bufferContentLength(buffer)).toBe(5); + expect(linesFromBottom(buffer, 0, 20)).toEqual(["PS> echo one", "one", "PS> echo two", "two", "PS> "]); + expect(linesFromBottom(buffer, 0, 2)).toEqual(["two", "PS> "]); + expect(linesFromBottom(buffer, 1, 3)).toEqual(["PS> echo two", "two"]); + }); + + it("keeps output drawn below the cursor", () => { + const rows = ["top", "", "", "status line", "", ""]; + expect(bufferContentLength(makeBuffer(rows, 0))).toBe(4); + }); + + it("counts scrollback rows above the viewport", () => { + const rows = ["old 1", "old 2", "old 3", "new 1", "new 2", "$ "]; + const buffer = makeBuffer(rows, 5, 3); + expect(bufferContentLength(buffer)).toBe(6); + expect(linesFromBottom(buffer, 0, 3)).toEqual(["new 1", "new 2", "$ "]); + }); +}); diff --git a/frontend/app/view/term/termutil.ts b/frontend/app/view/term/termutil.ts index add5e86e05..09e40e5e50 100644 --- a/frontend/app/view/term/termutil.ts +++ b/frontend/app/view/term/termutil.ts @@ -397,6 +397,24 @@ export function bufferLinesToText(buffer: TermTypes.IBuffer, startIndex: number, return lines; } +/** + * Number of buffer rows that hold terminal output: everything up to the cursor + * row, or up to the last non-blank row if output was drawn below the cursor. + * xterm keeps a full screen of rows, so on a fresh terminal most rows below the + * cursor are empty placeholders. Line numbers counted "from the bottom" must + * start at this point, or line 0 lands on a placeholder row. + */ +export function bufferContentLength(buffer: TermTypes.IBuffer): number { + const cursorEnd = Math.min(buffer.length, buffer.baseY + buffer.cursorY + 1); + for (let i = buffer.length - 1; i >= cursorEnd; i--) { + const line = buffer.getLine(i); + if (line && line.translateToString(true) !== "") { + return i + 1; + } + } + return cursorEnd; +} + export function isLikelyOnSameHost(lastCommand: string): boolean { if (lastCommand == null) { return true; From 9974a7c9c06ad40c01f8608f8f274d2d8e93728c Mon Sep 17 00:00:00 2001 From: Mradul Singh Date: Fri, 13 Nov 2026 20:15:24 +0530 Subject: [PATCH 2/2] Trim trailing blank lines when a range reaches the end of the output --- frontend/app/view/term/termutil.test.ts | 13 ++++++++++++- frontend/app/view/term/termutil.ts | 12 ++++++------ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/frontend/app/view/term/termutil.test.ts b/frontend/app/view/term/termutil.test.ts index 0370817625..6be0c1f5bd 100644 --- a/frontend/app/view/term/termutil.test.ts +++ b/frontend/app/view/term/termutil.test.ts @@ -17,7 +17,8 @@ function makeBuffer(rows: string[], cursorRow: number, baseY = 0): TermTypes.IBu function linesFromBottom(buffer: TermTypes.IBuffer, start: number, end: number): string[] { const total = bufferContentLength(buffer); - return bufferLinesToText(buffer, total - Math.min(total, end), total - start); + const endLine = end === 0 ? total : Math.min(total, end); + return bufferLinesToText(buffer, total - endLine, total - start); } describe("bufferContentLength", () => { @@ -35,6 +36,16 @@ describe("bufferContentLength", () => { expect(bufferContentLength(makeBuffer(rows, 0))).toBe(4); }); + it("trims blank rows at the content bottom but not inside a range", () => { + const rows = ["one", "", "", ...Array(27).fill("")]; + const buffer = makeBuffer(rows, 2); + expect(bufferContentLength(buffer)).toBe(3); + expect(linesFromBottom(buffer, 0, 0)).toEqual(["one"]); + expect(linesFromBottom(makeBuffer(Array(30).fill(""), 0), 0, 0)).toEqual([]); + const inner = makeBuffer(["one", "", "", "two", "$ ", ...Array(25).fill("")], 4); + expect(linesFromBottom(inner, 2, 5)).toEqual(["one", "", ""]); + }); + it("counts scrollback rows above the viewport", () => { const rows = ["old 1", "old 2", "old 3", "new 1", "new 2", "$ "]; const buffer = makeBuffer(rows, 5, 3); diff --git a/frontend/app/view/term/termutil.ts b/frontend/app/view/term/termutil.ts index 09e40e5e50..59e68bbbe2 100644 --- a/frontend/app/view/term/termutil.ts +++ b/frontend/app/view/term/termutil.ts @@ -379,16 +379,16 @@ export function bufferLinesToText(buffer: TermTypes.IBuffer, startIndex: number, } // Trim trailing blank lines only when the requested range extends to the - // actual end of the buffer. A terminal allocates a fixed number of rows - // (e.g. 80) but only the first few may contain real content; the rest are - // empty placeholder rows. We strip those so callers don't receive a wall - // of empty strings. + // end of the terminal output (see bufferContentLength). A terminal + // allocates a fixed number of rows (e.g. 80) but only the first few may + // contain real content; the rest are empty placeholder rows. We strip + // those, and a blank cursor row, so callers don't receive empty strings. // // Crucially, if the caller requested a specific sub-range (e.g. lines // 100-150) and lines 140-150 happen to be blank, those blanks are // intentional and must NOT be removed. We only trim when the range - // reaches the very end of the buffer. - if (clampedEnd >= buffer.length) { + // reaches the end of the output. + if (clampedEnd >= bufferContentLength(buffer)) { while (lines.length > 0 && lines[lines.length - 1] === "") { lines.pop(); }