@oh-my-pi/pi-coding-agent 17.2.1 → 17.2.3

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 (121) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/{CHANGELOG-dj46zzrm.md → CHANGELOG-c5hpqt9r.md} +61 -0
  3. package/dist/cli.js +3856 -3850
  4. package/dist/types/config/keybindings.d.ts +6 -1
  5. package/dist/types/config/settings-schema.d.ts +16 -5
  6. package/dist/types/edit/hashline/block-resolver.d.ts +5 -5
  7. package/dist/types/edit/hashline/diff.d.ts +4 -4
  8. package/dist/types/eval/executor-base.d.ts +27 -0
  9. package/dist/types/eval/js/context-manager.d.ts +7 -0
  10. package/dist/types/eval/js/executor.d.ts +2 -0
  11. package/dist/types/extensibility/extensions/runner.d.ts +64 -5
  12. package/dist/types/extensibility/extensions/types.d.ts +15 -0
  13. package/dist/types/extensibility/extensions/wrapper.d.ts +1 -1
  14. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -1
  15. package/dist/types/live/visualizer.d.ts +3 -1
  16. package/dist/types/modes/acp/acp-agent.d.ts +3 -3
  17. package/dist/types/modes/components/ask-dialog.d.ts +0 -1
  18. package/dist/types/modes/components/keybinding-hints.d.ts +1 -1
  19. package/dist/types/modes/controllers/mcp-command-controller.d.ts +13 -0
  20. package/dist/types/modes/interactive-mode.d.ts +1 -0
  21. package/dist/types/modes/theme/theme.d.ts +1 -1
  22. package/dist/types/modes/types.d.ts +2 -0
  23. package/dist/types/secrets/index.d.ts +22 -0
  24. package/dist/types/secrets/obfuscator.d.ts +1 -1
  25. package/dist/types/session/agent-session-types.d.ts +4 -0
  26. package/dist/types/session/agent-session.d.ts +9 -0
  27. package/dist/types/session/codex-auto-reset.d.ts +182 -64
  28. package/dist/types/session/session-storage.d.ts +5 -2
  29. package/dist/types/session/session-tools.d.ts +2 -1
  30. package/dist/types/session/turn-recovery.d.ts +7 -1
  31. package/dist/types/system-prompt.d.ts +8 -0
  32. package/dist/types/task/executor.d.ts +27 -0
  33. package/dist/types/task/persisted-revive.d.ts +7 -0
  34. package/dist/types/tools/bash-interceptor.d.ts +1 -1
  35. package/dist/types/tools/browser/attach.d.ts +9 -1
  36. package/dist/types/tools/browser/launch.d.ts +50 -1
  37. package/dist/types/tools/browser/registry.d.ts +7 -0
  38. package/dist/types/tools/browser/shared-daemon.d.ts +26 -0
  39. package/dist/types/tools/browser/tab-protocol.d.ts +8 -0
  40. package/dist/types/tools/browser/tab-supervisor.d.ts +1 -0
  41. package/dist/types/tools/browser/tab-worker.d.ts +3 -1
  42. package/dist/types/tools/shell-tokenize.d.ts +11 -0
  43. package/dist/types/utils/image-loading.d.ts +2 -0
  44. package/dist/types/web/search/providers/duckduckgo.d.ts +9 -0
  45. package/dist/types/web/search/types.d.ts +2 -2
  46. package/package.json +12 -12
  47. package/src/commit/agentic/index.ts +2 -1
  48. package/src/commit/pipeline.ts +3 -2
  49. package/src/config/keybindings.ts +6 -1
  50. package/src/config/settings-schema.ts +16 -3
  51. package/src/edit/hashline/block-resolver.ts +5 -5
  52. package/src/edit/hashline/diff.ts +22 -15
  53. package/src/edit/hashline/execute.ts +14 -13
  54. package/src/edit/renderer.ts +3 -3
  55. package/src/eval/executor-base.ts +39 -0
  56. package/src/eval/jl/executor.ts +13 -2
  57. package/src/eval/js/context-manager.ts +97 -14
  58. package/src/eval/js/executor.ts +3 -0
  59. package/src/eval/js/index.ts +1 -0
  60. package/src/eval/py/executor.ts +55 -15
  61. package/src/eval/rb/executor.ts +8 -1
  62. package/src/extensibility/extensions/runner.ts +121 -4
  63. package/src/extensibility/extensions/types.ts +15 -0
  64. package/src/extensibility/extensions/wrapper.ts +19 -2
  65. package/src/extensibility/legacy-pi-coding-agent-shim.ts +5 -5
  66. package/src/launch/broker.ts +59 -41
  67. package/src/live/visualizer.ts +8 -1
  68. package/src/lsp/index.ts +51 -11
  69. package/src/main.ts +3 -0
  70. package/src/modes/acp/acp-agent.ts +16 -5
  71. package/src/modes/components/ask-dialog.ts +65 -23
  72. package/src/modes/components/assistant-message.ts +5 -10
  73. package/src/modes/components/custom-editor.ts +1 -1
  74. package/src/modes/components/keybinding-hints.ts +4 -13
  75. package/src/modes/components/status-line/segments.ts +4 -5
  76. package/src/modes/components/tool-execution.ts +4 -5
  77. package/src/modes/controllers/input-controller.ts +18 -40
  78. package/src/modes/controllers/live-command-controller.ts +1 -0
  79. package/src/modes/controllers/mcp-command-controller.ts +28 -11
  80. package/src/modes/interactive-mode.ts +59 -2
  81. package/src/modes/theme/theme.ts +13 -6
  82. package/src/modes/types.ts +2 -0
  83. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  84. package/src/prompts/system/plan-mode-active.md +5 -5
  85. package/src/sdk.ts +47 -16
  86. package/src/secrets/index.ts +83 -3
  87. package/src/secrets/obfuscator.ts +70 -15
  88. package/src/session/agent-session-types.ts +7 -1
  89. package/src/session/agent-session.ts +394 -113
  90. package/src/session/codex-auto-reset.ts +594 -123
  91. package/src/session/eval-runner.ts +2 -0
  92. package/src/session/messages.ts +1 -1
  93. package/src/session/session-maintenance.ts +50 -3
  94. package/src/session/session-manager.ts +4 -3
  95. package/src/session/session-stats.ts +14 -3
  96. package/src/session/session-storage.ts +47 -15
  97. package/src/session/session-tools.ts +30 -2
  98. package/src/session/turn-recovery.ts +15 -2
  99. package/src/slash-commands/builtin-registry.ts +21 -15
  100. package/src/slash-commands/helpers/reset-usage.ts +2 -0
  101. package/src/system-prompt.ts +13 -1
  102. package/src/task/executor.ts +168 -15
  103. package/src/task/persisted-revive.ts +26 -1
  104. package/src/telemetry-export.ts +8 -4
  105. package/src/tools/bash-interceptor.ts +85 -8
  106. package/src/tools/bash.ts +12 -6
  107. package/src/tools/browser/attach.ts +35 -10
  108. package/src/tools/browser/launch.ts +110 -16
  109. package/src/tools/browser/registry.ts +140 -31
  110. package/src/tools/browser/shared-daemon.ts +190 -0
  111. package/src/tools/browser/tab-protocol.ts +8 -0
  112. package/src/tools/browser/tab-supervisor.ts +31 -4
  113. package/src/tools/browser/tab-worker.ts +32 -2
  114. package/src/tools/browser.ts +1 -1
  115. package/src/tools/output-meta.ts +16 -1
  116. package/src/tools/shell-tokenize.ts +101 -0
  117. package/src/utils/image-loading.ts +8 -3
  118. package/src/utils/shell-snapshot.ts +31 -21
  119. package/src/web/search/providers/codex.ts +31 -6
  120. package/src/web/search/providers/duckduckgo.ts +183 -15
  121. package/src/web/search/types.ts +6 -1
