@oh-my-pi/pi-coding-agent 15.10.2 → 15.10.4

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 (95) hide show
  1. package/CHANGELOG.md +66 -1
  2. package/dist/types/cli/gallery-fixtures/types.d.ts +7 -1
  3. package/dist/types/edit/index.d.ts +0 -1
  4. package/dist/types/eval/__tests__/js-context-manager.test.d.ts +1 -0
  5. package/dist/types/eval/bridge-timeout.d.ts +1 -1
  6. package/dist/types/eval/{llm-bridge.d.ts → completion-bridge.d.ts} +8 -8
  7. package/dist/types/eval/idle-timeout.d.ts +1 -1
  8. package/dist/types/lsp/index.d.ts +0 -5
  9. package/dist/types/main.d.ts +11 -0
  10. package/dist/types/modes/components/assistant-message.d.ts +0 -9
  11. package/dist/types/modes/components/late-diagnostics-message.d.ts +20 -0
  12. package/dist/types/modes/components/read-tool-group.d.ts +6 -0
  13. package/dist/types/modes/components/session-selector.d.ts +16 -7
  14. package/dist/types/modes/components/tool-execution.d.ts +0 -18
  15. package/dist/types/modes/types.d.ts +4 -0
  16. package/dist/types/session/messages.d.ts +11 -8
  17. package/dist/types/session/yield-queue.d.ts +10 -1
  18. package/dist/types/tools/eval-render.d.ts +0 -1
  19. package/dist/types/tools/index.d.ts +31 -0
  20. package/dist/types/tools/path-utils.d.ts +5 -1
  21. package/dist/types/tools/read.d.ts +2 -1
  22. package/dist/types/tools/render-utils.d.ts +3 -1
  23. package/dist/types/tools/renderers.d.ts +0 -15
  24. package/dist/types/tools/write.d.ts +0 -2
  25. package/dist/types/tui/code-cell.d.ts +0 -2
  26. package/dist/types/tui/hyperlink.d.ts +5 -7
  27. package/dist/types/tui/output-block.d.ts +0 -18
  28. package/package.json +9 -9
  29. package/src/cli/gallery-cli.ts +4 -0
  30. package/src/cli/gallery-fixtures/codeintel.ts +0 -1
  31. package/src/cli/gallery-fixtures/fs.ts +68 -1
  32. package/src/cli/gallery-fixtures/types.ts +8 -1
  33. package/src/commit/agentic/agent.ts +1 -0
  34. package/src/edit/hashline/diff.ts +86 -0
  35. package/src/edit/hashline/execute.ts +14 -1
  36. package/src/edit/index.ts +31 -17
  37. package/src/edit/renderer.ts +116 -31
  38. package/src/eval/__tests__/agent-bridge.test.ts +13 -0
  39. package/src/eval/__tests__/{llm-bridge.test.ts → completion-bridge.test.ts} +60 -54
  40. package/src/eval/__tests__/js-context-manager.test.ts +241 -0
  41. package/src/eval/agent-bridge.ts +6 -1
  42. package/src/eval/bridge-timeout.ts +1 -1
  43. package/src/eval/{llm-bridge.ts → completion-bridge.ts} +30 -27
  44. package/src/eval/idle-timeout.ts +1 -1
  45. package/src/eval/js/context-manager.ts +66 -6
  46. package/src/eval/js/shared/prelude.txt +28 -12
  47. package/src/eval/js/tool-bridge.ts +3 -3
  48. package/src/eval/js/worker-entry.ts +6 -0
  49. package/src/eval/py/prelude.py +3 -3
  50. package/src/internal-urls/docs-index.generated.ts +8 -7
  51. package/src/lsp/index.ts +128 -52
  52. package/src/main.ts +54 -14
  53. package/src/modes/components/assistant-message.ts +3 -15
  54. package/src/modes/components/late-diagnostics-message.ts +60 -0
  55. package/src/modes/components/plan-review-overlay.ts +26 -5
  56. package/src/modes/components/read-tool-group.ts +415 -35
  57. package/src/modes/components/session-selector.ts +89 -35
  58. package/src/modes/components/tips.txt +1 -1
  59. package/src/modes/components/tool-execution.ts +7 -49
  60. package/src/modes/components/transcript-container.ts +108 -32
  61. package/src/modes/controllers/event-controller.ts +6 -1
  62. package/src/modes/controllers/input-controller.ts +10 -2
  63. package/src/modes/types.ts +4 -0
  64. package/src/modes/utils/ui-helpers.ts +26 -5
  65. package/src/prompts/system/manual-continue.md +7 -0
  66. package/src/prompts/system/plan-mode-active.md +56 -72
  67. package/src/prompts/system/tiny-title-system.md +1 -1
  68. package/src/prompts/system/title-system.md +16 -3
  69. package/src/prompts/system/workflow-notice.md +1 -1
  70. package/src/prompts/tools/eval.md +6 -4
  71. package/src/prompts/tools/lsp-late-diagnostic.md +8 -0
  72. package/src/sdk.ts +59 -1
  73. package/src/session/agent-session.ts +5 -3
  74. package/src/session/messages.ts +21 -14
  75. package/src/session/session-manager.ts +2 -2
  76. package/src/session/yield-queue.ts +20 -2
  77. package/src/task/executor.ts +1 -0
  78. package/src/tiny/title-client.ts +6 -1
  79. package/src/tools/bash.ts +0 -7
  80. package/src/tools/eval-render.ts +6 -25
  81. package/src/tools/eval.ts +1 -1
  82. package/src/tools/find.ts +148 -106
  83. package/src/tools/index.ts +32 -0
  84. package/src/tools/path-utils.ts +19 -22
  85. package/src/tools/read.ts +16 -8
  86. package/src/tools/render-utils.ts +3 -1
  87. package/src/tools/renderers.ts +0 -15
  88. package/src/tools/ssh.ts +0 -1
  89. package/src/tools/todo.ts +1 -0
  90. package/src/tools/write.ts +3 -12
  91. package/src/tui/code-cell.ts +1 -6
  92. package/src/tui/hyperlink.ts +13 -23
  93. package/src/tui/output-block.ts +2 -97
  94. package/src/utils/title-generator.ts +2 -2
  95. /package/dist/types/eval/__tests__/{llm-bridge.test.d.ts → completion-bridge.test.d.ts} +0 -0
