From 2cfab7c269bb1fa12ab2704d05d18df3e5292e37 Mon Sep 17 00:00:00 2001 From: Sviatoslav Abakumov Date: Wed, 4 Nov 2026 12:19:38 +0300 Subject: [PATCH] fix(coding-agent): render session tree search cursor (#9157) This PR adds the cursor block to the **Type to search** input in **Session Tree**. This is similar to the search input in **Resume Session**. Before: Screenshot 2026-09-04 at 19 25 30 After: Screenshot 2026-09-04 at 19 25 50 --- .../interactive/components/tree-selector.ts | 30 +++++--- .../coding-agent/test/tree-selector.test.ts | 69 ++++++++++++++++++- 2 files changed, 88 insertions(+), 11 deletions(-) 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 5bcdd846d..1a9d4b1f1 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -1,6 +1,7 @@ import { type Component, Container, + CURSOR_MARKER, type Focusable, getKeybindings, Input, @@ -1152,7 +1153,8 @@ class TreeList implements Component { } /** Component that displays the current search query */ -class SearchLine implements Component { +class SearchLine implements Component, Focusable { + focused = false; private treeList: TreeList; constructor(treeList: TreeList) { @@ -1162,11 +1164,16 @@ class SearchLine implements Component { invalidate(): void {} render(width: number): string[] { + if (width <= 0) return [""]; + const query = this.treeList.getSearchQuery(); - if (query) { - return [truncateToWidth(` ${theme.fg("muted", "Type to search:")} ${theme.fg("accent", query)}`, width)]; - } - return [truncateToWidth(` ${theme.fg("muted", "Type to search:")}`, width)]; + const line = query + ? ` ${theme.fg("muted", "Type to search:")} ${theme.fg("accent", query)}` + : ` ${theme.fg("muted", "Type to search:")} `; + if (!this.focused) return [truncateToWidth(line, width)]; + + const content = truncateToWidth(line, Math.max(0, width - 1)); + return [`${content}${CURSOR_MARKER}\x1b[7m \x1b[27m`]; } handleInput(_keyData: string): void {} @@ -1325,20 +1332,21 @@ class LabelInput implements Component, Focusable { */ export class TreeSelectorComponent extends Container implements Focusable { private treeList: TreeList; + private searchLine: SearchLine; private labelInput: LabelInput | null = null; private labelInputContainer: Container; private treeContainer: Container; private onLabelChangeCallback?: (entryId: string, label: string | undefined) => void; public onCopy?: (text: string | undefined) => void; - // Focusable implementation - propagate to labelInput when active for IME cursor positioning + // Focusable implementation - propagate to the active input for IME cursor positioning private _focused = false; get focused(): boolean { return this._focused; } set focused(value: boolean) { this._focused = value; - // Propagate to labelInput when it's active + this.searchLine.focused = value && this.labelInput === null; if (this.labelInput) { this.labelInput.focused = value; } @@ -1364,6 +1372,7 @@ export class TreeSelectorComponent extends Container implements Focusable { this.treeList.onCancel = onCancel; this.treeList.onCopy = (text) => this.onCopy?.(text); this.treeList.onLabelEdit = (entryId, currentLabel) => this.showLabelInput(entryId, currentLabel); + this.searchLine = new SearchLine(this.treeList); this.treeContainer = new Container(); this.treeContainer.addChild(this.treeList); @@ -1374,7 +1383,7 @@ export class TreeSelectorComponent extends Container implements Focusable { this.addChild(new DynamicBorder()); this.addChild(new Text(theme.bold(" Session Tree"), 1, 0)); this.addChild(new TreeHelp()); - this.addChild(new SearchLine(this.treeList)); + this.addChild(this.searchLine); this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); this.addChild(this.treeContainer); @@ -1396,8 +1405,8 @@ export class TreeSelectorComponent extends Container implements Focusable { }; this.labelInput.onCancel = () => this.hideLabelInput(); - // Propagate current focused state to the new labelInput - this.labelInput.focused = this._focused; + // Propagate current focused state + this.focused = this._focused; this.treeContainer.clear(); this.labelInputContainer.clear(); @@ -1406,6 +1415,7 @@ export class TreeSelectorComponent extends Container implements Focusable { private hideLabelInput(): void { this.labelInput = null; + this.focused = this._focused; this.labelInputContainer.clear(); this.treeContainer.clear(); this.treeContainer.addChild(this.treeList); diff --git a/packages/coding-agent/test/tree-selector.test.ts b/packages/coding-agent/test/tree-selector.test.ts index 579511b33..06d1ee541 100644 --- a/packages/coding-agent/test/tree-selector.test.ts +++ b/packages/coding-agent/test/tree-selector.test.ts @@ -1,5 +1,5 @@ import { stripVTControlCharacters } from "node:util"; -import { setKeybindings, visibleWidth } from "@earendil-works/pi-tui"; +import { CURSOR_MARKER, setKeybindings, visibleWidth } from "@earendil-works/pi-tui"; import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { KeybindingsManager } from "../src/core/keybindings.ts"; import type { @@ -310,6 +310,73 @@ describe("TreeSelectorComponent", () => { }); }); + describe("search cursor", () => { + test("renders the cursor only while the selector is focused", () => { + const tree = buildTree([userMessage("user-1", null, "hello")]); + const selector = new TreeSelectorComponent( + tree, + "user-1", + 24, + () => {}, + () => {}, + ); + + expect(selector.render(80).join("\n")).not.toContain(CURSOR_MARKER); + + selector.focused = true; + const focusedLines = selector.render(80); + const cursorLines = focusedLines.filter((line) => line.includes(CURSOR_MARKER)); + expect(cursorLines).toHaveLength(1); + expect(stripVTControlCharacters(cursorLines[0]!.replace(CURSOR_MARKER, ""))).toBe(" Type to search: "); + expect(cursorLines[0]).toContain(`${CURSOR_MARKER}\x1b[7m \x1b[27m`); + + selector.focused = false; + expect(selector.render(80).join("\n")).not.toContain(CURSOR_MARKER); + }); + + test("moves focus to the label input and restores it after cancellation", () => { + const tree = buildTree([userMessage("user-1", null, "hello")]); + const selector = new TreeSelectorComponent( + tree, + "user-1", + 24, + () => {}, + () => {}, + ); + selector.focused = true; + + selector.handleInput("L"); + let rendered = selector.render(80); + let cursorLineIndex = rendered.findIndex((line) => line.includes(CURSOR_MARKER)); + expect(cursorLineIndex).toBeGreaterThan(0); + expect(stripVTControlCharacters(rendered[cursorLineIndex - 1]!)).toContain("Label (empty to remove):"); + expect(rendered.filter((line) => line.includes(CURSOR_MARKER))).toHaveLength(1); + + selector.handleInput("\x1b"); + rendered = selector.render(80); + cursorLineIndex = rendered.findIndex((line) => line.includes(CURSOR_MARKER)); + expect(stripVTControlCharacters(rendered[cursorLineIndex]!)).toContain("Type to search:"); + expect(rendered.filter((line) => line.includes(CURSOR_MARKER))).toHaveLength(1); + }); + + test("keeps the cursor within narrow terminal widths", () => { + const tree = buildTree([userMessage("user-1", null, "hello")]); + const selector = new TreeSelectorComponent( + tree, + "user-1", + 24, + () => {}, + () => {}, + ); + selector.focused = true; + selector.handleInput("a long search query"); + + const cursorLine = selector.render(10).find((line) => line.includes(CURSOR_MARKER)); + expect(cursorLine).toBeDefined(); + expect(visibleWidth(cursorLine!)).toBeLessThanOrEqual(10); + }); + }); + describe("copy", () => { test("copies the full selected message with ctrl+x", () => { const message = `${"long message ".repeat(30)}\nsecond line`;