@oh-my-pi/pi-coding-agent 16.2.13 → 16.3.0

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 (166) hide show
  1. package/CHANGELOG.md +108 -7
  2. package/dist/cli.js +6033 -5982
  3. package/dist/types/advisor/config.d.ts +4 -2
  4. package/dist/types/collab/host.d.ts +16 -0
  5. package/dist/types/collab/protocol.d.ts +9 -3
  6. package/dist/types/commit/model-selection.d.ts +5 -0
  7. package/dist/types/config/model-resolver.d.ts +12 -10
  8. package/dist/types/config/settings-schema.d.ts +28 -5
  9. package/dist/types/edit/modes/patch.d.ts +11 -0
  10. package/dist/types/eval/agent-bridge.d.ts +5 -1
  11. package/dist/types/exec/bash-executor.d.ts +1 -0
  12. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +86 -2
  13. package/dist/types/extensibility/skills.d.ts +2 -1
  14. package/dist/types/mnemopi/state.d.ts +12 -0
  15. package/dist/types/modes/components/agent-hub.d.ts +9 -5
  16. package/dist/types/modes/components/status-line/component.test.d.ts +1 -0
  17. package/dist/types/modes/components/usage-row.d.ts +1 -1
  18. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  19. package/dist/types/modes/controllers/extension-ui-controller.d.ts +6 -0
  20. package/dist/types/modes/controllers/streaming-reveal.d.ts +17 -1
  21. package/dist/types/modes/controllers/tool-args-reveal.d.ts +30 -3
  22. package/dist/types/modes/interactive-mode.d.ts +12 -7
  23. package/dist/types/modes/rpc/rpc-client.d.ts +5 -0
  24. package/dist/types/modes/rpc/rpc-mode.d.ts +64 -1
  25. package/dist/types/modes/session-teardown.d.ts +63 -0
  26. package/dist/types/modes/session-teardown.test.d.ts +1 -0
  27. package/dist/types/modes/theme/theme.d.ts +9 -4
  28. package/dist/types/modes/types.d.ts +2 -3
  29. package/dist/types/modes/utils/copy-targets.d.ts +2 -0
  30. package/dist/types/plan-mode/approved-plan-prompt.test.d.ts +1 -0
  31. package/dist/types/sdk.d.ts +9 -0
  32. package/dist/types/session/agent-session.d.ts +46 -13
  33. package/dist/types/session/exit-diagnostics.d.ts +48 -0
  34. package/dist/types/session/session-loader.d.ts +5 -0
  35. package/dist/types/task/executor.d.ts +12 -5
  36. package/dist/types/task/parallel.d.ts +1 -0
  37. package/dist/types/task/render.test.d.ts +1 -0
  38. package/dist/types/thinking.d.ts +2 -0
  39. package/dist/types/tools/checkpoint.d.ts +8 -0
  40. package/dist/types/tools/eval-render.d.ts +1 -0
  41. package/dist/types/tools/fetch.d.ts +11 -1
  42. package/dist/types/tools/index.d.ts +3 -1
  43. package/dist/types/tools/irc.d.ts +1 -0
  44. package/dist/types/tools/output-meta.d.ts +7 -0
  45. package/dist/types/tools/output-schema-validator.d.ts +7 -4
  46. package/dist/types/tools/path-utils.d.ts +16 -0
  47. package/dist/types/tools/render-utils.d.ts +4 -1
  48. package/dist/types/utils/git.d.ts +47 -2
  49. package/dist/types/web/search/provider.d.ts +7 -0
  50. package/dist/types/web/search/types.d.ts +1 -1
  51. package/package.json +12 -12
  52. package/src/advisor/config.ts +4 -2
  53. package/src/cli/bench-cli.ts +7 -2
  54. package/src/collab/guest.ts +94 -5
  55. package/src/collab/host.ts +80 -3
  56. package/src/collab/protocol.ts +9 -2
  57. package/src/commit/model-selection.ts +8 -2
  58. package/src/config/keybindings.ts +3 -1
  59. package/src/config/model-discovery.ts +66 -2
  60. package/src/config/model-registry.ts +7 -3
  61. package/src/config/model-resolver.ts +51 -23
  62. package/src/config/settings-schema.ts +44 -14
  63. package/src/edit/hashline/diff.ts +13 -2
  64. package/src/edit/index.ts +58 -8
  65. package/src/edit/modes/patch.ts +53 -18
  66. package/src/edit/streaming.ts +7 -6
  67. package/src/eval/__tests__/agent-bridge.test.ts +57 -1
  68. package/src/eval/agent-bridge.ts +15 -5
  69. package/src/exec/bash-executor.ts +7 -12
  70. package/src/extensibility/legacy-pi-coding-agent-shim.ts +534 -16
  71. package/src/extensibility/skills.ts +29 -6
  72. package/src/goals/guided-setup.ts +4 -3
  73. package/src/internal-urls/docs-index.generated.txt +1 -1
  74. package/src/lsp/client.ts +11 -14
  75. package/src/main.ts +38 -10
  76. package/src/mnemopi/state.ts +43 -7
  77. package/src/modes/acp/acp-agent.ts +1 -1
  78. package/src/modes/components/agent-hub.ts +10 -2
  79. package/src/modes/components/agent-transcript-viewer.ts +39 -7
  80. package/src/modes/components/assistant-message.ts +16 -10
  81. package/src/modes/components/chat-transcript-builder.ts +11 -1
  82. package/src/modes/components/custom-editor.test.ts +20 -0
  83. package/src/modes/components/hook-selector.ts +44 -25
  84. package/src/modes/components/model-selector.ts +1 -1
  85. package/src/modes/components/status-line/component.test.ts +44 -0
  86. package/src/modes/components/status-line/component.ts +9 -1
  87. package/src/modes/components/status-line/segments.ts +3 -3
  88. package/src/modes/components/usage-row.ts +16 -2
  89. package/src/modes/controllers/command-controller.ts +6 -21
  90. package/src/modes/controllers/event-controller.ts +11 -9
  91. package/src/modes/controllers/extension-ui-controller.ts +84 -3
  92. package/src/modes/controllers/input-controller.ts +16 -13
  93. package/src/modes/controllers/selector-controller.ts +3 -8
  94. package/src/modes/controllers/streaming-reveal.ts +75 -53
  95. package/src/modes/controllers/tool-args-reveal.ts +340 -10
  96. package/src/modes/interactive-mode.ts +122 -79
  97. package/src/modes/rpc/rpc-client.ts +21 -0
  98. package/src/modes/rpc/rpc-mode.ts +197 -46
  99. package/src/modes/session-teardown.test.ts +219 -0
  100. package/src/modes/session-teardown.ts +82 -0
  101. package/src/modes/setup-wizard/scenes/theme.ts +2 -2
  102. package/src/modes/skill-command.ts +7 -20
  103. package/src/modes/theme/theme.ts +29 -15
  104. package/src/modes/types.ts +2 -3
  105. package/src/modes/utils/copy-targets.ts +12 -0
  106. package/src/modes/utils/ui-helpers.ts +19 -2
  107. package/src/plan-mode/approved-plan-prompt.test.ts +36 -0
  108. package/src/prompts/advisor/system.md +1 -1
  109. package/src/prompts/agents/tester.md +6 -2
  110. package/src/prompts/skills/autoload.md +8 -0
  111. package/src/prompts/skills/user-invocation.md +11 -0
  112. package/src/prompts/steering/parent-irc.md +5 -0
  113. package/src/prompts/system/irc-incoming.md +2 -0
  114. package/src/prompts/system/mid-run-todo-nudge.md +3 -0
  115. package/src/prompts/system/plan-mode-approved.md +7 -10
  116. package/src/prompts/system/plan-mode-compact-instructions.md +2 -1
  117. package/src/prompts/system/plan-mode-reference.md +3 -4
  118. package/src/prompts/system/rewind-report.md +6 -0
  119. package/src/prompts/system/subagent-system-prompt.md +3 -0
  120. package/src/prompts/tools/irc.md +2 -1
  121. package/src/prompts/tools/job.md +2 -2
  122. package/src/prompts/tools/rewind.md +3 -2
  123. package/src/prompts/tools/task.md +4 -0
  124. package/src/sdk.ts +18 -4
  125. package/src/session/agent-session.ts +660 -114
  126. package/src/session/exit-diagnostics.ts +202 -0
  127. package/src/session/session-context.ts +25 -13
  128. package/src/session/session-loader.ts +58 -25
  129. package/src/session/session-manager.ts +0 -1
  130. package/src/session/session-persistence.ts +30 -4
  131. package/src/session/settings-stream-fn.ts +14 -0
  132. package/src/slash-commands/builtin-registry.ts +35 -3
  133. package/src/system-prompt.ts +15 -1
  134. package/src/task/executor.ts +31 -8
  135. package/src/task/index.ts +31 -9
  136. package/src/task/isolation-runner.ts +18 -2
  137. package/src/task/parallel.ts +7 -2
  138. package/src/task/render.test.ts +121 -0
  139. package/src/task/render.ts +48 -2
  140. package/src/task/worktree.ts +12 -13
  141. package/src/task/yield-assembly.ts +8 -5
  142. package/src/thinking.ts +5 -0
  143. package/src/tools/ask.ts +188 -9
  144. package/src/tools/ast-edit.ts +7 -0
  145. package/src/tools/ast-grep.ts +11 -0
  146. package/src/tools/bash.ts +6 -30
  147. package/src/tools/checkpoint.ts +15 -1
  148. package/src/tools/eval-render.ts +15 -0
  149. package/src/tools/fetch.ts +82 -18
  150. package/src/tools/gh.ts +1 -1
  151. package/src/tools/grep.ts +45 -27
  152. package/src/tools/index.ts +3 -1
  153. package/src/tools/irc.ts +24 -9
  154. package/src/tools/output-meta.ts +50 -0
  155. package/src/tools/output-schema-validator.ts +152 -15
  156. package/src/tools/path-utils.ts +55 -10
  157. package/src/tools/read.ts +1 -1
  158. package/src/tools/render-utils.ts +5 -3
  159. package/src/tools/yield.ts +41 -1
  160. package/src/utils/commit-message-generator.ts +2 -2
  161. package/src/utils/git.ts +271 -29
  162. package/src/utils/thinking-display.ts +13 -0
  163. package/src/web/search/index.ts +9 -28
  164. package/src/web/search/provider.ts +26 -1
  165. package/src/web/search/providers/duckduckgo.ts +1 -1
  166. package/src/web/search/types.ts +5 -1
