diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 7c2cbf3e9..8bcdc2fb8 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -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()}`), diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 47bb5240c..e22bf08a2 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -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";