@oh-my-pi/pi-tui 18.8.7 → 18.8.8

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 (40) hide show
  1. package/CHANGELOG.md +28 -64
  2. package/dist/types/chat/execution-shared.d.ts +6 -5
  3. package/dist/types/keybinding-matchers.d.ts +3 -0
  4. package/dist/types/overlays/annotation-overlay.d.ts +12 -0
  5. package/dist/types/overlays/annotation-types.d.ts +2 -0
  6. package/dist/types/overlays/hook-selector.d.ts +9 -4
  7. package/dist/types/overlays/model-browser.d.ts +2 -2
  8. package/dist/types/overlays/model-hub.d.ts +21 -4
  9. package/dist/types/overlays/session-observer-registry.d.ts +2 -0
  10. package/dist/types/overlays/usage-dashboard.d.ts +3 -1
  11. package/dist/types/status-line/types.d.ts +1 -1
  12. package/dist/types/theme/symbols.d.ts +1 -1
  13. package/dist/types/tools/eval.d.ts +1 -1
  14. package/dist/types/tools/native-view.d.ts +47 -0
  15. package/dist/types/tools/renderer.d.ts +15 -2
  16. package/dist/types/tui.d.ts +20 -0
  17. package/dist/types/vim.d.ts +19 -5
  18. package/package.json +9 -9
  19. package/src/chat/execution-shared.ts +42 -25
  20. package/src/chat/tool-execution.ts +9 -2
  21. package/src/components/editor.ts +182 -5
  22. package/src/keybinding-matchers.ts +6 -2
  23. package/src/overlays/agent-hub.ts +7 -3
  24. package/src/overlays/annotation-overlay.ts +87 -6
  25. package/src/overlays/annotation-types.ts +2 -0
  26. package/src/overlays/hook-selector.ts +12 -5
  27. package/src/overlays/model-browser.ts +3 -3
  28. package/src/overlays/model-hub.ts +107 -27
  29. package/src/overlays/session-observer-registry.ts +10 -2
  30. package/src/overlays/usage-dashboard.ts +70 -23
  31. package/src/prompt/composer.ts +1 -0
  32. package/src/status-line/segments.ts +3 -0
  33. package/src/status-line/types.ts +1 -1
  34. package/src/theme/symbols.ts +5 -0
  35. package/src/tools/bash.ts +82 -31
  36. package/src/tools/eval.ts +149 -123
  37. package/src/tools/native-view.ts +102 -1
  38. package/src/tools/renderer.ts +15 -2
  39. package/src/tui.ts +68 -0
  40. package/src/vim.ts +333 -18
@@ -20,6 +20,7 @@ import { DEFAULT_TERMINAL_PREVIEW_LINES, expandKeyHint } from "../render/render-
20
20
  import type { TspCardStatus, TspSpan, TspText, TspTone } from "@oh-my-pi/pi-wire";
21
21
  import { card, node, span, text } from "../native/describe";
22
22
  import type { NativeChild, NativeNode } from "../native/node";
23
+ import { runBox, runFoot, runInput, runOutput, type RunState } from "../tools/native-view";
23
24
 
24
25
  /** Output rows shown while an execution is collapsed. */
25
26
  export const PREVIEW_LINES = 20;
