fix(coding-agent): horizontally pan tree selector

Fixes #5830
This commit is contained in:
Vegard Stikbakke
2026-06-17 11:04:01 +02:00
Unverified
parent 29c1504cc1
commit 068ab5d19b
2 changed files with 64 additions and 5 deletions
@@ -6,6 +6,7 @@ import {
Input,
type Keybinding,
Spacer,
sliceByColumn,
Text,
truncateToWidth,
visibleWidth,
@@ -37,6 +38,59 @@ interface FlatNode {
isVirtualRootChild: boolean;
}
interface HorizontalViewportRow {
gutter: string;
body: string;
anchorCol: number;
bodyWidth: number;
isSelected: boolean;
}
const TREE_GUTTER_WIDTH = 2;
const MIN_VISIBLE_ANCHOR_CONTENT_WIDTH = 4;
const MAX_VISIBLE_ANCHOR_CONTENT_WIDTH = 20;
const MIN_ANCHOR_CONTEXT_WIDTH = 2;
const MAX_ANCHOR_CONTEXT_WIDTH = 12;
/**
* Render tree rows into a horizontally clipped viewport.
*
* The tree gutter is always kept visible. The row bodies are shifted left only
* when the selected row's anchor (the start of its entry text after tree
* indentation/markers) would otherwise be too far right to see useful content.
*/
function renderHorizontalViewport(rows: HorizontalViewportRow[], width: number): string[] {
const viewportWidth = Math.max(0, width - TREE_GUTTER_WIDTH);
const maxBodyWidth = rows.reduce((max, row) => Math.max(max, row.bodyWidth), 0);
const maxHorizontalScroll = Math.max(0, maxBodyWidth - viewportWidth);
const selectedRow = rows.find((row) => row.isSelected);
// Only pan horizontally when needed to keep enough selected-row content visible after its anchor.
let horizontalScroll = 0;
if (selectedRow && maxHorizontalScroll > 0) {
const minVisibleAnchorContentWidth = Math.min(
MAX_VISIBLE_ANCHOR_CONTENT_WIDTH,
Math.max(MIN_VISIBLE_ANCHOR_CONTENT_WIDTH, Math.floor(viewportWidth / 3)),
);
if (selectedRow.anchorCol > viewportWidth - minVisibleAnchorContentWidth) {
const anchorContextWidth = Math.min(
MAX_ANCHOR_CONTEXT_WIDTH,
Math.max(MIN_ANCHOR_CONTEXT_WIDTH, Math.floor(viewportWidth / 4)),
);
horizontalScroll = Math.min(maxHorizontalScroll, selectedRow.anchorCol - anchorContextWidth);
}
}
// Clip only the body; the fixed-width gutter remains visible as navigation context.
return rows.map((row) => {
const line =
horizontalScroll > 0
? `${row.gutter}${sliceByColumn(row.body, horizontalScroll, viewportWidth, true)}\x1b[0m`
: row.gutter + row.body;
return truncateToWidth(line, width, "");
});
}
/** Filter mode for tree display */
export type FilterMode = "default" | "no-tools" | "user-only" | "labeled-only" | "all";
@@ -619,6 +673,7 @@ class TreeList implements Component {
);
const endIndex = Math.min(startIndex + this.maxVisibleLines, this.filteredNodes.length);
const renderedRows: HorizontalViewportRow[] = [];
for (let i = startIndex; i < endIndex; i++) {
const flatNode = this.filteredNodes[i];
const entry = flatNode.node.entry;
@@ -682,14 +737,18 @@ class TreeList implements Component {
? theme.fg("muted", `${this.formatLabelTimestamp(flatNode.node.labelTimestamp)} `)
: "";
const content = this.getEntryDisplayText(flatNode.node, isSelected);
let line = cursor + theme.fg("dim", prefix) + foldMarker + pathMarker + label + labelTimestamp + content;
const prefixPart = theme.fg("dim", prefix) + foldMarker + pathMarker;
const anchorCol = visibleWidth(prefixPart);
let gutter = cursor;
let body = prefixPart + label + labelTimestamp + content;
if (isSelected) {
line = theme.bg("selectedBg", line);
gutter = theme.bg("selectedBg", gutter);
body = theme.bg("selectedBg", body);
}
lines.push(truncateToWidth(line, width));
renderedRows.push({ gutter, body, anchorCol, bodyWidth: visibleWidth(body), isSelected });
}
lines.push(...renderHorizontalViewport(renderedRows, width));
lines.push(
truncateToWidth(
theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${this.getStatusLabels()}`),
+1 -1
View File
@@ -106,4 +106,4 @@ export {
TUI,
} from "./tui.ts";
// Utilities
export { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils.ts";
export { sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils.ts";