@oh-my-pi/pi-coding-agent 15.10.11 → 15.11.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 (217) hide show
  1. package/CHANGELOG.md +103 -2
  2. package/dist/cli.js +5790 -5731
  3. package/dist/types/async/index.d.ts +0 -1
  4. package/dist/types/cli/args.d.ts +1 -0
  5. package/dist/types/cli/gallery-fixtures/types.d.ts +5 -0
  6. package/dist/types/cli-commands.d.ts +12 -0
  7. package/dist/types/commands/launch.d.ts +4 -0
  8. package/dist/types/config/api-key-resolver.d.ts +3 -0
  9. package/dist/types/config/keybindings.d.ts +6 -1
  10. package/dist/types/config/model-registry.d.ts +1 -0
  11. package/dist/types/config/model-resolver.d.ts +18 -0
  12. package/dist/types/config/settings-schema.d.ts +85 -34
  13. package/dist/types/config/settings.d.ts +7 -0
  14. package/dist/types/edit/hashline/noop-loop-guard.d.ts +72 -0
  15. package/dist/types/eval/py/executor.d.ts +5 -0
  16. package/dist/types/eval/py/kernel.d.ts +6 -1
  17. package/dist/types/eval/py/runtime.d.ts +9 -0
  18. package/dist/types/exec/bash-executor.d.ts +2 -0
  19. package/dist/types/export/html/template.generated.d.ts +1 -1
  20. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  21. package/dist/types/extensibility/extensions/runner.d.ts +3 -2
  22. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  23. package/dist/types/extensibility/shared-events.d.ts +2 -2
  24. package/dist/types/internal-urls/history-protocol.d.ts +14 -0
  25. package/dist/types/internal-urls/index.d.ts +1 -0
  26. package/dist/types/internal-urls/types.d.ts +1 -1
  27. package/dist/types/irc/bus.d.ts +66 -0
  28. package/dist/types/memory-backend/index.d.ts +1 -0
  29. package/dist/types/memory-backend/runtime.d.ts +4 -0
  30. package/dist/types/memory-backend/types.d.ts +66 -1
  31. package/dist/types/modes/components/agent-hub.d.ts +30 -0
  32. package/dist/types/modes/components/compaction-summary-message.d.ts +10 -4
  33. package/dist/types/modes/components/custom-editor.d.ts +2 -0
  34. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  35. package/dist/types/modes/components/ttsr-notification.d.ts +5 -1
  36. package/dist/types/modes/components/welcome.d.ts +3 -9
  37. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  38. package/dist/types/modes/index.d.ts +3 -3
  39. package/dist/types/modes/interactive-mode.d.ts +10 -4
  40. package/dist/types/modes/oauth-manual-input.d.ts +7 -0
  41. package/dist/types/modes/rpc/rpc-client.d.ts +39 -2
  42. package/dist/types/modes/rpc/rpc-mode.d.ts +31 -2
  43. package/dist/types/modes/rpc/rpc-subagents.d.ts +24 -0
  44. package/dist/types/modes/rpc/rpc-types.d.ts +75 -1
  45. package/dist/types/modes/setup-wizard/index.d.ts +5 -1
  46. package/dist/types/modes/setup-wizard/lazy.d.ts +2 -0
  47. package/dist/types/modes/theme/theme.d.ts +2 -1
  48. package/dist/types/modes/types.d.ts +5 -2
  49. package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
  50. package/dist/types/registry/agent-lifecycle.d.ts +51 -0
  51. package/dist/types/registry/agent-registry.d.ts +16 -5
  52. package/dist/types/secrets/index.d.ts +1 -1
  53. package/dist/types/secrets/obfuscator.d.ts +8 -2
  54. package/dist/types/session/agent-session.d.ts +49 -32
  55. package/dist/types/session/messages.d.ts +2 -4
  56. package/dist/types/session/session-history-format.d.ts +12 -0
  57. package/dist/types/session/session-manager.d.ts +21 -3
  58. package/dist/types/session/streaming-output.d.ts +46 -0
  59. package/dist/types/slash-commands/acp-builtins.d.ts +16 -0
  60. package/dist/types/slash-commands/builtin-registry.d.ts +1 -0
  61. package/dist/types/slash-commands/types.d.ts +1 -1
  62. package/dist/types/system-prompt.d.ts +2 -0
  63. package/dist/types/task/executor.d.ts +12 -2
  64. package/dist/types/task/index.d.ts +13 -6
  65. package/dist/types/task/output-manager.d.ts +0 -7
  66. package/dist/types/task/repair-args.d.ts +8 -7
  67. package/dist/types/task/types.d.ts +63 -51
  68. package/dist/types/thinking.d.ts +4 -0
  69. package/dist/types/tiny/title-client.d.ts +11 -0
  70. package/dist/types/tiny/title-protocol.d.ts +1 -0
  71. package/dist/types/tools/browser/tab-worker.d.ts +3 -1
  72. package/dist/types/tools/find.d.ts +0 -11
  73. package/dist/types/tools/grouped-file-output.d.ts +0 -49
  74. package/dist/types/tools/index.d.ts +7 -3
  75. package/dist/types/tools/irc.d.ts +76 -38
  76. package/dist/types/tools/job.d.ts +7 -1
  77. package/dist/types/utils/git.d.ts +15 -2
  78. package/dist/types/utils/title-generator.d.ts +3 -2
  79. package/examples/extensions/with-deps/package.json +1 -0
  80. package/package.json +11 -10
  81. package/scripts/bundle-dist.ts +28 -19
  82. package/src/async/index.ts +0 -1
  83. package/src/auto-thinking/classifier.ts +1 -0
  84. package/src/cli/args.ts +3 -0
  85. package/src/cli/gallery-cli.ts +1 -1
  86. package/src/cli/gallery-fixtures/agentic.ts +230 -115
  87. package/src/cli/gallery-fixtures/types.ts +5 -0
  88. package/src/cli-commands.ts +29 -0
  89. package/src/cli.ts +28 -15
  90. package/src/commands/launch.ts +4 -0
  91. package/src/commit/agentic/tools/analyze-file.ts +38 -19
  92. package/src/commit/model-selection.ts +3 -2
  93. package/src/config/api-key-resolver.ts +8 -6
  94. package/src/config/keybindings.ts +6 -1
  95. package/src/config/model-registry.ts +97 -30
  96. package/src/config/model-resolver.ts +60 -0
  97. package/src/config/settings-schema.ts +99 -55
  98. package/src/config/settings.ts +68 -3
  99. package/src/edit/hashline/execute.ts +39 -2
  100. package/src/edit/hashline/noop-loop-guard.ts +99 -0
  101. package/src/eval/__tests__/agent-bridge.test.ts +5 -3
  102. package/src/eval/agent-bridge.ts +3 -16
  103. package/src/eval/completion-bridge.ts +1 -0
  104. package/src/eval/js/shared/prelude.txt +1 -1
  105. package/src/eval/py/executor.ts +29 -7
  106. package/src/eval/py/index.ts +6 -1
  107. package/src/eval/py/kernel.ts +31 -11
  108. package/src/eval/py/prelude.py +5 -6
  109. package/src/eval/py/runtime.ts +37 -0
  110. package/src/exec/bash-executor.ts +82 -3
  111. package/src/export/html/template.generated.ts +1 -1
  112. package/src/export/html/template.js +38 -13
  113. package/src/extensibility/custom-tools/types.ts +2 -2
  114. package/src/extensibility/extensions/get-commands-handler.ts +2 -1
  115. package/src/extensibility/extensions/runner.ts +6 -1
  116. package/src/extensibility/extensions/types.ts +3 -0
  117. package/src/extensibility/shared-events.ts +2 -2
  118. package/src/hindsight/bank.ts +17 -2
  119. package/src/internal-urls/docs-index.generated.ts +11 -11
  120. package/src/internal-urls/history-protocol.ts +113 -0
  121. package/src/internal-urls/index.ts +1 -0
  122. package/src/internal-urls/router.ts +3 -1
  123. package/src/internal-urls/types.ts +1 -1
  124. package/src/irc/bus.ts +292 -0
  125. package/src/main.ts +26 -66
  126. package/src/memories/index.ts +2 -0
  127. package/src/memory-backend/index.ts +1 -0
  128. package/src/memory-backend/local-backend.ts +9 -0
  129. package/src/memory-backend/off-backend.ts +9 -0
  130. package/src/memory-backend/runtime.ts +66 -0
  131. package/src/memory-backend/types.ts +81 -1
  132. package/src/mnemopi/backend.ts +151 -4
  133. package/src/modes/acp/acp-agent.ts +119 -11
  134. package/src/modes/components/{session-observer-overlay.ts → agent-hub.ts} +586 -367
  135. package/src/modes/components/assistant-message.ts +19 -21
  136. package/src/modes/components/compaction-summary-message.ts +68 -32
  137. package/src/modes/components/custom-editor.ts +10 -0
  138. package/src/modes/components/footer.ts +3 -1
  139. package/src/modes/components/status-line/component.ts +118 -34
  140. package/src/modes/components/tool-execution.ts +31 -1
  141. package/src/modes/components/ttsr-notification.ts +72 -30
  142. package/src/modes/components/welcome.ts +9 -33
  143. package/src/modes/controllers/command-controller.ts +1 -1
  144. package/src/modes/controllers/event-controller.ts +65 -0
  145. package/src/modes/controllers/extension-ui-controller.ts +8 -8
  146. package/src/modes/controllers/input-controller.ts +19 -2
  147. package/src/modes/controllers/mcp-command-controller.ts +38 -3
  148. package/src/modes/controllers/selector-controller.ts +21 -17
  149. package/src/modes/index.ts +3 -21
  150. package/src/modes/interactive-mode.ts +47 -22
  151. package/src/modes/oauth-manual-input.ts +30 -3
  152. package/src/modes/rpc/rpc-client.ts +154 -3
  153. package/src/modes/rpc/rpc-mode.ts +97 -12
  154. package/src/modes/rpc/rpc-subagents.ts +265 -0
  155. package/src/modes/rpc/rpc-types.ts +81 -1
  156. package/src/modes/setup-wizard/index.ts +12 -2
  157. package/src/modes/setup-wizard/lazy.ts +16 -0
  158. package/src/modes/theme/theme.ts +18 -5
  159. package/src/modes/types.ts +5 -5
  160. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  161. package/src/modes/utils/ui-helpers.ts +51 -49
  162. package/src/prompts/system/irc-incoming.md +3 -4
  163. package/src/prompts/system/orchestrate-notice.md +2 -2
  164. package/src/prompts/system/subagent-system-prompt.md +0 -5
  165. package/src/prompts/system/system-prompt.md +1 -0
  166. package/src/prompts/system/workflow-notice.md +2 -2
  167. package/src/prompts/tools/eval.md +3 -3
  168. package/src/prompts/tools/irc.md +29 -19
  169. package/src/prompts/tools/read.md +2 -2
  170. package/src/prompts/tools/task-summary.md +5 -16
  171. package/src/prompts/tools/task.md +38 -29
  172. package/src/registry/agent-lifecycle.ts +218 -0
  173. package/src/registry/agent-registry.ts +16 -5
  174. package/src/sdk.ts +37 -10
  175. package/src/secrets/index.ts +8 -1
  176. package/src/secrets/obfuscator.ts +39 -18
  177. package/src/session/agent-session.ts +422 -291
  178. package/src/session/messages.ts +11 -78
  179. package/src/session/session-history-format.ts +246 -0
  180. package/src/session/session-manager.ts +59 -5
  181. package/src/session/streaming-output.ts +226 -10
  182. package/src/slash-commands/acp-builtins.ts +24 -0
  183. package/src/slash-commands/builtin-registry.ts +20 -0
  184. package/src/slash-commands/types.ts +1 -1
  185. package/src/system-prompt.ts +14 -0
  186. package/src/task/executor.ts +851 -461
  187. package/src/task/index.ts +721 -796
  188. package/src/task/output-manager.ts +0 -11
  189. package/src/task/render.ts +148 -63
  190. package/src/task/repair-args.ts +21 -9
  191. package/src/task/types.ts +82 -66
  192. package/src/thinking.ts +7 -0
  193. package/src/tiny/title-client.ts +34 -5
  194. package/src/tiny/title-protocol.ts +1 -1
  195. package/src/tiny/worker.ts +6 -4
  196. package/src/tools/ask.ts +4 -2
  197. package/src/tools/bash.ts +61 -10
  198. package/src/tools/browser/tab-worker.ts +26 -7
  199. package/src/tools/browser.ts +28 -1
  200. package/src/tools/find.ts +2 -27
  201. package/src/tools/grouped-file-output.ts +1 -118
  202. package/src/tools/image-gen.ts +11 -4
  203. package/src/tools/index.ts +17 -13
  204. package/src/tools/inspect-image.ts +1 -0
  205. package/src/tools/irc.ts +596 -171
  206. package/src/tools/job.ts +41 -7
  207. package/src/tools/read.ts +57 -1
  208. package/src/tools/renderers.ts +2 -0
  209. package/src/tools/resolve.ts +4 -1
  210. package/src/utils/commit-message-generator.ts +1 -0
  211. package/src/utils/git.ts +267 -13
  212. package/src/utils/title-generator.ts +24 -5
  213. package/dist/types/async/support.d.ts +0 -2
  214. package/dist/types/modes/components/session-observer-overlay.d.ts +0 -11
  215. package/dist/types/task/simple-mode.d.ts +0 -8
  216. package/src/async/support.ts +0 -5
  217. package/src/task/simple-mode.ts +0 -27
