@oh-my-pi/pi-coding-agent 16.1.15 → 16.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/cli.js +3725 -4029
  3. package/dist/types/cli/args.d.ts +2 -5
  4. package/dist/types/cli/flag-tables.d.ts +2 -2
  5. package/dist/types/cli/session-picker.d.ts +4 -2
  6. package/dist/types/commands/launch.d.ts +1 -1
  7. package/dist/types/config/settings-schema.d.ts +12 -1
  8. package/dist/types/eval/agent-bridge.d.ts +19 -0
  9. package/dist/types/eval/js/shared/helpers.d.ts +1 -13
  10. package/dist/types/eval/js/shared/types.d.ts +1 -1
  11. package/dist/types/eval/js/worker-protocol.d.ts +1 -1
  12. package/dist/types/eval/py/executor.d.ts +1 -1
  13. package/dist/types/internal-urls/local-protocol.d.ts +18 -1
  14. package/dist/types/main.d.ts +2 -0
  15. package/dist/types/modes/components/plugin-settings.d.ts +5 -0
  16. package/dist/types/modes/components/session-selector.d.ts +25 -0
  17. package/dist/types/task/isolation-runner.d.ts +128 -0
  18. package/dist/types/task/worktree.d.ts +14 -1
  19. package/dist/types/thinking.d.ts +15 -0
  20. package/dist/types/tools/eval-render.d.ts +3 -0
  21. package/dist/types/tools/eval.d.ts +11 -17
  22. package/dist/types/tools/todo.d.ts +26 -28
  23. package/dist/types/tui/output-block.d.ts +8 -0
  24. package/dist/types/utils/image-resize.d.ts +2 -0
  25. package/dist/types/web/search/providers/exa.d.ts +2 -0
  26. package/package.json +12 -12
  27. package/scripts/build-binary.ts +18 -4
  28. package/src/cli/args.ts +4 -5
  29. package/src/cli/flag-tables.ts +3 -3
  30. package/src/cli/gallery-fixtures/interaction.ts +6 -9
  31. package/src/cli/gallery-fixtures/shell.ts +15 -23
  32. package/src/cli/session-picker.ts +17 -3
  33. package/src/commands/launch.ts +3 -3
  34. package/src/config/settings-schema.ts +13 -1
  35. package/src/edit/renderer.ts +34 -12
  36. package/src/eval/__tests__/agent-bridge.test.ts +462 -3
  37. package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
  38. package/src/eval/__tests__/julia-prelude.test.ts +1 -30
  39. package/src/eval/__tests__/prelude-agent.test.ts +42 -8
  40. package/src/eval/agent-bridge.ts +301 -71
  41. package/src/eval/jl/prelude.jl +32 -227
  42. package/src/eval/jl/runner.jl +38 -12
  43. package/src/eval/js/shared/helpers.ts +1 -114
  44. package/src/eval/js/shared/prelude.txt +13 -27
  45. package/src/eval/js/shared/runtime.ts +0 -6
  46. package/src/eval/js/shared/types.ts +1 -1
  47. package/src/eval/js/worker-protocol.ts +1 -1
  48. package/src/eval/py/__tests__/prelude.test.ts +13 -0
  49. package/src/eval/py/executor.ts +1 -1
  50. package/src/eval/py/prelude.py +47 -105
  51. package/src/eval/py/runner.py +0 -6
  52. package/src/eval/rb/prelude.rb +21 -189
  53. package/src/eval/rb/runner.rb +116 -9
  54. package/src/export/html/tool-views.generated.js +29 -29
  55. package/src/internal-urls/docs-index.generated.txt +1 -1
  56. package/src/internal-urls/local-protocol.ts +100 -53
  57. package/src/main.ts +15 -4
  58. package/src/modes/acp/acp-event-mapper.ts +7 -2
  59. package/src/modes/components/plugin-settings.ts +7 -1
  60. package/src/modes/components/session-selector.ts +143 -29
  61. package/src/modes/controllers/command-controller.ts +5 -0
  62. package/src/modes/rpc/rpc-mode.ts +6 -0
  63. package/src/modes/utils/copy-targets.ts +7 -2
  64. package/src/prompts/system/system-prompt.md +3 -3
  65. package/src/prompts/system/workflow-notice.md +3 -3
  66. package/src/prompts/tools/bash.md +16 -0
  67. package/src/prompts/tools/eval.md +19 -19
  68. package/src/prompts/tools/todo.md +1 -1
  69. package/src/session/agent-session.ts +231 -50
  70. package/src/task/index.ts +61 -207
  71. package/src/task/isolation-runner.ts +354 -0
  72. package/src/task/worktree.ts +46 -9
  73. package/src/thinking.ts +20 -0
  74. package/src/tools/ask.ts +44 -38
  75. package/src/tools/bash.ts +9 -2
  76. package/src/tools/browser/tab-worker.ts +1 -1
  77. package/src/tools/eval-render.ts +34 -27
  78. package/src/tools/eval.ts +100 -103
  79. package/src/tools/index.ts +8 -1
  80. package/src/tools/read.ts +136 -60
  81. package/src/tools/todo.ts +60 -64
  82. package/src/tui/code-cell.ts +1 -1
  83. package/src/tui/output-block.ts +11 -0
  84. package/src/utils/image-resize.ts +30 -0
  85. package/src/web/search/providers/exa.ts +85 -1