@@ -342,6 +342,15 @@ class DaemonBroker {
342
342
  readonly #token: string;
343
343
  readonly #idleGraceMs: number;
344
344
  readonly #records = new Map<string, ManagedDaemon>();
345
+ /**
346
+ * Names reserved by an in-flight `start` before its record lands in
347
+ * `#records`. Requests dispatch concurrently, and `#start` awaits (cwd stat,
348
+ * log open) between the duplicate check and the record insert; without a
349
+ * synchronous reservation two clients can both pass the check and spawn
350
+ * duplicate processes — one exits on a held resource (e.g. a Chromium
351
+ * profile lock) or keeps running untracked.
352
+ */
353
+ readonly #startingNames = new Set<string>();
345
354
  readonly #clients = new Set<net.Socket>();
346
355
  readonly #finished = Promise.withResolvers<void>();
347
356
  readonly #sockets = new Set<net.Socket>();
@@ -500,50 +509,59 @@ class DaemonBroker {
500
509
  ) {
501
510
  throw new Error('Windows batch files require application "cmd.exe" with the batch path after "/c"');
502
511
  }
503
- const existing = this.#records.get(spec.name);
504
- if (existing) await this.#refreshDetached(existing);
505
- if (existing && !terminalState(existing.snapshot.state)) {
506
- throw new Error(`Daemon ${spec.name} is already ${existing.snapshot.state}`);
512
+ if (this.#startingNames.has(spec.name)) {
513
+ throw new Error(`Daemon ${spec.name} is already starting`);
507
514
  }
508
- if (spec.ready?.log) {
509
- try {
510
- new RegExp(spec.ready.log, "u");
511
- } catch (error) {
512
- throw new Error(`Invalid readiness regex: ${error instanceof Error ? error.message : String(error)}`);
515
+ this.#startingNames.add(spec.name);
516
+ let record: ManagedDaemon;
517
+ try {
518
+ const existing = this.#records.get(spec.name);
519
+ if (existing) await this.#refreshDetached(existing);
520
+ if (existing && !terminalState(existing.snapshot.state)) {
521
+ throw new Error(`Daemon ${spec.name} is already ${existing.snapshot.state}`);
522
+ }
523
+ if (spec.ready?.log) {
524
+ try {
525
+ new RegExp(spec.ready.log, "u");
526
+ } catch (error) {
527
+ throw new Error(`Invalid readiness regex: ${error instanceof Error ? error.message : String(error)}`);
528
+ }
513
529
  }
530
+ const stat = await fs.stat(spec.cwd);
531
+ if (!stat.isDirectory()) throw new Error(`Daemon cwd is not a directory: ${spec.cwd}`);
532
+ const dir = path.join(this.#runtimeDir, "daemons", spec.name);
533
+ const now = Date.now();
534
+ record = {
535
+ spec,
536
+ snapshot: {
537
+ name: spec.name,
538
+ id: crypto.randomUUID(),
539
+ state: "starting",
540
+ createdAt: now,
541
+ startedAt: now,
542
+ restartCount: 0,
543
+ outputBytes: 0,
544
+ owner,
545
+ persist: spec.persist,
546
+ detached: spec.detached,
547
+ },
548
+ dir,
549
+ log: await DaemonLog.open(dir),
550
+ generation: 0,
551
+ stopRequested: false,
552
+ logReady: !spec.ready?.log,
553
+ portReady: spec.ready?.port === undefined,
554
+ readinessBuffer: "",
555
+ outputOffset: 0,
556
+ readyPattern: spec.ready?.log ? new RegExp(spec.ready.log, "u") : undefined,
557
+ consecutiveFailures: 0,
558
+ persistQueue: Promise.resolve(),
559
+ };
560
+ syncReadyPending(record);
561
+ this.#records.set(spec.name, record);
562
+ } finally {
563
+ this.#startingNames.delete(spec.name);
514
564
  }
515
- const stat = await fs.stat(spec.cwd);
516
- if (!stat.isDirectory()) throw new Error(`Daemon cwd is not a directory: ${spec.cwd}`);
517
- const dir = path.join(this.#runtimeDir, "daemons", spec.name);
518
- const now = Date.now();
519
- const record: ManagedDaemon = {
520
- spec,
521
- snapshot: {
522
- name: spec.name,
523
- id: crypto.randomUUID(),
524
- state: "starting",
525
- createdAt: now,
526
- startedAt: now,
527
- restartCount: 0,
528
- outputBytes: 0,
529
- owner,
530
- persist: spec.persist,
531
- detached: spec.detached,
532
- },
533
- dir,
534
- log: await DaemonLog.open(dir),
535
- generation: 0,
536
- stopRequested: false,
537
- logReady: !spec.ready?.log,
538
- portReady: spec.ready?.port === undefined,
539
- readinessBuffer: "",
540
- outputOffset: 0,
541
- readyPattern: spec.ready?.log ? new RegExp(spec.ready.log, "u") : undefined,
542
- consecutiveFailures: 0,
543
- persistQueue: Promise.resolve(),
544
- };
545
- syncReadyPending(record);
546
- this.#records.set(spec.name, record);
547
565
  await this.#launch(record);
548
566
  let readyTimedOut = false;
549
567
  if (spec.ready && !terminalState(record.snapshot.state)) {
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  type Component,
3
+ type KeyId,
3
4
  matchesKey,
4
5
  replaceTabs,
5
6
  sliceWithWidth,
@@ -15,6 +16,8 @@ export type LivePhase = "connecting" | "listening" | "working" | "speaking" | "m
15
16
  export interface LiveVisualizerOptions {
16
17
  onStop(): void;
17
18
  onToggleMute(): void;
19
+ /** Configured `app.live.toggle` chords that also end the call (Ctrl+L by default). */
20
+ stopKeys?: readonly KeyId[];
18
21
  }
19
22
 
20
23
  function normalizeTranscript(text: string): string {
@@ -100,7 +103,11 @@ export class LiveVisualizer implements Component {
100
103
 
101
104
  /** Processes user keypresses. */
102
105
  handleInput(data: string): void {
103
- if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
106
+ if (
107
+ matchesKey(data, "escape") ||
108
+ matchesKey(data, "ctrl+c") ||
109
+ this.#options.stopKeys?.some(key => matchesKey(data, key))
110
+ ) {
104
111
  this.#options.onStop();
105
112
  } else if (matchesKey(data, "space")) {
106
113
  this.#options.onToggleMute();
package/src/lsp/index.ts CHANGED
@@ -7,7 +7,7 @@ import type {
7
7
  AgentToolUpdateCallback,
8
8
  ToolApprovalDecision,
9
9
  } from "@oh-my-pi/pi-agent-core";
10
- import { logger, once, prompt, untilAborted } from "@oh-my-pi/pi-utils";
10
+ import { isEnoent, logger, once, prompt, untilAborted } from "@oh-my-pi/pi-utils";
11
11
  import type { BunFile } from "bun";
12
12
  import { type Theme, theme } from "../modes/theme/theme";
13
13
  import lspDescription from "../prompts/tools/lsp.md" with { type: "text" };
@@ -1114,11 +1114,14 @@ export async function writethroughNoop(
1114
1114
 
1115
1115
  interface PendingWritethrough {
1116
1116
  dst: string;
1117
- content: string;
1118
1117
  file?: BunFile;
1119
1118
  changeType: FileChangeType;
1120
1119
  }
1121
1120
 
1121
+ interface RunLspWritethroughOptions {
1122
+ contentAlreadyWritten?: boolean;
1123
+ }
1124
+
1122
1125
  interface LspWritethroughBatchRequest {
1123
1126
  id: string;
1124
1127
  flush: boolean;
@@ -1317,12 +1320,16 @@ async function runLspWritethrough(
1317
1320
  onDeferredDiagnostics: (diagnostics: FileDiagnosticsResult) => void;
1318
1321
  signal: AbortSignal;
1319
1322
  },
1323
+ runOptions?: RunLspWritethroughOptions,
1320
1324
  ): Promise<FileDiagnosticsResult | undefined> {
1321
1325
  const { enableFormat, enableDiagnostics } = options;
1326
+ const contentAlreadyWritten = runOptions?.contentAlreadyWritten ?? false;
1322
1327
 
1323
1328
  let finalContent = content;
1324
1329
  const writeContent = async (value: string) => (file ? file.write(value) : Bun.write(dst, value));
1325
- const getWritePromise = once(() => writeContent(finalContent));
1330
+ const getWritePromise = once(() =>
1331
+ contentAlreadyWritten && finalContent === content ? Promise.resolve() : writeContent(finalContent),
1332
+ );
1326
1333
  let writeNotified = false;
1327
1334
  const notifyWriteCommitted = async (notifySignal: AbortSignal | undefined = signal) => {
1328
1335
  if (writeNotified) return;
@@ -1383,7 +1390,7 @@ async function runLspWritethrough(
1383
1390
  if (useCustomFormatter) {
1384
1391
  // Custom linters operate on on-disk input; the shared pre-write also
1385
1392
  // supports implementations that inspect the file before formatting.
1386
- await writeContent(content);
1393
+ if (!contentAlreadyWritten) await writeContent(content);
1387
1394
  const [formattedContent, capturedVersions] = await Promise.all([
1388
1395
  formatContent(dst, content, cwd, customLinterServers, operationSignal),
1389
1396
  minVersionsPromise,
@@ -1391,7 +1398,7 @@ async function runLspWritethrough(
1391
1398
  finalContent = formattedContent;
1392
1399
  minVersions = capturedVersions;
1393
1400
  formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED;
1394
- await writeContent(finalContent);
1401
+ if (!contentAlreadyWritten || finalContent !== content) await writeContent(finalContent);
1395
1402
  await notifyWriteCommitted(operationSignal);
1396
1403
  await syncFileContent(dst, finalContent, cwd, lspServers, operationSignal, enableDiagnostics);
1397
1404
  } else {
@@ -1485,6 +1492,14 @@ async function flushWritethroughBatch(
1485
1492
  const results: Array<FileDiagnosticsResult | undefined> = [];
1486
1493
  for (const entry of batch) {
1487
1494
  const bundle = getDeferred?.(entry.dst);
1495
+ let content: string;
1496
+ try {
1497
+ content = await fs.promises.readFile(entry.dst, "utf8");
1498
+ } catch (error) {
1499
+ if (!isEnoent(error)) throw error;
1500
+ bundle?.finalize(undefined);
1501
+ continue;
1502
+ }
1488
1503
  const deferredInner =
1489
1504
  bundle &&
1490
1505
  ({
@@ -1493,13 +1508,14 @@ async function flushWritethroughBatch(
1493
1508
  } as const);
1494
1509
  const diag = await runLspWritethrough(
1495
1510
  entry.dst,
1496
- entry.content,
1511
+ content,
1497
1512
  cwd,
1498
1513
  options,
1499
1514
  entry.changeType,
1500
1515
  signal,
1501
1516
  entry.file,
1502
1517
  deferredInner,
1518
+ { contentAlreadyWritten: true },
1503
1519
  );
1504
1520
  bundle?.finalize(diag);
1505
1521
  results.push(diag);
@@ -1545,14 +1561,38 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions
1545
1561
  return diagnostics;
1546
1562
  }
1547
1563
 
1548
- const state = getOrCreateWritethroughBatch(batch.id, resolvedOptions);
1549
- state.entries.set(dst, { dst, content, file, changeType });
1550
-
1551
- if (!batch.flush) {
1564
+ // File commits are never deferred: the batch owns only LSP post-processing,
1565
+ // so a later flush cannot replay an obsolete whole-file snapshot.
1566
+ try {
1552
1567
  await writethroughNoop(dst, content, signal, file);
1553
- return undefined;
1568
+ } catch (error) {
1569
+ if (batch.flush) {
1570
+ const pending = writethroughBatches.get(batch.id);
1571
+ if (pending) {
1572
+ writethroughBatches.delete(batch.id);
1573
+ try {
1574
+ await flushWritethroughBatch(
1575
+ Array.from(pending.entries.values()),
1576
+ cwd,
1577
+ pending.options,
1578
+ signal,
1579
+ getDeferred,
1580
+ );
1581
+ } catch (flushError) {
1582
+ logger.warn("Failed to flush pending LSP batch after final write failure", {
1583
+ batchId: batch.id,
1584
+ error: flushError instanceof Error ? flushError.message : String(flushError),
1585
+ });
1586
+ }
1587
+ }
1588
+ }
1589
+ throw error;
1554
1590
  }
1555
1591
 
1592
+ const state = getOrCreateWritethroughBatch(batch.id, resolvedOptions);
1593
+ state.entries.set(dst, { dst, file, changeType });
1594
+ if (!batch.flush) return undefined;
1595
+
1556
1596
  writethroughBatches.delete(batch.id);
1557
1597
  return flushWritethroughBatch(Array.from(state.entries.values()), cwd, state.options, signal, getDeferred);
1558
1598
  };
package/src/main.ts CHANGED
@@ -504,6 +504,7 @@ async function runInteractiveMode(
504
504
  }
505
505
 
506
506
  if (initialMessage !== undefined) {
507
+ session.maybeStartTitleGeneration(initialMessage);
507
508
  try {
508
509
  using _keepalive = new EventLoopKeepalive();
509
510
  await session.prompt(initialMessage, { images: initialImages });
@@ -514,6 +515,7 @@ async function runInteractiveMode(
514
515
  }
515
516
 
516
517
  for (const message of initialMessages) {
518
+ session.maybeStartTitleGeneration(message);
517
519
  try {
518
520
  using _keepalive = new EventLoopKeepalive();
519
521
  await session.prompt(message);
@@ -1608,6 +1610,7 @@ export async function runRootCommand(
1608
1610
  modelRegistry,
1609
1611
  settings: settingsInstance,
1610
1612
  enableLsp: sessionOptions.enableLsp ?? true,
1613
+ eventBus,
1611
1614
  }),
1612
1615
  Math.trunc(Number(settingsInstance.get("task.agentIdleTtlMs") ?? 420_000) || 0),
1613
1616
  );
@@ -296,7 +296,7 @@ function buildAcpSpeechModelsCatalog(): Record<string, unknown> {
296
296
  async function elicitFromAcpClient(
297
297
  connection: AgentSideConnection,
298
298
  sessionId: string,
299
- method: "select" | "confirm" | "input",
299
+ method: "select" | "confirm" | "input" | "editor",
300
300
  message: string,
301
301
  property: ElicitationPropertySchema,
302
302
  dialogOptions: ExtensionUIDialogOptions | undefined,
@@ -377,9 +377,9 @@ function isAcceptedElicitation(
377
377
  * symmetric with every other `sessionUpdate` call in this file
378
378
  * (`record.session.sessionId` is always evaluated at emit time).
379
379
  *
380
- * The non-elicitation surface (custom components, editor, theming,
381
- * terminal input) remains stubbed — ACP clients render those themselves
382
- * or not at all. Capability gating respects the client's `initialize`
380
+ * The non-elicitation surface (custom components, theming, terminal
381
+ * input) remains stubbed — ACP clients render those themselves or not
382
+ * at all. Capability gating respects the client's `initialize`
383
383
  * advertisement.
384
384
  */
385
385
  export function createAcpExtensionUiContext(
@@ -443,7 +443,18 @@ export function createAcpExtensionUiContext(
443
443
  pasteToEditor: () => {},
444
444
  setEditorText: () => {},
445
445
  getEditorText: () => "",
446
- editor: async () => undefined,
446
+ editor: async (title, prefill, dialogOptions) => {
447
+ if (!supportsForm) return undefined;
448
+ const value = await elicitFromAcpClient(
449
+ connection,
450
+ getSessionId(),
451
+ "editor",
452
+ title,
453
+ { type: "string", ...(prefill ? { default: prefill } : {}) },
454
+ dialogOptions,
455
+ );
456
+ return typeof value === "string" ? value : undefined;
457
+ },
447
458
  addAutocompleteProvider: () => {},
448
459
  setEditorComponent: () => {},
449
460
  get theme() {
@@ -15,6 +15,7 @@ import {
15
15
  wrapTextWithAnsi,
16
16
  } from "@oh-my-pi/pi-tui";
17
17
  import type {
18
+ ExtensionAskDialogOption,
18
19
  ExtensionAskDialogQuestion,
19
20
  ExtensionAskDialogResultItem,
20
21
  ExtensionAskDialogSubmitResult,
@@ -337,6 +338,45 @@ function renderRowLabel(
337
338
  return lines;
338
339
  }
339
340
 
341
+ /**
342
+ * Coerce untrusted dialog questions into a render-safe shape. The live ask
343
+ * dialog is reached from the public `askDialog` extension surface and from
344
+ * streamed tool args, where a question entry can arrive with a missing or
345
+ * non-string `question` field. The render helpers (`replaceTabs`,
346
+ * `renderQuestionTitle`, `questionTabLabel`) assume strings, so a malformed
347
+ * entry throws and takes down the whole TUI render loop. Mirrors
348
+ * `normalizeRenderQuestions` on the transcript path.
349
+ */
350
+ function normalizeDialogQuestions(questions: ExtensionAskDialogQuestion[]): ExtensionAskDialogQuestion[] {
351
+ if (!Array.isArray(questions)) return [];
352
+ const out: ExtensionAskDialogQuestion[] = [];
353
+ for (const entry of questions) {
354
+ if (!entry || typeof entry !== "object") continue;
355
+ const q = entry as Partial<ExtensionAskDialogQuestion>;
356
+ const options: ExtensionAskDialogOption[] = [];
357
+ if (Array.isArray(q.options)) {
358
+ for (const opt of q.options) {
359
+ if (!opt || typeof opt !== "object") continue;
360
+ const o = opt as Partial<ExtensionAskDialogOption>;
361
+ options.push({
362
+ label: typeof o.label === "string" ? o.label : "",
363
+ ...(typeof o.description === "string" ? { description: o.description } : {}),
364
+ ...(typeof o.preview === "string" ? { preview: o.preview } : {}),
365
+ });
366
+ }
367
+ }
368
+ out.push({
369
+ id: typeof q.id === "string" ? q.id : "?",
370
+ question: typeof q.question === "string" ? q.question : "",
371
+ ...(typeof q.header === "string" ? { header: q.header } : {}),
372
+ options,
373
+ ...(typeof q.multi === "boolean" ? { multi: q.multi } : {}),
374
+ ...(Number.isInteger(q.recommended) ? { recommended: q.recommended } : {}),
375
+ });
376
+ }
377
+ return out;
378
+ }
379
+
340
380
  export class AskDialogComponent implements Component {
341
381
  #states: QuestionState[];
342
382
  #activeTabIndex = 0;
@@ -352,13 +392,15 @@ export class AskDialogComponent implements Component {
352
392
  #stableHeight: { key: string; total: number } | undefined;
353
393
  #previewCache: PreviewRenderCache = new Map();
354
394
  #overflowLayouts = new WeakMap<ExtensionAskDialogQuestion, Set<string>>();
395
+ readonly #questions: ExtensionAskDialogQuestion[];
355
396
 
356
397
  constructor(
357
- private readonly questions: ExtensionAskDialogQuestion[],
398
+ questions: ExtensionAskDialogQuestion[],
358
399
  private readonly callbacks: AskDialogCallbacks,
359
400
  private readonly options: AskDialogOptions = {},
360
401
  ) {
361
- this.#states = questions.map(question => {
402
+ this.#questions = normalizeDialogQuestions(questions);
403
+ this.#states = this.#questions.map(question => {
362
404
  const recommended = Number.isInteger(question.recommended) ? question.recommended : 0;
363
405
  const maxIndex = Math.max(0, question.options.length - 1);
364
406
  return {
@@ -474,8 +516,8 @@ export class AskDialogComponent implements Component {
474
516
  const tabBarRows = this.#hasSubmitTab() ? 1 : 0;
475
517
  const mdTheme = getMarkdownTheme();
476
518
  let needed = MIN_DIALOG_ROWS;
477
- for (let index = 0; index < this.questions.length; index++) {
478
- const question = this.questions[index];
519
+ for (let index = 0; index < this.#questions.length; index++) {
520
+ const question = this.#questions[index];
479
521
  const state = this.#states[index];
480
522
  if (!question || !state) continue;
481
523
  const headerRows = tabBarRows + renderQuestionTitle(question, width).length;
@@ -493,7 +535,7 @@ export class AskDialogComponent implements Component {
493
535
  if (this.#hasSubmitTab()) {
494
536
  // Warning line + blank, one summary line per question, blank, and
495
537
  // the Submit row; note lines added later scroll within the body.
496
- const body = 2 + this.questions.length + 2;
538
+ const body = 2 + this.#questions.length + 2;
497
539
  needed = Math.max(needed, chrome + tabBarRows + 1 + Math.max(MIN_BODY_ROWS, body));
498
540
  }
499
541
  return Math.min(needed, maxHeight);
@@ -507,11 +549,11 @@ export class AskDialogComponent implements Component {
507
549
  // Multi questions confirm on the Submit tab (Enter toggles, never
508
550
  // submits), so any multi question forces the tab even when there is
509
551
  // only one question.
510
- return this.questions.length > 1 || this.questions.some(question => question.multi);
552
+ return this.#questions.length > 1 || this.#questions.some(question => question.multi);
511
553
  }
512
554
 
513
555
  #submitTabIndex(): number {
514
- return this.questions.length;
556
+ return this.#questions.length;
515
557
  }
516
558
 
517
559
  #isSubmitTab(): boolean {
@@ -519,7 +561,7 @@ export class AskDialogComponent implements Component {
519
561
  }
520
562
 
521
563
  #currentQuestionIndex(): number {
522
- return clamp(this.#activeTabIndex, 0, Math.max(0, this.questions.length - 1));
564
+ return clamp(this.#activeTabIndex, 0, Math.max(0, this.#questions.length - 1));
523
565
  }
524
566
 
525
567
  #requestRender(): void {
@@ -530,7 +572,7 @@ export class AskDialogComponent implements Component {
530
572
  const lines: string[] = [];
531
573
  if (this.#hasSubmitTab()) {
532
574
  const tabs: Tab[] = [
533
- ...this.questions.map((question, index) => ({
575
+ ...this.#questions.map((question, index) => ({
534
576
  id: String(index),
535
577
  label: questionTabLabel(question, index),
536
578
  })),
@@ -545,7 +587,7 @@ export class AskDialogComponent implements Component {
545
587
  return lines;
546
588
  }
547
589
  const questionIndex = this.#currentQuestionIndex();
548
- const question = this.questions[questionIndex];
590
+ const question = this.#questions[questionIndex];
549
591
  if (!question) return lines;
550
592
  lines.push(...renderQuestionTitle(question, width));
551
593
  return lines;
@@ -559,7 +601,7 @@ export class AskDialogComponent implements Component {
559
601
  const scroll = indicator ? ` ${indicator} scroll ·` : "";
560
602
  return `Enter submit · ↑/↓ scroll ·${scroll} ${cancel}`;
561
603
  }
562
- const question = this.questions[this.#currentQuestionIndex()];
604
+ const question = this.#questions[this.#currentQuestionIndex()];
563
605
  const action = question?.multi ? "Space/Enter toggle · n note" : "Enter select · n note";
564
606
  const tabs = this.#hasSubmitTab() ? " · Tab/←/→" : "";
565
607
  if (this.#questionCanPage && indicator) {
@@ -585,7 +627,7 @@ export class AskDialogComponent implements Component {
585
627
  }
586
628
 
587
629
  #activeQuestionState(): { question: ExtensionAskDialogQuestion; state: QuestionState } | undefined {
588
- const question = this.questions[this.#currentQuestionIndex()];
630
+ const question = this.#questions[this.#currentQuestionIndex()];
589
631
  const state = this.#states[this.#currentQuestionIndex()];
590
632
  if (!question || !state) return undefined;
591
633
  return { question, state };
@@ -672,18 +714,18 @@ export class AskDialogComponent implements Component {
672
714
  }
673
715
 
674
716
  #switchTab(direction: 1 | -1): void {
675
- const tabCount = this.questions.length + 1;
717
+ const tabCount = this.#questions.length + 1;
676
718
  this.#activeTabIndex = (this.#activeTabIndex + direction + tabCount) % tabCount;
677
719
  this.#submitScrollOffset = 0;
678
720
  }
679
721
 
680
722
  #advanceAfterQuestion(): void {
681
723
  const current = this.#currentQuestionIndex();
682
- if (this.questions.length === 1) {
724
+ if (this.#questions.length === 1) {
683
725
  this.#finishSubmit();
684
726
  return;
685
727
  }
686
- this.#activeTabIndex = current + 1 < this.questions.length ? current + 1 : this.#submitTabIndex();
728
+ this.#activeTabIndex = current + 1 < this.#questions.length ? current + 1 : this.#submitTabIndex();
687
729
  this.#submitScrollOffset = 0;
688
730
  this.#requestRender();
689
731
  }
@@ -829,8 +871,8 @@ export class AskDialogComponent implements Component {
829
871
  );
830
872
  allLines.push("");
831
873
  }
832
- for (let index = 0; index < this.questions.length; index++) {
833
- const question = this.questions[index];
874
+ for (let index = 0; index < this.#questions.length; index++) {
875
+ const question = this.#questions[index];
834
876
  const state = this.#states[index];
835
877
  if (!question || !state) continue;
836
878
  const label = questionTabLabel(question, index);
@@ -895,8 +937,8 @@ export class AskDialogComponent implements Component {
895
937
 
896
938
  #unansweredCount(): number {
897
939
  let count = 0;
898
- for (let index = 0; index < this.questions.length; index++) {
899
- const question = this.questions[index];
940
+ for (let index = 0; index < this.#questions.length; index++) {
941
+ const question = this.#questions[index];
900
942
  const state = this.#states[index];
901
943
  if (!question || !state) continue;
902
944
  if (state.selectedOptions.size === 0 && state.customInput === undefined) count += 1;
@@ -911,8 +953,8 @@ export class AskDialogComponent implements Component {
911
953
  return;
912
954
  }
913
955
  this.options.onTimeout?.();
914
- for (let index = 0; index < this.questions.length; index++) {
915
- const question = this.questions[index];
956
+ for (let index = 0; index < this.#questions.length; index++) {
957
+ const question = this.#questions[index];
916
958
  const state = this.#states[index];
917
959
  if (!question || !state) continue;
918
960
  if (state.selectedOptions.size === 0 && state.customInput === undefined) {
@@ -952,8 +994,8 @@ export class AskDialogComponent implements Component {
952
994
 
953
995
  #buildResults(): ExtensionAskDialogResultItem[] {
954
996
  const results: ExtensionAskDialogResultItem[] = [];
955
- for (let index = 0; index < this.questions.length; index++) {
956
- const question = this.questions[index];
997
+ for (let index = 0; index < this.#questions.length; index++) {
998
+ const question = this.#questions[index];
957
999
  const state = this.#states[index];
958
1000
  if (!question || !state) continue;
959
1001
  const selectedOptions = question.options
@@ -15,6 +15,7 @@ import chalk from "chalk";
15
15
  import type { AssistantThinkingRenderer } from "../../extensibility/extensions/types";
16
16
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
17
17
  import { expandKeyHint, getPreviewLines, resolveImageOptions, TRUNCATE_LENGTHS } from "../../tools/render-utils";
18
+ import { convertImageToPng } from "../../utils/image-loading";
18
19
  import { canonicalizeMessage, formatThinkingForDisplay, hasDisplayableThinking } from "../../utils/thinking-display";
19
20
  import { resolveAssistantErrorPresentation } from "../utils/transcript-render-helpers";
20
21
  import { type CacheInvalidation, CacheInvalidationMarkerComponent } from "./cache-invalidation-marker";
@@ -607,16 +608,10 @@ export class AssistantMessageComponent extends Container {
607
608
  if (image.mimeType === "image/png") continue;
608
609
  if (this.#convertedKittyImages.has(key) || this.#kittyConversionsInFlight.has(key)) continue;
609
610
  this.#kittyConversionsInFlight.add(key);
610
- new Bun.Image(Buffer.from(image.data, "base64"))
611
- .png()
612
- .toBase64()
613
- .then(data => {
611
+ convertImageToPng(image)
612
+ .then(converted => {
614
613
  this.#kittyConversionsInFlight.delete(key);
615
- this.#convertedKittyImages.set(key, {
616
- type: "image",
617
- data,
618
- mimeType: "image/png",
619
- });
614
+ this.#convertedKittyImages.set(key, converted);
620
615
  if (this.#lastMessage) {
621
616
  this.updateContent(this.#lastMessage, { transient: this.#lastUpdateTransient });
622
617
  }
@@ -871,7 +866,7 @@ export class AssistantMessageComponent extends Container {
871
866
  // Set paddingY=0 to avoid extra spacing before tool executions
872
867
  const trimmed = content.text.trim();
873
868
  const mdOptions = this.#textColorTransform ? { color: this.#textColorTransform } : undefined;
874
- const md = new Markdown(trimmed, 1, 0, getMarkdownTheme(), mdOptions);
869
+ const md = new Markdown(trimmed, 1, 0, getMarkdownTheme(), mdOptions, 0);
875
870
  this.#contentContainer.addChild(md);
876
871
  captureItems?.push({ md, contentIndex: i, blockType: "text", lastText: trimmed });
877
872
  hasRenderedContent = true;
@@ -46,7 +46,7 @@ const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
46
46
  "app.clear": ["ctrl+c"],
47
47
  "app.exit": ["ctrl+d"],
48
48
  "app.suspend": ["ctrl+z"],
49
- "app.display.reset": ["ctrl+l"],
49
+ "app.display.reset": ["alt+l"],
50
50
  "app.thinking.cycle": ["shift+tab"],
51
51
  "app.model.cycleForward": ["ctrl+p"],
52
52
  "app.model.cycleBackward": ["shift+ctrl+p"],
@@ -1,31 +1,22 @@
1
1
  /**
2
2
  * Utilities for formatting keybinding hints in the UI.
3
3
  */
4
- import { getKeybindings, type Keybinding, type KeyId } from "@oh-my-pi/pi-tui";
5
- import type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
4
+ import { getKeybindings, type Keybinding } from "@oh-my-pi/pi-tui";
5
+ import { type AppKeybinding, formatKeyHints, type KeybindingsManager } from "../../config/keybindings";
6
6
  import { theme } from "../../modes/theme/theme";
7
7
 
8
- /**
9
- * Format keys array as display string (e.g., ["ctrl+c", "escape"] -> "ctrl+c/escape").
10
- */
11
- function formatKeys(keys: KeyId[]): string {
12
- if (keys.length === 0) return "";
13
- if (keys.length === 1) return keys[0]!;
14
- return keys.join("/");
15
- }
16
-
17
8
  /**
18
9
  * Get display string for an editor action.
19
10
  */
20
11
  export function editorKey(action: Keybinding): string {
21
- return formatKeys(getKeybindings().getKeys(action));
12
+ return formatKeyHints(getKeybindings().getKeys(action));
22
13
  }
23
14
 
24
15
  /**
25
16
  * Get display string for an app action.
26
17
  */
27
18
  export function appKey(keybindings: KeybindingsManager, action: AppKeybinding): string {
28
- return formatKeys(keybindings.getKeys(action));
19
+ return formatKeyHints(keybindings.getKeys(action));
29
20
  }
30
21
 
31
22
  /**
@@ -5,6 +5,7 @@ import { TERMINAL } from "@oh-my-pi/pi-tui";
5
5
  import { formatDuration, formatNumber, getProjectDir, pathIsWithin, relativePathWithinRoot } from "@oh-my-pi/pi-utils";
6
6
  import { type ThemeColor, theme } from "../../../modes/theme/theme";
7
7
  import { shortenPath, TRUNCATE_LENGTHS, truncateToWidth } from "../../../tools/render-utils";
8
+ import { fileHyperlink } from "../../../tui/hyperlink";
8
9
  import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color";
9
10
  import { sanitizeStatusText } from "../../shared";
10
11
  import { formatContextUsage, getContextUsageLevel, getContextUsageThemeColor } from "./context-thresholds";
@@ -280,7 +281,8 @@ const pathSegment: StatusLineSegment = {
280
281
  if (stripPrefix && ctx.worktree) {
281
282
  const { projectName, worktreeName } = ctx.worktree;
282
283
  const label = ctx.git.branch === worktreeName ? projectName : `${projectName}/${worktreeName}`;
283
- const content = withIcon(theme.icon.worktree, clampPathLength(label, opts.maxLength ?? 40));
284
+ const text = fileHyperlink(getProjectDir(), clampPathLength(label, opts.maxLength ?? 40));
285
+ const content = withIcon(theme.icon.worktree, text);
284
286
  return { content: theme.fg("statusLinePath", content), visible: true };
285
287
  }
286
288
 
@@ -301,13 +303,10 @@ const pathSegment: StatusLineSegment = {
301
303
  }
302
304
 
303
305
  pwd = clampPathLength(pwd, opts.maxLength ?? 40);
304
- if (repoSuffix) {
305
- pwd = `${pwd}${repoSuffix}`;
306
- }
307
306
 
308
307
  const showScratchIcon = scratch && stripPrefix;
309
308
  const icon = showScratchIcon ? theme.icon.scratchFolder : theme.icon.folder;
310
- const content = withIcon(icon, pwd);
309
+ const content = withIcon(icon, `${fileHyperlink(projectDir, pwd)}${repoSuffix}`);
311
310
  return { content: theme.fg("statusLinePath", content), visible: true };
312
311
  },
313
312
  };