@@ -190,7 +191,7 @@ export interface ExecutionToolInput {
190
191
  /** Tool name for the head icon (`bash`, `eval`). */
191
192
  readonly name: string;
192
193
  readonly title: string;
193
- /** The command / cell source, shown once in the head. */
194
+ /** The command / cell source: the run box's input and the head's Copy command. */
194
195
  readonly command: string;
195
196
  readonly lang: string;
196
197
  /** Excluded from context (`!!`/`$$`). */
@@ -208,39 +209,55 @@ export interface ExecutionToolInput {
208
209
  readonly artifactError?: OutputArtifactError;
209
210
  }
210
211
 
212
+ const EXECUTION_RUN_STATE: Record<ExecutionStatus, RunState> = {
213
+ running: "running",
214
+ complete: "done",
215
+ cancelled: "cancelled",
216
+ error: "failed",
217
+ };
218
+
211
219
  /**
212
- * A user `!`/`$` run as the agent's `tool` frame (§6.6): the command in the
213
- * head with a `you` badge (plus `not sent` when excluded from context), the
214
- * timer, exit chip and cancelled note; the body is the output as an `ansi`
215
- * mini terminal following its tail, then one quiet truncation line.
220
+ * A user `!`/`$` run as the agent's `tool` frame (§6.6): a head with a `you`
221
+ * badge (plus `not sent` when excluded from context) and the command for Copy
222
+ * command; the body is one run box — the source, the output as an `ansi` mini
223
+ * terminal following its tail, and the foot (state, time, truncation) — with
224
+ * any images below it. Timer, exit chip and cancelled note stay head data.
216
225
  */
217
226
  export function describeExecutionTool(input: ExecutionToolInput): NativeNode {
218
227
  const running = input.status === "running";
219
- const body: NativeChild[] = [];
220
- if (input.output) body.push(node("ansi", { text: input.output, follow: running }, undefined, "output"));
221
- if (input.images) body.push(...input.images);
222
- const notes: string[] = [];
223
- if (input.truncation) notes.push(formatTruncationMetaNotice(input.truncation));
224
- if (input.artifactError) notes.push(formatArtifactErrorNotice(input.artifactError));
225
- if (notes.length > 0) {
226
- body.push({
227
- ...text([span(notes.join(" · "), "muted")], { wrap: "word", role: "omp.tool.notice" }),
228
- key: "foot",
229
- });
230
- }
228
+ const shell = input.name === "bash";
231
229
  const ms = Math.max(0, Math.round((input.endedAt ?? performance.now()) - input.startedAt));
230
+ const state = EXECUTION_RUN_STATE[input.status];
231
+ const box = runBox(
232
+ [
233
+ runInput(input.command, {
234
+ role: shell ? "omp.tool.bash.command" : "omp.tool.eval.input",
235
+ lang: input.lang,
236
+ wrap: shell,
237
+ }),
238
+ input.output.length > 0 &&
239
+ runOutput(input.output, {
240
+ role: shell ? "omp.tool.bash.output" : "omp.tool.eval.output",
241
+ previewLines: DEFAULT_TERMINAL_PREVIEW_LINES,
242
+ }),
243
+ runFoot({
244
+ state,
245
+ exitCode: input.exitCode,
246
+ elapsedMs: ms,
247
+ meta: { truncation: input.truncation, artifactError: input.artifactError },
248
+ }),
249
+ ],
250
+ { key: "run", tone: state === "failed" ? "error" : undefined },
251
+ );
232
252
  const badges: { text: string; tone?: TspTone; title?: string }[] = [{ text: "you", tone: "user" }];
233
253
  if (input.excluded) badges.push({ text: "not sent", title: "Not sent to the model", tone: "muted" });
234
- const hasBody = body.length > 0;
235
254
  return node(
236
255
  "tool",
237
256
  {
238
257
  role: input.role,
239
258
  name: input.name,
240
259
  title: input.title,
241
- target: input.command,
242
- targetKind: "command",
243
- lang: input.lang,
260
+ command: input.command,
244
261
  badges,
245
262
  status: EXECUTION_CARD_STATUS[input.status],
246
263
  age: running ? ms : undefined,
@@ -248,11 +265,11 @@ export function describeExecutionTool(input: ExecutionToolInput): NativeNode {
248
265
  exit: input.status === "error" ? input.exitCode : undefined,
249
266
  note: input.status === "cancelled" ? "cancelled" : undefined,
250
267
  frame: "card",
251
- collapsible: hasBody,
252
- collapsed: hasBody ? !input.expanded : undefined,
253
- preview: hasBody ? { tail: DEFAULT_TERMINAL_PREVIEW_LINES } : undefined,
268
+ collapsible: true,
269
+ collapsed: !input.expanded,
270
+ preview: "children",
254
271
  },
255
- body,
272
+ [box, ...(input.images ?? [])],
256
273
  );
257
274
  }
258
275
 
@@ -111,10 +111,13 @@ function resolveEditModeForTool(toolName: string, tool: AgentTool | undefined):
111
111
 
112
112
  type ToolRendererStage = "call" | "result";
113
113
 
114
- /** A renderer's preview as the fallback card's clamp (`tail` becomes a head clamp of the same size). */
114
+ /**
115
+ * A renderer's preview as the fallback card's clamp (`tail` becomes a head
116
+ * clamp of the same size; `children` leaves the clamping to the body's own previews).
117
+ */
115
118
  function cardPreview(preview: NativeToolView["preview"]): TspPreview | undefined {
116
119
  if (preview === undefined) return { lines: DEFAULT_TERMINAL_PREVIEW_LINES };
117
- if (preview === "none") return undefined;
120
+ if (preview === "none" || preview === "children") return undefined;
118
121
  if (preview !== "auto" && "tail" in preview) return { lines: preview.tail };
119
122
  return preview;
120
123
  }
@@ -1025,6 +1028,7 @@ export class ToolExecutionComponent extends Container {
1025
1028
  target: head?.target,
1026
1029
  targetKind: head?.targetKind,
1027
1030
  lang: head?.lang,
1031
+ command: head?.command,
1028
1032
  href: head?.href,
1029
1033
  meta: head?.meta,
1030
1034
  badges: badges.length > 0 ? badges : undefined,
@@ -1124,11 +1128,14 @@ export class ToolExecutionComponent extends Container {
1124
1128
  * A renderer that throws falls back to the generic card.
1125
1129
  */
1126
1130
  #nativeView(): NativeToolView {
1131
+ const status = this.#nativeStatus();
1127
1132
  const options: RenderResultContextOptions = {
1128
1133
  expanded: this.#expanded,
1129
1134
  isPartial: this.#isPartial,
1130
1135
  argsComplete: this.#argsComplete,
1131
1136
  executionStarted: this.#executionStarted,
1137
+ elapsedMs: this.#elapsedMs(status),
1138
+ cancelled: status === "cancelled",
1132
1139
  renderContext: this.#buildRenderContext(),
1133
1140
  };
1134
1141
  // Custom tools (MCP, extensions) carry their own describe hooks, mirroring
@@ -83,6 +83,7 @@ const VIM_MODE_LABELS: Record<VimMode, string | undefined> = {
83
83
  normal: "NORMAL",
84
84
  visual: "VISUAL",
85
85
  "visual-line": "VISUAL",
86
+ replace: "REPLACE",
86
87
  };
87
88
 
88
89
  const PASSTHROUGH_COLOR = (text: string): string => text;
@@ -672,7 +673,9 @@ export class Editor implements Component, Focusable {
672
673
  // Emacs-style kill ring
673
674
  #killRing = new KillRing();
674
675
  /** Previous edit, for kill/yank chaining, undo coalescing, and the provisional space after a Tab word accept. */
675
- #lastAction: "kill" | "yank" | "type-word" | "accept-word" | null = null;
676
+ #lastAction: "kill" | "yank" | "type-word" | "accept-word" | "replace" | null = null;
677
+ /** Graphemes overwritten in the current `R` session, so Backspace can restore them. */
678
+ #replaceLog: { line: number; col: number; removed: string; text: string }[] = [];
676
679
 
677
680
  // Character jump mode
678
681
  #jumpMode: "forward" | "backward" | null = null;
@@ -2252,18 +2255,43 @@ export class Editor implements Component, Focusable {
2252
2255
  return this.isShowingAutocomplete() ? false : this.#runVimKey("escape", vim);
2253
2256
  }
2254
2257
  if (vim.mode === "insert") return false;
2258
+ if (vim.mode === "replace") {
2259
+ if (canonical === "backspace" || matchesKey(data, "backspace") || matchesKey(data, "shift+backspace")) {
2260
+ this.#replaceBackspace();
2261
+ return true;
2262
+ }
2263
+ const replacing = extractPrintableText(data);
2264
+ if (replacing) {
2265
+ for (const seg of segmenter.segment(replacing)) this.#overwriteReplaceGrapheme(seg.segment);
2266
+ return true;
2267
+ }
2268
+ }
2255
2269
 
2270
+ // `f`/`t`/`r` are waiting for a character. Space is that character; arrows and other named
2271
+ // keys cancel, instead of being rewritten to `h`/`l` and inserted or searched for.
2272
+ const awaitingTarget = /[fFtTr]$/.test(vim.pendingText);
2273
+ if (awaitingTarget && (canonical === "space" || data === " ")) return this.#runVimKey(" ", vim);
2256
2274
  const mapped = canonical === undefined ? undefined : VIM_NAV_KEYS[canonical];
2275
+ if (awaitingTarget && mapped !== undefined) return this.#runVimKey("escape", vim);
2257
2276
  if (mapped !== undefined) return this.#runVimKey(mapped, vim);
2258
2277
 
2259
- // Control chords carry no printable text and stay with the host.
2278
+ // Control chords, Enter, and Tab carry no printable text and stay with the host. A half-typed
2279
+ // command (`r`, `f`, `d`) is cancelled first, like character-jump mode, so it cannot outlive a
2280
+ // submit and swallow the first key of the next prompt.
2260
2281
  const printable = extractPrintableText(data);
2261
- if (!printable) return false;
2282
+ if (!printable) {
2283
+ if (vim.pending) this.#runVimKey("escape", vim);
2284
+ return false;
2285
+ }
2262
2286
 
2263
2287
  // Batched stdin can deliver several keystrokes at once, so replay the run one grapheme at a
2264
- // time. A command that drops out of Normal mode part-way (`iabc`) turns the rest of the run
2265
- // back into literal text rather than swallowing it.
2288
+ // time. A command that drops out of Normal mode part-way (`iabc`, `Rxx`) turns the rest of
2289
+ // the run into literal text or replace-mode overwrites rather than more Normal commands.
2266
2290
  for (const seg of segmenter.segment(printable)) {
2291
+ if (vim.mode === "replace") {
2292
+ this.#overwriteReplaceGrapheme(seg.segment);
2293
+ continue;
2294
+ }
2267
2295
  if (this.#runVimKey(seg.segment, vim)) continue;
2268
2296
  this.#insertCharacter(printable.slice(seg.index));
2269
2297
  return true;
@@ -2284,11 +2312,22 @@ export class Editor implements Component, Focusable {
2284
2312
  if (vim.mode !== before || vim.pendingText !== pendingBefore || this.vimSelectedLines !== selectedLinesBefore) {
2285
2313
  this.onVimModeChange?.(vim.mode);
2286
2314
  }
2315
+ if (before === "replace" && vim.mode !== "replace") this.#replaceLog.length = 0;
2316
+ if (vim.mode === "replace" && before !== "replace") this.#replaceLog.length = 0;
2287
2317
  return true;
2288
2318
  }
2289
2319
 
2290
2320
  #applyVimCommands(commands: readonly VimCommand[]): void {
2291
2321
  for (const command of commands) {
2322
+ // A delete or other edit in replace mode invalidates Backspace restores. Motions do not.
2323
+ if (
2324
+ this.#vim?.mode === "replace" &&
2325
+ command.kind !== "move" &&
2326
+ command.kind !== "mode" &&
2327
+ command.kind !== "yank"
2328
+ ) {
2329
+ this.#replaceLog.length = 0;
2330
+ }
2292
2331
  switch (command.kind) {
2293
2332
  case "move":
2294
2333
  this.#moveVimCursor(command.to);
@@ -2320,12 +2359,133 @@ export class Editor implements Component, Focusable {
2320
2359
  case "undo":
2321
2360
  this.#applyUndo();
2322
2361
  break;
2362
+ case "replace":
2363
+ this.#replaceVimSpan(command.from, command.to, command.text);
2364
+ break;
2365
+ case "join":
2366
+ this.#joinVimLines(command.fromLine, command.lines);
2367
+ break;
2368
+ case "indent":
2369
+ this.#indentVimLines(command.fromLine, command.toLine, command.out);
2370
+ break;
2323
2371
  }
2324
2372
  }
2325
2373
  this.#clampVimCursor();
2326
2374
  this.invalidate();
2327
2375
  }
2328
2376
 
2377
+ #replaceVimSpan(from: VimPosition, to: VimPosition, text: string): void {
2378
+ const line = this.#state.lines[from.line] ?? "";
2379
+ if (this.#spanCutsAtomicToken(line, from.col, to.col)) return;
2380
+ this.#recordUndoState();
2381
+ this.#lastAction = null;
2382
+ this.#state.lines[from.line] = line.slice(0, from.col) + text + line.slice(to.col);
2383
+ this.#state.cursorLine = from.line;
2384
+ let last = from.col;
2385
+ for (const seg of segmenter.segment(text)) last = from.col + seg.index;
2386
+ this.#setCursorCol(last);
2387
+ this.#afterVimEdit();
2388
+ }
2389
+
2390
+ #overwriteReplaceGrapheme(grapheme: string): void {
2391
+ const lineIdx = this.#state.cursorLine;
2392
+ const line = this.#state.lines[lineIdx] ?? "";
2393
+ const col = this.#state.cursorCol;
2394
+ if (this.#atomicTokenAt(line, col)) return;
2395
+ // Any other edit since the last overwrite (setText, `x`, paste) resets `#lastAction`, which
2396
+ // makes the logged columns meaningless; start a fresh undo step and a fresh log.
2397
+ if (this.#lastAction !== "replace") {
2398
+ this.#recordUndoState();
2399
+ this.#replaceLog.length = 0;
2400
+ }
2401
+ this.#lastAction = "replace";
2402
+ // Editing a recalled prompt makes it a draft, so Up/Down at an edge move the cursor again
2403
+ // instead of navigating history over the overwritten text (same as `#afterVimEdit`).
2404
+ this.#historyIndex = -1;
2405
+ const end = col >= line.length ? col : nextGraphemeStart(line, col);
2406
+ const removed = line.slice(col, end);
2407
+ this.#state.lines[lineIdx] = line.slice(0, col) + grapheme + line.slice(end);
2408
+ this.#replaceLog.push({ line: lineIdx, col, removed, text: grapheme });
2409
+ this.#setCursorCol(col + grapheme.length);
2410
+ this.#notifyChange();
2411
+ }
2412
+
2413
+ #replaceBackspace(): void {
2414
+ if (this.#lastAction !== "replace") {
2415
+ this.#replaceLog.length = 0;
2416
+ return;
2417
+ }
2418
+ const entry = this.#replaceLog.pop();
2419
+ if (!entry) return;
2420
+ const line = this.#state.lines[entry.line] ?? "";
2421
+ const stillThere = line.slice(entry.col, entry.col + entry.text.length) === entry.text;
2422
+ const atCursor = this.#state.cursorLine === entry.line && this.#state.cursorCol === entry.col + entry.text.length;
2423
+ if (!stillThere) return;
2424
+ if (!atCursor) {
2425
+ this.#replaceLog.push(entry);
2426
+ return;
2427
+ }
2428
+ this.#state.lines[entry.line] =
2429
+ line.slice(0, entry.col) + entry.removed + line.slice(entry.col + entry.text.length);
2430
+ this.#setCursorCol(entry.col);
2431
+ this.#notifyChange();
2432
+ }
2433
+
2434
+ #joinVimLines(fromLine: number, span: number): void {
2435
+ const start = Math.max(0, Math.min(fromLine, this.#state.lines.length - 1));
2436
+ const last = Math.min(start + Math.max(span, 2) - 1, this.#state.lines.length - 1);
2437
+ if (last <= start) return;
2438
+ this.#recordUndoState();
2439
+ let text = this.#state.lines[start] ?? "";
2440
+ let cursor = text.length;
2441
+ for (let i = start + 1; i <= last; i++) {
2442
+ const raw = this.#state.lines[i] ?? "";
2443
+ let trim = /^[ \t]*/.exec(raw)?.[0].length ?? 0;
2444
+ const token = this.#atomicTokenAt(raw, 0);
2445
+ if (token && token.start < trim) trim = token.start;
2446
+ const next = raw.slice(trim);
2447
+ const at = text.length;
2448
+ if (i === start + 1) cursor = at;
2449
+ // Vim join: no extra space after whitespace, before `)`, or for an empty line; two spaces after `.!?`.
2450
+ const gap =
2451
+ text.length === 0 || next.length === 0 || /[ \t]$/.test(text) || next.startsWith(")")
2452
+ ? ""
2453
+ : /[.!?]$/.test(text)
2454
+ ? " "
2455
+ : " ";
2456
+ text += gap + next;
2457
+ }
2458
+ this.#state.lines.splice(start, last - start + 1, text);
2459
+ this.#state.cursorLine = start;
2460
+ this.#setCursorCol(cursor);
2461
+ this.#lastAction = null;
2462
+ this.#afterVimEdit();
2463
+ }
2464
+
2465
+ #indentVimLines(fromLine: number, toLine: number, out: boolean): void {
2466
+ const first = Math.max(0, Math.min(fromLine, this.#state.lines.length - 1));
2467
+ const last = Math.max(first, Math.min(toLine, this.#state.lines.length - 1));
2468
+ // Two spaces, not Vim's shiftwidth of 8: a prompt draft should not jump a tab stop. A leading tab shifts by one tab so the line keeps its own indent style.
2469
+ // Empty lines stay empty, as in Vim, so shifting a paragraph never leaves trailing blanks.
2470
+ const next = this.#state.lines.slice(first, last + 1).map(line => {
2471
+ const text = line ?? "";
2472
+ if (!out) return text.length === 0 ? text : (text.startsWith("\t") ? "\t" : " ") + text;
2473
+ if (text.startsWith("\t")) return text.slice(1);
2474
+ const spaces = /^ */.exec(text)?.[0].length ?? 0;
2475
+ return text.slice(Math.min(2, spaces));
2476
+ });
2477
+ if (next.every((line, i) => line === (this.#state.lines[first + i] ?? ""))) return;
2478
+ this.#recordUndoState();
2479
+ for (let i = first; i <= last; i++) this.#state.lines[i] = next[i - first] ?? "";
2480
+ this.#state.cursorLine = first;
2481
+ const landed = this.#state.lines[first] ?? "";
2482
+ let col = 0;
2483
+ while (col < landed.length && /\s/.test(landed.charAt(col))) col++;
2484
+ this.#setCursorCol(col);
2485
+ this.#lastAction = null;
2486
+ this.#afterVimEdit();
2487
+ }
2488
+
2329
2489
  #moveVimCursor(to: VimPosition): void {
2330
2490
  this.#state.cursorLine = Math.max(0, Math.min(to.line, this.#state.lines.length - 1));
2331
2491
  const line = this.#state.lines[this.#state.cursorLine] ?? "";
@@ -3409,6 +3569,23 @@ export class Editor implements Component, Focusable {
3409
3569
  return undefined;
3410
3570
  }
3411
3571
 
3572
+ /** True when `[from, to)` overlaps a placeholder. Counted `r` must no-op rather than split it.
3573
+ * One pass over the line's matches, so a long counted replace stays linear. */
3574
+ #spanCutsAtomicToken(line: string, from: number, to: number): boolean {
3575
+ const re = this.#getAtomicTokenRe();
3576
+ if (re === undefined) return false;
3577
+ re.lastIndex = 0;
3578
+ for (;;) {
3579
+ const match = re.exec(line);
3580
+ if (match === null || match.index >= to) return false;
3581
+ if (match[0].length === 0) {
3582
+ re.lastIndex = match.index + 1;
3583
+ continue;
3584
+ }
3585
+ if (match.index + match[0].length > from) return true;
3586
+ }
3587
+ }
3588
+
3412
3589
  /** Expand the half-open range [start, end) so it never cuts through an atomic
3413
3590
  * placeholder token: a boundary landing inside a token pulls the whole token in. */
3414
3591
  #expandRangeOverAtomicTokens(line: string, start: number, end: number): { start: number; end: number } {
@@ -53,11 +53,15 @@ export function matchesSelectPageDown(data: string): boolean {
53
53
  return getKeybindings().matches(data, "tui.select.pageDown");
54
54
  }
55
55
 
56
+ /** Effective external-editor key: the first bound key, or Ctrl+G when unbound. */
57
+ export function appExternalEditorKey(): KeyId {
58
+ return getKeybindings().getKeys("app.editor.external")[0] ?? "ctrl+g";
59
+ }
60
+
56
61
  /** Match the external-editor keybinding, falling back to Ctrl+G when unbound. */
57
62
  export function matchesAppExternalEditor(data: string): boolean {
58
63
  const keybindings = getKeybindings();
59
- const externalEditorKeys = keybindings.getKeys("app.editor.external");
60
- if (externalEditorKeys.length > 0) {
64
+ if (keybindings.getKeys("app.editor.external").length > 0) {
61
65
  return keybindings.matches(data, "app.editor.external");
62
66
  }
63
67
  return matchesKey(data, "ctrl+g");
@@ -951,12 +951,16 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
951
951
  return { ...item, depth, open: this.#childrenByParent.has(ref.id) ? true : undefined };
952
952
  }
953
953
  if (!metrics) return item;
954
+ // A running agent's active time ticks terminal-side from the age at send; a settled one
955
+ // is frozen text. Progress `durationMs` was current when its frame arrived, so age it to now
956
+ // — re-sending the stale value on every repaint rewinds Tern's clock. A first frame can
957
+ // report 0 ms, so a progress timestamp alone makes the row clockable.
958
+ const progressAt = observed?.progress ? observed.progressAt : undefined;
954
959
  const facts: Record<string, string | number> = {
955
960
  cost: formatCost(metrics.cost),
956
- // A running agent's active time ticks terminal-side; a settled one is frozen text.
957
961
  time:
958
- ref.status === "running" && metrics.durationMs > 0
959
- ? metrics.durationMs
962
+ ref.status === "running" && (metrics.durationMs > 0 || progressAt !== undefined)
963
+ ? metrics.durationMs + (progressAt === undefined ? 0 : Math.max(0, Date.now() - progressAt))
960
964
  : formatDuration(metrics.durationMs),
961
965
  req: metrics.requests,
962
966
  tools: metrics.tools,
@@ -13,7 +13,7 @@ import {
13
13
  } from "../index";
14
14
  import { compositeLineAt } from "../render/composite";
15
15
  import { wrapLiteralLine } from "../utils";
16
- import { appKey, editorKey } from "../chrome/keybinding-hints";
16
+ import { editorKey } from "../chrome/keybinding-hints";
17
17
  import { formatKeyHint, formatKeyHints, type KeybindingsManager } from "../app-keybindings";
18
18
  import type { Keybinding } from "../keybindings";
19
19
  import { sanitizeText } from "@oh-my-pi/pi-utils";
@@ -38,7 +38,7 @@ import {
38
38
  topBorder,
39
39
  topBorderSplit,
40
40
  } from "../chrome/overlay-box";
41
- import { matchesAppExternalEditor } from "../keybinding-matchers";
41
+ import { appExternalEditorKey, matchesAppExternalEditor } from "../keybinding-matchers";
42
42
  import type { KeyName } from "../key-hint-format";
43
43
  import { item, keyed, node, row as rowNode, span, text } from "../native/describe";
44
44
  import { leafKey, type NativeChild, type NativeNode, type NativeUiEvent } from "../native/node";
@@ -70,6 +70,8 @@ export interface AnnotationOverlayCallbacks {
70
70
  onWarning?(message: string): void;
71
71
  /** Open the draft in the host's editor and invoke `commit` with its result. */
72
72
  onAnnotationExternalEditor?(draft: string, commit: (text: string | null) => void): void | Promise<void>;
73
+ /** Open the current source in the host's editor. The overlay stays open. */
74
+ onExternalEditor?(): void | Promise<void>;
73
75
  }
74
76
 
75
77
  export interface CodeReviewOverlayCallbacks extends AnnotationOverlayCallbacks {
@@ -171,6 +173,8 @@ export class AnnotationOverlay implements Focusable {
171
173
  #editingAnnotationIndex: number | undefined;
172
174
  #annotationChooser: AnnotationChooser | undefined;
173
175
  #externalOperation = false;
176
+ /** Set once {@link replaceTextSource} runs; the paste result then carries the edited text. */
177
+ #textSourceEdited = false;
174
178
  #finished = false;
175
179
  #annotations: CommittedAnnotation[] = [];
176
180
  #textAnnotations: CommittedTextAnnotation[] = [];
@@ -306,6 +310,61 @@ export class AnnotationOverlay implements Focusable {
306
310
  return this.#textAnnotations.map(entry => ({ ...entry.annotation }));
307
311
  }
308
312
 
313
+ /** Repo-relative path of the diff file under the cursor, if this is a code review. */
314
+ reviewFilePath(): string | undefined {
315
+ const file = this.#files[this.#fileIndex];
316
+ if (!file) return undefined;
317
+ return file.newPath ?? file.oldPath ?? file.path;
318
+ }
319
+
320
+ /** Current text-source bytes, including edits applied by {@link replaceTextSource}. */
321
+ textSourceText(): string | undefined {
322
+ return this.#textSource?.text;
323
+ }
324
+
325
+ /**
326
+ * Replace the text under review. Line notes whose quote still sits on the
327
+ * same row stay; a quote that moved to exactly one row is retargeted;
328
+ * every other line note is dropped. Returns how many were dropped.
329
+ */
330
+ replaceTextSource(text: string): number {
331
+ if (!this.#textSource) return 0;
332
+ this.#textSource = { ...this.#textSource, text };
333
+ this.#textSourceEdited = true;
334
+ this.#textLines = splitTextLines(text);
335
+ this.#viewportDriven = false;
336
+ this.#sourceIndex = Math.min(this.#sourceIndex, Math.max(0, this.#textLines.length - 1));
337
+ let dropped = 0;
338
+ const kept: CommittedTextAnnotation[] = [];
339
+ for (const entry of this.#textAnnotations) {
340
+ if (entry.annotation.scope !== "line") {
341
+ kept.push(entry);
342
+ continue;
343
+ }
344
+ const quote = entry.annotation.quote;
345
+ if (this.#textLines[entry.sourceIndex] === quote) {
346
+ kept.push(entry);
347
+ continue;
348
+ }
349
+ const matches = this.#textLines.flatMap((line, index) => (line === quote ? [index] : []));
350
+ const only = matches.length === 1 ? matches[0] : undefined;
351
+ if (only !== undefined) {
352
+ kept.push({
353
+ sourceIndex: only,
354
+ annotation: { ...entry.annotation, line: only + 1 },
355
+ });
356
+ continue;
357
+ }
358
+ dropped++;
359
+ }
360
+ this.#textAnnotations = kept;
361
+ // Earlier snapshots hold notes anchored to the old text; undo must not resurrect them.
362
+ this.#undoStack = [];
363
+ this.#annotationRev++;
364
+ this.#nativeBody = undefined;
365
+ return dropped;
366
+ }
367
+
309
368
  handleInput(data: string): void {
310
369
  if (this.#finished || this.#externalOperation) return;
311
370
  if (this.#annotationChooser) {
@@ -328,6 +387,10 @@ export class AnnotationOverlay implements Focusable {
328
387
  this.#escape();
329
388
  return;
330
389
  }
390
+ if (this.#callbacks.onExternalEditor && matchesAppExternalEditor(data)) {
391
+ void this.#openSourceEditor();
392
+ return;
393
+ }
331
394
  if (data === "A" && (this.#focus === "files" || this.#focus === "diff")) {
332
395
  this.#startAnnotation(this.#textSource ? "text" : "file");
333
396
  return;
@@ -514,6 +577,7 @@ export class AnnotationOverlay implements Focusable {
514
577
  this.#finish({
515
578
  action: "paste",
516
579
  annotations: this.getTextAnnotations(),
580
+ ...(this.#textSourceEdited ? { editedText: this.#textSource.text } : {}),
517
581
  });
518
582
  } else {
519
583
  this.#finish({
@@ -832,6 +896,22 @@ export class AnnotationOverlay implements Focusable {
832
896
  }
833
897
  }
834
898
 
899
+ async #openSourceEditor(): Promise<void> {
900
+ const openEditor = this.#callbacks.onExternalEditor;
901
+ if (this.#externalOperation || !openEditor) return;
902
+ this.#externalOperation = true;
903
+ try {
904
+ await openEditor();
905
+ } catch (error) {
906
+ this.#callbacks.onWarning?.(
907
+ `Failed to open external editor: ${error instanceof Error ? error.message : String(error)}`,
908
+ );
909
+ } finally {
910
+ this.#externalOperation = false;
911
+ this.#tui.requestRender(true);
912
+ }
913
+ }
914
+
835
915
  #annotationCount(fileIndex: number): number {
836
916
  let count = 0;
837
917
  for (const entry of this.#annotations) if (entry.fileIndex === fileIndex) count++;
@@ -1091,8 +1171,7 @@ export class AnnotationOverlay implements Focusable {
1091
1171
  `${editorKey("tui.input.newLine")} newline`,
1092
1172
  `${this.#key("tui.select.cancel")} cancel`,
1093
1173
  ];
1094
- const externalEditorKey = appKey(this.#keybindings, "app.editor.external");
1095
- if (externalEditorKey) hints.push(`${externalEditorKey} editor`);
1174
+ hints.push(`${formatKeyHint(appExternalEditorKey())} editor`);
1096
1175
  return [caption, ...this.#editor.render(width), this.#theme.fg("dim", hints.join(" · "))];
1097
1176
  }
1098
1177
  return [this.#theme.fg("dim", this.#helpText())];
@@ -1143,7 +1222,8 @@ export class AnnotationOverlay implements Focusable {
1143
1222
  : this.#focus === "diff"
1144
1223
  ? `${upDown} line · ${formatKeyHint("shift")} faster · ${this.#key("tui.select.pageUp")}/${this.#key("tui.select.pageDown")} · ${formatKeyHints(["g", "shift+g"])} ends · ${formatKeyHint("a")} line note · ${formatKeyHint("shift+a")} ${this.#textSource ? "text" : "file"} note · ${editNote}`
1145
1224
  : `${upDown} select · ${confirm} confirm`;
1146
- return `${focusHelp} · ${formatKeyHint("u")} undo · ${formatKeyHint("tab")} regions · ${this.#key("tui.select.cancel")} cancel`;
1225
+ const editorHint = this.#callbacks.onExternalEditor ? ` · ${formatKeyHint(appExternalEditorKey())} editor` : "";
1226
+ return `${focusHelp} · ${formatKeyHint("u")} undo · ${formatKeyHint("tab")} regions${editorHint} · ${this.#key("tui.select.cancel")} cancel`;
1147
1227
  }
1148
1228
 
1149
1229
  #ensureCursorVisible(renderedRowBySource: readonly number[]): boolean {
@@ -1523,7 +1603,7 @@ export class AnnotationOverlay implements Focusable {
1523
1603
  this.#hint("tui.input.submit", "save"),
1524
1604
  this.#hint("tui.input.newLine", "newline"),
1525
1605
  this.#hint("tui.select.cancel", "cancel"),
1526
- this.#hint("app.editor.external", "editor"),
1606
+ { keys: [appExternalEditorKey()], label: "editor" },
1527
1607
  ],
1528
1608
  "annotateHints",
1529
1609
  ),
@@ -1549,6 +1629,7 @@ export class AnnotationOverlay implements Focusable {
1549
1629
  editNote,
1550
1630
  ]
1551
1631
  : [upDown("select"), this.#hint("tui.select.confirm", "confirm")];
1632
+ if (this.#callbacks.onExternalEditor) hints.push({ keys: [appExternalEditorKey()], label: "editor" });
1552
1633
  hints.push(key("u", "undo"), key("tab", "regions"), this.#hint("tui.select.cancel", "cancel"));
1553
1634
  return [hintsRow(hints)];
1554
1635
  }
@@ -75,4 +75,6 @@ export type TextReviewAnnotation =
75
75
  export interface TextReviewOverlayResult {
76
76
  action: "paste";
77
77
  annotations: TextReviewAnnotation[];
78
+ /** Source text after an external-editor edit; absent when the source was not edited. */
79
+ editedText?: string;
78
80
  }
@@ -94,6 +94,10 @@ export interface HookSelectorOptions {
94
94
  /** Number of leading options (original order) that receive a selection
95
95
  * marker. Defaults to every option when {@link selectionMarker} is set. */
96
96
  markableCount?: number;
97
+ /** Describe as a card in the dock (the composer slot) instead of a picker
98
+ * sheet, so dock content above it (e.g. a `/omfg` candidate rule) stays
99
+ * visible and the transcript stays scrollable on native surfaces. */
100
+ inline?: boolean;
97
101
  }
98
102
 
99
103
  export interface HookSelectorOption {
@@ -210,6 +214,7 @@ export class HookSelectorComponent extends OverlayPanel {
210
214
  #sliderComponent: Text | undefined;
211
215
  #lastRenderWidth: number | undefined;
212
216
  readonly #detailLines: readonly string[];
217
+ readonly #inline: boolean;
213
218
  readonly #helpText: string | undefined;
214
219
  /** Described option rows by original index; `marker` is the radio state baked into the label. */
215
220
  readonly #nativeItems = new Map<number, { marker: boolean; node: NativeNode }>();
@@ -264,6 +269,7 @@ export class HookSelectorComponent extends OverlayPanel {
264
269
  this.#baseTitle = this.title;
265
270
  this.#detailLines = title.split(/\r?\n/).slice(1);
266
271
  this.#helpText = opts?.helpText;
272
+ this.#inline = opts?.inline ?? false;
267
273
  this.#onLeftCallback = opts?.onLeft;
268
274
  this.#onRightCallback = opts?.onRight;
269
275
  this.#onExternalEditorCallback = opts?.onExternalEditor;
@@ -726,13 +732,14 @@ export class HookSelectorComponent extends OverlayPanel {
726
732
  }
727
733
 
728
734
  /**
729
- * A plain option list is a picker sheet. Otherwise a card headed by the
730
- * title (plus the countdown `elapsed` when a timeout runs), the extra title
731
- * lines, the slider as `tabs`, the options as a `list` keyed by original
732
- * option index, the search query while typing, and the key hints.
735
+ * A plain option list is a picker sheet. Otherwise (inline, slider, or
736
+ * countdown) a card headed by the title (plus the countdown `elapsed` when a
737
+ * timeout runs), the extra title lines, the slider as `tabs`, the options as
738
+ * a `list` keyed by original option index, the search query while typing,
739
+ * and the key hints.
733
740
  */
734
741
  override describe(cx: DescribeContext): NativeNode {
735
- if (cx.supports("picker") && !this.#slider && !this.#countdown) return this.#describePicker();
742
+ if (cx.supports("picker") && !this.#inline && !this.#slider && !this.#countdown) return this.#describePicker();
736
743
  const items = this.#menu.visibleItems;
737
744
  const selected = this.#menu.selectedKey;
738
745
  const query = this.#menu.query;