diff --git a/packages/pi-coding-agent/src/modes/interactive/components/session-selector.ts b/packages/pi-coding-agent/src/modes/interactive/components/session-selector.ts index f38de288f..0aee17361 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/session-selector.ts @@ -20,6 +20,12 @@ import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; import { appKey, appKeyHint, keyHint } from "./keybinding-hints.js"; import { filterAndSortSessions, hasSessionName, type NameFilter, type SortMode } from "./session-selector-search.js"; +import { + applyRowHighlight, + buildTreePrefix, + computeScrollWindow, + renderCursor, +} from "./tree-render-utils.js"; type SessionScope = "current" | "all"; @@ -420,11 +426,11 @@ class SessionList implements Component, Focusable { } // Calculate visible range with scrolling - const startIndex = Math.max( - 0, - Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredSessions.length - this.maxVisible), + const { startIndex, endIndex } = computeScrollWindow( + this.selectedIndex, + this.filteredSessions.length, + this.maxVisible, ); - const endIndex = Math.min(startIndex + this.maxVisible, this.filteredSessions.length); // Render visible sessions (one line each with tree structure) for (let i = startIndex; i < endIndex; i++) { @@ -435,7 +441,7 @@ class SessionList implements Component, Focusable { const isCurrent = this.currentSessionFilePath === session.path; // Build tree prefix - const prefix = this.buildTreePrefix(node); + const prefix = this.buildNodeTreePrefix(node); // Session display text (name or first message) const hasName = !!session.name; @@ -454,7 +460,7 @@ class SessionList implements Component, Focusable { } // Cursor - const cursor = isSelected ? theme.fg("accent", "› ") : " "; + const cursor = renderCursor(isSelected); // Calculate available width for message const prefixWidth = visibleWidth(prefix); @@ -483,11 +489,8 @@ class SessionList implements Component, Focusable { const spacing = Math.max(1, width - leftWidth - visibleWidth(rightPart)); const styledRight = theme.fg(isConfirmingDelete ? "error" : "dim", rightPart); - let line = leftPart + " ".repeat(spacing) + styledRight; - if (isSelected) { - line = theme.bg("selectedBg", line); - } - lines.push(truncateToWidth(line, width)); + const line = leftPart + " ".repeat(spacing) + styledRight; + lines.push(applyRowHighlight(line, isSelected, width)); } // Add scroll indicator if needed @@ -500,14 +503,8 @@ class SessionList implements Component, Focusable { return lines; } - private buildTreePrefix(node: FlatSessionNode): string { - if (node.depth === 0) { - return ""; - } - - const parts = node.ancestorContinues.map((continues) => (continues ? "│ " : " ")); - const branch = node.isLast ? "└─ " : "├─ "; - return parts.join("") + branch; + private buildNodeTreePrefix(node: FlatSessionNode): string { + return buildTreePrefix(node.ancestorContinues, node.isLast, node.depth); } handleInput(keyData: string): void { diff --git a/packages/pi-coding-agent/src/modes/interactive/components/tree-render-utils.ts b/packages/pi-coding-agent/src/modes/interactive/components/tree-render-utils.ts new file mode 100644 index 000000000..71dec5b41 --- /dev/null +++ b/packages/pi-coding-agent/src/modes/interactive/components/tree-render-utils.ts @@ -0,0 +1,81 @@ +import { truncateToWidth } from "@gsd/pi-tui"; +import { theme } from "../theme/theme.js"; + +// ── Tree connector characters ──────────────────────────────────────── +export const TREE_BRANCH = "\u251C\u2500 "; // "├─ " +export const TREE_LAST = "\u2514\u2500 "; // "└─ " +export const TREE_PIPE = "\u2502 "; // "│ " +export const TREE_SPACE = " "; // 3 spaces + +/** + * Build a tree prefix string from ancestor-continuation flags and branch position. + * + * Each ancestor level contributes either a pipe ("│ ") or blank spacing (" ") + * depending on whether that ancestor has more siblings after it. The final segment + * is the branch connector: "├─ " (more siblings) or "└─ " (last sibling). + * + * Used by session-selector for its simpler flat tree display. + * tree-selector uses its own gutter-based char-by-char builder for richer rendering. + */ +export function buildTreePrefix(ancestorContinues: boolean[], isLast: boolean, depth: number): string { + if (depth === 0) return ""; + const parts = ancestorContinues.map((continues) => (continues ? TREE_PIPE : TREE_SPACE)); + const branch = isLast ? TREE_LAST : TREE_BRANCH; + return parts.join("") + branch; +} + +// ── Scroll window ──────────────────────────────────────────────────── + +export interface ScrollWindow { + /** First visible index (inclusive) */ + startIndex: number; + /** Last visible index (exclusive) */ + endIndex: number; +} + +/** + * Compute a centered scroll window around `selectedIndex` within a list of `totalItems`. + * + * The window tries to center the selected item. When near the beginning or end of the + * list the window clamps so it doesn't exceed bounds. + */ +export function computeScrollWindow(selectedIndex: number, totalItems: number, maxVisible: number): ScrollWindow { + const startIndex = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), totalItems - maxVisible)); + const endIndex = Math.min(startIndex + maxVisible, totalItems); + return { startIndex, endIndex }; +} + +// ── Cursor & selection helpers ─────────────────────────────────────── + +/** + * Return the cursor indicator for a list row. + * + * Selected: "› " (accent-colored) + * Unselected: " " (two spaces, matching width) + */ +export function renderCursor(isSelected: boolean): string { + return isSelected ? theme.fg("accent", "\u203A ") : " "; +} + +/** + * Apply selected-row background highlight and truncate to `width`. + */ +export function applyRowHighlight(line: string, isSelected: boolean, width: number): string { + const truncated = truncateToWidth(line, width); + return isSelected ? theme.bg("selectedBg", truncated) : truncated; +} + +// ── Scroll position indicator ──────────────────────────────────────── + +/** + * Render a muted "(current/total)" position indicator, optionally with a suffix label. + */ +export function renderScrollPosition( + selectedIndex: number, + totalItems: number, + width: number, + suffixLabel?: string, +): string { + const suffix = suffixLabel ?? ""; + return truncateToWidth(theme.fg("muted", ` (${selectedIndex + 1}/${totalItems})${suffix}`), width); +} diff --git a/packages/pi-coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/pi-coding-agent/src/modes/interactive/components/tree-selector.ts index a5072a98f..5432eec5d 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/tree-selector.ts @@ -14,6 +14,12 @@ import type { SessionTreeNode } from "../../../core/session-manager.js"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; import { keyHint } from "./keybinding-hints.js"; +import { + applyRowHighlight, + computeScrollWindow, + renderCursor, + renderScrollPosition, +} from "./tree-render-utils.js"; /** Gutter info: position (displayIndent where connector was) and whether to show │ */ interface GutterInfo { @@ -595,14 +601,11 @@ class TreeList implements Component { return lines; } - const startIndex = Math.max( - 0, - Math.min( - this.selectedIndex - Math.floor(this.maxVisibleLines / 2), - this.filteredNodes.length - this.maxVisibleLines, - ), + const { startIndex, endIndex } = computeScrollWindow( + this.selectedIndex, + this.filteredNodes.length, + this.maxVisibleLines, ); - const endIndex = Math.min(startIndex + this.maxVisibleLines, this.filteredNodes.length); for (let i = startIndex; i < endIndex; i++) { const flatNode = this.filteredNodes[i]; @@ -610,7 +613,7 @@ class TreeList implements Component { const isSelected = i === this.selectedIndex; // Build line: cursor + prefix + path marker + label + content - const cursor = isSelected ? theme.fg("accent", "› ") : " "; + const cursor = renderCursor(isSelected); // If multiple roots, shift display (roots at 0, not 1) const displayIndent = this.multipleRoots ? Math.max(0, flatNode.indent - 1) : flatNode.indent; @@ -664,19 +667,11 @@ class TreeList implements Component { const label = flatNode.node.label ? theme.fg("warning", `[${flatNode.node.label}] `) : ""; const content = this.getEntryDisplayText(flatNode.node, isSelected); - let line = cursor + theme.fg("dim", prefix) + foldMarker + pathMarker + label + content; - if (isSelected) { - line = theme.bg("selectedBg", line); - } - lines.push(truncateToWidth(line, width)); + const line = cursor + theme.fg("dim", prefix) + foldMarker + pathMarker + label + content; + lines.push(applyRowHighlight(line, isSelected, width)); } - lines.push( - truncateToWidth( - theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${this.getFilterLabel()}`), - width, - ), - ); + lines.push(renderScrollPosition(this.selectedIndex, this.filteredNodes.length, width, this.getFilterLabel())); return lines; }