@@ -157,7 +157,7 @@ export function resolveLocalUrlToPath(
157
157
  }
158
158
 
159
159
  /**
160
- * On-disk roots the eval helpers (`read`/`write`/`append`) substitute for
160
+ * On-disk roots the eval helpers (`read`/`write`) substitute for
161
161
  * internal-URL schemes so e.g. `write("local://x.md")` lands where a later
162
162
  * `read local://x.md` resolves — instead of a literal `local:/` directory under
163
163
  * the cwd (a stdlib `pathlib.Path`/`path.resolve` collapses `local://` to
@@ -169,6 +169,96 @@ export function buildEvalUrlRoots(options: LocalProtocolOptions): Record<string,
169
169
  return { local: resolveLocalRoot(options) };
170
170
  }
171
171
 
172
+ const LOCAL_WRITE_NOTE = "Use write path local://<file> to persist large intermediate artifacts across turns.";
173
+
174
+ type ResolvedLocalTarget =
175
+ | { kind: "listing"; root: string }
176
+ | { kind: "directory"; path: string }
177
+ | { kind: "file"; path: string; size: number };
178
+
179
+ /**
180
+ * Resolve a local:// URL to its on-disk target with realpath + containment
181
+ * checks on the root, parent, and target so symlinks cannot escape the session
182
+ * local root. Does NOT read or decode file contents — callers decide how to
183
+ * consume the resolved path. Shared by {@link LocalProtocolHandler.resolve} and
184
+ * {@link resolveLocalUrlToFile}.
185
+ */
186
+ async function resolveLocalTarget(url: InternalUrl, opts: LocalProtocolOptions): Promise<ResolvedLocalTarget> {
187
+ const localRoot = path.resolve(resolveLocalRoot(opts));
188
+ await fs.mkdir(localRoot, { recursive: true });
189
+
190
+ let resolvedRoot: string;
191
+ try {
192
+ resolvedRoot = await fs.realpath(localRoot);
193
+ } catch (error) {
194
+ if (isEnoent(error)) {
195
+ throw new Error("Unable to initialize local:// root");
196
+ }
197
+ throw error;
198
+ }
199
+
200
+ const relativePath = extractRelativePath(url);
201
+ const targetPath = relativePath ? path.resolve(resolvedRoot, relativePath) : resolvedRoot;
202
+ ensureWithinRoot(targetPath, resolvedRoot);
203
+
204
+ if (targetPath === resolvedRoot) {
205
+ return { kind: "listing", root: resolvedRoot };
206
+ }
207
+
208
+ const parentDir = path.dirname(targetPath);
209
+ try {
210
+ const realParent = await fs.realpath(parentDir);
211
+ ensureWithinRoot(realParent, resolvedRoot);
212
+ } catch (error) {
213
+ if (!isEnoent(error)) throw error;
214
+ }
215
+
216
+ let realTargetPath: string;
217
+ try {
218
+ realTargetPath = await fs.realpath(targetPath);
219
+ } catch (error) {
220
+ if (isEnoent(error)) {
221
+ throw new Error(`Local file not found: ${url.href}`);
222
+ }
223
+ throw error;
224
+ }
225
+
226
+ ensureWithinRoot(realTargetPath, resolvedRoot);
227
+
228
+ const stat = await fs.stat(realTargetPath);
229
+ if (stat.isDirectory()) {
230
+ return { kind: "directory", path: realTargetPath };
231
+ }
232
+ if (!stat.isFile()) {
233
+ throw new Error(`local:// URL must resolve to a file or directory: ${url.href}`);
234
+ }
235
+ return { kind: "file", path: realTargetPath, size: stat.size };
236
+ }
237
+
238
+ /**
239
+ * Resolve a local:// URL to a regular on-disk file, applying the same
240
+ * realpath + containment guarantees as {@link LocalProtocolHandler.resolve}
241
+ * but WITHOUT reading or UTF-8-decoding its contents. Returns null when there
242
+ * is no active session or when the URL targets the root listing or a directory;
243
+ * throws the handler's not-found and "escapes local root" errors for missing
244
+ * files and symlink escapes.
245
+ *
246
+ * Options are resolved via {@link LocalProtocolHandler.resolveOptions} so the
247
+ * caller-options → override → registry order matches router resolution exactly.
248
+ * The read tool uses this to detect and emit image files from their real path
249
+ * before the text-only resource contract would decode the binary into mojibake.
250
+ */
251
+ export async function resolveLocalUrlToFile(
252
+ input: string | InternalUrl,
253
+ context?: ResolveContext,
254
+ ): Promise<{ path: string; size: number } | null> {
255
+ const opts = LocalProtocolHandler.resolveOptions(context);
256
+ if (!opts) return null;
257
+ const url = typeof input === "string" ? parseLocalUrl(input) : input;
258
+ const resolved = await resolveLocalTarget(url, opts);
259
+ return resolved.kind === "file" ? { path: resolved.path, size: resolved.size } : null;
260
+ }
261
+
172
262
  /**
173
263
  * Protocol handler for local:// URLs.
174
264
  *
@@ -238,65 +328,22 @@ export class LocalProtocolHandler implements ProtocolHandler {
238
328
  throw new Error("No session - local:// unavailable");
239
329
  }
240
330
 
241
- const localRoot = path.resolve(resolveLocalRoot(opts));
242
- await fs.mkdir(localRoot, { recursive: true });
243
-
244
- let resolvedRoot: string;
245
- try {
246
- resolvedRoot = await fs.realpath(localRoot);
247
- } catch (error) {
248
- if (isEnoent(error)) {
249
- throw new Error("Unable to initialize local:// root");
250
- }
251
- throw error;
252
- }
253
-
254
- const relativePath = extractRelativePath(url);
255
- const targetPath = relativePath ? path.resolve(resolvedRoot, relativePath) : resolvedRoot;
256
- ensureWithinRoot(targetPath, resolvedRoot);
257
-
258
- if (targetPath === resolvedRoot) {
259
- return buildListing(url, resolvedRoot);
260
- }
261
-
262
- const parentDir = path.dirname(targetPath);
263
- try {
264
- const realParent = await fs.realpath(parentDir);
265
- ensureWithinRoot(realParent, resolvedRoot);
266
- } catch (error) {
267
- if (!isEnoent(error)) throw error;
268
- }
269
-
270
- let realTargetPath: string;
271
- try {
272
- realTargetPath = await fs.realpath(targetPath);
273
- } catch (error) {
274
- if (isEnoent(error)) {
275
- throw new Error(`Local file not found: ${url.href}`);
276
- }
277
- throw error;
278
- }
279
-
280
- ensureWithinRoot(realTargetPath, resolvedRoot);
281
-
282
- const stat = await fs.stat(realTargetPath);
283
- if (stat.isDirectory()) {
284
- return buildDirectoryResource(url.href, realTargetPath, [
285
- "Use write path local://<file> to persist large intermediate artifacts across turns.",
286
- ]);
331
+ const resolved = await resolveLocalTarget(url, opts);
332
+ if (resolved.kind === "listing") {
333
+ return buildListing(url, resolved.root);
287
334
  }
288
- if (!stat.isFile()) {
289
- throw new Error(`local:// URL must resolve to a file or directory: ${url.href}`);
335
+ if (resolved.kind === "directory") {
336
+ return buildDirectoryResource(url.href, resolved.path, [LOCAL_WRITE_NOTE]);
290
337
  }
291
338
 
292
- const content = await Bun.file(realTargetPath).text();
339
+ const content = await Bun.file(resolved.path).text();
293
340
  return {
294
341
  url: url.href,
295
342
  content,
296
- contentType: getContentType(realTargetPath),
343
+ contentType: getContentType(resolved.path),
297
344
  size: Buffer.byteLength(content, "utf-8"),
298
- sourcePath: realTargetPath,
299
- notes: ["Use write path local://<file> to persist large intermediate artifacts across turns."],
345
+ sourcePath: resolved.path,
346
+ notes: [LOCAL_WRITE_NOTE],
300
347
  };
301
348
  }
302
349
 
package/src/main.ts CHANGED
@@ -945,6 +945,7 @@ async function buildSessionOptions(
945
945
  interface RunRootCommandDependencies {
946
946
  createAgentSession?: typeof createAgentSession;
947
947
  discoverAuthStorage?: typeof discoverAuthStorage;
948
+ selectSession?: typeof selectSession;
948
949
  runAcpMode?: RunAcpMode;
949
950
  settings?: Settings;
950
951
  forceSetupWizard?: boolean;
@@ -1131,7 +1132,8 @@ export async function runRootCommand(
1131
1132
  // (see issue #1668).
1132
1133
  if (typeof parsedArgs.resume === "string" && !sessionManager) {
1133
1134
  writeStartupNotice(parsedArgs, `${chalk.dim("Resume cancelled: session is in another project.")}\n`);
1134
- return;
1135
+ stopStartupWatchdog();
1136
+ process.exit(0);
1135
1137
  }
1136
1138
 
1137
1139
  // Handle --resume (no value): show session picker
@@ -1147,17 +1149,26 @@ export async function runRootCommand(
1147
1149
  preloadedAllSessions = await logger.time("SessionManager.listAll", SessionManager.listAll);
1148
1150
  if (preloadedAllSessions.length === 0) {
1149
1151
  writeStartupNotice(parsedArgs, `${chalk.dim("No sessions found")}\n`);
1150
- return;
1152
+ stopStartupWatchdog();
1153
+ process.exit(0);
1151
1154
  }
1152
1155
  }
1153
1156
  pauseStartupWatchdog();
1154
- const selected = await logger.time("selectSession", selectSession, folderSessions, {
1157
+ const selected = await logger.time("selectSession", deps.selectSession ?? selectSession, folderSessions, {
1155
1158
  allSessions: preloadedAllSessions,
1156
1159
  });
1157
1160
  resumeStartupWatchdog();
1158
1161
  if (!selected) {
1159
1162
  writeStartupNotice(parsedArgs, `${chalk.dim("No session selected")}\n`);
1160
- return;
1163
+ // Quit instead of returning: startup already armed long-lived handles
1164
+ // (theme watcher + SIGWINCH/macOS appearance listeners via initTheme,
1165
+ // settings save timer, model registry) that keep the event loop alive,
1166
+ // so a bare return hangs the process after the picker leaves the alt
1167
+ // screen. No session was built here, so there is nothing to flush. The
1168
+ // in-session `/resume` picker (selector-controller.ts) takes a different
1169
+ // onCancel that just closes the overlay — only this startup path exits.
1170
+ stopStartupWatchdog();
1171
+ process.exit(0);
1161
1172
  }
1162
1173
  // Resuming a session from another project: switch the process into that
1163
1174
  // project's directory and refresh cwd-derived caches before the session is
@@ -468,8 +468,13 @@ function buildEvalStartText(args: unknown): string | undefined {
468
468
  if (typeof args !== "object" || args === null || Array.isArray(args)) {
469
469
  return undefined;
470
470
  }
471
- const cells = (args as EvalCellContainer).cells;
472
- if (!Array.isArray(cells) || cells.length === 0) {
471
+ const container = args as EvalCellContainer & EvalCellLike;
472
+ const cells = Array.isArray(container.cells)
473
+ ? container.cells
474
+ : typeof container.code === "string"
475
+ ? [container]
476
+ : [];
477
+ if (cells.length === 0) {
473
478
  return undefined;
474
479
  }
475
480
  const lines: string[] = [];
@@ -11,6 +11,7 @@
11
11
  import {
12
12
  Container,
13
13
  Input,
14
+ matchesKey,
14
15
  type SelectItem,
15
16
  SelectList,
16
17
  type SettingItem,
@@ -36,13 +37,18 @@ import { DynamicBorder } from "./dynamic-border";
36
37
 
37
38
  /**
38
39
  * Forwards a keystroke to `input`, but cancels via `onCancel` when the user presses Escape.
40
+ *
41
+ * Escape is decoded via `matchesKey` rather than a raw `\x1b` compare: inside the
42
+ * fullscreen settings overlay the kitty keyboard protocol is active (ghostty/kitty),
43
+ * where the Escape key arrives as the CSI-u sequence `\x1b[27u`, not a bare `\x1b`.
44
+ * The literal fallbacks preserve legacy single/double-escape on terminals without it.
39
45
  */
