From a444d171df584521c9e625a38cfdb7b56d8754d5 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 17 Jun 2026 11:04:01 +0200 Subject: [PATCH] fix(coding-agent): horizontally pan tree selector Fixes #5830 --- packages/coding-agent/CHANGELOG.md | 4 + .../interactive/components/tree-selector.ts | 76 +++++++++++++++++-- .../coding-agent/test/tree-selector.test.ts | 29 +++++++ packages/tui/CHANGELOG.md | 4 + packages/tui/src/index.ts | 2 +- 5 files changed, 109 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e4bf022c2..5f920b140 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `/tree` to horizontally pan deep session branches so the selected entry text remains readable ([#5830](https://github.com/earendil-works/pi/issues/5830)). + ## [0.79.6] - 2026-06-16 ### Fixed 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..4cf92542c 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,61 @@ interface FlatNode { isVirtualRootChild: boolean; } +interface HorizontalViewportRow { + gutter: string; + body: string; + anchorCol: number; + bodyWidth: number; + isSelected: boolean; +} + +interface HorizontalViewportResult { + lines: string[]; + status: string; +} + +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; + +function renderHorizontalViewport(rows: HorizontalViewportRow[], width: number): HorizontalViewportResult { + 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); + let horizontalScroll = 0; + const selectedRow = rows.find((row) => row.isSelected); + + 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); + } + } + + const lines = 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, ""); + }); + const status = + maxHorizontalScroll > 0 + ? ` [cols ${horizontalScroll + 1}-${Math.min(maxBodyWidth, horizontalScroll + viewportWidth)}/${maxBodyWidth}]` + : ""; + + return { lines, status }; +} + /** Filter mode for tree display */ export type FilterMode = "default" | "no-tools" | "user-only" | "labeled-only" | "all"; @@ -619,6 +675,8 @@ 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,17 +740,25 @@ 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 }); } + const viewport = renderHorizontalViewport(renderedRows, width); + lines.push(...viewport.lines); lines.push( truncateToWidth( - theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${this.getStatusLabels()}`), + theme.fg( + "muted", + ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${this.getStatusLabels()}${viewport.status}`, + ), width, ), ); diff --git a/packages/coding-agent/test/tree-selector.test.ts b/packages/coding-agent/test/tree-selector.test.ts index 4281b986d..46153696e 100644 --- a/packages/coding-agent/test/tree-selector.test.ts +++ b/packages/coding-agent/test/tree-selector.test.ts @@ -272,6 +272,35 @@ describe("TreeSelectorComponent", () => { }); }); + describe("horizontal scrolling", () => { + test("keeps deeply indented selected entries readable", () => { + const entries: SessionEntry[] = [userMessage("root", null, "root")]; + let parentId = "root"; + let selectedId = parentId; + for (let depth = 1; depth <= 14; depth++) { + selectedId = `active-${depth}`; + entries.push(userMessage(selectedId, parentId, `deep focused entry ${depth} marker-${depth}`)); + entries.push(userMessage(`side-${depth}`, parentId, `side branch ${depth}`)); + parentId = selectedId; + } + + const selector = new TreeSelectorComponent( + buildTree(entries), + selectedId, + 24, + () => {}, + () => {}, + ); + + const plainLines = selector.getTreeList().render(50).map(stripVTControlCharacters); + const selectedLine = plainLines.find((line) => line.includes("›")); + + expect(selectedLine).toContain("user: deep focused entry 14"); + expect(plainLines.every((line) => visibleWidth(line) <= 50)).toBe(true); + expect(plainLines.at(-1)).toContain("cols"); + }); + }); + describe("label timestamps", () => { test("toggles label timestamps for labeled nodes", () => { const entries = [userMessage("user-1", null, "hello"), assistantMessage("asst-1", "user-1", "hi")]; diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 8fa900ce1..5d3d68285 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Exported `sliceByColumn()` for ANSI-aware horizontal viewport rendering. + ## [0.79.6] - 2026-06-16 ## [0.79.5] - 2026-06-16 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";