@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
@@ -0,0 +1,44 @@
1
+ import { beforeAll, describe, expect, it } from "bun:test";
2
+ import { Settings } from "../../../config/settings";
3
+ import type { AgentSession } from "../../../session/agent-session";
4
+ import { getThemeByName, setThemeInstance } from "../../theme/theme";
5
+ import { StatusLineComponent } from "./component";
6
+
7
+ function makeSessionWithLastMessage(lastMessage: unknown) {
8
+ return {
9
+ messages: [lastMessage],
10
+ model: { contextWindow: 128000 },
11
+ contextUsageRevision: 0,
12
+ systemPrompt: [],
13
+ agent: { state: { tools: [] } },
14
+ skills: [],
15
+ getContextUsage: () => ({ tokens: 42, contextWindow: 128000 }),
16
+ };
17
+ }
18
+
19
+ beforeAll(async () => {
20
+ await Settings.init({ inMemory: true });
21
+ const loaded = await getThemeByName("dark");
22
+ if (!loaded) throw new Error("theme unavailable");
23
+ setThemeInstance(loaded);
24
+ });
25
+
26
+ describe("StatusLineComponent", () => {
27
+ it("fingerprints tool-call arguments containing bigint values", () => {
28
+ const statusLine = new StatusLineComponent(
29
+ makeSessionWithLastMessage({
30
+ role: "assistant",
31
+ timestamp: 1,
32
+ content: [
33
+ {
34
+ type: "toolCall",
35
+ name: "read",
36
+ arguments: { offset: 1n, nested: { limit: 2n } },
37
+ },
38
+ ],
39
+ }) as unknown as AgentSession,
40
+ );
41
+
42
+ expect(statusLine.getCachedContextBreakdown()).toEqual({ usedTokens: 42, contextWindow: 128000 });
43
+ });
44
+ });
@@ -118,7 +118,15 @@ function messageFingerprint(msg: AgentMessage): string {
118
118
  redactedLen += b.data.length;
119
119
  } else if (b.type === "toolCall") {
120
120
  if (typeof b.name === "string") textLen += b.name.length;
121
- textLen += b.arguments === undefined ? 0 : JSON.stringify(b.arguments).length;
121
+ if (b.arguments !== undefined) {
122
+ try {
123
+ textLen += JSON.stringify(b.arguments, (_key, value) =>
124
+ typeof value === "bigint" ? value.toString() : value,
125
+ ).length;
126
+ } catch {
127
+ textLen += String(b.arguments).length;
128
+ }
129
+ }
122
130
  }
123
131
  }
124
132
  }
@@ -379,7 +379,7 @@ const tokenRateSegment: StatusLineSegment = {
379
379
  const { tokensPerSecond } = ctx.usageStats;
380
380
  if (!tokensPerSecond) return { content: "", visible: false };
381
381
 
382
- const content = withIcon(theme.icon.output, `${tokensPerSecond.toFixed(1)}/s`);
382
+ const content = withIcon(theme.icon.throughput, `${tokensPerSecond.toFixed(1)}/s`);
383
383
  return { content: theme.fg("statusLineOutput", content), visible: true };
384
384
  },
385
385
  };
@@ -498,7 +498,7 @@ const cacheReadSegment: StatusLineSegment = {
498
498
  const { cacheRead } = ctx.usageStats;
499
499
  if (!cacheRead) return { content: "", visible: false };
500
500
 
501
- const parts = [theme.icon.cache, theme.icon.output, formatNumber(cacheRead)].filter(Boolean);
501
+ const parts = [theme.icon.cache, formatNumber(cacheRead)].filter(Boolean);
502
502
  const content = parts.join(" ");
503
503
  return { content: theme.fg("statusLineSpend", content), visible: true };
504
504
  },
