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..6be0c1f5bd --- /dev/null +++ b/frontend/app/view/term/termutil.test.ts @@ -0,0 +1,55 @@ +// 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); + const endLine = end === 0 ? total : Math.min(total, end); + return bufferLinesToText(buffer, total - endLine, 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("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); + 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..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(); } @@ -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;