package/src/lsp/index.ts CHANGED
@@ -40,7 +40,6 @@ import {
40
40
  rangesOverlap,
41
41
  } from "./edits";
42
42
  import { detectLspmux } from "./lspmux";
43
- import { renderCall, renderResult } from "./render";
44
43
  import {
45
44
  type CodeAction,
46
45
  type CodeActionContext,
@@ -302,6 +301,22 @@ function isProjectAwareLspServer(serverConfig: ServerConfig): boolean {
302
301
  const DIAGNOSTIC_MESSAGE_LIMIT = 50;
303
302
  const SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS = 3000;
304
303
  const BATCH_DIAGNOSTICS_WAIT_TIMEOUT_MS = 400;
304
+ const DIAGNOSTICS_POLL_MS = 100;
305
+ const DIAGNOSTICS_SETTLE_MS = 250;
306
+ /**
307
+ * How long the edit/write writethrough blocks inline waiting for fresh
308
+ * diagnostics before handing slow servers off to the deferred late-injection
309
+ * channel. Keeps the common fast-server case inline while letting an edit
310
+ * return promptly when a server (e.g. a large-monorepo tsserver) is slow to
311
+ * publish fresh diagnostics.
312
+ */
313
+ const INLINE_DIAGNOSTICS_WAIT_TIMEOUT_MS = 500;
314
+ /**
315
+ * Inner per-server diagnostics wait budget for the background/deferred fetch.
316
+ * Longer than the inline cap (and the old 3s default) so a slow server still
317
+ * delivers late instead of giving up before it ever publishes.
318
+ */
319
+ const DEFERRED_DIAGNOSTICS_WAIT_TIMEOUT_MS = 12_000;
305
320
  const MAX_GLOB_DIAGNOSTIC_TARGETS = 20;
306
321
  const WORKSPACE_SYMBOL_LIMIT = 200;
307
322
  const PROJECT_INDEXED_ACTIONS: ReadonlySet<string> = new Set([
@@ -461,27 +476,15 @@ interface WaitForDiagnosticsOptions {
461
476
  signal?: AbortSignal;
462
477
  minVersion?: number;
463
478
  expectedDocumentVersion?: number;
464
- allowUnversioned?: boolean;
465
- }
466
-
467
- function getAcceptedDiagnostics(
468
- publishedDiagnostics: PublishedDiagnostics | undefined,
469
- expectedDocumentVersion?: number,
470
- allowUnversioned = true,
471
- ): Diagnostic[] | undefined {
472
- if (!publishedDiagnostics) {
473
- return undefined;
474
- }
475
- if (expectedDocumentVersion === undefined) {
476
- return publishedDiagnostics.diagnostics;
477
- }
478
- if (publishedDiagnostics.version === expectedDocumentVersion) {
479
- return publishedDiagnostics.diagnostics;
480
- }
481
- if (allowUnversioned && publishedDiagnostics.version == null) {
482
- return publishedDiagnostics.diagnostics;
483
- }
484
- return undefined;
479
+ /**
480
+ * Quiescence window (ms). typescript-language-server never echoes the document
481
+ * version (issue #983) and emits diagnostics from several sources at different
482
+ * times, so there is no single "complete, version-matched" publish to gate on.
483
+ * When the server does not exact-version-match, accept the latest publish only
484
+ * after no newer one has arrived for this long, letting an in-flight pre-edit
485
+ * publish be superseded by the fresh one.
486
+ */
487
+ settleMs?: number;
485
488
  }
486
489
 
487
490
  async function waitForDiagnostics(
@@ -489,26 +492,35 @@ async function waitForDiagnostics(
489
492
  uri: string,
490
493
  options: WaitForDiagnosticsOptions = {},
491
494
  ): Promise<Diagnostic[]> {
492
- const { timeoutMs = 3000, signal, minVersion, expectedDocumentVersion, allowUnversioned = true } = options;
495
+ const { timeoutMs = 3000, signal, minVersion, expectedDocumentVersion, settleMs = DIAGNOSTICS_SETTLE_MS } = options;
493
496
  const start = Date.now();
497
+ let settledRef: PublishedDiagnostics | undefined;
498
+ let settledAt = 0;
494
499
  while (Date.now() - start < timeoutMs) {
495
500
  throwIfAborted(signal);
496
501
  const versionOk = minVersion === undefined || client.diagnosticsVersion > minVersion;
497
- const diagnostics = getAcceptedDiagnostics(
498
- client.diagnostics.get(uri),
499
- expectedDocumentVersion,
500
- allowUnversioned,
501
- );
502
- if (diagnostics !== undefined && versionOk) {
503
- return diagnostics;
502
+ const published = client.diagnostics.get(uri);
503
+ if (published && versionOk) {
504
+ // Server honored our exact document version → authoritative, accept now.
505
+ if (expectedDocumentVersion !== undefined && published.version === expectedDocumentVersion) {
506
+ return published.diagnostics;
507
+ }
508
+ // Unversioned/mismatched publish: wait for the stream to go quiet so an
509
+ // in-flight publish for the pre-edit content is superseded by the fresh one.
510
+ if (published !== settledRef) {
511
+ settledRef = published;
512
+ settledAt = Date.now();
513
+ } else if (Date.now() - settledAt >= settleMs) {
514
+ return published.diagnostics;
515
+ }
504
516
  }
505
- await Bun.sleep(100);
517
+ await Bun.sleep(DIAGNOSTICS_POLL_MS);
506
518
  }
507
519
  const versionOk = minVersion === undefined || client.diagnosticsVersion > minVersion;
508
520
  if (!versionOk) {
509
521
  return [];
510
522
  }
511
- return getAcceptedDiagnostics(client.diagnostics.get(uri), expectedDocumentVersion, allowUnversioned) ?? [];
523
+ return client.diagnostics.get(uri)?.diagnostics ?? [];
512
524
  }
513
525
 
514
526
  /** Project type detection result */
@@ -613,7 +625,8 @@ interface GetDiagnosticsForFileOptions {
613
625
  signal?: AbortSignal;
614
626
  minVersions?: ServerVersionMap;
615
627
  expectedDocumentVersions?: ServerVersionMap;
616
- allowUnversionedLspDiagnostics?: boolean;
628
+ /** Per-server wait budget (ms). Defaults to {@link SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS}. */
629
+ timeoutMs?: number;
617
630
  }
618
631
 
619
632
  /**
@@ -669,7 +682,7 @@ async function getDiagnosticsForFile(
669
682
  servers: Array<[string, ServerConfig]>,
670
683
  options: GetDiagnosticsForFileOptions = {},
671
684
  ): Promise<FileDiagnosticsResult | undefined> {
672
- const { signal, minVersions, expectedDocumentVersions, allowUnversionedLspDiagnostics = true } = options;
685
+ const { signal, minVersions, expectedDocumentVersions, timeoutMs } = options;
673
686
  if (servers.length === 0) {
674
687
  return undefined;
675
688
  }
@@ -701,11 +714,10 @@ async function getDiagnosticsForFile(
701
714
  const minVersion = minVersions?.get(serverName);
702
715
  const expectedDocumentVersion = expectedDocumentVersions?.get(serverName);
703
716
  const diagnostics = await waitForDiagnostics(client, uri, {
704
- timeoutMs: 3000,
717
+ timeoutMs: timeoutMs ?? SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS,
705
718
  signal,
706
719
  minVersion,
707
720
  expectedDocumentVersion,
708
- allowUnversioned: allowUnversionedLspDiagnostics,
709
721
  });
710
722
  return { serverName, diagnostics };
711
723
  }),
@@ -1007,6 +1019,7 @@ async function scheduleDeferredDiagnosticsFetch(args: {
1007
1019
  signal: combined,
1008
1020
  minVersions: args.minVersions,
1009
1021
  expectedDocumentVersions: args.expectedDocumentVersions,
1022
+ timeoutMs: DEFERRED_DIAGNOSTICS_WAIT_TIMEOUT_MS,
1010
1023
  });
1011
1024
  if (args.signal.aborted || diagnostics === undefined) return;
1012
1025
  args.callback(diagnostics);
@@ -1015,6 +1028,70 @@ async function scheduleDeferredDiagnosticsFetch(args: {
1015
1028
  }
1016
1029
  }
1017
1030
 
1031
+ /**
1032
+ * Fetch post-write diagnostics without making the edit/write block on a slow
1033
+ * language server.
1034
+ *
1035
+ * Blocks inline only briefly ({@link INLINE_DIAGNOSTICS_WAIT_TIMEOUT_MS}) for a
1036
+ * fresh result. Freshness is enforced by the pre-edit `minVersions` baseline:
1037
+ * exact document-version matches return immediately, and unversioned/mismatched
1038
+ * publishes must settle with no newer publish before inline acceptance. If
1039
+ * nothing fresh arrives in the inline window and a deferred
1040
+ * channel is available, the in-flight fetch is handed off to deliver late via
1041
+ * `onDeferredDiagnostics`, and this returns `undefined` so the tool result
1042
+ * lands immediately. Without a deferred channel (direct/CI callers) it blocks
1043
+ * for the standard budget so the result is still returned inline.
1044
+ */
1045
+ async function fetchDiagnosticsWithDeferral(args: {
1046
+ dst: string;
1047
+ cwd: string;
1048
+ servers: Array<[string, ServerConfig]>;
1049
+ minVersions: ServerVersionMap | undefined;
1050
+ expectedDocumentVersions: ServerVersionMap | undefined;
1051
+ transformDiagnostics?: ResolvedWritethroughOptions["transformDiagnostics"];
1052
+ deferred?: { onDeferredDiagnostics: (diagnostics: FileDiagnosticsResult) => void; signal: AbortSignal };
1053
+ signal?: AbortSignal;
1054
+ }): Promise<FileDiagnosticsResult | undefined> {
1055
+ const { dst, cwd, servers, minVersions, expectedDocumentVersions, transformDiagnostics, deferred, signal } = args;
1056
+ const apply = (d: FileDiagnosticsResult | undefined) =>
1057
+ d && transformDiagnostics ? transformDiagnostics(dst, d) : d;
1058
+
1059
+ if (!deferred) {
1060
+ // No late-injection channel: block for the standard budget and return inline.
1061
+ return apply(
1062
+ await getDiagnosticsForFile(dst, cwd, servers, {
1063
+ signal,
1064
+ minVersions,
1065
+ expectedDocumentVersions,
1066
+ }),
1067
+ );
1068
+ }
1069
+
1070
+ // One background fetch with a generous inner budget; await it only briefly inline.
1071
+ const fetchPromise = getDiagnosticsForFile(dst, cwd, servers, {
1072
+ signal: deferred.signal,
1073
+ minVersions,
1074
+ expectedDocumentVersions,
1075
+ timeoutMs: DEFERRED_DIAGNOSTICS_WAIT_TIMEOUT_MS,
1076
+ });
1077
+ const INLINE_TIMEOUT = Symbol("inline-diagnostics-timeout");
1078
+ const raced = await Promise.race([
1079
+ fetchPromise,
1080
+ Bun.sleep(INLINE_DIAGNOSTICS_WAIT_TIMEOUT_MS).then(() => INLINE_TIMEOUT),
1081
+ ]);
1082
+ if (raced !== INLINE_TIMEOUT) {
1083
+ return apply(raced as FileDiagnosticsResult | undefined);
1084
+ }
1085
+ // Slow server: deliver late via the deferred channel; nothing inline. The
1086
+ // deferred sink (edit tool) applies its own dedup, so pass the raw result.
1087
+ void fetchPromise
1088
+ .then(diagnostics => {
1089
+ if (diagnostics && !deferred.signal.aborted) deferred.onDeferredDiagnostics(diagnostics);
1090
+ })
1091
+ .catch(() => {});
1092
+ return undefined;
1093
+ }
1094
+
1018
1095
  async function runLspWritethrough(
1019
1096
  dst: string,
1020
1097
  content: string,
@@ -1047,6 +1124,7 @@ async function runLspWritethrough(
1047
1124
  let formatter: FileFormatResult | undefined;
1048
1125
  let diagnostics: FileDiagnosticsResult | undefined;
1049
1126
  let timedOut = false;
1127
+ let synced = false;
1050
1128
  try {
1051
1129
  const timeoutSignal = AbortSignal.timeout(5_000);
1052
1130
  timeoutSignal.addEventListener(
@@ -1090,19 +1168,8 @@ async function runLspWritethrough(
1090
1168
 
1091
1169
  // 5. Notify saved to LSP servers
1092
1170
  await notifyFileSaved(dst, cwd, lspServers, operationSignal);
1093
-
1094
- // 6. Get diagnostics from all servers (wait for fresh results)
1095
- if (enableDiagnostics) {
1096
- const fetched = await getDiagnosticsForFile(dst, cwd, servers, {
1097
- signal: operationSignal,
1098
- minVersions,
1099
- expectedDocumentVersions,
1100
- allowUnversionedLspDiagnostics: false,
1101
- });
1102
- diagnostics =
1103
- fetched && options.transformDiagnostics ? options.transformDiagnostics(dst, fetched) : fetched;
1104
- }
1105
1171
  });
1172
+ synced = true;
1106
1173
  } catch {
1107
1174
  if (timedOut) {
1108
1175
  formatter = undefined;
@@ -1123,6 +1190,19 @@ async function runLspWritethrough(
1123
1190
  await getWritePromise();
1124
1191
  }
1125
1192
 
1193
+ if (synced && enableDiagnostics) {
1194
+ diagnostics = await fetchDiagnosticsWithDeferral({
1195
+ dst,
1196
+ cwd,
1197
+ servers,
1198
+ minVersions,
1199
+ expectedDocumentVersions,
1200
+ transformDiagnostics: options.transformDiagnostics,
1201
+ deferred,
1202
+ signal,
1203
+ });
1204
+ }
1205
+
1126
1206
  if (formatter !== undefined) {
1127
1207
  diagnostics ??= {
1128
1208
  server: servers.map(([name]) => name).join(", "),
@@ -1229,10 +1309,6 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1229
1309
  readonly summary = "Query LSP (language server) for diagnostics, hover info, and references";
1230
1310
  readonly description: string;
1231
1311
  readonly parameters = lspSchema;
1232
- readonly renderCall = renderCall;
1233
- readonly renderResult = renderResult;
1234
- readonly mergeCallAndResult = true;
1235
- readonly inline = true;
1236
1312
  readonly strict = true;
1237
1313
 
1238
1314
  constructor(private readonly session: ToolSession) {
package/src/main.ts CHANGED
@@ -171,7 +171,8 @@ export async function submitInteractiveInput(
171
171
 
172
172
  try {
173
173
  using _keepalive = new EventLoopKeepalive();
174
- // Continue shortcuts submit an already-started empty prompt with no optimistic user message.
174
+ // Continue shortcuts submit an already-started synthetic developer prompt with
175
+ // no optimistic user message.
175
176
  if (!input.started && !mode.markPendingSubmissionStarted(input)) {
176
177
  return;
177
178
  }
@@ -182,6 +183,8 @@ export async function submitInteractiveInput(
182
183
  display: input.display ?? false,
183
184
  attribution: "agent",
184
185
  });
186
+ } else if (input.synthetic) {
187
+ await session.prompt(input.text, { synthetic: true, expandPromptTemplates: false });
185
188
  } else {
186
189
  await session.prompt(input.text, { images: input.images });
187
190
  }
@@ -381,6 +384,22 @@ async function promptMoveSession(session: SessionInfo): Promise<SessionPromptRes
381
384
  }
382
385
  }
383
386
 
387
+ /**
388
+ * Friendly CLI failure raised by {@link createSessionManager} when the user's
389
+ * session-resolution flags (`--resume`/`--fork`/cross-project prompts) cannot
390
+ * be satisfied. {@link runRootCommand} catches it and prints a clean stderr
391
+ * message instead of letting it surface as `[Uncaught Exception]`
392
+ * (see issue #2084).
393
+ */
394
+ export class SessionResolutionError extends Error {
395
+ readonly hint?: string;
396
+ constructor(message: string, hint?: string) {
397
+ super(message);
398
+ this.name = "SessionResolutionError";
399
+ this.hint = hint;
400
+ }
401
+ }
402
+
384
403
  type MissingCwdMoveResult =
385
404
  | { status: "not-needed" }
386
405
  | { status: "declined" }
@@ -400,7 +419,7 @@ async function moveMissingCwdSessionIfNeeded(
400
419
 
401
420
  const movePromptResult = await askToMoveSession(session);
402
421
  if (movePromptResult === "unavailable") {
403
- throw new Error(
422
+ throw new SessionResolutionError(
404
423
  `Session "${sessionArg}" belongs to a directory that no longer exists (${sourceCwd}); run interactively to move it into the current project.`,
405
424
  );
406
425
  }
@@ -463,7 +482,7 @@ export async function createSessionManager(
463
482
  ): Promise<SessionManager | undefined> {
464
483
  if (parsed.fork) {
465
484
  if (parsed.noSession) {
466
- throw new Error("--fork requires session persistence");
485
+ throw new SessionResolutionError("--fork requires session persistence");
467
486
  }
468
487
  const forkSource = parsed.fork;
469
488
  if (forkSource.includes("/") || forkSource.includes("\\") || forkSource.endsWith(".jsonl")) {
@@ -471,7 +490,10 @@ export async function createSessionManager(
471
490
  }
472
491
  const match = await resolveResumableSession(forkSource, cwd, parsed.sessionDir);
473
492
  if (!match) {
474
- throw new Error(`Session "${forkSource}" not found.`);
493
+ throw new SessionResolutionError(
494
+ `Session "${forkSource}" not found.`,
495
+ "Run `omp --resume` without an argument to pick from recent sessions, or `omp` to start a new one.",
496
+ );
475
497
  }
476
498
  return await SessionManager.forkFrom(match.session.path, cwd, parsed.sessionDir);
477
499
  }
@@ -486,7 +508,10 @@ export async function createSessionManager(
486
508
  }
487
509
  const match = await resolveResumableSession(sessionArg, cwd, parsed.sessionDir);
488
510
  if (!match) {
489
- throw new Error(`Session "${sessionArg}" not found.`);
511
+ throw new SessionResolutionError(
512
+ `Session "${sessionArg}" not found.`,
513
+ "Run `omp --resume` without an argument to pick from recent sessions, or `omp` to start a new one.",
514
+ );
490
515
  }
491
516
  if (match.scope === "local") {
492
517
  const moveResult = await moveMissingCwdSessionIfNeeded(
@@ -522,7 +547,7 @@ export async function createSessionManager(
522
547
  }
523
548
  const forkPromptResult = await askToForkSession(match.session);
524
549
  if (forkPromptResult === "unavailable") {
525
- throw new Error(
550
+ throw new SessionResolutionError(
526
551
  `Session "${sessionArg}" is in another project (${match.session.cwd}); run interactively to fork it into the current project.`,
527
552
  );
528
553
  }
@@ -919,14 +944,29 @@ export async function runRootCommand(
919
944
  );
920
945
  }
921
946
 
922
- // Create session manager based on CLI flags
923
- let sessionManager = await logger.time(
924
- "createSessionManager",
925
- createSessionManager,
926
- parsedArgs,
927
- cwd,
928
- settingsInstance,
929
- );
947
+ // Create session manager based on CLI flags. SessionResolutionError signals a
948
+ // user-facing failure (unknown --resume/--fork id, non-interactive fork
949
+ // prompt, --fork with --no-session): print + exit cleanly instead of letting
950
+ // it surface as `[Uncaught Exception]` (see issue #2084).
951
+ let sessionManager: SessionManager | undefined;
952
+ try {
953
+ sessionManager = await logger.time(
954
+ "createSessionManager",
955
+ createSessionManager,
956
+ parsedArgs,
957
+ cwd,
958
+ settingsInstance,
959
+ );
960
+ } catch (error: unknown) {
961
+ if (error instanceof SessionResolutionError) {
962
+ process.stderr.write(`${chalk.red(`Error: ${error.message}`)}\n`);
963
+ if (error.hint) {
964
+ process.stderr.write(`${chalk.dim(error.hint)}\n`);
965
+ }
966
+ process.exit(1);
967
+ }
968
+ throw error;
969
+ }
930
970
 
931
971
  // User declined the cross-project fork prompt — exit cleanly with a friendly
932
972
  // message rather than letting the decline bubble up as an uncaught exception
@@ -4,7 +4,7 @@ import { formatNumber } from "@oh-my-pi/pi-utils";
4
4
  import { settings } from "../../config/settings";
5
5
  import type { AssistantThinkingRenderer } from "../../extensibility/extensions/types";
6
6
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
7
- import { isSilentAbort, resolveAbortLabel } from "../../session/messages";
7
+ import { resolveAbortLabel, shouldRenderAbortReason } from "../../session/messages";
8
8
  import { resolveImageOptions } from "../../tools/render-utils";
9
9
 
10
10
  /**
@@ -74,18 +74,6 @@ export class AssistantMessageComponent extends Container {
74
74
  return this.#transcriptBlockFinalized;
75
75
  }
76
76
 
77
- /**
78
- * Assistant text/thinking streams in append-only: earlier rendered rows never
79
- * re-layout, new content only grows the block at the bottom. The transcript
80
- * reports this so the renderer may commit scrolled-off head rows of a long
81
- * streamed reply to native scrollback instead of dropping them (see
82
- * `NativeScrollbackLiveRegion#getNativeScrollbackCommitSafeEnd`). Volatile
83
- * blocks (tool previews that collapse) intentionally do not implement this.
84
- */
85
- isTranscriptBlockAppendOnly(): boolean {
86
- return true;
87
- }
88
-
89
77
  markTranscriptBlockFinalized(): void {
90
78
  this.#transcriptBlockFinalized = true;
91
79
  }
@@ -252,7 +240,7 @@ export class AssistantMessageComponent extends Container {
252
240
  // But only if there are no tool calls (tool execution components will show the error)
253
241
  const hasToolCalls = message.content.some(c => c.type === "toolCall");
254
242
  if (!hasToolCalls) {
255
- if (message.stopReason === "aborted" && !isSilentAbort(message.errorMessage)) {
243
+ if (message.stopReason === "aborted" && shouldRenderAbortReason(message.errorMessage)) {
256
244
  const abortMessage = resolveAbortLabel(message.errorMessage);
257
245
  if (hasVisibleContent) {
258
246
  this.#contentContainer.addChild(new Spacer(1));
@@ -268,7 +256,7 @@ export class AssistantMessageComponent extends Container {
268
256
  }
269
257
  if (
270
258
  message.errorMessage &&
271
- !isSilentAbort(message.errorMessage) &&
259
+ shouldRenderAbortReason(message.errorMessage) &&
272
260
  message.stopReason !== "aborted" &&
273
261
  message.stopReason !== "error"
274
262
  ) {
@@ -0,0 +1,60 @@
1
+ import { Container, Text } from "@oh-my-pi/pi-tui";
2
+ import { formatDiagnostics } from "../../tools/render-utils";
3
+ import { getLanguageFromPath, theme } from "../theme/theme";
4
+
5
+ /** One file's worth of late LSP diagnostics, as carried on the transcript message. */
6
+ export interface LateDiagnosticsFile {
7
+ path?: string;
8
+ summary?: string;
9
+ errored?: boolean;
10
+ messages?: string[];
11
+ }
12
+
13
+ /**
14
+ * Renders late LSP diagnostics (arrived after edit/write returned) in the
15
+ * transcript, reusing the same tree renderer the edit/write tools use so the
16
+ * styling stays consistent. Supports the global tool-output expand toggle.
17
+ */
18
+ export class LateDiagnosticsMessageComponent extends Container {
19
+ #expanded = false;
20
+
21
+ constructor(private readonly files: LateDiagnosticsFile[]) {
22
+ super();
23
+ this.#rebuild();
24
+ }
25
+
26
+ setExpanded(expanded: boolean): void {
27
+ if (this.#expanded === expanded) return;
28
+ this.#expanded = expanded;
29
+ this.#rebuild();
30
+ }
31
+
32
+ override invalidate(): void {
33
+ super.invalidate();
34
+ this.#rebuild();
35
+ }
36
+
37
+ #rebuild(): void {
38
+ this.clear();
39
+
40
+ const messages: string[] = [];
41
+ const summaries: string[] = [];
42
+ let errored = false;
43
+ for (const file of this.files) {
44
+ if (file.messages?.length) messages.push(...file.messages);
45
+ if (file.summary) summaries.push(file.summary);
46
+ if (file.errored) errored = true;
47
+ }
48
+ if (messages.length === 0) return;
49
+
50
+ const text = formatDiagnostics(
51
+ { errored, summary: summaries.join(", "), messages },
52
+ this.#expanded,
53
+ theme,
54
+ fp => theme.getLangIcon(getLanguageFromPath(fp)),
55
+ { title: "Late diagnostics" },
56
+ );
57
+ const body = text.replace(/^\n+/, "");
58
+ if (body) this.addChild(new Text(body, 1, 0));
59
+ }
60
+ }
@@ -141,6 +141,9 @@ export class PlanReviewOverlay implements Component {
141
141
  #bodyClickRows = new Set<number>();
142
142
  /** 1-based column at/under which a region-row click targets the sidebar. */
143
143
  #sidebarClickMaxCol = 0;
144
+ /** Option index the pointer is currently hovering, or undefined. Updated from
145
+ * motion mouse reports and cleared when the pointer leaves the option rows. */
146
+ #hoveredOption: number | undefined;
144
147
 
145
148
  #annotating = false;
146
149
  #input: Input;
@@ -315,9 +318,10 @@ export class PlanReviewOverlay implements Component {
315
318
  * Hit-test an SGR mouse report (`\x1b[<b;x;yM/m`) against the click maps the
316
319
  * last render recorded. Returns true when consumed. The fullscreen overlay
317
320
  * paints from screen row 0, so a 1-based mouse row maps directly to the
318
- * rendered-line index. Wheel scrolls the body; a left click on an option
319
- * activates it (select + confirm), on a ToC row jumps to that section, and on
320
- * the body column focuses the body.
321
+ * rendered-line index. Wheel scrolls the body; pointer motion lights up the
322
+ * hovered option row; a left click on an option activates it (select +
323
+ * confirm), on a ToC row jumps to that section, and on the body column focuses
324
+ * the body.
321
325
  */
322
326
  #handleMouse(data: string): boolean {
323
327
  const match = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(data);
@@ -331,7 +335,13 @@ export class PlanReviewOverlay implements Component {
331
335
  return true;
332
336
  }
333
337
  if (match[4] !== "M") return true; // release
334
- if (button & 32) return true; // motion/drag
338
+ if (button & 32) {
339
+ // Motion (hover or drag): light up the option row under the pointer so a
340
+ // mouse user gets the same affordance the keyboard cursor gives. Any
341
+ // non-option row clears the highlight.
342
+ this.#setHoveredOption(this.#optionClickRows.get(row));
343
+ return true;
344
+ }
335
345
  if ((button & 3) !== 0) return true; // not the left button
336
346
  const optionIndex = this.#optionClickRows.get(row);
337
347
  if (optionIndex !== undefined) {
@@ -355,6 +365,12 @@ export class PlanReviewOverlay implements Component {
355
365
  return true;
356
366
  }
357
367
 
368
+ /** Set the hovered option from a hit-tested row, ignoring disabled rows and
369
+ * non-option rows (both clear the highlight). */
370
+ #setHoveredOption(index: number | undefined): void {
371
+ this.#hoveredOption = index !== undefined && !this.#disabled.has(index) ? index : undefined;
372
+ }
373
+
358
374
  #cycleRegion(direction: number): void {
359
375
  // Sidebar is skipped from the cycle when it is not shown.
360
376
  const regions: Focus[] = this.#sidebarShown ? ["toc", "body", "actions"] : ["body", "actions"];
@@ -611,14 +627,19 @@ export class PlanReviewOverlay implements Component {
611
627
  return this.#options.map((label, i) => {
612
628
  const selected = i === this.#selectedIndex;
613
629
  const isDisabled = this.#disabled.has(i);
630
+ const hovered = !isDisabled && i === this.#hoveredOption;
614
631
  // The cursor marks the selected option; it dims when actions are not the
615
632
  // focused region so the active region's highlight stays unambiguous.
616
633
  const cursor = selected ? theme.fg(active ? "accent" : "dim", `${theme.nav.cursor} `) : " ";
617
- const text = isDisabled
634
+ let text = isDisabled
618
635
  ? theme.fg("dim", label)
619
636
  : selected && active
620
637
  ? theme.bold(theme.fg("accent", label))
621
638
  : theme.fg("text", label);
639
+ // A pointer hovering an option paints a highlight band behind its label,
640
+ // distinct from the keyboard selection (cursor glyph + bold accent) which
641
+ // stays where it is. One space of padding gives the band a button shape.
642
+ if (hovered) text = theme.bg("selectedBg", ` ${text} `);
622
643
  return cursor + text;
623
644
  });
624
645
  }