Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions frontend/app/view/term/term-wsh.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -120,7 +120,7 @@ export class TermWshClient extends WshClient {
}

const buffer = termWrap.terminal.buffer.active;
const totalLines = buffer.length;
const totalLines = bufferContentLength(buffer);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

if (data.lastcommand) {
if (globalStore.get(termWrap.shellIntegrationStatusAtom) == null) {
Expand Down
55 changes: 55 additions & 0 deletions frontend/app/view/term/termutil.test.ts
Original file line number Diff line number Diff line change
@@ -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", "$ "]);
});
});
30 changes: 24 additions & 6 deletions frontend/app/view/term/termutil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand All @@ -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;
Expand Down
Loading