@@ -222,7 +222,9 @@ export class AssistantMessageComponent extends Container {
222
222
  this.#contentContainer.clear();
223
223
 
224
224
  const hasVisibleContent = message.content.some(
225
- c => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()),
225
+ c =>
226
+ (c.type === "text" && c.text.trim()) ||
227
+ (!this.hideThinkingBlock && c.type === "thinking" && c.thinking.trim()),
226
228
  );
227
229
 
228
230
  // Render content in order
@@ -236,32 +238,28 @@ export class AssistantMessageComponent extends Container {
236
238
  markdown.transientRenderCache = this.#lastUpdateTransient;
237
239
  this.#contentContainer.addChild(markdown);
238
240
  } else if (content.type === "thinking" && content.thinking.trim()) {
241
+ if (this.hideThinkingBlock) {
242
+ thinkingIndex += 1;
243
+ continue;
244
+ }
239
245
  // Add spacing only when another visible assistant content block follows.
240
246
  // This avoids a superfluous blank line before separately-rendered tool execution blocks.
241
247
  const hasVisibleContentAfter = message.content
242
248
  .slice(i + 1)
243
249
  .some(c => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()));
244
250
 
245
- if (this.hideThinkingBlock) {
246
- // Show static "Thinking..." label when hidden
247
- this.#contentContainer.addChild(new Text(theme.italic(theme.fg("thinkingText", "Thinking...")), 1, 0));
248
- if (hasVisibleContentAfter) {
249
- this.#contentContainer.addChild(new Spacer(1));
250
- }
251
- } else {
252
- const thinkingText = content.thinking.trim();
253
- // Thinking traces in thinkingText color, italic
254
- const thinkingMarkdown = new Markdown(thinkingText, 1, 0, getMarkdownTheme(), {
255
- color: (text: string) => theme.fg("thinkingText", text),
256
- italic: true,
257
- });
258
- thinkingMarkdown.transientRenderCache = this.#lastUpdateTransient;
259
- this.#contentContainer.addChild(thinkingMarkdown);
260
- this.#appendThinkingExtensions(i, thinkingIndex, thinkingText);
261
- thinkingIndex += 1;
262
- if (hasVisibleContentAfter) {
263
- this.#contentContainer.addChild(new Spacer(1));
264
- }
251
+ const thinkingText = content.thinking.trim();
252
+ // Thinking traces in thinkingText color, italic
253
+ const thinkingMarkdown = new Markdown(thinkingText, 1, 0, getMarkdownTheme(), {
254
+ color: (text: string) => theme.fg("thinkingText", text),
255
+ italic: true,
256
+ });
257
+ thinkingMarkdown.transientRenderCache = this.#lastUpdateTransient;
258
+ this.#contentContainer.addChild(thinkingMarkdown);
259
+ this.#appendThinkingExtensions(i, thinkingIndex, thinkingText);
260
+ thinkingIndex += 1;
261
+ if (hasVisibleContentAfter) {
262
+ this.#contentContainer.addChild(new Spacer(1));
265
263
  }
266
264
  }