40
46
  export function handleInputOrEscape(
41
47
  data: string,
42
48
  input: { handleInput(data: string): void },
43
49
  onCancel: () => void,
44
50
  ): void {
45
- if (data === "\x1b" || data === "\x1b\x1b") {
51
+ if (data === "\x1b" || data === "\x1b\x1b" || matchesKey(data, "escape")) {
46
52
  onCancel();
47
53
  return;
48
54
  }
@@ -5,6 +5,7 @@ import {
5
5
  Input,
6
6
  matchesKey,
7
7
  padding,
8
+ parseSgrMouse,
8
9
  replaceTabs,
9
10
  ScrollView,
10
11
  Spacer,
@@ -161,6 +162,12 @@ export function mergeSessionRanking(
161
162
  class SessionList implements Component {
162
163
  #filteredSessions: SessionInfo[] = [];
163
164
  #selectedIndex: number = 0;
165
+ // Maps a 0-based line within this list's own render to a filtered-session
166
+ // index, or undefined for chrome rows (search line, blanks, scrollbar gap).
167
+ // Rebuilt every render so the picker's mouse hit-testing tracks the live
168
+ // scroll window. Only consulted while the picker holds the alternate screen
169
+ // (where the overlay enables mouse tracking and paints from screen row 0).
170
+ #hitRows: (number | undefined)[] = [];
164
171
  readonly #searchInput: Input;
165
172
  onSelect?: (session: SessionInfo) => void;
166
173
  onCancel?: () => void;
@@ -257,12 +264,32 @@ class SessionList implements Component {
257
264
  }
258
265
  }
259
266
 
267
+ /** Resolve a list-local rendered-line index to a filtered-session index. */
268
+ hitTestSession(line: number): number | undefined {
269
+ return this.#hitRows[line];
270
+ }
271
+
272
+ /** Wheel notch: move the selection one step (clamped, no wrap). */
273
+ handleWheel(delta: -1 | 1): void {
274
+ if (this.#filteredSessions.length === 0) return;
275
+ this.#selectedIndex = Math.max(0, Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + delta));
276
+ }
277
+
278
+ /** Mouse click: select the session under the pointer and resume it. */
279
+ selectAndConfirm(index: number): void {
280
+ const session = this.#filteredSessions[index];
281
+ if (!session) return;
282
+ this.#selectedIndex = index;
283
+ this.onSelect?.(session);
284
+ }
285
+
260
286
  invalidate(): void {
261
287
  // No cached state to invalidate currently
262
288
  }
263
289
 
264
290
  render(width: number): readonly string[] {
265
291
  const lines: string[] = [];
292
+ this.#hitRows = [];
266
293
 
267
294
  // Render search input
268
295
  lines.push(...this.#searchInput.render(width));
@@ -311,9 +338,11 @@ class SessionList implements Component {
311
338
  // Each session block is built into sessionLines, then wrapped by ScrollView
312
339
  // so the right-edge scrollbar is proportional at the physical-line level.
313
340
  const sessionLines: string[] = [];
341
+ const sessionRowIndex: number[] = [];
314
342
  const overflow = this.#filteredSessions.length > maxVisible;
315
343
  const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
316
344
  for (let i = startIndex; i < endIndex; i++) {
345
+ const blockStart = sessionLines.length;
317
346
  const session = this.#filteredSessions[i];
318
347
  const isSelected = i === this.#selectedIndex;
319
348
 
@@ -363,6 +392,7 @@ class SessionList implements Component {
363
392
 
364
393
  sessionLines.push(metadataLine);
365
394
  sessionLines.push(""); // Blank line between sessions
395
+ for (let k = blockStart; k < sessionLines.length; k++) sessionRowIndex[k] = i;
366
396
  }
367
397
 
368
398
  // Wrap the rendered window in a ScrollView for a proportional right-edge bar.
@@ -375,16 +405,10 @@ class SessionList implements Component {
375
405
  theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
376
406
  });
377
407
  sv.setScrollOffset(Math.round(startIndex * linesPerItem));
378
- lines.push(...sv.render(width));
379
-
380
- // Add keybinding hint
381
- lines.push("");
382
- lines.push(
383
- theme.fg(
384
- "muted",
385
- ` [Del delete · Enter select · Tab ${this.#showCwd ? "current folder" : "all projects"} · Esc cancel]`,
386
- ),
387
- );
408
+ const sessionRegionStart = lines.length;
409
+ const svLines = sv.render(width);
410
+ for (let k = 0; k < svLines.length; k++) this.#hitRows[sessionRegionStart + k] = sessionRowIndex[k];
411
+ lines.push(...svLines);
388
412
 
389
413
  return lines;
390
414
  }
@@ -462,6 +486,13 @@ export interface SessionSelectorOptions {
462
486
  * Omitted only in tests; defaults to a conservative 24 rows.
463
487
  */
464
488
  getTerminalRows?: () => number;
489
+ /**
490
+ * Fill the whole viewport and pin the footer (hint + bottom border) to the
491
+ * last rows, so the footer stops drifting as the list window changes height.
492
+ * Set by the standalone `--resume` picker (fullscreen alternate screen); the
493
+ * in-editor selector leaves it off and renders compactly.
494
+ */
495
+ fillHeight?: boolean;
465
496
  }
466
497
 
467
498
  /**
@@ -470,6 +501,13 @@ export interface SessionSelectorOptions {
470
501
  export class SessionSelectorComponent extends Container {
471
502
  #sessionList: SessionList;
472
503
  #confirmationDialog: HookSelectorComponent | null = null;
504
+ // Hosts whichever of `#sessionList` / `#confirmationDialog` is live this
505
+ // frame. The delete dialog REPLACES the list in this slot rather than being
506
+ // appended below the picker chrome, so the picker is always
507
+ // `chrome + max(list, dialog) + chrome` and never overflows the viewport
508
+ // (issue #3283: an overflowing dialog frame committed the header into
509
+ // scrollback, stranding it above the viewport once the dialog closed).
510
+ #contentSlot: Container;
473
511
  #messageContainer: Container;
474
512
  #headerText: Text;
475
513
  #onDelete?: (session: SessionInfo) => Promise<boolean>;
@@ -479,6 +517,18 @@ export class SessionSelectorComponent extends Container {
479
517
  #globalSessions: SessionInfo[] | null = null;
480
518
  #scope: "folder" | "all" = "folder";
481
519
  #toggling = false;
520
+ // 0-based line where the session list begins within this component's own
521
+ // render, captured each frame. The fullscreen picker overlay paints from
522
+ // screen row 0, so a mouse row maps to `row - #listLineOffset` inside the
523
+ // list. Only meaningful while the picker holds the alternate screen.
524
+ #listLineOffset = 0;
525
+ // 0-based line where the pinned footer begins; clicks at or below it never
526
+ // hit-test the list, so a footer click on a cramped (trimmed) frame can't
527
+ // resume a session scrolled off-screen.
528
+ #footerStart = 0;
529
+ readonly #getTerminalRows: () => number;
530
+ readonly #fillHeight: boolean;
531
+ readonly #bottomBorder = new DynamicBorder();
482
532
 
483
533
  constructor(
484
534
  sessions: SessionInfo[],
@@ -494,6 +544,8 @@ export class SessionSelectorComponent extends Container {
494
544
  this.#loadAllSessions = options.loadAllSessions;
495
545
  this.#folderSessions = sessions;
496
546
  this.#globalSessions = options.allSessions ?? null;
547
+ this.#getTerminalRows = options.getTerminalRows ?? (() => 24);
548
+ this.#fillHeight = options.fillHeight ?? false;
497
549
  // Add header
498
550
  this.addChild(new Spacer(1));
499
551
  this.#headerText = new Text(this.#headerLabel(), 1, 0);
@@ -517,11 +569,9 @@ export class SessionSelectorComponent extends Container {
517
569
  void this.#toggleScope();
518
570
  };
519
571
  }
520
- this.addChild(this.#sessionList);
521
-
522
- // Add bottom border
523
- this.addChild(new Spacer(1));
524
- this.addChild(new DynamicBorder());
572
+ this.#contentSlot = new Container();
573
+ this.#contentSlot.addChild(this.#sessionList);
574
+ this.addChild(this.#contentSlot);
525
575
  }
526
576
 
527
577
  #headerLabel(): string {
@@ -582,6 +632,15 @@ export class SessionSelectorComponent extends Container {
582
632
 
583
633
  #showDeleteConfirmation(session: SessionInfo): void {
584
634
  const displayName = session.title || session.firstMessage.slice(0, 40) || session.id;
635
+ const closeDialog = () => {
636
+ this.#confirmationDialog = null;
637
+ // Restore the SessionList into the content slot so the picker is back
638
+ // to its normal layout on the very next render — the same frame the
639
+ // dialog disappears.
640
+ this.#contentSlot.clear();
641
+ this.#contentSlot.addChild(this.#sessionList);
642
+ this.#onRequestRender?.();
643
+ };
585
644
  this.#confirmationDialog = new HookSelectorComponent(
586
645
  `Delete session?\n${displayName}`,
587
646
  ["Yes", "No"],
@@ -597,25 +656,61 @@ export class SessionSelectorComponent extends Container {
597
656
  this.#showError(err instanceof Error ? err.message : String(err));
598
657
  }
599
658
  }
600
- // Close confirmation dialog
601
- this.removeChild(this.#confirmationDialog!);
602
- this.#confirmationDialog = null;
603
- // Request rerender
604
- this.#onRequestRender?.();
605
- },
606
- () => {
607
- // Cancel - close confirmation dialog
608
- this.removeChild(this.#confirmationDialog!);
609
- this.#confirmationDialog = null;
610
- // Request rerender
611
- this.#onRequestRender?.();
659
+ closeDialog();
612
660
  },
661
+ closeDialog,
613
662
  );
614
- // Show confirmation dialog
615
- this.addChild(this.#confirmationDialog);
663
+ // Swap the SessionList out of the content slot and mount the dialog in its
664
+ // place: the dialog competes only with the SessionList's rendered budget,
665
+ // never the SessionList AND the picker chrome, so the picker frame stays
666
+ // inside the terminal viewport and the TUI never commits the header into
667
+ // scrollback (issue #3283).
668
+ this.#contentSlot.clear();
669
+ this.#contentSlot.addChild(this.#confirmationDialog);
670
+ this.#onRequestRender?.();
671
+ }
672
+
673
+ /**
674
+ * Concatenate the children's renders (like {@link Container}) while recording
675
+ * the line where the session list begins, so the fullscreen picker can hit-
676
+ * test mouse rows against the live list window. SessionList rebuilds its lines
677
+ * every frame, so Container's reference-memoization never applied here.
678
+ *
679
+ * In fill-height mode the body is padded (or, on a cramped terminal, trimmed)
680
+ * to leave exactly enough room for the footer at the screen bottom, so the
681
+ * footer is always visible and never drifts as the list window resizes. The
682
+ * in-editor selector just appends the footer directly.
683
+ */
684
+ render(width: number): readonly string[] {
685
+ const lines: string[] = [];
686
+ for (const child of this.children) {
687
+ const childLines = child.render(width);
688
+ if (child === this.#contentSlot) this.#listLineOffset = lines.length;
689
+ for (const line of childLines) lines.push(line);
690
+ }
691
+ const footer = this.#footerLines(width);
692
+ if (this.#fillHeight) {
693
+ const target = Math.max(0, this.#getTerminalRows() - footer.length);
694
+ if (lines.length > target) lines.length = target;
695
+ else for (let i = lines.length; i < target; i++) lines.push("");
696
+ }
697
+ this.#footerStart = lines.length;
698
+ for (const line of footer) lines.push(line);
699
+ return lines;
700
+ }
701
+
702
+ /** Blank · keybinding hint · bottom border. Rendered by {@link render}. */
703
+ #footerLines(width: number): string[] {
704
+ const scopeHint = this.#scope === "all" ? "current folder" : "all projects";
705
+ const hint = theme.fg("muted", ` [Del delete · Enter select · Tab ${scopeHint} · Esc cancel]`);
706
+ return ["", hint, "", ...this.#bottomBorder.render(width)];
616
707
  }
617
708
 
618
709
  handleInput(keyData: string): void {
710
+ if (keyData.startsWith("\x1b[<")) {
711
+ this.#handleMouse(keyData);
712
+ return;
713
+ }
619
714
  if (this.#confirmationDialog) {
620
715
  this.#confirmationDialog.handleInput(keyData);
621
716
  } else {
@@ -623,6 +718,25 @@ export class SessionSelectorComponent extends Container {
623
718
  }
624
719
  }
625
720
 
721
+ /**
722
+ * SGR mouse reports, delivered only while the picker holds the alternate
723
+ * screen (the fullscreen overlay enables tracking and paints from screen row
724
+ * 0). Wheel scrolls the list; a left click resumes the session under the
725
+ * pointer. Mouse is inert while the delete-confirmation dialog is open.
726
+ */
727
+ #handleMouse(data: string): void {
728
+ if (this.#confirmationDialog) return;
729
+ const event = parseSgrMouse(data);
730
+ if (!event) return;
731
+ if (event.wheel !== null) {
732
+ this.#sessionList.handleWheel(event.wheel);
733
+ return;
734
+ }
735
+ if (!event.leftClick || event.row >= this.#footerStart) return;
736
+ const index = this.#sessionList.hitTestSession(event.row - this.#listLineOffset);
737
+ if (index !== undefined) this.#sessionList.selectAndConfirm(index);
738
+ }
739
+
626
740
  getSessionList(): SessionList {
627
741
  return this.#sessionList;
628
742
  }
@@ -1172,6 +1172,11 @@ export class CommandController {
1172
1172
  }
1173
1173
 
1174
1174
  async handleHandoffCommand(customInstructions?: string): Promise<void> {
1175
+ if (this.ctx.session.isStreaming) {
1176
+ this.ctx.showWarning("Wait for the current response to finish or abort it before handing off.");
1177
+ return;
1178
+ }
1179
+
1175
1180
  const entries = this.ctx.sessionManager.getEntries();
1176
1181
  const messageCount = entries.filter(e => e.type === "message").length;
1177
1182
 
@@ -1008,6 +1008,12 @@ export async function runRpcMode(
1008
1008
  }
1009
1009
 
1010
1010
  case "handoff": {
1011
+ // Resetting the agent mid-stream lets the live turn keep emitting into a
1012
+ // session that handoff has already torn down. Refuse while a prompt is in
1013
+ // flight (mirrors the TUI /handoff guard).
1014
+ if (session.isStreaming) {
1015
+ return error(id, "handoff", "Cannot hand off while a response is in progress");
1016
+ }
1011
1017
  const result = await session.handoff(command.customInstructions);
1012
1018
  return success(id, "handoff", result ? { savedPath: result.savedPath } : null);
1013
1019
  }
@@ -127,8 +127,13 @@ export function extractQuoteBlocks(text: string): QuoteBlock[] {
127
127
 
128
128
  function extractEvalCode(args: unknown): { code: string; language: string } | undefined {
129
129
  if (!args || typeof args !== "object") return undefined;
130
- const cells = (args as { cells?: unknown }).cells;
131
- if (!Array.isArray(cells)) return undefined;
130
+ const argsObj = args as { cells?: unknown; code?: unknown };
131
+ const cells = Array.isArray(argsObj.cells)
132
+ ? argsObj.cells
133
+ : typeof argsObj.code === "string"
134
+ ? [argsObj]
135
+ : undefined;
136
+ if (!cells) return undefined;
132
137
 
133
138
  const codeBlocks: string[] = [];
134
139
  let language = "python";
@@ -109,9 +109,9 @@ You MUST use the specialized tool over its shell equivalent:
109
109
  {{#has tools "lsp"}}- Code intelligence → `{{toolRefs.lsp}}`.{{/has}}
110
110
  {{#has tools "search"}}- Regex search → `{{toolRefs.search}}`, not `grep`, `rg`, or `awk`.{{/has}}
111
111
  {{#has tools "find"}}- Globbing → `{{toolRefs.find}}`, not `ls **/*.ext` or `fd`.{{/has}}
112
- {{#has tools "eval"}}- Quick compute → `{{toolRefs.eval}}`; you SHOULD go step by step.{{/has}}
113
- {{#has tools "bash"}}- Use `{{toolRefs.bash}}` for terminal work—builds, tests, git, package managers—and pipelines that COMPUTE a fact: `wc -l`, `sort | uniq -c`, `comm`, `diff a b`, checksums. Commands shadowing the tools above are blocked.
114
- - Litmus: produces a count, frequency, set difference, or checksum no tool returnsbash. Merely moves, pages, or trims bytes a tool can fetch → use the tool.{{/has}}
112
+ {{#has tools "eval"}}- Default for any compute: `{{toolRefs.eval}}` cells. Bash is the EXCEPTION — only single binary calls or short fact-computing pipelines (`wc -l`, `sort | uniq -c`, `diff`, checksums). The moment a command grows a loop, conditional, heredoc, `-e`/`-c` script, `$(…)` nesting, or >2 pipe stages, it's a program → `{{toolRefs.eval}}`. NEVER write multiline or inline-script bash.{{/has}}
113
+ {{#has tools "bash"}}- `{{toolRefs.bash}}`: real binaries and short fact pipelines only. Commands shadowing the specialized tools above are blocked.{{/has}}
114
+ {{#has tools "bash"}}- Litmus: one external-CLI call or short pipeline returning a count, frequency, set difference, or checksum bash.{{#has tools "eval"}} Needs control flow, state, or fights shell quoting `{{toolRefs.eval}}`.{{/has}} Merely moves, pages, or trims bytes a tool can fetch → use the tool.{{/has}}
115
115
 
116
116
  {{#has tools "report_tool_issue"}}
117
117
  <critical>
@@ -11,16 +11,16 @@ Worth it when the task benefits from decomposition + parallel coverage, or from
11
11
  </when>
12
12
 
13
13
  <helpers>
14
- State persists across cells, so scout in one cell and fan out in the next. Every cell has:
14
+ State persists across eval calls, so scout in one call and fan out in the next. Every eval call has:
15
15
 
16
- - `agent(prompt, *, agent_type="task", model=None, label=None, schema=None)` — run ONE subagent; returns its final text, or the validated object when `schema` (a JSON Schema dict) is given. With `schema` the subagent is forced to emit structured output that is validated for you — branch on the object, not on parsed prose. `agent_type` picks a discovered agent ("explore", "reviewer", "oracle", …); `label` names the artifact. Shared background goes in a `local://` file referenced from each prompt, not a parameter. Subagents are told their final text IS the return value, so they hand back raw data. `agent()` blocks until the subagent finishes; eval-spawned agents nest at most 3 deep.
16
+ - `agent(prompt, *, agent="task", model=None, label=None, schema=None, isolated=None, apply=None, merge=None, handle=False)` — run ONE subagent; returns its final text, or the validated object when `schema` (a JSON Schema dict) is given. With `schema` the subagent is forced to emit structured output that is validated for you — branch on the object, not on parsed prose. `agent` picks a discovered agent ("explore", "reviewer", "oracle", …); `label` names the artifact. Shared background goes in a `local://` file referenced from each prompt, not a parameter. Subagents are told their final text IS the return value, so they hand back raw data. `agent()` blocks until the subagent finishes; eval-spawned agents nest at most 3 deep. Pass `isolated=True` to run the spawn in a copy-on-write worktree so parallel `agent()` calls can edit overlapping files safely — strict opt-in, mirrors the `task` tool, defaults off regardless of `task.isolation.mode`; `isolated=True` while the setting is `"none"` errors out instead of silently downgrading. With isolation, `apply=False` keeps changes in the worktree, and `merge=False` forces patch mode even when the setting is `"branch"`. Captured root patch path, branch name, nested repo patches, and apply summary reach the workflow through `handle=True` — combine it with `apply=False` (or `apply=False, schema=…`) and read `node["patch_path"]`, `node["branch_name"]`, `node["nested_patches"]`, `node["changes_applied"]`, `node["isolation_summary"]` (JS: same keys camelCased) to recover artifacts.
17
17
  - `parallel(thunks)` — run zero-arg callables concurrently through a bounded pool, preserving input order; returns once all finish. The pool is bounded by the session's `task` concurrency — don't hand-tune it; fan out as wide as the work divides. A thunk that raises propagates — wrap risky work in `try/except` inside the thunk to keep partial results. In a loop, bind each closure's value with a default arg (`lambda d=d: …`) or every thunk captures the last one.
18
18
  - `pipeline(items, *stages)` — map items through `stages` left-to-right. There is a BARRIER between stages: ALL items clear stage N before stage N+1 begins. Each stage is a one-arg callable; stage 1 gets the original item, later stages get the previous result. Same pool width as `parallel()`.
19
19
  - `completion(prompt, *, model="default", system=None, schema=None)` — oneshot, stateless model call (no tools, no history). Tiers: "smol", "default", "slow". Cheap classification/scoring inside a fan-out.
20
20
  - `log(message)` — emit a progress line above the status tree. `phase(title)` — start a phase; the status lines that follow group under it.
21
21
  - `budget` — `budget.total` (output-token ceiling, or `None` when none is set), `budget.spent()` (tokens spent this turn — main loop + eval subagents), `budget.remaining()` (`math.inf` when total is `None`), `budget.hard` (whether it's enforced). A ceiling is set by the user: `+Nk` in their message is advisory (you self-limit via `budget.remaining()`), `+Nk!` (or Goal Mode) is hard — `agent()` refuses to spawn once spent reaches it. Gate loops on `budget.total` first, since it's `None` when the user set no budget.
22
22
 
23
- Everything runs INLINE and synchronously inside the eval call — no background mode, no resume, no separate progress app. Each eval call is one well-scoped fan-out; chain several across cells and turns for multi-phase work, reading each result before you decide the next phase.
23
+ Everything runs INLINE and synchronously inside the eval call — no background mode, no resume, no separate progress app. Each eval call is one well-scoped fan-out; chain several across calls and turns for multi-phase work, reading each result before you decide the next phase.
24
24
  </helpers>
25
25
 
26
26
  <structure>
@@ -1,5 +1,19 @@
1
1
  Runs bash in a shell session — terminal ops: git, bun, cargo, python.
2
2
 
3
+ # When to use bash — and when not to
4
+
5
+ Bash invokes **real binaries** with simple args. It is NOT a scripting surface.
6
+
7
+ Use bash ONLY for: a single binary call, or one short pipeline that COMPUTES a fact (`wc -l`, `sort | uniq -c`, `comm`, `diff`, a checksum, `git status`).
8
+
9
+ Anything below → `eval` cell, not bash:
10
+ - Inline interpreter scripts (`-e`/`-c`/`--eval`) when an eval runtime exists for that language
11
+ - Heredocs (`<<EOF`), `while`/`for`/`if`/`case` shell control flow
12
+ - `$(…)` command substitution nested inside another command
13
+ - Pipelines with more than two stages, or stages that need control flow or quote/JSON escaping
14
+ - Multiline commands, `&&`-chains mixing control flow
15
+ - Quote/JSON escaping that fights the shell
16
+
3
17
  <instruction>
4
18
  - `cwd` sets the working dir, not `cd dir && …`
5
19
  - `env: { NAME: "…" }` for multiline / quote-heavy / untrusted values; reference `$NAME`
@@ -14,7 +28,9 @@ Runs bash in a shell session — terminal ops: git, bun, cargo, python.
14
28
  </instruction>
15
29
 
16
30
  <critical>
31
+ - Bash invokes real binaries with simple args; it is NOT a scripting surface. Loops, conditionals, heredocs, inline interpreter scripts (`-e`/`-c`/`--eval`) when an eval runtime exists, several piped stages, or quote/JSON escaping mean you're writing a program → use `eval` cells: restartable, stateful, and free of shell-quoting traps.
17
32
  - NEVER shell out to search content or files: `grep/rg` → `search`.
33
+ - NEVER use `ls` or `find` to list or locate files — `ls` → `read` (a directory path lists entries), `find` → the `find` tool (globbing). This is non-negotiable, even for a single quick listing.
18
34
  - Avoid head/tail/redirections: stderr already merged; long output auto-truncated, FULL capture kept at `artifact://<id>`.
19
35
  </critical>
20
36