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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user