feat(coding-agent): added mouse support and fullscreen rendering to session picker

- Enabled fullscreen overlay rendering for the terminal session picker.
- Implemented full mouse support including wheel-based scrolling and click-to-select functionality.
- Anchored the session picker footer to the bottom of the viewport to correct UI flickering.
- Added comprehensive unit tests for mouse interaction and layout constancy during resizing.
This commit is contained in:
can1357
2026-06-23 02:25:30 +02:00
parent c4e23fed15
commit cffb804d3a
4 changed files with 275 additions and 18 deletions
+4 -1
View File
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Renamed the eval `agent()` helper parameters `agent_type` → `agent` and `return_handle` → `handle` across every workflow runtime (Python, JavaScript, Ruby, Julia), so the names are identical in every language (no camelCase/snake_case split) and the agent-selection parameter matches the `task` tool's `agent`. The `__agent__` eval bridge wire protocol was renamed to match.
@@ -12,12 +13,15 @@
### Changed
- Made the session picker fullscreen with mouse support for clicking rows and scrolling
- Pinned the session picker footer to the bottom of the screen to prevent layout flickering
- Simplified `eval` tool to accept a single logical step (code block) instead of an array of cells
- Updated `eval` tool documentation to emphasize incremental, single-step execution
- Restricted `bash` tool from using `ls` or `find`, requiring the use of `read` or `find` tools
- Simplified `todo` tool interface to accept a single operation directly instead of an array of ops
- Reinforced routing of fragile, multi-step shell logic to the `eval` tool over `bash`. The system-prompt tool policy, `bash.md`, and `eval.md` now treat loops, conditionals, heredocs, inline `-e`/`-c` scripts, multi-stage pipelines, and quote/JSON escaping as the signal to write an `eval` cell; bash's "compute a fact" carveout is narrowed to single short pipelines, and `eval.md` now actively claims that territory with runtime-templated examples (only enabled backends are advertised).
- Made `eval` an essential built-in tool (`loadMode: "essential"`, added to the default essential tool set) so it stays active under `tools.discoveryMode: "all"` instead of being hidden behind `search_tool_bm25`.
- Made the `--resume` session picker fullscreen on the terminal's alternate screen, so the list scrolls with the mouse wheel and a row resumes its session on left click. Rows are hit-tested against the live scroll window, and the keybinding hint + bottom border are now pinned to the screen bottom instead of drifting up and down as the visible window changes height.
### Removed
@@ -29,7 +33,6 @@
### Fixed
- Fixed the `eval` tool card not streaming a still-running cell's stdout: a long-running cell (e.g. a `time.sleep()` monitor loop) showed nothing until it returned or was interrupted, then dumped everything at once. The renderer draws cell output from `details.cells[i].output`, which was only populated after `backend.execute()` resolved — live stdout streamed into the transient result `content` tail (and `renderContext.output`), which the per-cell render branch ignores. Streamed chunks now append to the active cell's `output` (a dedicated per-cell tail buffer, capped like the aggregate) as they arrive, so the card shows progress live; on completion the authoritative full output overwrites the live tail. `log()`/`phase()`/`display()` and status ops were unaffected because they already stream via the status channel.
- Fixed Escape doing nothing in the Settings text-input fields (e.g. "Python Interpreter") on terminals with the kitty keyboard protocol active (ghostty/kitty). Inside the fullscreen settings overlay the protocol reports Escape as the CSI-u sequence `\x1b[27u`, which the text-input submenu's raw `\x1b` compare missed; `handleInputOrEscape` now decodes Escape via `matchesKey`, matching every other Escape-to-cancel path.
- Fixed Julia `eval` graph/plot visualization (Plots.jl, GraphRecipes, Makie, etc.) never rendering inline. Two bugs: (1) the runner's `build_mime_bundle`/`emit_error` dispatched `show`/`showable`/`showerror` directly from the long-lived `main()` loop, whose world age is frozen before any cell ran, so rich `show(::IO, ::MIME"image/png", …)` methods registered when a plotting package is `using`-ed inside a cell were invisible — `show` fell back to the default struct repr (which itself threw on Julia 1.12, aborting the whole result). These calls now route through `Base.invokelatest`, and the `text/plain` probe is guarded so a failing repr can no longer suppress the image MIME. (2) The default GR backend popped up a native `gksqt` GUI window on each plot; the runner now defaults `GKSwstype=100` (headless, overridable) so plots render only as inline PNGs, mirroring the Python runner's `MPLBACKEND=Agg` default.
- Fixed streaming output blocks incorrectly calculating preview height, preventing flickering banners
@@ -8,8 +8,10 @@ import { FileSessionStorage } from "../session/session-storage";
/**
* Show the TUI session selector and return the selected session, or null if
* cancelled. Tab toggles between current-folder and all-projects scope; the
* all-projects list is loaded lazily via `SessionManager.listAll`.
* cancelled. Rendered as a fullscreen overlay on the terminal's alternate
* screen, so the list scrolls and rows are clickable with the mouse. Tab
* toggles between current-folder and all-projects scope; the all-projects list
* is loaded lazily via `SessionManager.listAll`.
*/
export async function selectSession(
sessions: SessionInfo[],
@@ -65,6 +67,7 @@ export async function selectSession(
loadAllSessions: () => SessionManager.listAll(storage),
allSessions: options?.allSessions,
getTerminalRows: () => ui.terminal.rows,
fillHeight: true,
},
);
return selector;
@@ -72,7 +75,18 @@ export async function selectSession(
const selector = showSelector();
selector.setOnRequestRender(() => ui.requestRender());
ui.addChild(selector);
// Present as a fullscreen overlay so the picker borrows the terminal's
// alternate screen buffer (vim/less idiom): the list scrolls and rows are
// clickable via the mouse tracking the overlay enables for its lifetime.
// Anchored top-left at full size so a mouse row maps directly to a rendered
// line (the overlay paints from screen row 0).
ui.showOverlay(selector, {
anchor: "top-left",
width: "100%",
maxHeight: "100%",
margin: 0,
fullscreen: true,
});
ui.setFocus(selector);
ui.start();
return promise;
@@ -5,6 +5,7 @@ import {
Input,
matchesKey,
padding,
parseSgrMouse,
replaceTabs,
ScrollView,
Spacer,
@@ -161,6 +162,12 @@ export function mergeSessionRanking(
class SessionList implements Component {
#filteredSessions: SessionInfo[] = [];
#selectedIndex: number = 0;
// Maps a 0-based line within this list's own render to a filtered-session
// index, or undefined for chrome rows (search line, blanks, scrollbar gap).
// Rebuilt every render so the picker's mouse hit-testing tracks the live
// scroll window. Only consulted while the picker holds the alternate screen
// (where the overlay enables mouse tracking and paints from screen row 0).
#hitRows: (number | undefined)[] = [];
readonly #searchInput: Input;
onSelect?: (session: SessionInfo) => void;
onCancel?: () => void;
@@ -257,12 +264,32 @@ class SessionList implements Component {
}
}
/** Resolve a list-local rendered-line index to a filtered-session index. */
hitTestSession(line: number): number | undefined {
return this.#hitRows[line];
}
/** Wheel notch: move the selection one step (clamped, no wrap). */
handleWheel(delta: -1 | 1): void {
if (this.#filteredSessions.length === 0) return;
this.#selectedIndex = Math.max(0, Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + delta));
}
/** Mouse click: select the session under the pointer and resume it. */
selectAndConfirm(index: number): void {
const session = this.#filteredSessions[index];
if (!session) return;
this.#selectedIndex = index;
this.onSelect?.(session);
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): readonly string[] {
const lines: string[] = [];
this.#hitRows = [];
// Render search input
lines.push(...this.#searchInput.render(width));
@@ -311,9 +338,11 @@ class SessionList implements Component {
// Each session block is built into sessionLines, then wrapped by ScrollView
// so the right-edge scrollbar is proportional at the physical-line level.
const sessionLines: string[] = [];
const sessionRowIndex: number[] = [];
const overflow = this.#filteredSessions.length > maxVisible;
const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
for (let i = startIndex; i < endIndex; i++) {
const blockStart = sessionLines.length;
const session = this.#filteredSessions[i];
const isSelected = i === this.#selectedIndex;
@@ -363,6 +392,7 @@ class SessionList implements Component {
sessionLines.push(metadataLine);
sessionLines.push(""); // Blank line between sessions
for (let k = blockStart; k < sessionLines.length; k++) sessionRowIndex[k] = i;
}
// Wrap the rendered window in a ScrollView for a proportional right-edge bar.
@@ -375,16 +405,10 @@ class SessionList implements Component {
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
});
sv.setScrollOffset(Math.round(startIndex * linesPerItem));
lines.push(...sv.render(width));
// Add keybinding hint
lines.push("");
lines.push(
theme.fg(
"muted",
` [Del delete · Enter select · Tab ${this.#showCwd ? "current folder" : "all projects"} · Esc cancel]`,
),
);
const sessionRegionStart = lines.length;
const svLines = sv.render(width);
for (let k = 0; k < svLines.length; k++) this.#hitRows[sessionRegionStart + k] = sessionRowIndex[k];
lines.push(...svLines);
return lines;
}
@@ -462,6 +486,13 @@ export interface SessionSelectorOptions {
* Omitted only in tests; defaults to a conservative 24 rows.
*/
getTerminalRows?: () => number;
/**
* Fill the whole viewport and pin the footer (hint + bottom border) to the
* last rows, so the footer stops drifting as the list window changes height.
* Set by the standalone `--resume` picker (fullscreen alternate screen); the
* in-editor selector leaves it off and renders compactly.
*/
fillHeight?: boolean;
}
/**
@@ -479,6 +510,18 @@ export class SessionSelectorComponent extends Container {
#globalSessions: SessionInfo[] | null = null;
#scope: "folder" | "all" = "folder";
#toggling = false;
// 0-based line where the session list begins within this component's own
// render, captured each frame. The fullscreen picker overlay paints from
// screen row 0, so a mouse row maps to `row - #listLineOffset` inside the
// list. Only meaningful while the picker holds the alternate screen.
#listLineOffset = 0;
// 0-based line where the pinned footer begins; clicks at or below it never
// hit-test the list, so a footer click on a cramped (trimmed) frame can't
// resume a session scrolled off-screen.
#footerStart = 0;
readonly #getTerminalRows: () => number;
readonly #fillHeight: boolean;
readonly #bottomBorder = new DynamicBorder();
constructor(
sessions: SessionInfo[],
@@ -494,6 +537,8 @@ export class SessionSelectorComponent extends Container {
this.#loadAllSessions = options.loadAllSessions;
this.#folderSessions = sessions;
this.#globalSessions = options.allSessions ?? null;
this.#getTerminalRows = options.getTerminalRows ?? (() => 24);
this.#fillHeight = options.fillHeight ?? false;
// Add header
this.addChild(new Spacer(1));
this.#headerText = new Text(this.#headerLabel(), 1, 0);
@@ -518,10 +563,6 @@ export class SessionSelectorComponent extends Container {
};
}
this.addChild(this.#sessionList);
// Add bottom border
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
}
#headerLabel(): string {
@@ -615,7 +656,47 @@ export class SessionSelectorComponent extends Container {
this.addChild(this.#confirmationDialog);
}
/**
* Concatenate the children's renders (like {@link Container}) while recording
* the line where the session list begins, so the fullscreen picker can hit-
* test mouse rows against the live list window. SessionList rebuilds its lines
* every frame, so Container's reference-memoization never applied here.
*
* In fill-height mode the body is padded (or, on a cramped terminal, trimmed)
* to leave exactly enough room for the footer at the screen bottom, so the
* footer is always visible and never drifts as the list window resizes. The
* in-editor selector just appends the footer directly.
*/
render(width: number): readonly string[] {
const lines: string[] = [];
for (const child of this.children) {
const childLines = child.render(width);
if (child === this.#sessionList) this.#listLineOffset = lines.length;
for (const line of childLines) lines.push(line);
}
const footer = this.#footerLines(width);
if (this.#fillHeight) {
const target = Math.max(0, this.#getTerminalRows() - footer.length);
if (lines.length > target) lines.length = target;
else for (let i = lines.length; i < target; i++) lines.push("");
}
this.#footerStart = lines.length;
for (const line of footer) lines.push(line);
return lines;
}
/** Blank · keybinding hint · bottom border. Rendered by {@link render}. */
#footerLines(width: number): string[] {
const scopeHint = this.#scope === "all" ? "current folder" : "all projects";
const hint = theme.fg("muted", ` [Del delete · Enter select · Tab ${scopeHint} · Esc cancel]`);
return ["", hint, "", ...this.#bottomBorder.render(width)];
}
handleInput(keyData: string): void {
if (keyData.startsWith("\x1b[<")) {
this.#handleMouse(keyData);
return;
}
if (this.#confirmationDialog) {
this.#confirmationDialog.handleInput(keyData);
} else {
@@ -623,6 +704,25 @@ export class SessionSelectorComponent extends Container {
}
}
/**
* SGR mouse reports, delivered only while the picker holds the alternate
* screen (the fullscreen overlay enables tracking and paints from screen row
* 0). Wheel scrolls the list; a left click resumes the session under the
* pointer. Mouse is inert while the delete-confirmation dialog is open.
*/
#handleMouse(data: string): void {
if (this.#confirmationDialog) return;
const event = parseSgrMouse(data);
if (!event) return;
if (event.wheel !== null) {
this.#sessionList.handleWheel(event.wheel);
return;
}
if (!event.leftClick || event.row >= this.#footerStart) return;
const index = this.#sessionList.hitTestSession(event.row - this.#listLineOffset);
if (index !== undefined) this.#sessionList.selectAndConfirm(index);
}
getSessionList(): SessionList {
return this.#sessionList;
}
@@ -0,0 +1,140 @@
import { beforeAll, describe, expect, it } from "bun:test";
import { SessionSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/session-selector";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { SessionInfo } from "@oh-my-pi/pi-coding-agent/session/session-listing";
beforeAll(async () => {
await initTheme();
});
function makeSession(id: string, title: string | undefined): SessionInfo {
return {
path: `/work/${id}.jsonl`,
id,
cwd: "/work",
title,
created: new Date("2024-01-01T00:00:00Z"),
modified: new Date("2024-01-02T00:00:00Z"),
messageCount: 1,
size: 1024,
firstMessage: `body for ${id}`,
allMessagesText: `body for ${id}`,
};
}
/** SGR left-button press at a 1-based screen row (column is irrelevant for row hit-testing). */
function leftClick(row1Based: number, col1Based = 4): string {
return `\x1b[<0;${col1Based};${row1Based}M`;
}
/** SGR wheel notch: button 64 = up, 65 = down. */
function wheel(direction: "up" | "down"): string {
return `\x1b[<${direction === "down" ? 65 : 64};1;1M`;
}
function makeSelector(
sessions: SessionInfo[],
onSelect: (s: SessionInfo) => void,
rows = 40,
): SessionSelectorComponent {
return new SessionSelectorComponent(
sessions,
onSelect,
() => {},
() => {},
{
getTerminalRows: () => rows,
fillHeight: true,
},
);
}
describe("SessionSelectorComponent mouse", () => {
it("resumes the session under a left click", () => {
const sessions = [
makeSession("aaaa", "Alpha session"),
makeSession("bbbb", "Beta session"),
makeSession("cccc", "Gamma session"),
];
let picked: SessionInfo | undefined;
const selector = makeSelector(sessions, s => {
picked = s;
});
// Render first so the hit-test map and list offset reflect this frame.
const lines = selector.render(80);
const betaRow = lines.findIndex(line => line.includes("Beta session"));
expect(betaRow).toBeGreaterThanOrEqual(0);
// Mouse rows are 1-based; the fullscreen overlay paints from screen row 0.
selector.handleInput(leftClick(betaRow + 1));
expect(picked?.id).toBe("bbbb");
});
it("scrolls the selection with the wheel, then resumes it on Enter", () => {
const sessions = [
makeSession("aaaa", "Alpha session"),
makeSession("bbbb", "Beta session"),
makeSession("cccc", "Gamma session"),
];
let picked: SessionInfo | undefined;
const selector = makeSelector(sessions, s => {
picked = s;
});
selector.render(80);
// Selection starts at the first row; two notches down lands on Gamma.
selector.handleInput(wheel("down"));
selector.handleInput(wheel("down"));
selector.handleInput("\n");
expect(picked?.id).toBe("cccc");
});
it("ignores a click on the pinned footer (never resumes a hidden session)", () => {
const sessions = Array.from({ length: 20 }, (_, i) => makeSession(`s${i}`, `Title ${i}`));
let picked: SessionInfo | undefined;
const selector = makeSelector(
sessions,
s => {
picked = s;
},
40,
);
const lines = selector.render(80);
const footerRow = lines.findIndex(line => line.includes("Esc cancel"));
expect(footerRow).toBeGreaterThanOrEqual(0);
// Click directly on the footer hint row: must not resume anything.
selector.handleInput(leftClick(footerRow + 1));
expect(picked).toBeUndefined();
});
});
describe("SessionSelectorComponent fill-height footer", () => {
// First half titled (4 rows each), second half untitled (3 rows each), so the
// scrolled window changes height — the regression that made the footer drift.
function mixedSessions(count: number): SessionInfo[] {
return Array.from({ length: count }, (_, i) => makeSession(`s${i}`, i < count / 2 ? `Titled ${i}` : undefined));
}
it("fills the viewport and pins the footer to the bottom regardless of scroll", () => {
const rows = 40;
const selector = makeSelector(mixedSessions(20), () => {}, rows);
const top = selector.render(80);
const topHint = top.findIndex(line => line.includes("Esc cancel"));
expect(top.length).toBe(rows);
expect(topHint).toBe(rows - 3);
expect(top[rows - 1]!.trim().length).toBeGreaterThan(0); // bottom border on the last row
// Scroll to the bottom of the list (now an untitled window of a different
// height); the footer must not move.
for (let i = 0; i < 25; i++) selector.handleInput(wheel("down"));
const bottom = selector.render(80);
const bottomHint = bottom.findIndex(line => line.includes("Esc cancel"));
expect(bottom.length).toBe(rows);
expect(bottomHint).toBe(topHint);
expect(bottom[rows - 1]!.trim().length).toBeGreaterThan(0);
});
});