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:

<img width="1120" height="778" alt="Screenshot 2026-09-04 at 19 25 30"
src="https://github.com/user-attachments/assets/8f2e0ba9-8476-452b-a6be-1ad9b8d7d6c8"
/>

After:

<img width="1120" height="778" alt="Screenshot 2026-09-04 at 19 25 50"
src="https://github.com/user-attachments/assets/7f6ffcbf-db5a-4732-84fe-6ee0544116fa"
/>
This commit is contained in:
Sviatoslav Abakumov 2026-11-04 12:19:38 +03:00 • committed by GitHub
parent c3b938e3e9
commit 2cfab7c269
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 88 additions and 11 deletions

View file

@ -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`;