@@ -510,7 +510,7 @@ const cacheWriteSegment: StatusLineSegment = {
510
510
  const { cacheWrite } = ctx.usageStats;
511
511
  if (!cacheWrite) return { content: "", visible: false };
512
512
 
513
- const parts = [theme.icon.cache, theme.icon.input, formatNumber(cacheWrite)].filter(Boolean);
513
+ const parts = [theme.icon.cache, formatNumber(cacheWrite)].filter(Boolean);
514
514
  const content = parts.join(" ");
515
515
  return { content: theme.fg("statusLineOutput", content), visible: true };
516
516
  },
@@ -3,13 +3,27 @@ import { Container, Spacer, Text } from "@oh-my-pi/pi-tui";
3
3
  import { formatNumber } from "@oh-my-pi/pi-utils";
4
4
  import { theme } from "../../modes/theme/theme";
5
5
 
6
- export function createUsageRowBlock(usage: Usage): Container {
6
+ /** Below this the rate is nonsense (cached/instant responses yield absurd tok/s). */
7
+ const MIN_DURATION_MS = 100;
8
+
9
+ export function createUsageRowBlock(usage: Usage, durationMs?: number, ttftMs?: number): Container {
7
10
  const totalInput = usage.input + usage.cacheWrite;
8
11
  const parts: string[] = [];
9
12
  parts.push(`${theme.icon.input} ${formatNumber(totalInput)}`);
10
13
  parts.push(`${theme.icon.output} ${formatNumber(usage.output)}`);
11
14
  if (usage.cacheRead > 0) {
12
- parts.push(`cache: ${formatNumber(usage.cacheRead)}`);
15
+ parts.push(`${theme.icon.cache} ${formatNumber(usage.cacheRead)}`);
16
+ }
17
+ if (ttftMs && ttftMs > 0) {
18
+ parts.push(`${theme.icon.time} ${(ttftMs / 1000).toFixed(1)}s`);
19
+ }
20
+ if (durationMs && durationMs > MIN_DURATION_MS && usage.output > 0) {
21
+ // Throughput excludes TTFT — generation time is duration minus time-to-first-token.
22
+ const genMs = durationMs - (ttftMs ?? 0);
23
+ if (genMs > MIN_DURATION_MS) {
24
+ const tokPerSec = (usage.output / genMs) * 1000;
25
+ parts.push(`${theme.icon.throughput} ${tokPerSec.toFixed(1)}/s`);
26
+ }
13
27
  }
14
28
  const block = new Container();
15
29
  block.addChild(new Spacer(1));
@@ -867,7 +867,7 @@ export class CommandController {
867
867
 
868
868
  this.ctx.statusLine.invalidate();
869
869
  this.ctx.statusLine.resetActiveTime();
870
- this.ctx.updateEditorTopBorder();
870
+ this.ctx.ui.requestRender();
871
871
  this.ctx.updateEditorBorderColor();
872
872
  this.ctx.chatContainer.clear();
873
873
  this.ctx.pendingMessagesContainer.clear();
@@ -893,7 +893,7 @@ export class CommandController {
893
893
  }
894
894
  const stateLabel = result.closedProviderSessions === 1 ? "provider state" : "provider states";
895
895
  this.ctx.statusLine.invalidate();
896
- this.ctx.updateEditorTopBorder();
896
+ this.ctx.ui.requestRender();
897
897
  this.ctx.showStatus(`Fresh provider session started (${result.closedProviderSessions} ${stateLabel} pruned).`);
898
898
  }
899
899
 
@@ -923,7 +923,7 @@ export class CommandController {
923
923
  }
924
924
 
925
925
  this.ctx.statusLine.invalidate();
926
- this.ctx.updateEditorTopBorder();
926
+ this.ctx.ui.requestRender();
927
927
 
928
928
  const sessionFile = this.ctx.session.sessionFile;
929
929
  const shortPath = sessionFile ? sessionFile.split("/").pop() : "new session";
@@ -1156,25 +1156,10 @@ export class CommandController {
1156
1156
  }
1157
1157
  this.ctx.rebuildChatFromMessages();
1158
1158
  this.ctx.statusLine.invalidate();
1159
- this.ctx.updateEditorTopBorder();
1159
+ this.ctx.ui.requestRender();
1160
1160
  this.ctx.showStatus(formatShakeSummary(result));
1161
1161
  }
1162
1162
 
1163
- async handleSkillCommand(skillPath: string, args: string): Promise<void> {
1164
- try {
1165
- const content = await Bun.file(skillPath).text();
1166
- const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim();
1167
- const metaLines = [`Skill: ${skillPath}`];
1168
- if (args) {
1169
- metaLines.push(`User: ${args}`);
1170
- }
1171
- const message = `${body}\n\n---\n\n${metaLines.join("\n")}`;
1172
- await this.ctx.session.prompt(message);
1173
- } catch (err) {
1174
- this.ctx.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`);
1175
- }
1176
- }
1177
-
1178
1163
  async executeCompaction(
1179
1164
  customInstructionsOrOptions?: string | CompactOptions,
1180
1165
  isAuto = false,
@@ -1219,7 +1204,7 @@ export class CommandController {
1219
1204
  this.ctx.rebuildChatFromMessages();
1220
1205
 
1221
1206
  this.ctx.statusLine.invalidate();
1222
- this.ctx.updateEditorTopBorder();
1207
+ this.ctx.ui.requestRender();
1223
1208
  } catch (error) {
1224
1209
  if (error instanceof CompactionCancelledError) {
1225
1210
  outcome = "cancelled";
@@ -1285,7 +1270,7 @@ export class CommandController {
1285
1270
  this.ctx.rebuildChatFromMessages();
1286
1271
 
1287
1272
  this.ctx.statusLine.invalidate();
1288
- this.ctx.updateEditorTopBorder();
1273
+ this.ctx.ui.requestRender();
1289
1274
  this.ctx.updateEditorBorderColor();
1290
1275
  await this.ctx.reloadTodos();
1291
1276
 
@@ -32,7 +32,7 @@ import { canonicalizeMessage } from "../../utils/thinking-display";
32
32
  import { interruptHint } from "../shared";
33
33
  import { createAssistantMessageComponent } from "../utils/interactive-context-helpers";
34
34
  import { StreamingRevealController } from "./streaming-reveal";
35
- import { ToolArgsRevealController } from "./tool-args-reveal";
35
+ import { streamingStringKeysForTool, ToolArgsRevealController } from "./tool-args-reveal";
36
36
 
37
37
  type AgentSessionEventKind = AgentSessionEvent["type"];
38
38
 
@@ -287,7 +287,7 @@ export class EventController {
287
287
  }
288
288
 
289
289
  this.ctx.statusLine.invalidate();
290
- this.ctx.updateEditorTopBorder();
290
+ this.ctx.ui.requestRender();
291
291
 
292
292
  const run = this.#handlers[event.type] as (e: AgentSessionEvent) => Promise<void>;
293
293
  await run(event);
@@ -646,7 +646,7 @@ export class EventController {
646
646
  renderArgs = this.#toolArgsReveal.setTarget(content.id, partialJson, {
647
647
  rawInput,
648
648
  exposeRawPartialJson: exposesRawPartialJson(content.name, rawInput, tool),
649
- fullArgs: content.arguments,
649
+ streamingStringKeys: streamingStringKeysForTool(content.name, rawInput),
650
650
  });
651
651
  } else {
652
652
  this.#toolArgsReveal.finish(content.id);
@@ -786,7 +786,9 @@ export class EventController {
786
786
  this.#lastAssistantComponent = this.ctx.streamingComponent;
787
787
  this.#lastAssistantComponent.markTranscriptBlockFinalized();
788
788
  if (settings.get("display.showTokenUsage")) {
789
- this.ctx.chatContainer.addChild(createUsageRowBlock(event.message.usage));
789
+ this.ctx.chatContainer.addChild(
790
+ createUsageRowBlock(event.message.usage, event.message.duration, event.message.ttft),
791
+ );
790
792
  }
791
793
  this.ctx.streamingComponent = undefined;
792
794
  this.ctx.streamingMessage = undefined;
@@ -800,7 +802,7 @@ export class EventController {
800
802
  this.ctx.showPinnedError(event.message.errorMessage);
801
803
  }
802
804
  this.ctx.statusLine.invalidate();
803
- this.ctx.updateEditorTopBorder();
805
+ this.ctx.ui.requestRender();
804
806
  }
805
807
  this.ctx.ui.requestRender();
806
808
  }
@@ -1158,21 +1160,21 @@ export class EventController {
1158
1160
  if (!event.skipped) {
1159
1161
  this.ctx.rebuildChatFromMessages();
1160
1162
  this.ctx.statusLine.invalidate();
1161
- this.ctx.updateEditorTopBorder();
1163
+ this.ctx.ui.requestRender();
1162
1164
  }
1163
1165
  this.ctx.showWarning(event.errorMessage);
1164
1166
  } else if (!event.skipped) {
1165
1167
  this.ctx.lastAssistantUsage = undefined;
1166
1168
  this.ctx.rebuildChatFromMessages();
1167
1169
  this.ctx.statusLine.invalidate();
1168
- this.ctx.updateEditorTopBorder();
1170
+ this.ctx.ui.requestRender();
1169
1171
  this.ctx.showStatus("Auto-shake completed");
1170
1172
  }
1171
1173
  } else if (event.result) {
1172
1174
  this.ctx.lastAssistantUsage = undefined;
1173
1175
  this.ctx.rebuildChatFromMessages();
1174
1176
  this.ctx.statusLine.invalidate();
1175
- this.ctx.updateEditorTopBorder();
1177
+ this.ctx.ui.requestRender();
1176
1178
  } else if (event.errorMessage) {
1177
1179
  this.ctx.showWarning(event.errorMessage);
1178
1180
  } else if (isHandoffAction) {
@@ -1180,7 +1182,7 @@ export class EventController {
1180
1182
  this.ctx.lastAssistantUsage = undefined;
1181
1183
  this.ctx.rebuildChatFromMessages();
1182
1184
  this.ctx.statusLine.invalidate();
1183
- this.ctx.updateEditorTopBorder();
1185
+ this.ctx.ui.requestRender();
1184
1186
  await this.ctx.reloadTodos();
1185
1187
  this.ctx.showStatus("Auto-handoff completed");
1186
1188
  } else if (event.skipped) {
@@ -1,5 +1,6 @@
1
1
  import type { Component, OverlayHandle, TUI } from "@oh-my-pi/pi-tui";
2
2
  import { Container, Spacer, Text } from "@oh-my-pi/pi-tui";
3
+ import type { CollabUiRequestDraft, CollabUiSelectItem } from "@oh-my-pi/pi-wire";
3
4
  import { KeybindingsManager } from "../../config/keybindings";
4
5
  import type {
5
6
  CompactOptions,
@@ -28,6 +29,21 @@ import { setSessionTerminalTitle, setTerminalTitle } from "../../utils/title-gen
28
29
 
29
30
  const MAX_WIDGET_LINES = 10;
30
31
 
32
+ interface CollabDialogWinner {
33
+ source: "local" | "remote";
34
+ value: string | undefined;
35
+ }
36
+
37
+ function toWireSelectOptions(options: ExtensionUISelectItem[]): CollabUiSelectItem[] {
38
+ return options.map(option =>
39
+ typeof option === "string"
40
+ ? option
41
+ : option.description
42
+ ? { label: option.label, description: option.description }
43
+ : { label: option.label },
44
+ );
45
+ }
46
+
31
47
  export class ExtensionUiController {
32
48
  #extensionTerminalInputUnsubscribers = new Set<() => void>();
33
49
  #hookWidgetsAbove = new Map<string, ExtensionUiComponent>();
@@ -45,7 +61,7 @@ export class ExtensionUiController {
45
61
  async initHooksAndCustomTools(): Promise<void> {
46
62
  // Create and set hook & tool UI context
47
63
  const uiContext: ExtensionUIContext = {
48
- select: (title, options, dialogOptions) => this.showHookSelector(title, options, dialogOptions),
64
+ select: (title, options, dialogOptions) => this.showCollabAwareSelector(title, options, dialogOptions),
49
65
  confirm: (title, message, _dialogOptions) => this.showHookConfirm(title, message),
50
66
  input: (title, placeholder, dialogOptions) => this.showHookInput(title, placeholder, dialogOptions),
51
67
  notify: (message, type) => this.showHookNotify(message, type),
@@ -61,7 +77,7 @@ export class ExtensionUiController {
61
77
  },
62
78
  getEditorText: () => this.ctx.editor.getText(),
63
79
  editor: (title, prefill, dialogOptions, editorOptions) =>
64
- this.showHookEditor(title, prefill, dialogOptions, editorOptions),
80
+ this.showCollabAwareEditor(title, prefill, dialogOptions, editorOptions),
65
81
  get theme() {
66
82
  return theme;
67
83
  },
@@ -171,7 +187,6 @@ export class ExtensionUiController {
171
187
  // Reset and update status line
172
188
  this.ctx.statusLine.invalidate();
173
189
  this.ctx.statusLine.resetActiveTime();
174
- this.ctx.updateEditorTopBorder();
175
190
  this.ctx.ui.requestRender();
176
191
 
177
192
  // Clear UI state
@@ -531,6 +546,72 @@ export class ExtensionUiController {
531
546
  this.ctx.ui.requestRender();
532
547
  }
533
548
 
549
+ async showCollabAwareSelector(
550
+ title: string,
551
+ options: ExtensionUISelectItem[],
552
+ dialogOptions?: InteractiveSelectorDialogOptions,
553
+ extra?: { slider?: HookSelectorSlider },
554
+ ): Promise<string | undefined> {
555
+ const request: CollabUiRequestDraft = {
556
+ kind: "select",
557
+ title,
558
+ options: toWireSelectOptions(options),
559
+ initialIndex: dialogOptions?.initialIndex,
560
+ selectionMarker: dialogOptions?.selectionMarker,
561
+ checkedIndices: dialogOptions?.checkedIndices ? [...dialogOptions.checkedIndices] : undefined,
562
+ markableCount: dialogOptions?.markableCount,
563
+ helpText: dialogOptions?.helpText,
564
+ };
565
+ return this.#raceCollabDialog(request, dialogOptions?.signal, signal =>
566
+ this.showHookSelector(title, options, { ...dialogOptions, signal }, extra),
567
+ );
568
+ }
569
+
570
+ async showCollabAwareEditor(
571
+ title: string,
572
+ prefill?: string,
573
+ dialogOptions?: ExtensionUIDialogOptions,
574
+ editorOptions?: { promptStyle?: boolean },
575
+ ): Promise<string | undefined> {
576
+ const request: CollabUiRequestDraft = { kind: "editor", title, prefill };
577
+ return this.#raceCollabDialog(request, dialogOptions?.signal, signal =>
578
+ this.showHookEditor(title, prefill, { ...dialogOptions, signal }, editorOptions),
579
+ );
580
+ }
581
+
582
+ /**
583
+ * Race the local hook dialog against a mirrored guest ask. First *answer*
584
+ * wins and cancels the other side. A remote `unavailable` settlement
585
+ * (collab teardown, relay drop, abort) is NOT an answer: the local dialog
586
+ * keeps running — the host user may be mid-keystroke in it — and its
587
+ * eventual result is returned.
588
+ */
589
+ async #raceCollabDialog(
590
+ request: CollabUiRequestDraft,
591
+ signal: AbortSignal | undefined,
592
+ local: (signal: AbortSignal | undefined) => Promise<string | undefined>,
593
+ ): Promise<string | undefined> {
594
+ const host = this.ctx.collabHost;
595
+ if (!host) return local(signal);
596
+ const localAbort = new AbortController();
597
+ const remoteAbort = new AbortController();
598
+ const remote = host.requestGuestUi(
599
+ request,
600
+ signal ? AbortSignal.any([signal, remoteAbort.signal]) : remoteAbort.signal,
601
+ );
602
+ if (!remote) return local(signal);
603
+ const localWinner = local(signal ? AbortSignal.any([signal, localAbort.signal]) : localAbort.signal).then(
604
+ (value): CollabDialogWinner => ({ source: "local", value }),
605
+ );
606
+ const remoteWinner: Promise<CollabDialogWinner> = remote.then(result =>
607
+ result.kind === "answered" ? { source: "remote", value: result.value } : localWinner,
608
+ );
609
+ const winner = await Promise.race([localWinner, remoteWinner]);
610
+ if (winner.source === "remote") localAbort.abort();
611
+ else remoteAbort.abort();
612
+ return winner.value;
613
+ }
614
+
534
615
  /**
535
616
  * Show a selector for hooks.
536
617
  */
@@ -110,6 +110,17 @@ function wrapPasteInAttachmentBlock(content: string): string {
110
110
  return `<attachment>\n${content}\n</attachment>`;
111
111
  }
112
112
 
113
+ /** Run a teardown abort that must never throw (Esc / Ctrl+C path). A thrown
114
+ * error is logged at debug instead of silently swallowed, so a failing abort
115
+ * stays diagnosable without disturbing teardown ordering. */
116
+ function safeAbort(label: string, fn: () => void): void {
117
+ try {
118
+ fn();
119
+ } catch (err) {
120
+ logger.debug(`Failed to abort ${label}`, { error: err instanceof Error ? err.message : String(err) });
121
+ }
122
+ }
123
+
113
124
  const TINY_TITLE_PROGRESS_DONE_TTL_MS = 3_000;
114
125
  // A cached model fires its file-load events in a short burst and then goes silent
115
126
  // while onnxruntime builds the session; a genuine download keeps streaming progress
@@ -306,21 +317,15 @@ export class InputController {
306
317
  const viewSession = this.ctx.viewSession;
307
318
  let aborted = false;
308
319
  if (viewSession.isCompacting) {
309
- try {
310
- viewSession.abortCompaction();
311
- } catch {}
320
+ safeAbort("compaction", () => viewSession.abortCompaction());
312
321
  aborted = true;
313
322
  }
314
323
  if (viewSession.isGeneratingHandoff) {
315
- try {
316
- viewSession.abortHandoff();
317
- } catch {}
324
+ safeAbort("handoff", () => viewSession.abortHandoff());
318
325
  aborted = true;
319
326
  }
320
327
  if (viewSession.isRetrying) {
321
- try {
322
- viewSession.abortRetry();
323
- } catch {}
328
+ safeAbort("retry", () => viewSession.abortRetry());
324
329
  aborted = true;
325
330
  }
326
331
  if (aborted) return;
@@ -382,9 +387,7 @@ export class InputController {
382
387
  } else if (this.ctx.session.isStreaming) {
383
388
  this.#handleStreamingEscape();
384
389
  } else if (this.ctx.editor.getText().trim()) {
385
- // Esc with typed text clears the draft instead of (or before) any double-Esc action
386
- this.ctx.editor.setText("");
387
- this.ctx.ui.requestRender();
390
+ // Esc must not destroy an in-progress draft; it only disarms a previous empty-editor Esc.
388
391
  this.ctx.lastEscapeTime = 0;
389
392
  this.#clearStreamingEscapeArm();
390
393
  } else {
@@ -836,7 +839,7 @@ export class InputController {
836
839
  this.ctx.session.sessionId,
837
840
  this.ctx.session.model,
838
841
  provider => this.ctx.session.agent.metadataForProvider(provider),
839
- this.ctx.titleSystemPrompt,
842
+ this.ctx.session.titleSystemPrompt,
840
843
  )
841
844
  .then(async title => {
842
845
  // Re-check: a concurrent attempt for an earlier message may have
@@ -157,7 +157,6 @@ export class SelectorController {
157
157
  const result = await previewTheme(themeName);
158
158
  if (result.success) {
159
159
  this.ctx.statusLine.invalidate();
160
- this.ctx.updateEditorTopBorder();
161
160
  this.ctx.ui.invalidate();
162
161
  this.ctx.ui.requestRender();
163
162
  }
@@ -175,7 +174,6 @@ export class SelectorController {
175
174
  compactThinkingLevel: settings.get("statusLine.compactThinkingLevel"),
176
175
  ...previewSettings,
177
176
  });
178
- this.ctx.updateEditorTopBorder();
179
177
  this.ctx.ui.requestRender();
180
178
  },
181
179
  getStatusLinePreview: () => {
@@ -203,7 +201,6 @@ export class SelectorController {
203
201
  transparent: settings.get("statusLine.transparent"),
204
202
  compactThinkingLevel: settings.get("statusLine.compactThinkingLevel"),
205
203
  });
206
- this.ctx.updateEditorTopBorder();
207
204
  this.ctx.ui.requestRender();
208
205
  },
209
206
  },
@@ -456,7 +453,6 @@ export class SelectorController {
456
453
  case "tui.tight":
457
454
  setTuiTight(value as boolean);
458
455
  this.ctx.ui.invalidate();
459
- this.ctx.updateEditorTopBorder();
460
456
  this.ctx.ui.requestRender();
461
457
  break;
462
458
 
@@ -472,7 +468,7 @@ export class SelectorController {
472
468
  case "theme": {
473
469
  setTheme(value as string, true).then(result => {
474
470
  this.ctx.statusLine.invalidate();
475
- this.ctx.updateEditorTopBorder();
471
+ this.ctx.ui.requestRender();
476
472
  this.ctx.ui.invalidate();
477
473
  if (!result.success) {
478
474
  this.ctx.showError(`Failed to load theme "${value}": ${result.error}\nFell back to dark theme.`);
@@ -483,7 +479,7 @@ export class SelectorController {
483
479
  case "symbolPreset": {
484
480
  setSymbolPreset(value as "unicode" | "nerd" | "ascii").then(() => {
485
481
  this.ctx.statusLine.invalidate();
486
- this.ctx.updateEditorTopBorder();
482
+ this.ctx.ui.requestRender();
487
483
  this.ctx.ui.invalidate();
488
484
  });
489
485
  break;
@@ -557,7 +553,6 @@ export class SelectorController {
557
553
  compactThinkingLevel: settings.get("statusLine.compactThinkingLevel"),
558
554
  };
559
555
  this.ctx.statusLine.updateSettings(statusLineSettings);
560
- this.ctx.updateEditorTopBorder();
561
556
  this.ctx.ui.requestRender();
562
557
  break;
563
558
  }
@@ -1046,7 +1041,7 @@ export class SelectorController {
1046
1041
  this.ctx.clearTransientSessionUi();
1047
1042
  this.ctx.statusLine.invalidate();
1048
1043
  this.ctx.statusLine.resetActiveTime();
1049
- this.ctx.updateEditorTopBorder();
1044
+ this.ctx.ui.requestRender();
1050
1045
  this.ctx.updateEditorBorderColor();
1051
1046
  this.ctx.renderInitialMessages({ clearTerminalHistory: true });
1052
1047
  await this.ctx.reloadTodos();