package/src/lsp/client.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as path from "node:path";
2
- import { isEnoent, logger, ptree, untilAborted } from "@oh-my-pi/pi-utils";
2
+ import { isEnoent, logger, postmortem, ptree, untilAborted } from "@oh-my-pi/pi-utils";
3
3
  import { MessageFramer } from "../jsonrpc/message-framing";
4
4
  import { ToolAbortError, throwIfAborted } from "../tools/tool-errors";
5
5
  import { applyWorkspaceEdit } from "./edits";
@@ -1232,21 +1232,18 @@ export function getActiveClients(): LspServerStatus[] {
1232
1232
  // Process Cleanup
1233
1233
  // =============================================================================
1234
1234
 
1235
- // Register cleanup on module unload
1235
+ // Route signal-triggered LSP cleanup through the shared `postmortem` cleanup
1236
+ // list so it runs alongside every other session teardown (draft save,
1237
+ // `session.dispose()`, kernels, MCP) instead of racing them via a
1238
+ // module-owned `SIGINT`/`SIGTERM` handler + `process.exit(0)`. Historically
1239
+ // this file registered its own signal handlers that called `shutdownAll()`
1240
+ // then `process.exit(0)` — winning the race would drop `session_shutdown`
1241
+ // extensions, orphan background bash/task jobs, and skip the editor draft
1242
+ // save (issue #4080). `beforeExit` stays as-is: it fires only when the event
1243
+ // loop drains with no more work, distinct from signal delivery.
1236
1244
  if (typeof process !== "undefined") {
1237
1245
  process.on("beforeExit", () => {
1238
1246
  void shutdownAll();
1239
1247
  });
1240
- process.on("SIGINT", () => {
1241
- void (async () => {
1242
- await shutdownAll();
1243
- process.exit(0);
1244
- })();
1245
- });
1246
- process.on("SIGTERM", () => {
1247
- void (async () => {
1248
- await shutdownAll();
1249
- process.exit(0);
1250
- })();
1251
- });
1248
+ postmortem.register("lsp-shutdown", () => shutdownAll());
1252
1249
  }
package/src/main.ts CHANGED
@@ -66,6 +66,7 @@ import {
66
66
  } from "./sdk";
67
67
  import type { AgentSession } from "./session/agent-session";
68
68
  import type { AuthStorage } from "./session/auth-storage";
69
+ import { describePendingToolCalls } from "./session/exit-diagnostics";
69
70
  import { resolveResumableSession, type SessionInfo } from "./session/session-listing";
70
71
  import { SessionManager } from "./session/session-manager";
71
72
  import { executeBuiltinSlashCommand } from "./slash-commands/builtin-registry";
@@ -73,7 +74,7 @@ import { shouldShowStartupSplash } from "./startup-splash";
73
74
  import { discoverTitleSystemPromptFile, resolvePromptInput } from "./system-prompt";
74
75
  import { createPersistedSubagentReviverFactory } from "./task/persisted-revive";
75
76
  import { initTelemetryExport, isTelemetryExportEnabled } from "./telemetry-export";
76
- import { AUTO_THINKING, parseConfiguredThinkingLevel } from "./thinking";
77
+ import { concreteThinkingLevel, parseConfiguredThinkingLevel } from "./thinking";
77
78
  import type { LspStartupServerInfo } from "./tools";
78
79
  import {
79
80
  getChangelogPath,
@@ -361,6 +362,13 @@ export function createAcpSessionFactory(args: AcpSessionFactoryOptions): AcpSess
361
362
  const nextSettings = await args.settings.cloneForCwd(cwd);
362
363
  const nextSessionManager = SessionManager.create(cwd, args.sessionDir);
363
364
  const agentId = `acp:${nextSessionManager.getSessionId()}`;
365
+ // `baseOptions.titleSystemPrompt` is resolved from the launch cwd; an ACP
366
+ // host can open `session/new` for any client-supplied workspace, so
367
+ // re-discover `TITLE_SYSTEM.md` against THIS session's `cwd` to keep the
368
+ // replan-driven title refresh consistent with the target project's
369
+ // policy (PR #3736 follow-up).
370
+ const titleSystemPromptSource = discoverTitleSystemPromptFile(cwd);
371
+ const titleSystemPrompt = await resolvePromptInput(titleSystemPromptSource, "title system prompt");
364
372
  const { session: nextSession } = await args.createSession({
365
373
  ...args.baseOptions,
366
374
  cwd,
@@ -371,6 +379,7 @@ export function createAcpSessionFactory(args: AcpSessionFactoryOptions): AcpSess
371
379
  agentId,
372
380
  hasUI: false,
373
381
  enableMCP: false,
382
+ titleSystemPrompt,
374
383
  });
375
384
  if (args.parsedArgs.apiKey && !args.baseOptions.model && nextSession.model) {
376
385
  args.authStorage.setRuntimeApiKey(nextSession.model.provider, args.parsedArgs.apiKey);
@@ -396,7 +405,6 @@ async function runInteractiveMode(
396
405
  eventBus?: EventBus,
397
406
  initialMessage?: string,
398
407
  initialImages?: ImageContent[],
399
- titleSystemPrompt?: string,
400
408
  joinLink?: string,
401
409
  ): Promise<void> {
402
410
  const mode = new InteractiveMode(
@@ -407,7 +415,6 @@ async function runInteractiveMode(
407
415
  lspServers,
408
416
  mcpManager,
409
417
  eventBus,
410
- titleSystemPrompt,
411
418
  );
412
419
 
413
420
  // Cold-launch gate: the full setup wizard (every scene + the overlay and
@@ -785,7 +792,7 @@ async function buildSessionOptions(
785
792
  sessionManager: SessionManager | undefined,
786
793
  modelRegistry: ModelRegistry,
787
794
  activeSettings: Settings,
788
- ): Promise<{ options: CreateAgentSessionOptions; titleSystemPrompt?: string }> {
795
+ ): Promise<CreateAgentSessionOptions> {
789
796
  const options: CreateAgentSessionOptions = {
790
797
  cwd: parsed.cwd ?? getProjectDir(),
791
798
  autoApprove: parsed.autoApprove ?? false,
@@ -887,9 +894,9 @@ async function buildSessionOptions(
887
894
  if (scopedModels.length > 0) {
888
895
  // `auto` is a session-level concept only; per-scoped-model (Ctrl+P) thinking
889
896
  // overrides stay concrete, so coerce the auto default to "unset" here.
890
- const defaultThinkingLevelSetting = parseConfiguredThinkingLevel(activeSettings.get("defaultThinkingLevel"));
891
- const defaultThinkingLevel =
892
- defaultThinkingLevelSetting === AUTO_THINKING ? undefined : defaultThinkingLevelSetting;
897
+ const defaultThinkingLevel = concreteThinkingLevel(
898
+ parseConfiguredThinkingLevel(activeSettings.get("defaultThinkingLevel")),
899
+ );
893
900
  options.scopedModels = scopedModels.map(scopedModel => ({
894
901
  model: scopedModel.model,
895
902
  thinkingLevel: scopedModel.explicitThinkingLevel
@@ -903,6 +910,13 @@ async function buildSessionOptions(
903
910
 
904
911
  // System prompt
905
912
  applyResolvedSystemPromptInputs(options, resolvedSystemPrompt, resolvedAppendPrompt);
913
+ // Replan-driven title refresh resolves the override from this same field on
914
+ // `AgentSession`, so threading it through `CreateAgentSessionOptions` keeps
915
+ // both first-input titling (`input-controller.ts`) and replan refresh
916
+ // (`AgentSession.#refreshTitleAfterReplan`) on one source of truth.
917
+ if (titleSystemPrompt) {
918
+ options.titleSystemPrompt = titleSystemPrompt;
919
+ }
906
920
 
907
921
  // Tools
908
922
  if (parsed.noTools) {
@@ -939,7 +953,7 @@ async function buildSessionOptions(
939
953
  options.additionalExtensionPaths = [];
940
954
  }
941
955
 
942
- return { options, titleSystemPrompt };
956
+ return options;
943
957
  }
944
958
 
945
959
  interface RunRootCommandDependencies {
@@ -1195,6 +1209,21 @@ export async function runRootCommand(
1195
1209
  sessionManager = await SessionManager.open(selected.path);
1196
1210
  }
1197
1211
 
1212
+ if (sessionManager && (parsedArgs.continue || parsedArgs.resume || parsedArgs.fork)) {
1213
+ const pendingToolWarning = describePendingToolCalls(sessionManager.getBranch());
1214
+ if (pendingToolWarning) {
1215
+ logger.warn("Resumed session has pending tool calls", {
1216
+ sessionId: sessionManager.getSessionId(),
1217
+ sessionFile: sessionManager.getSessionFile(),
1218
+ });
1219
+ if (isInteractive) {
1220
+ notifs.push({ kind: "warn", message: pendingToolWarning });
1221
+ } else {
1222
+ process.stderr.write(`${chalk.yellow(`${pendingToolWarning}\n`)}`);
1223
+ }
1224
+ }
1225
+ }
1226
+
1198
1227
  await pluginPreloadPromise;
1199
1228
 
1200
1229
  scheduleMarketplaceAutoUpdate({
@@ -1203,7 +1232,7 @@ export async function runRootCommand(
1203
1232
  clearPluginRootsCache: clearPluginRootsAndCaches,
1204
1233
  });
1205
1234
 
1206
- const { options: sessionOptions, titleSystemPrompt } = await logger.time(
1235
+ const sessionOptions = await logger.time(
1207
1236
  "buildSessionOptions",
1208
1237
  buildSessionOptions,
1209
1238
  parsedArgs,
@@ -1410,7 +1439,6 @@ export async function runRootCommand(
1410
1439
  eventBus,
1411
1440
  initialMessage,
1412
1441
  initialImages,
1413
- titleSystemPrompt,
1414
1442
  parsedArgs.join,
1415
1443
  );
1416
1444
  } else {
@@ -433,19 +433,55 @@ export class MnemopiSessionState {
433
433
  * e.g. `mnemopiBackend.clear` — pass `{ consolidate: false }` to skip the
434
434
  * extraction/sleep pass, since spending tokens on memories that will be
435
435
  * wiped on the next line is wasted work (PR #2327 review).
436
+ *
437
+ * `timeoutMs` caps how long the consolidate await blocks the caller
438
+ * (the user-visible `/quit` / `/exit` shutdown path passes this so
439
+ * dispose returns within a UX budget — issue #3641). When the cap is
440
+ * hit, dispose returns immediately and detaches the still-in-flight
441
+ * consolidate; the SQLite handles are closed in the background once
442
+ * the consolidate settles so writes never race a closed handle, and
443
+ * any pending embeddings are SIGKILL'd along with the embed worker
444
+ * (a tolerable loss — working memory rows are durable; only the
445
+ * episodic promotion / embedding for the LAST few turns is skipped,
446
+ * and `maybeRetainOnAgentEnd` has already retained earlier turns).
436
447
  */
437
- async dispose(options: { consolidate?: boolean } = {}): Promise<void> {
448
+ async dispose(options: { consolidate?: boolean; timeoutMs?: number } = {}): Promise<void> {
438
449
  this.unsubscribe?.();
439
450
  this.unsubscribe = undefined;
440
451
  if (this.aliasOf) return;
441
- if (options.consolidate !== false) {
442
- try {
443
- await this.consolidate();
444
- } catch (error) {
445
- logger.warn("Mnemopi: consolidation on dispose failed.", { error: String(error) });
452
+ const closeOwned = (): void => {
453
+ for (const memory of this.scoped.owned) memory.close();
454
+ };
455
+ if (options.consolidate === false) {
456
+ closeOwned();
457
+ return;
458
+ }
459
+ const consolidatePromise = this.consolidate().catch((error: unknown) => {
460
+ logger.warn("Mnemopi: consolidation on dispose failed.", { error: String(error) });
461
+ });
462
+ const { timeoutMs } = options;
463
+ if (timeoutMs !== undefined && timeoutMs > 0) {
464
+ const TIMED_OUT = Symbol("mnemopi.dispose.timedOut");
465
+ const winner = await Promise.race([
466
+ consolidatePromise.then(() => undefined as unknown),
467
+ Bun.sleep(timeoutMs).then(() => TIMED_OUT as unknown),
468
+ ]);
469
+ if (winner === TIMED_OUT) {
470
+ logger.warn("Mnemopi: consolidate-on-dispose exceeded shutdown budget; detaching to background.", {
471
+ timeoutMs,
472
+ });
473
+ // Defer close until the in-flight consolidate settles so SQLite
474
+ // writes don't race a closed handle. The process is on the way
475
+ // to `postmortem.quit(0)`; if it exits first, the OS reclaims
476
+ // the handles (and a still-pending embed() goes down with the
477
+ // embed worker the caller is about to SIGKILL).
478
+ void consolidatePromise.finally(closeOwned);
479
+ return;
446
480
  }
481
+ } else {
482
+ await consolidatePromise;
447
483
  }
448
- for (const memory of this.scoped.owned) memory.close();
484
+ closeOwned();
449
485
  }
450
486
  }
451
487
 
@@ -842,7 +842,7 @@ export class AcpAgent implements Agent {
842
842
  if (!skill) {
843
843
  return false;
844
844
  }
845
- const built = await buildSkillPromptMessage(skill, parsed.args);
845
+ const built = await buildSkillPromptMessage(skill, parsed.args, "user");
846
846
  await record.session.promptCustomMessage({
847
847
  customType: SKILL_PROMPT_MESSAGE_TYPE,
848
848
  content: built.message,
@@ -130,13 +130,21 @@ function registerPersistedSubagentsFromDir(registry: AgentRegistry, dir: string,
130
130
  }
131
131
  }
132
132
 
133
+ /** Result of one host-backed transcript read for the Agent Hub viewer. */
134
+ export interface AgentHubRemoteTranscript {
135
+ text: string;
136
+ newSize: number;
137
+ /** Terminal read failure reported by the host; guests should surface it instead of retrying hot. */
138
+ error?: string;
139
+ }
140
+
133
141
  /** Guest-side proxy for hub actions executed on the collab host. */
134
142
  export interface AgentHubRemote {
135
143
  chat(id: string, text: string): void;
136
144
  kill(id: string): void;
137
145
  revive(id: string): void;
138
- /** Mirrors readFileIncremental: text from fromByte (complete JSONL lines), newSize = next fromByte base; null = unavailable. */
139
- readTranscript(id: string, fromByte: number): Promise<{ text: string; newSize: number } | null>;
146
+ /** Mirrors readFileIncremental: text from fromByte (complete JSONL lines), newSize = next fromByte base; null = temporarily unavailable. */
147
+ readTranscript(id: string, fromByte: number): Promise<AgentHubRemoteTranscript | null>;
140
148
  }
141
149
 
142
150
  export interface AgentHubDeps {
@@ -23,6 +23,7 @@ import type { AgentLifecycleManager } from "../../registry/agent-lifecycle";
23
23
  import type { AgentRegistry, AgentStatus } from "../../registry/agent-registry";
24
24
  import type { FileEntry, SessionMessageEntry } from "../../session/session-entries";
25
25
  import { parseSessionEntries } from "../../session/session-loader";
26
+ import { replaceTabs, shortenPath, truncateToWidth } from "../../tools/render-utils";
26
27
  import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
27
28
  import { getEditorTheme, theme } from "../theme/theme";
28
29
  import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
@@ -61,6 +62,18 @@ const POLL_MS = 250;
61
62
 
62
63
  const SENTINEL_BYTES = 4096;
63
64
 
65
+ /** Sanitize wire-delivered error text for a single TUI row: tabs → spaces,
66
+ * newlines collapsed, absolute paths shortened, truncated to `maxWidth`.
67
+ * `#remoteError` arrives as `String(err)` from the host — it can carry
68
+ * multi-line stacks and absolute host paths that would break the frame's
69
+ * 1-row accounting and leak host filesystem layout to guests. */
70
+ function sanitizeErrorLine(text: string, maxWidth: number): string {
71
+ const singleLine = replaceTabs(text)
72
+ .replace(/[\r\n]+/g, " ")
73
+ .replace(/\/[^\s'")\]]+/g, p => shortenPath(p));
74
+ return truncateToWidth(singleLine, Math.max(10, maxWidth));
75
+ }
76
+
64
77
  interface LocalTranscriptSentinel {
65
78
  offset: number;
66
79
  bytes: Buffer;
@@ -137,6 +150,7 @@ export class AgentTranscriptViewer implements Component {
137
150
  #remoteFetchInFlight = false;
138
151
  #remoteToken = 0;
139
152
  #remoteUnavailable = false;
153
+ #remoteError = "";
140
154
  #hasRemoteData = false;
141
155
 
142
156
  #model: string | undefined;
@@ -177,14 +191,17 @@ export class AgentTranscriptViewer implements Component {
177
191
 
178
192
  dispose(): void {
179
193
  this.#disposed = true;
180
- if (this.#pollTimer) {
181
- clearInterval(this.#pollTimer);
182
- this.#pollTimer = undefined;
183
- }
194
+ this.#stopPolling();
184
195
  this.#remoteToken++;
185
196
  this.#builder.dispose();
186
197
  }
187
198
 
199
+ #stopPolling(): void {
200
+ if (!this.#pollTimer) return;
201
+ clearInterval(this.#pollTimer);
202
+ this.#pollTimer = undefined;
203
+ }
204
+
188
205
  // ========================================================================
189
206
  // Transcript loading
190
207
  // ========================================================================
@@ -345,11 +362,20 @@ export class AgentTranscriptViewer implements Component {
345
362
  }
346
363
  return;
347
364
  }
365
+ if (result.error) {
366
+ this.#remoteError = result.error;
367
+ this.#hasRemoteData = true;
368
+ this.#remoteUnavailable = false;
369
+ this.#stopPolling();
370
+ this.deps.requestRender();
371
+ return;
372
+ }
348
373
  if (result.newSize < fromByte) {
349
374
  // Host transcript rotated/truncated — drop the stale rendered rows
350
375
  // before restarting; otherwise the post-rotation fetch would stack
351
376
  // new content under the pre-rotation history.
352
377
  this.#remoteBytes = 0;
378
+ this.#remoteError = "";
353
379
  this.#hasRemoteData = false;
354
380
  this.#model = undefined;
355
381
  this.#rebuild([]);
@@ -357,6 +383,7 @@ export class AgentTranscriptViewer implements Component {
357
383
  return;
358
384
  }
359
385
  this.#remoteUnavailable = false;
386
+ this.#remoteError = "";
360
387
  const firstData = !this.#hasRemoteData;
361
388
  this.#hasRemoteData = true;
362
389
  const lastNewline = result.text.lastIndexOf("\n");
@@ -534,7 +561,11 @@ export class AgentTranscriptViewer implements Component {
534
561
 
535
562
  const headerLines = this.#headerLines(ref?.status, ref?.kind, ref?.parentId);
536
563
  const footerLines = this.#footerLines();
537
- const noticeLine = this.#notice ? ` ${theme.fg("error", this.#notice)}` : undefined;
564
+ const noticeLine = this.#notice
565
+ ? ` ${theme.fg("error", sanitizeErrorLine(this.#notice, innerWidth))}`
566
+ : this.#remoteError && !this.#builder.isEmpty
567
+ ? ` ${theme.fg("error", sanitizeErrorLine(this.#remoteError, innerWidth))}`
568
+ : undefined;
538
569
  const editorLines = this.#editor ? this.#editor.render(innerWidth) : [];
539
570
 
540
571
  // Chrome: top border + header rows + divider border + (notice) + editor + footer + bottom border.
@@ -542,7 +573,7 @@ export class AgentTranscriptViewer implements Component {
542
573
  const viewportHeight = Math.max(3, termHeight - chrome);
543
574
 
544
575
  const contentLines = this.#builder.isEmpty
545
- ? [` ${theme.fg("dim", this.#placeholder())}`]
576
+ ? [` ${theme.fg("dim", this.#placeholder(Math.max(10, contentWidth - 1)))}`]
546
577
  : this.#builder.container.render(contentWidth);
547
578
  this.#scrollView.setLines(contentLines);
548
579
  this.#scrollView.setHeight(viewportHeight);
@@ -606,8 +637,9 @@ export class AgentTranscriptViewer implements Component {
606
637
  return parts.join(theme.sep.dot);
607
638
  }
608
639
 
609
- #placeholder(): string {
640
+ #placeholder(maxWidth: number): string {
610
641
  if (this.deps.remote) {
642
+ if (this.#remoteError) return sanitizeErrorLine(this.#remoteError, maxWidth);
611
643
  if (this.#remoteUnavailable) return "Transcript lives on the host — not available.";
612
644
  return this.#hasRemoteData ? "No messages yet." : "Loading transcript from host…";
613
645
  }
@@ -34,11 +34,16 @@ type ThinkingContentBlock = Extract<AssistantMessage["content"][number], { type:
34
34
  type DisplayThinkingContentBlock = ThinkingContentBlock & { rawThinking?: string };
35
35
 
36
36
  function resolveThinkingDisplay(block: ThinkingContentBlock, proseOnly: boolean): { text: string; visible: boolean } {
37
- const rawThinking = (block as DisplayThinkingContentBlock).rawThinking ?? block.thinking;
38
- const formatted = formatThinkingForDisplay(block.thinking, proseOnly);
37
+ const rawThinking = (block as DisplayThinkingContentBlock).rawThinking;
38
+ // When rawThinking is set, `block.thinking` is already the formatted display
39
+ // text that buildDisplayMessage produced (then revealed/sliced by the
40
+ // streaming controller) — re-running the formatter would double-process it,
41
+ // and the growing revealed slice would never hit the per-tick memo. Only
42
+ // format raw (non-display) thinking blocks.
43
+ const formatted = rawThinking !== undefined ? block.thinking : formatThinkingForDisplay(block.thinking, proseOnly);
39
44
  return {
40
45
  text: formatted.trim(),
41
- visible: hasDisplayableThinking(rawThinking, formatted),
46
+ visible: hasDisplayableThinking(rawThinking ?? block.thinking, formatted),
42
47
  };
43
48
  }
44
49
 
@@ -329,17 +334,18 @@ export class AssistantMessageComponent extends Container {
329
334
  #thinkingDotsLabel(): string {
330
335
  const glyph = THINKING_DOTS_FRAMES[this.#thinkingDotsFrame % THINKING_DOTS_FRAMES.length] ?? "…";
331
336
  const coloredGlyph = theme.fg("thinkingText", glyph);
337
+ const thinkingLabel = theme.fg("muted", " Thinking");
332
338
  const rate = Math.min(SPEED_MAX, sharedSpeedTracker.getSpeed());
333
339
  // The numeric badge ("<total> · <rate> toks/s") only renders while this block
334
340
  // is genuinely streaming provider tokens. A block that has observed no token
335
341
  // delta (e.g. a provider that reports usage only at turn end) or whose rate
336
- // has decayed to zero (a streaming lull) drops it entirely — the bare pulse
337
- // keeps signalling that the model is thinking. The liveness flag also stops
338
- // the session-wide gauge from leaking a previous turn's rate onto a fresh
339
- // token-less block.
340
- if (!this.#thinkingRateLive || rate < 0.05) return coloredGlyph;
342
+ // has decayed to zero (a streaming lull) drops it entirely — the persistent
343
+ // text label keeps the pulse descriptive for terminals and screen readers.
344
+ // The liveness flag also stops the session-wide gauge from leaking a previous
345
+ // turn's rate onto a fresh token-less block.
346
+ if (!this.#thinkingRateLive || rate < 0.05) return coloredGlyph + thinkingLabel;
341
347
  // Total provider tokens, dimmed, sit next to the pulse.
342
- const totalSpan = this.#thinkingTokens > 0 ? theme.fg("dim", ` ${formatNumber(this.#thinkingTokens)}`) : "";
348
+ const totalSpan = this.#thinkingTokens > 0 ? theme.fg("dim", ` · ${formatNumber(this.#thinkingTokens)}`) : "";
343
349
  // Speed badge color: dim gray at rest, brightening toward the theme accent as
344
350
  // streaming speed climbs (gray → bright accent). Ease (sqrt) so typical
345
351
  // mid-stream rates already read as clearly accent-tinted instead of staying
@@ -348,7 +354,7 @@ export class AssistantMessageComponent extends Container {
348
354
  const hex = lerpHex(theme.getColorHex("dim"), theme.getAccentColorHex(), ratio);
349
355
  const rateText = ` · ${rate.toFixed(1)} toks/s`;
350
356
  const rateSpan = theme.getColorMode() === "truecolor" ? chalk.hex(hex)(rateText) : theme.fg("muted", rateText);
351
- return coloredGlyph + totalSpan + rateSpan;
357
+ return coloredGlyph + thinkingLabel + totalSpan + rateSpan;
352
358
  }
353
359
 
354
360
  #startThinkingAnimation(): void {
@@ -81,6 +81,8 @@ export class ChatTranscriptBuilder {
81
81
  #readArgs = new Map<string, Record<string, unknown>>();
82
82
  #readGroup: ReadToolGroupComponent | null = null;
83
83
  #pendingUsage: Usage | undefined;
84
+ #pendingUsageDuration: number | undefined;
85
+ #pendingUsageTtft: number | undefined;
84
86
  #lastAssistantUsage: Usage | undefined;
85
87
  #waitingPoll: ToolExecutionComponent | null = null;
86
88
  #todoSnapshot: ToolExecutionComponent | null = null;
@@ -127,6 +129,8 @@ export class ChatTranscriptBuilder {
127
129
  this.#readArgs.clear();
128
130
  this.#readGroup = null;
129
131
  this.#pendingUsage = undefined;
132
+ this.#pendingUsageDuration = undefined;
133
+ this.#pendingUsageTtft = undefined;
130
134
  this.#lastAssistantUsage = undefined;
131
135
  this.#waitingPoll = null;
132
136
  this.#todoSnapshot = null;
@@ -192,8 +196,12 @@ export class ChatTranscriptBuilder {
192
196
  if (!this.#pendingUsage) return;
193
197
  this.#readGroup?.seal();
194
198
  this.#readGroup = null;
195
- this.container.addChild(createUsageRowBlock(this.#pendingUsage));
199
+ this.container.addChild(
200
+ createUsageRowBlock(this.#pendingUsage, this.#pendingUsageDuration, this.#pendingUsageTtft),
201
+ );
196
202
  this.#pendingUsage = undefined;
203
+ this.#pendingUsageDuration = undefined;
204
+ this.#pendingUsageTtft = undefined;
197
205
  }
198
206
 
199
207
  #appendChatMessage(message: AgentMessage): void {
@@ -343,6 +351,8 @@ export class ChatTranscriptBuilder {
343
351
  }
344
352
 
345
353
  this.#pendingUsage = settings.get("display.showTokenUsage") ? message.usage : undefined;
354
+ this.#pendingUsageDuration = message.duration;
355
+ this.#pendingUsageTtft = message.ttft;
346
356
  }
347
357
 
348
358
  #appendToolResult(message: Extract<AgentMessage, { role: "toolResult" }>): void {
@@ -1,5 +1,7 @@
1
1
  import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
2
+ import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys";
2
3
  import { $ } from "bun";
4
+ import { getDefaultPasteImageKeys } from "../../config/keybindings";
3
5
  import { getEditorTheme, initTheme } from "../theme/theme";
4
6
  import {
5
7
  CustomEditor,
@@ -124,6 +126,24 @@ describe("CustomEditor bracketed path paste", () => {
124
126
  expect(imagePathCalls).toBe(0);
125
127
  });
126
128
  });
129
+ describe("CustomEditor configured paste image keys", () => {
130
+ it("routes Ghostty Cmd+V kitty key events through the macOS image-paste default", () => {
131
+ const { editor } = makeEditor();
132
+ const onPasteImage = vi.fn();
133
+ editor.onPasteImage = onPasteImage;
134
+ editor.setActionKeys("app.clipboard.pasteImage", getDefaultPasteImageKeys("darwin"));
135
+ setKittyProtocolActive(true);
136
+
137
+ try {
138
+ editor.handleInput("\x1b[118;9u");
139
+ } finally {
140
+ setKittyProtocolActive(false);
141
+ }
142
+
143
+ expect(onPasteImage).toHaveBeenCalledTimes(1);
144
+ expect(editor.getText()).toBe("");
145
+ });
146
+ });
127
147
 
128
148
  describe("extractImagePathFromText (issue #3506)", () => {
129
149
  it("returns the path when the text is a single image file path", () => {
@@ -113,11 +113,23 @@ function splitLeadingSpacesForWrap(line: string, width: number): { indent: strin
113
113
  };
114
114
  }
115
115
 
116
+ /** One row fed to {@link OutlinedList} or the plain list container. `highlight`
117
+ * causes the row (and its wrapped continuations, plus trailing padding) to be
118
+ * painted with the theme's `selectedBg` band — the focus cue that survives
119
+ * themes where `accent` fg is close to the terminal foreground. */
120
+ type SelectorRow = { text: string; highlight: boolean };
121
+
122
+ /** Paint `content` with the `selectedBg` background, applied AFTER any inner
123
+ * ANSI styling so the band spans padding as well as content. */
124
+ function paintSelectedRow(content: string): string {
125
+ return theme.bg("selectedBg", content);
126
+ }
127
+
116
128
  class OutlinedList extends Container {
117
- #lines: string[] = [];
129
+ #rows: SelectorRow[] = [];
118
130
 
119
- setLines(lines: string[]): void {
120
- this.#lines = lines;
131
+ setLines(rows: readonly SelectorRow[]): void {
132
+ this.#rows = rows.slice();
121
133
  this.invalidate();
122
134
  }
123
135
 
@@ -126,16 +138,16 @@ class OutlinedList extends Container {
126
138
  const horizontal = borderColor(theme.boxRound.horizontal.repeat(Math.max(1, width)));
127
139
  const innerWidth = Math.max(1, width - 2);
128
140
  const content: string[] = [];
129
- for (const line of this.#lines) {
130
- const normalized = replaceTabs(line);
141
+ for (const row of this.#rows) {
142
+ const normalized = replaceTabs(row.text);
131
143
  const { indent, body } = splitLeadingSpacesForWrap(normalized, innerWidth);
132
144
  const wrapped = wrapTextWithAnsi(body, Math.max(1, innerWidth - visibleWidth(indent)));
133
145
  for (const wrappedBody of wrapped.length > 0 ? wrapped : [""]) {
134
146
  const wrappedLine = `${indent}${wrappedBody}`;
135
147
  const pad = Math.max(0, innerWidth - visibleWidth(wrappedLine));
136
- content.push(
137
- `${borderColor(theme.boxRound.vertical)}${wrappedLine}${padding(pad)}${borderColor(theme.boxRound.vertical)}`,
138
- );
148
+ const filled = `${wrappedLine}${padding(pad)}`;
149
+ const painted = row.highlight ? paintSelectedRow(filled) : filled;
150
+ content.push(`${borderColor(theme.boxRound.vertical)}${painted}${borderColor(theme.boxRound.vertical)}`);
139
151
  }
140
152
  }
141
153
  return [horizontal, ...content, horizontal];
@@ -472,7 +484,7 @@ export class HookSelectorComponent extends Container {
472
484
  }
473
485
 
474
486
  #updateList(renderWidth = this.#lastRenderWidth): void {
475
- const lines: string[] = [];
487
+ const rows: SelectorRow[] = [];
476
488
  const total = this.#filteredOptions.length;
477
489
  const mdTheme = getMarkdownTheme();
478
490
  // Compact mode kicks in exactly when the fully-expanded list (all
@@ -500,34 +512,41 @@ export class HookSelectorComponent extends Container {
500
512
  const filtered = this.#filteredOptions[i];
501
513
  if (filtered === undefined) continue;
502
514
  const isSelected = i === this.#selectedIndex;
515
+ const isDisabled = this.#isDisabled(filtered.index);
503
516
  const descMode: number | "full" = compact ? (isSelected ? selectedDescRows : 0) : "full";
504
- lines.push(
505
- ...this.#renderOptionLines(
506
- filtered.option,
507
- isSelected,
508
- this.#isDisabled(filtered.index),
509
- mdTheme,
510
- descMode,
511
- renderWidth,
512
- filtered.index,
513
- ),
514
- );
517
+ // Highlight the whole option block (label + wrapped description rows)
518
+ // so the focus band reads as one continuous bar rather than a stripe
519
+ // under the label alone. Disabled rows never claim focus even if the
520
+ // index momentarily lands on one during initial coercion.
521
+ const highlight = isSelected && !isDisabled;
522
+ for (const text of this.#renderOptionLines(
523
+ filtered.option,
524
+ isSelected,
525
+ isDisabled,
526
+ mdTheme,
527
+ descMode,
528
+ renderWidth,
529
+ filtered.index,
530
+ )) {
531
+ rows.push({ text, highlight });
532
+ }
515
533
  }
516
534
 
517
535
  if (total === 0) {
518
- lines.push(theme.fg("dim", " No matching options"));
536
+ rows.push({ text: theme.fg("dim", " No matching options"), highlight: false });
519
537
  }
520
538
 
521
539
  if (startIndex > 0 || endIndex < total || this.#shouldRenderSearchStatus(renderWidth, mdTheme)) {
522
- lines.push(this.#renderStatusLine(total));
540
+ rows.push({ text: this.#renderStatusLine(total), highlight: false });
523
541
  }
524
542
  if (this.#outlinedList) {
525
- this.#outlinedList.setLines(lines);
543
+ this.#outlinedList.setLines(rows);
526
544
  return;
527
545
  }
528
546
  this.#listContainer?.clear();
529
- for (const line of lines) {
530
- this.#listContainer?.addChild(new Text(line, 1, 0));
547
+ for (const row of rows) {
548
+ const bgFn = row.highlight ? paintSelectedRow : undefined;
549
+ this.#listContainer?.addChild(new Text(row.text, 1, 0, bgFn));
531
550
  }
532
551
  }
533
552
 
@@ -912,7 +912,7 @@ export class ModelSelectorComponent extends Container {
912
912
  }
913
913
  #getResolvedRoleThinkingLevel(
914
914
  role: string,
915
- resolved: { explicitThinkingLevel: boolean; thinkingLevel?: ThinkingLevel },
915
+ resolved: { explicitThinkingLevel: boolean; thinkingLevel?: ConfiguredThinkingLevel },
916
916
  ): ConfiguredThinkingLevel {
917
917
  if (resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined) {
918
918
  return resolved.thinkingLevel;