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,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);

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