diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b2f80d4b1..2d28e2b5b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/cli/session-picker.ts b/packages/coding-agent/src/cli/session-picker.ts index 0a7e5facd..0debd5274 100644 --- a/packages/coding-agent/src/cli/session-picker.ts +++ b/packages/coding-agent/src/cli/session-picker.ts @@ -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; diff --git a/packages/coding-agent/src/modes/components/session-selector.ts b/packages/coding-agent/src/modes/components/session-selector.ts index c6f403894..8d9537faa 100644 --- a/packages/coding-agent/src/modes/components/session-selector.ts +++ b/packages/coding-agent/src/modes/components/session-selector.ts @@ -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; } diff --git a/packages/coding-agent/test/modes/components/session-selector-mouse.test.ts b/packages/coding-agent/test/modes/components/session-selector-mouse.test.ts new file mode 100644 index 000000000..87fc5fb90 --- /dev/null +++ b/packages/coding-agent/test/modes/components/session-selector-mouse.test.ts @@ -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); + }); +});