267
265
  }
@@ -1,51 +1,87 @@
1
- import { Box, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
1
+ import { Box, type Component, Markdown } from "@oh-my-pi/pi-tui";
2
2
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
3
3
  import type { CompactionSummaryMessage } from "../../session/messages";
4
4
 
5
5
  /**
6
- * Component that renders a compaction message with collapsed/expanded state.
7
- * Uses same background color as hook messages for visual consistency.
6
+ * Compaction point in the transcript, rendered as a slim horizontal divider:
7
+ *
8
+ * ──────── 📷 compacted · ctrl+o ────────
9
+ *
10
+ * The conversation above the divider stays visible (display transcript keeps
11
+ * full history); only the LLM context was reset. Expanding (ctrl+o) reveals
12
+ * the compaction summary below the divider.
8
13
  */
9
- export class CompactionSummaryMessageComponent extends Box {
14
+ export class CompactionSummaryMessageComponent implements Component {
10
15
  #expanded = false;
16
+ #cache?: { width: number; lines: string[] };
17
+ #detail?: Box;
11
18
 
12
- constructor(private readonly message: CompactionSummaryMessage) {
13
- super(1, 1, t => theme.bg("customMessageBg", t));
14
- this.#updateDisplay();
15
- }
19
+ constructor(private readonly message: CompactionSummaryMessage) {}
16
20
 
17
21
  setExpanded(expanded: boolean): void {
22
+ if (this.#expanded === expanded) return;
18
23
  this.#expanded = expanded;
19
- this.#updateDisplay();
24
+ this.#cache = undefined;
20
25
  }
21
26
 
22
- override invalidate(): void {
23
- super.invalidate();
24
- this.#updateDisplay();
27
+ invalidate(): void {
28
+ this.#cache = undefined;
29
+ // Theme may have changed — rebuild the detail box lazily on next render.
30
+ this.#detail = undefined;
25
31
  }
26
32
 
27
- #updateDisplay(): void {
28
- this.clear();
33
+ render(width: number): readonly string[] {
34
+ width = Math.max(1, width);
35
+ if (this.#cache?.width === width) {
36
+ return this.#cache.lines;
37
+ }
38
+ const lines = this.#expanded
39
+ ? ["", this.#divider(width), "", ...this.#detailBox().render(width)]
40
+ : ["", this.#divider(width), ""];
41
+ this.#cache = { width, lines };
42
+ return lines;
43
+ }
29
44
 
30
- const tokenStr = this.message.tokensBefore.toLocaleString();
31
- const label = theme.fg("customMessageLabel", theme.bold("[compaction]"));
32
- this.addChild(new Text(label, 0, 0));
33
- this.addChild(new Spacer(1));
45
+ #divider(width: number): string {
46
+ const rule = theme.tree.horizontal;
47
+ const label = `${theme.icon.camera} compacted`;
48
+ // sep.dot ships pre-padded (" · "); trim so the hint joins with single spaces.
49
+ const hint = `${theme.sep.dot.trim()} ctrl+o`;
50
+ const plainWidth = Bun.stringWidth(`${label} ${hint}`, { countAnsiEscapeCodes: false });
51
+ // ` label hint ` framed by rules on both sides.
52
+ const remaining = width - plainWidth - 2;
53
+ if (remaining < 4) {
54
+ // Too narrow for a framed rule — emit the bare label.
55
+ return theme.fg("muted", label);
56
+ }
57
+ const left = Math.floor(remaining / 2);
58
+ const right = remaining - left;
59
+ return (
60
+ theme.fg("dim", rule.repeat(left)) +
61
+ ` ${theme.fg("muted", label)} ${theme.fg("dim", hint)} ` +
62
+ theme.fg("dim", rule.repeat(right))
63
+ );
64
+ }
34
65
 
35
- if (this.#expanded) {
36
- const header = `**Compacted from ${tokenStr} tokens**\n\n`;
37
- this.addChild(
38
- new Markdown(header + this.message.summary, 0, 0, getMarkdownTheme(), {
66
+ #detailBox(): Box {
67
+ if (this.#detail) return this.#detail;
68
+ const box = new Box(1, 1, t => theme.bg("customMessageBg", t));
69
+ const tokenStr = this.message.tokensBefore.toLocaleString();
70
+ const frameCount = this.message.images?.length ?? 0;
71
+ const frameNote =
72
+ frameCount > 0 ? `\n\n_${frameCount} snapcompact frame${frameCount === 1 ? "" : "s"} attached_` : "";
73
+ box.addChild(
74
+ new Markdown(
75
+ `**Compacted from ${tokenStr} tokens**\n\n${this.message.summary}${frameNote}`,
76
+ 0,
77
+ 0,
78
+ getMarkdownTheme(),
79
+ {
39
80
  color: (text: string) => theme.fg("customMessageText", text),
40
- }),
41
- );
42
- } else {
43
- this.addChild(
44
- new Text(theme.fg("customMessageText", `Compacted from ${tokenStr} tokens (ctrl+o to expand)`), 0, 0),
45
- );
46
- if (this.message.shortSummary) {
47
- this.addChild(new Text(theme.fg("customMessageText", this.message.shortSummary), 0, 1));
48
- }
49
- }
81
+ },
82
+ ),
83
+ );
84
+ this.#detail = box;
85
+ return box;
50
86
  }
51
87
  }
@@ -175,6 +175,8 @@ export class CustomEditor extends Editor {
175
175
  onDequeue?: () => void;
176
176
  /** Called when Caps Lock is pressed. */
177
177
  onCapsLock?: () => void;
178
+ /** Called when left-arrow is pressed while the editor is empty (cursor necessarily at start). */
179
+ onLeftAtStart?: () => void;
178
180
 
179
181
  /** Custom key handlers from extensions and non-built-in app actions. */
180
182
  #customKeyHandlers = new Map<KeyId, () => void>();
@@ -257,6 +259,14 @@ export class CustomEditor extends Editor {
257
259
  const parsedKey = parseKey(data);
258
260
  const canonical = parsedKey !== undefined ? canonicalKeyId(parsedKey) : undefined;
259
261
 
262
+ // Left-arrow on an empty editor: surface for the agent-hub double-tap
263
+ // gesture. Plain "left" only — modified arrows and any in-text cursor
264
+ // movement fall through to normal handling.
265
+ if (canonical === "left" && this.onLeftAtStart && this.getText().trim() === "") {
266
+ this.onLeftAtStart();
267
+ return;
268
+ }
269
+
260
270
  if (canonical !== undefined) {
261
271
  // Intercept configured image paste (async - fires and handles result)
262
272
  if (this.#matchesAction(canonical, "app.clipboard.pasteImage") && this.onPasteImage) {
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import * as path from "node:path";
2
3
  import { stripVTControlCharacters } from "node:util";
3
4
  import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
4
5
  import { type Component, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
@@ -65,7 +66,8 @@ export class FooterComponent implements Component {
65
66
  }
66
67
 
67
68
  try {
68
- this.#gitWatcher = fs.watch(head.headPath, () => {
69
+ const watchPath = head.isReftable ? path.join(head.gitDir, "reftable") : head.headPath;
70
+ this.#gitWatcher = fs.watch(watchPath, () => {
69
71
  this.#cachedBranch = undefined; // Invalidate cache
70
72
  if (this.#onBranchChange) {
71
73
  this.#onBranchChange();
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import * as path from "node:path";
2
3
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
3
4
  import { estimateTokens } from "@oh-my-pi/pi-agent-core/compaction";
4
5
  import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
@@ -120,6 +121,18 @@ function tokensForMessage(msg: AgentMessage): number {
120
121
  return tokens;
121
122
  }
122
123
 
124
+ interface MessageTokenTotalsCache {
125
+ messagesRef: readonly AgentMessage[];
126
+ stableCount: number;
127
+ stableTokens: number;
128
+ lastStableMessage: AgentMessage | undefined;
129
+ lastStableFingerprint: string | undefined;
130
+ }
131
+
132
+ function hasContextSegment(segments: readonly StatusLineSegmentId[]): boolean {
133
+ return segments.includes("context_pct") || segments.includes("context_total");
134
+ }
135
+
123
136
  // ═══════════════════════════════════════════════════════════════════════════
124
137
  // StatusLineComponent
125
138
  // ═══════════════════════════════════════════════════════════════════════════
@@ -129,6 +142,7 @@ export class StatusLineComponent implements Component {
129
142
  #effectiveSettings: EffectiveStatusLineSettings | undefined;
130
143
  #cachedBranch: string | null | undefined = undefined;
131
144
  #cachedBranchRepoId: string | null | undefined = undefined;
145
+ #cachedBranchCwd: string | undefined = undefined;
132
146
  #gitWatcher: fs.FSWatcher | null = null;
133
147
  #onBranchChange: (() => void) | null = null;
134
148
  #autoCompactEnabled: boolean = true;
@@ -159,20 +173,19 @@ export class StatusLineComponent implements Component {
159
173
  } | null = null;
160
174
  #usageFetchedAt = 0;
161
175
  #usageInFlight = false;
162
- // Context breakdown — incremental cache. Replaces the previous 2-second
163
- // TTL design (which re-walked every message on each refresh and produced
164
- // ~1.1 s sync freezes on 2,000+ message sessions because `updateEditorTopBorder`
165
- // is called on every agent event in event-controller). The new scheme
166
- // caches by message-object identity (a Symbol-keyed sidecar on each
167
- // message) plus a cheap content fingerprint, so in-place mutations of
168
- // an existing message (post-hoc error attachment, retry-truncated
169
- // branch rebuild, replaceMessages with the same length) are detected
170
- // and recomputed.
176
+ // Context breakdown — incremental rolling cache. The status line refreshes
177
+ // on every agent event, so the hot path must not re-tokenize the full
178
+ // message list. Stable messages are accumulated once; normal streaming
179
+ // refreshes only recompute the current tail message and newly appended
180
+ // entries. History rewrites/compaction replace or shrink the message array
181
+ // and rebuild this cache. Stable messages are treated as immutable after
182
+ // promotion, matching the normal append-only session flow.
171
183
  // Cached non-message total (system prompt + tools + skills). Invalidated
172
184
  // when the inputs-identity fingerprint changes (model swap, skill toggle,
173
185
  // tool registration).
174
186
  #nonMessageTokensCache: number | undefined;
175
187
  #nonMessageInputsKey: string | undefined;
188
+ #messageTokenTotalsCache: MessageTokenTotalsCache | undefined;
176
189
 
177
190
  constructor(private readonly session: AgentSession) {
178
191
  this.#settings = {
@@ -238,11 +251,15 @@ export class StatusLineComponent implements Component {
238
251
  this.#gitWatcher = null;
239
252
  }
240
253
 
241
- const gitHeadPath = git.repo.resolveSync(getProjectDir())?.headPath ?? null;
242
- if (!gitHeadPath) return;
254
+ const repository = git.repo.resolveSync(getProjectDir());
255
+ if (!repository) return;
256
+
257
+ const watchPath = git.repo.isReftableSync(repository)
258
+ ? path.join(repository.gitDir, "reftable")
259
+ : repository.headPath;
243
260
 
244
261
  try {
245
- this.#gitWatcher = fs.watch(gitHeadPath, () => {
262
+ this.#gitWatcher = fs.watch(watchPath, () => {
246
263
  this.#invalidateGitCaches();
247
264
  if (this.#onBranchChange) {
248
265
  this.#onBranchChange();
@@ -267,15 +284,18 @@ export class StatusLineComponent implements Component {
267
284
  #invalidateGitCaches(): void {
268
285
  this.#cachedBranch = undefined;
269
286
  this.#cachedBranchRepoId = undefined;
287
+ this.#cachedBranchCwd = undefined;
270
288
  this.#cachedPrContext = undefined;
271
289
  }
272
290
  #getCurrentBranch(): string | null {
273
- const head = git.head.resolveSync(getProjectDir());
274
- const gitHeadPath = head?.headPath ?? null;
275
- if (this.#cachedBranch !== undefined && this.#cachedBranchRepoId === gitHeadPath) {
291
+ const cwd = getProjectDir();
292
+ if (this.#cachedBranch !== undefined && this.#cachedBranchCwd === cwd) {
276
293
  return this.#cachedBranch;
277
294
  }
278
295
 
296
+ const head = git.head.resolveSync(cwd);
297
+ const gitHeadPath = head?.headPath ?? null;
298
+ this.#cachedBranchCwd = cwd;
279
299
  this.#cachedBranchRepoId = gitHeadPath;
280
300
  if (!head) {
281
301
  this.#cachedBranch = null;
@@ -503,24 +523,79 @@ export class StatusLineComponent implements Component {
503
523
  this.#nonMessageInputsKey = inputsKey;
504
524
  }
505
525
 
506
- // 2) Message tokens — incremental. The sidecar cache lives on the
507
- // message object itself (Symbol-keyed), keyed by identity and
508
- // validated by a cheap content fingerprint. Mutations that
509
- // replace messages (replaceMessages, branch rebuild, compaction)
510
- // yield fresh objects cache miss recompute. In-place
511
- // mutations on the same object are caught by fingerprint
512
- // mismatch. The LAST message is always recomputed because it
513
- // may still be growing during streaming.
514
- let messagesTokens = 0;
515
- const lastIdx = messages.length - 1;
516
- for (let i = 0; i < messages.length; i++) {
517
- messagesTokens += i === lastIdx ? estimateTokens(messages[i]) : tokensForMessage(messages[i]);
518
- }
526
+ // 2) Message tokens — incremental rolling total. The sidecar cache lives
527
+ // on each stable message object (all but the current tail). Normal
528
+ // streaming turns only recompute the last message and newly appended
529
+ // entries. Full rebuild only when the message array is replaced,
530
+ // shrinks, or the recently-promoted stable tail mutates in place.
531
+ const messagesTokens = this.#getCachedMessageTokens(messages);
519
532
 
520
533
  const usedTokens = this.#nonMessageTokensCache + messagesTokens;
521
534
  return { usedTokens, contextWindow };
522
535
  }
523
536
 
537
+ #getCachedMessageTokens(messages: readonly AgentMessage[]): number {
538
+ const cache = this.#messageTokenTotalsCache;
539
+ if (!cache || cache.messagesRef !== messages || messages.length <= cache.stableCount) {
540
+ return this.#rebuildMessageTokenTotals(messages);
541
+ }
542
+
543
+ let stableTokens = cache.stableTokens;
544
+ let stableCount = cache.stableCount;
545
+ const stableLimit = Math.max(0, messages.length - 1);
546
+
547
+ if (
548
+ cache.lastStableMessage &&
549
+ stableCount > 0 &&
550
+ messages[stableCount - 1] === cache.lastStableMessage &&
551
+ cache.lastStableFingerprint !== undefined &&
552
+ cache.lastStableFingerprint !== messageFingerprint(cache.lastStableMessage)
553
+ ) {
554
+ return this.#rebuildMessageTokenTotals(messages);
555
+ }
556
+
557
+ while (stableCount < stableLimit) {
558
+ const promoted = messages[stableCount]!;
559
+ stableTokens += tokensForMessage(promoted);
560
+ stableCount++;
561
+ }
562
+
563
+ const lastStableMessage = stableCount > 0 ? messages[stableCount - 1] : undefined;
564
+ const lastStableFingerprint = lastStableMessage ? messageFingerprint(lastStableMessage) : undefined;
565
+ const lastMessage = messages.at(-1);
566
+ const lastTokens = lastMessage ? estimateTokens(lastMessage) : 0;
567
+ this.#messageTokenTotalsCache = {
568
+ messagesRef: messages,
569
+ stableCount,
570
+ stableTokens,
571
+ lastStableMessage,
572
+ lastStableFingerprint,
573
+ };
574
+ return stableTokens + lastTokens;
575
+ }
576
+
577
+ #rebuildMessageTokenTotals(messages: readonly AgentMessage[]): number {
578
+ let stableTokens = 0;
579
+ const stableLimit = Math.max(0, messages.length - 1);
580
+ for (let i = 0; i < stableLimit; i++) {
581
+ stableTokens += tokensForMessage(messages[i]!);
582
+ }
583
+
584
+ const lastStableMessage = stableLimit > 0 ? messages[stableLimit - 1] : undefined;
585
+ const lastStableFingerprint = lastStableMessage ? messageFingerprint(lastStableMessage) : undefined;
586
+ const lastMessage = messages.at(-1);
587
+ const lastTokens = lastMessage ? estimateTokens(lastMessage) : 0;
588
+
589
+ this.#messageTokenTotalsCache = {
590
+ messagesRef: messages,
591
+ stableCount: stableLimit,
592
+ stableTokens,
593
+ lastStableMessage,
594
+ lastStableFingerprint,
595
+ };
596
+ return stableTokens + lastTokens;
597
+ }
598
+
524
599
  /**
525
600
  * Build an identity fingerprint for the non-message inputs (system prompt,
526
601
  * tools, skills). When this changes, the non-message token cache must be
@@ -535,7 +610,11 @@ export class StatusLineComponent implements Component {
535
610
  return `${modelId}|${sp.length}:${sp[0]?.length ?? 0}|${tools.length}|${skills.length}`;
536
611
  }
537
612
 
538
- #buildSegmentContext(width: number, segmentOptions: StatusLineSettings["segmentOptions"]): SegmentContext {
613
+ #buildSegmentContext(
614
+ width: number,
615
+ segmentOptions: StatusLineSettings["segmentOptions"],
616
+ includeContext: boolean,
617
+ ): SegmentContext {
539
618
  const state = this.session.state;
540
619
 
541
620
  // Trigger background fetch (5-min TTL); render uses cached value
@@ -555,10 +634,13 @@ export class StatusLineComponent implements Component {
555
634
  tokensPerSecond: this.#getTokensPerSecond(),
556
635
  };
557
636
 
558
- // Context usage — aligned with /context command so both surfaces report the same value
559
- const breakdown = this.getCachedContextBreakdown();
560
- const contextTokens = breakdown.usedTokens;
561
- const contextWindow = breakdown.contextWindow || state.model?.contextWindow || 0;
637
+ let contextTokens = 0;
638
+ let contextWindow = state.model?.contextWindow ?? this.session.model?.contextWindow ?? 0;
639
+ if (includeContext) {
640
+ const breakdown = this.getCachedContextBreakdown();
641
+ contextTokens = breakdown.usedTokens;
642
+ contextWindow = breakdown.contextWindow || contextWindow;
643
+ }
562
644
  const contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
563
645
 
564
646
  return {
@@ -626,7 +708,9 @@ export class StatusLineComponent implements Component {
626
708
 
627
709
  #buildStatusLine(width: number): string {
628
710
  const effectiveSettings = this.#resolveSettings();
629
- const ctx = this.#buildSegmentContext(width, effectiveSettings.segmentOptions);
711
+ const includeContext =
712
+ hasContextSegment(effectiveSettings.leftSegments) || hasContextSegment(effectiveSettings.rightSegments);
713
+ const ctx = this.#buildSegmentContext(width, effectiveSettings.segmentOptions, includeContext);
630
714
  const separatorDef = getSeparator(effectiveSettings.separator ?? "powerline-thin", theme);
631
715
 
632
716
  const bgAnsi = theme.getBgAnsi("statusLineBg");
@@ -19,6 +19,7 @@ import type { Theme } from "../../modes/theme/theme";
19
19
  import { theme } from "../../modes/theme/theme";
20
20
  import { BASH_DEFAULT_PREVIEW_LINES } from "../../tools/bash";
21
21
  import { EVAL_DEFAULT_PREVIEW_LINES } from "../../tools/eval";
22
+ import { isWaitingPollDetails } from "../../tools/job";
22
23
  import {
23
24
  formatArgsInline,
24
25
  JSON_TREE_MAX_DEPTH_COLLAPSED,
@@ -194,6 +195,11 @@ export class ToolExecutionComponent extends Container {
194
195
  // sealed the block stays in the transcript's repaintable live region so a
195
196
  // late result still repaints instead of stranding the streaming preview.
196
197
  #sealed = false;
198
+ // A `job` poll result whose watched jobs are all still running. Such a
199
+ // block never finalizes (stays in the transcript live region) so a
200
+ // follow-up `job` call can displace it instead of stacking another
201
+ // "waiting on N jobs" frame. Cleared by `seal()`.
202
+ #displaceable = false;
197
203
  #renderState: {
198
204
  spinnerFrame?: number;
199
205
  expanded: boolean;
@@ -359,6 +365,11 @@ export class ToolExecutionComponent extends Container {
359
365
  ): void {
360
366
  this.#result = result;
361
367
  this.#isPartial = isPartial;
368
+ // A `job` poll that found every watched job still running is transient
369
+ // "still waiting" chrome; keep the block displaceable so the next `job`
370
+ // call replaces it instead of stacking another waiting frame (see the
371
+ // event controller's displaceable-poll bookkeeping).
372
+ this.#displaceable = this.#toolName === "job" && result.isError !== true && isWaitingPollDetails(result.details);
362
373
  // When tool is complete, ensure args are marked complete so spinner stops
363
374
  if (!isPartial) {
364
375
  this.#argsComplete = true;
@@ -425,7 +436,11 @@ export class ToolExecutionComponent extends Container {
425
436
  (this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
426
437
  const isBackgroundAsyncTask = this.#toolName === "task" && isBackgroundAsyncRunning;
427
438
  const isPartialTask = this.#isPartial && this.#toolName === "task" && !isBackgroundAsyncTask;
428
- const needsSpinner = isStreamingArgs || isPartialTask;
439
+ // A displaceable waiting poll keeps its spinner ticking: it reads as one
440
+ // persistent live poll, and the changing leading glyph keeps the
441
+ // transcript's stable-prefix ratchet from committing rows of a block
442
+ // that a follow-up `job` call may remove.
443
+ const needsSpinner = isStreamingArgs || isPartialTask || this.isDisplaceableBlock();
429
444
  if (needsSpinner && !this.#spinnerInterval) {
430
445
  const now = performance.now();
431
446
  const frameCount = theme.spinnerFrames.length;
@@ -513,6 +528,9 @@ export class ToolExecutionComponent extends Container {
513
528
  isTranscriptBlockFinalized(): boolean {
514
529
  if (this.#sealed) return true;
515
530
  if (this.#result === undefined) return false;
531
+ // A displaceable waiting poll stays live: its rows are kept out of
532
+ // native scrollback so a follow-up `job` call can remove the block.
533
+ if (this.#displaceable) return false;
516
534
  if (!this.#isPartial) return true;
517
535
  // Partial result: a background async tool is accepted to freeze (the agent
518
536
  // continues while it runs and would otherwise pin an unbounded live region);
@@ -528,11 +546,23 @@ export class ToolExecutionComponent extends Container {
528
546
  seal(): void {
529
547
  if (this.#sealed) return;
530
548
  this.#sealed = true;
549
+ this.#displaceable = false;
531
550
  this.stopAnimation();
532
551
  this.#updateDisplay();
533
552
  this.#ui.requestRender();
534
553
  }
535
554
 
555
+ /**
556
+ * Whether this block is a waiting `job` poll (every watched job still
557
+ * running) that has not been sealed. Such a block never finalized, so none
558
+ * of its rows entered native scrollback (the ticking spinner keeps the
559
+ * stable-prefix ratchet at zero) and the whole block can be removed when a
560
+ * follow-up `job` call supersedes it.
561
+ */
562
+ isDisplaceableBlock(): boolean {
563
+ return this.#displaceable && !this.#sealed;
564
+ }
565
+
536
566
  /**
537
567
  * Stop spinner animation and cleanup resources.
538
568
  */