@oh-my-pi/pi-coding-agent 15.12.2 → 15.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. package/CHANGELOG.md +49 -1
  2. package/dist/cli.js +1121 -871
  3. package/dist/types/autoresearch/tools/init-experiment.d.ts +1 -1
  4. package/dist/types/autoresearch/tools/log-experiment.d.ts +1 -1
  5. package/dist/types/autoresearch/tools/run-experiment.d.ts +1 -1
  6. package/dist/types/autoresearch/tools/update-notes.d.ts +1 -1
  7. package/dist/types/cli/args.d.ts +0 -1
  8. package/dist/types/cli/models-cli.d.ts +49 -0
  9. package/dist/types/commands/launch.d.ts +0 -3
  10. package/dist/types/commands/models.d.ts +33 -0
  11. package/dist/types/commands/token.d.ts +25 -0
  12. package/dist/types/commit/agentic/tools/analyze-file.d.ts +1 -1
  13. package/dist/types/commit/agentic/tools/git-file-diff.d.ts +1 -1
  14. package/dist/types/commit/agentic/tools/git-hunk.d.ts +1 -1
  15. package/dist/types/commit/agentic/tools/git-overview.d.ts +1 -1
  16. package/dist/types/commit/agentic/tools/propose-changelog.d.ts +1 -1
  17. package/dist/types/commit/agentic/tools/propose-commit.d.ts +1 -1
  18. package/dist/types/commit/agentic/tools/recent-commits.d.ts +1 -1
  19. package/dist/types/commit/agentic/tools/schemas.d.ts +1 -1
  20. package/dist/types/commit/agentic/tools/split-commit.d.ts +1 -1
  21. package/dist/types/commit/changelog/generate.d.ts +1 -1
  22. package/dist/types/commit/shared-llm.d.ts +1 -1
  23. package/dist/types/config/model-registry.d.ts +7 -0
  24. package/dist/types/config/models-config-schema.d.ts +1 -1
  25. package/dist/types/config/settings-schema.d.ts +21 -1
  26. package/dist/types/edit/hashline/params.d.ts +1 -1
  27. package/dist/types/edit/modes/apply-patch.d.ts +1 -1
  28. package/dist/types/edit/modes/patch.d.ts +1 -1
  29. package/dist/types/edit/modes/replace.d.ts +1 -1
  30. package/dist/types/extensibility/custom-commands/types.d.ts +2 -2
  31. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  32. package/dist/types/extensibility/extensions/types.d.ts +2 -2
  33. package/dist/types/extensibility/hooks/types.d.ts +2 -2
  34. package/dist/types/goals/tools/goal-tool.d.ts +1 -1
  35. package/dist/types/lsp/types.d.ts +1 -1
  36. package/dist/types/mcp/manager.d.ts +8 -0
  37. package/dist/types/mnemopi/config.d.ts +28 -0
  38. package/dist/types/modes/acp/acp-agent.d.ts +1 -2
  39. package/dist/types/modes/components/index.d.ts +1 -0
  40. package/dist/types/modes/components/logout-account-selector.d.ts +8 -0
  41. package/dist/types/modes/components/status-line/component.d.ts +9 -5
  42. package/dist/types/modes/components/status-line/types.d.ts +2 -1
  43. package/dist/types/modes/controllers/event-controller.d.ts +0 -17
  44. package/dist/types/modes/interactive-mode.d.ts +0 -3
  45. package/dist/types/modes/types.d.ts +0 -5
  46. package/dist/types/session/agent-session.d.ts +14 -33
  47. package/dist/types/session/agent-storage.d.ts +2 -1
  48. package/dist/types/session/indexed-session-storage.d.ts +1 -0
  49. package/dist/types/session/messages.d.ts +8 -10
  50. package/dist/types/session/session-manager.d.ts +15 -0
  51. package/dist/types/session/session-storage.d.ts +5 -0
  52. package/dist/types/slash-commands/helpers/logout.d.ts +15 -0
  53. package/dist/types/task/types.d.ts +1 -1
  54. package/dist/types/tools/ask.d.ts +1 -1
  55. package/dist/types/tools/ast-edit.d.ts +1 -1
  56. package/dist/types/tools/ast-grep.d.ts +1 -1
  57. package/dist/types/tools/bash.d.ts +1 -1
  58. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +202 -0
  59. package/dist/types/tools/browser/cmux/rpc.d.ts +70 -0
  60. package/dist/types/tools/browser/cmux/socket-client.d.ts +19 -0
  61. package/dist/types/tools/browser/registry.d.ts +16 -3
  62. package/dist/types/tools/browser/render.d.ts +2 -0
  63. package/dist/types/tools/browser/tab-protocol.d.ts +2 -0
  64. package/dist/types/tools/browser/tab-supervisor.d.ts +16 -4
  65. package/dist/types/tools/browser.d.ts +3 -1
  66. package/dist/types/tools/checkpoint.d.ts +1 -1
  67. package/dist/types/tools/debug.d.ts +1 -1
  68. package/dist/types/tools/eval.d.ts +1 -1
  69. package/dist/types/tools/find.d.ts +1 -1
  70. package/dist/types/tools/gh.d.ts +1 -1
  71. package/dist/types/tools/image-gen.d.ts +1 -1
  72. package/dist/types/tools/index.d.ts +3 -1
  73. package/dist/types/tools/inspect-image.d.ts +1 -1
  74. package/dist/types/tools/irc.d.ts +1 -1
  75. package/dist/types/tools/job.d.ts +1 -1
  76. package/dist/types/tools/memory-edit.d.ts +1 -1
  77. package/dist/types/tools/memory-recall.d.ts +1 -1
  78. package/dist/types/tools/memory-reflect.d.ts +1 -1
  79. package/dist/types/tools/memory-retain.d.ts +1 -1
  80. package/dist/types/tools/read.d.ts +1 -1
  81. package/dist/types/tools/render-mermaid.d.ts +1 -1
  82. package/dist/types/tools/resolve.d.ts +1 -1
  83. package/dist/types/tools/review.d.ts +1 -1
  84. package/dist/types/tools/search-tool-bm25.d.ts +1 -1
  85. package/dist/types/tools/search.d.ts +1 -1
  86. package/dist/types/tools/ssh.d.ts +1 -1
  87. package/dist/types/tools/todo.d.ts +1 -1
  88. package/dist/types/tools/tts.d.ts +1 -1
  89. package/dist/types/tools/write.d.ts +1 -1
  90. package/dist/types/utils/clipboard.d.ts +4 -3
  91. package/dist/types/utils/image-loading.d.ts +18 -1
  92. package/dist/types/utils/thinking-display.d.ts +17 -0
  93. package/dist/types/web/search/index.d.ts +1 -1
  94. package/package.json +14 -14
  95. package/src/autoresearch/storage.ts +2 -1
  96. package/src/autoresearch/tools/init-experiment.ts +1 -1
  97. package/src/autoresearch/tools/log-experiment.ts +1 -1
  98. package/src/autoresearch/tools/run-experiment.ts +1 -1
  99. package/src/autoresearch/tools/update-notes.ts +1 -1
  100. package/src/cli/args.ts +0 -8
  101. package/src/cli/auth-gateway-cli.ts +1 -1
  102. package/src/cli/bench-cli.ts +1 -1
  103. package/src/cli/dry-balance-cli.ts +1 -1
  104. package/src/cli/models-cli.ts +427 -0
  105. package/src/cli-commands.ts +2 -0
  106. package/src/collab/host.ts +9 -12
  107. package/src/commands/launch.ts +0 -3
  108. package/src/commands/models.ts +61 -0
  109. package/src/commands/token.ts +89 -0
  110. package/src/commit/agentic/tools/analyze-file.ts +1 -1
  111. package/src/commit/agentic/tools/git-file-diff.ts +1 -1
  112. package/src/commit/agentic/tools/git-hunk.ts +1 -1
  113. package/src/commit/agentic/tools/git-overview.ts +1 -1
  114. package/src/commit/agentic/tools/propose-changelog.ts +1 -1
  115. package/src/commit/agentic/tools/propose-commit.ts +1 -1
  116. package/src/commit/agentic/tools/recent-commits.ts +1 -1
  117. package/src/commit/agentic/tools/schemas.ts +1 -1
  118. package/src/commit/agentic/tools/split-commit.ts +1 -1
  119. package/src/commit/analysis/summary.ts +1 -1
  120. package/src/commit/changelog/generate.ts +1 -1
  121. package/src/commit/shared-llm.ts +1 -1
  122. package/src/config/model-registry.ts +15 -12
  123. package/src/config/model-resolver.ts +2 -2
  124. package/src/config/models-config-schema.ts +1 -1
  125. package/src/config/settings-schema.ts +19 -1
  126. package/src/edit/hashline/params.ts +1 -1
  127. package/src/edit/modes/apply-patch.ts +1 -1
  128. package/src/edit/modes/patch.ts +1 -1
  129. package/src/edit/modes/replace.ts +1 -1
  130. package/src/eval/agent-bridge.ts +1 -1
  131. package/src/eval/completion-bridge.ts +1 -1
  132. package/src/export/html/template.js +24 -2
  133. package/src/export/html/tool-views.generated.js +2 -2
  134. package/src/extensibility/custom-commands/loader.ts +1 -1
  135. package/src/extensibility/custom-commands/types.ts +2 -2
  136. package/src/extensibility/custom-tools/loader.ts +1 -1
  137. package/src/extensibility/custom-tools/types.ts +2 -2
  138. package/src/extensibility/extensions/loader.ts +2 -2
  139. package/src/extensibility/extensions/types.ts +2 -2
  140. package/src/extensibility/hooks/loader.ts +1 -1
  141. package/src/extensibility/hooks/types.ts +2 -2
  142. package/src/extensibility/skills.ts +18 -3
  143. package/src/goals/tools/goal-tool.ts +1 -1
  144. package/src/internal-urls/docs-index.generated.ts +6 -3
  145. package/src/lsp/types.ts +1 -1
  146. package/src/main.ts +0 -25
  147. package/src/mcp/config-writer.ts +7 -3
  148. package/src/mcp/manager.ts +11 -0
  149. package/src/memories/index.ts +3 -1
  150. package/src/memories/storage.ts +2 -1
  151. package/src/mnemopi/config.ts +95 -11
  152. package/src/modes/acp/acp-agent.ts +5 -48
  153. package/src/modes/acp/acp-event-mapper.ts +5 -1
  154. package/src/modes/components/agent-hub.ts +2 -1
  155. package/src/modes/components/assistant-message.ts +8 -7
  156. package/src/modes/components/index.ts +1 -0
  157. package/src/modes/components/logout-account-selector.ts +130 -0
  158. package/src/modes/components/mcp-add-wizard.ts +1 -1
  159. package/src/modes/components/model-selector.ts +2 -2
  160. package/src/modes/components/status-line/component.ts +54 -157
  161. package/src/modes/components/status-line/segments.ts +1 -1
  162. package/src/modes/components/status-line/types.ts +2 -1
  163. package/src/modes/controllers/command-controller.ts +0 -12
  164. package/src/modes/controllers/event-controller.ts +23 -62
  165. package/src/modes/controllers/input-controller.ts +60 -31
  166. package/src/modes/controllers/mcp-command-controller.ts +44 -3
  167. package/src/modes/controllers/selector-controller.ts +56 -10
  168. package/src/modes/controllers/streaming-reveal.ts +4 -3
  169. package/src/modes/interactive-mode.ts +2 -8
  170. package/src/modes/theme/theme.ts +1 -1
  171. package/src/modes/types.ts +0 -5
  172. package/src/modes/utils/ui-helpers.ts +2 -1
  173. package/src/prompts/system/empty-stop-retry.md +4 -6
  174. package/src/sdk.ts +15 -19
  175. package/src/session/agent-session.ts +125 -234
  176. package/src/session/agent-storage.ts +18 -9
  177. package/src/session/history-storage.ts +2 -1
  178. package/src/session/indexed-session-storage.ts +7 -0
  179. package/src/session/messages.ts +9 -11
  180. package/src/session/session-dump-format.ts +4 -2
  181. package/src/session/session-manager.ts +116 -0
  182. package/src/session/session-storage.ts +20 -0
  183. package/src/slash-commands/builtin-registry.ts +15 -1
  184. package/src/slash-commands/helpers/logout.ts +88 -0
  185. package/src/task/types.ts +1 -1
  186. package/src/tools/ask.ts +1 -1
  187. package/src/tools/ast-edit.ts +13 -4
  188. package/src/tools/ast-grep.ts +1 -1
  189. package/src/tools/bash.ts +1 -1
  190. package/src/tools/browser/cmux/cmux-tab.ts +1264 -0
  191. package/src/tools/browser/cmux/rpc.ts +156 -0
  192. package/src/tools/browser/cmux/socket-client.ts +309 -0
  193. package/src/tools/browser/registry.ts +37 -3
  194. package/src/tools/browser/render.ts +6 -1
  195. package/src/tools/browser/tab-protocol.ts +2 -0
  196. package/src/tools/browser/tab-supervisor.ts +189 -18
  197. package/src/tools/browser/tab-worker.ts +1 -1
  198. package/src/tools/browser.ts +16 -1
  199. package/src/tools/checkpoint.ts +1 -1
  200. package/src/tools/debug.ts +1 -1
  201. package/src/tools/eval.ts +11 -6
  202. package/src/tools/fetch.ts +13 -2
  203. package/src/tools/find.ts +1 -1
  204. package/src/tools/gh.ts +1 -1
  205. package/src/tools/github-cache.ts +2 -1
  206. package/src/tools/image-gen.ts +1 -1
  207. package/src/tools/index.ts +3 -1
  208. package/src/tools/inspect-image.ts +3 -1
  209. package/src/tools/irc.ts +1 -1
  210. package/src/tools/job.ts +1 -1
  211. package/src/tools/memory-edit.ts +1 -1
  212. package/src/tools/memory-recall.ts +1 -1
  213. package/src/tools/memory-reflect.ts +1 -1
  214. package/src/tools/memory-retain.ts +1 -1
  215. package/src/tools/read.ts +8 -2
  216. package/src/tools/render-mermaid.ts +1 -1
  217. package/src/tools/report-tool-issue.ts +3 -2
  218. package/src/tools/resolve.ts +1 -1
  219. package/src/tools/review.ts +1 -1
  220. package/src/tools/search-tool-bm25.ts +1 -1
  221. package/src/tools/search.ts +1 -1
  222. package/src/tools/ssh.ts +1 -1
  223. package/src/tools/todo.ts +1 -1
  224. package/src/tools/tts.ts +1 -1
  225. package/src/tools/write.ts +1 -1
  226. package/src/utils/clipboard.ts +35 -18
  227. package/src/utils/image-loading.ts +35 -4
  228. package/src/utils/thinking-display.ts +37 -0
  229. package/src/web/search/index.ts +1 -1
  230. package/dist/types/cli/list-models.d.ts +0 -30
  231. package/src/cli/list-models.ts +0 -194
@@ -262,7 +262,7 @@ export class MCPAddWizard extends Container {
262
262
  }
263
263
 
264
264
  this.#contentContainer.addChild(
265
- new Text(theme.fg("muted", "[Only letters, numbers, dash, underscore, dot]"), 0, 0),
265
+ new Text(theme.fg("muted", "[Only letters, numbers, dash, underscore, dot, colon]"), 0, 0),
266
266
  );
267
267
  this.#contentContainer.addChild(new Text(theme.fg("muted", "[Enter to continue, Esc to cancel]"), 0, 0));
268
268
  }
@@ -711,7 +711,7 @@ export class ModelSelectorComponent extends Container {
711
711
  if (!this.#isModelOverContextLimit(model)) {
712
712
  return "";
713
713
  }
714
- return ` ${theme.status.disabled} context>${formatNumber(model.contextWindow).toLowerCase()}`;
714
+ return ` ${theme.status.disabled} context>${formatNumber(model.contextWindow ?? 0).toLowerCase()}`;
715
715
  }
716
716
 
717
717
  #getVisibleItems(): ReadonlyArray<ModelItem | CanonicalModelItem> {
@@ -1016,7 +1016,7 @@ export class ModelSelectorComponent extends Container {
1016
1016
  const limitWarning = this.#isItemDisabled(selected)
1017
1017
  ? theme.fg(
1018
1018
  "dim",
1019
- ` — current context ${formatNumber(this.#currentContextTokens).toLowerCase()} > ${formatNumber(selected.model.contextWindow).toLowerCase()} limit`,
1019
+ ` — current context ${formatNumber(this.#currentContextTokens).toLowerCase()} > ${formatNumber(selected.model.contextWindow ?? 0).toLowerCase()} limit`,
1020
1020
  )
1021
1021
  : "";
1022
1022
  this.#listContainer.addChild(
@@ -1,7 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
4
- import { estimateTokens } from "@oh-my-pi/pi-agent-core/compaction";
5
4
  import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
6
5
  import { getProjectDir } from "@oh-my-pi/pi-utils";
7
6
  import { $ } from "bun";
@@ -11,7 +10,6 @@ import * as git from "../../../utils/git";
11
10
  import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color";
12
11
  import { sanitizeStatusText } from "../../shared";
13
12
  import { theme } from "../../theme/theme";
14
- import { computeNonMessageTokens } from "../../utils/context-usage";
15
13
  import { canReuseCachedPr, createPrCacheContext, isSamePrCacheContext, type PrCacheContext } from "./git-utils";
16
14
  import { getPreset } from "./presets";
17
15
  import { renderSegment, type SegmentContext } from "./segments";
@@ -26,30 +24,15 @@ import type {
26
24
  } from "./types";
27
25
 
28
26
  // ═══════════════════════════════════════════════════════════════════════════
29
- // Per-message token cache
27
+ // Context-usage memo
30
28
  // ═══════════════════════════════════════════════════════════════════════════
31
29
 
32
30
  /**
33
- * Symbol-keyed sidecar tagged onto each `AgentMessage` to memoize its
34
- * `estimateTokens` result. Keyed by message identity (the object itself);
35
- * a cheap content fingerprint detects in-place mutations (post-hoc error
36
- * attachment, retry-truncated branch rebuild, etc.) and forces recompute.
37
- *
38
- * Cache lives on the message — multiple `StatusLineComponent` instances
39
- * share it for free, and entries collect with the message itself when the
40
- * conversation is replaced or compacted.
41
- */
42
- const kTokenCache = Symbol("statusLine.tokenCache");
43
- interface TaggedMessage {
44
- [kTokenCache]?: { fingerprint: string; tokens: number };
45
- }
46
-
47
- /**
48
- * Cheap structural fingerprint mirroring `estimateTokens`'s content walk.
49
- * O(blocks) — only reads string `.length` and primitives, never copies or
50
- * serializes content. Any in-place mutation that alters total tokenized
51
- * content also alters one of the byte-length sums or block counts captured
52
- * here, forcing the cached `estimateTokens` value to be recomputed.
31
+ * Cheap structural fingerprint of a message's tokenizable content. O(blocks) —
32
+ * only reads string `.length` and primitives, never copies or serializes.
33
+ * Detects in-place growth of the streaming tail (and other in-place mutations)
34
+ * so the cached `getContextUsage()` result is recomputed when — and only when —
35
+ * the numbers it depends on change.
53
36
  */
54
37
  function messageFingerprint(msg: AgentMessage): string {
55
38
  const role = (msg as { role?: string }).role ?? "";
@@ -107,29 +90,17 @@ function messageFingerprint(msg: AgentMessage): string {
107
90
  return `${role}:${ts}:${textLen}:${blocks}:${images}`;
108
91
  }
109
92
 
110
- /**
111
- * Token count for a single message, using the per-message sidecar cache.
112
- * The caller MUST skip caching for the last message during streaming —
113
- * it may still be growing and its tokens belong recomputed each refresh.
114
- */
115
- function tokensForMessage(msg: AgentMessage): number {
116
- const fp = messageFingerprint(msg);
117
- const tagged = msg as TaggedMessage;
118
- const cached = tagged[kTokenCache];
119
- if (cached && cached.fingerprint === fp) return cached.tokens;
120
- const tokens = estimateTokens(msg);
121
- tagged[kTokenCache] = { fingerprint: fp, tokens };
122
- return tokens;
123
- }
124
-
125
- interface MessageTokenTotalsCache {
93
+ interface ContextUsageMemo {
126
94
  messagesRef: readonly AgentMessage[];
127
- stableCount: number;
128
- stableTokens: number;
129
- lastStableMessage: AgentMessage | undefined;
130
- lastStableFingerprint: string | undefined;
95
+ length: number;
96
+ lastFingerprint: string | undefined;
97
+ modelContextWindow: number;
98
+ usedTokens: number | null;
99
+ contextWindow: number;
131
100
  }
132
101
 
102
+ const EMPTY_MESSAGES: readonly AgentMessage[] = [];
103
+
133
104
  function hasContextSegment(segments: readonly StatusLineSegmentId[]): boolean {
134
105
  return segments.includes("context_pct") || segments.includes("context_total");
135
106
  }
@@ -176,19 +147,12 @@ export class StatusLineComponent implements Component {
176
147
  } | null = null;
177
148
  #usageFetchedAt = 0;
178
149
  #usageInFlight = false;
179
- // Context breakdown — incremental rolling cache. The status line refreshes
180
- // on every agent event, so the hot path must not re-tokenize the full
181
- // message list. Stable messages are accumulated once; normal streaming
182
- // refreshes only recompute the current tail message and newly appended
183
- // entries. History rewrites/compaction replace or shrink the message array
184
- // and rebuild this cache. Stable messages are treated as immutable after
185
- // promotion, matching the normal append-only session flow.
186
- // Cached non-message total (system prompt + tools + skills). Invalidated
187
- // when the inputs-identity fingerprint changes (model swap, skill toggle,
188
- // tool registration).
189
- #nonMessageTokensCache: number | undefined;
190
- #nonMessageInputsKey: string | undefined;
191
- #messageTokenTotalsCache: MessageTokenTotalsCache | undefined;
150
+ // Context-usage memo. The status line redraws on every agent event, so the
151
+ // hot path must not recompute context tokens unless an input changed.
152
+ // `getContextUsage()` anchors on the last assistant's real prompt-token
153
+ // count (matching the provider and the `/context` panel), so a stable
154
+ // message list + model window yields a stable result we can return verbatim.
155
+ #contextUsageCache: ContextUsageMemo | undefined;
192
156
 
193
157
  constructor(private session: AgentSession) {
194
158
  this.#settings = {
@@ -310,9 +274,7 @@ export class StatusLineComponent implements Component {
310
274
  this.#cachedUsage = null;
311
275
  this.#usageFetchedAt = 0;
312
276
  this.#usageInFlight = false;
313
- this.#nonMessageTokensCache = undefined;
314
- this.#nonMessageInputsKey = undefined;
315
- this.#messageTokenTotalsCache = undefined;
277
+ this.#contextUsageCache = undefined;
316
278
  this.#lastTokensPerSecond = null;
317
279
  this.#lastTokensPerSecondTimestamp = null;
318
280
  }
@@ -544,109 +506,44 @@ export class StatusLineComponent implements Component {
544
506
  }
545
507
 
546
508
  /**
547
- * Compute the (cached) used-tokens / context-window totals for the
548
- * status-line context% segment. Exposed (non-private) so unit tests can
549
- * verify the incremental-cache invariants; not part of any external
550
- * API.
509
+ * Used-tokens / context-window totals for the status-line context% segment,
510
+ * memoized so the per-event redraw stays O(1) when nothing changed.
511
+ *
512
+ * The numerator comes from `session.getContextUsage()`, which anchors on the
513
+ * last assistant's real prompt-token count — so the bar matches the provider
514
+ * and the `/context` panel — and reports `null` while that count is unknown
515
+ * (right after compaction, before the next response). Exposed (non-private)
516
+ * for unit tests and the collab host's state broadcast.
551
517
  */
552
- getCachedContextBreakdown(): { usedTokens: number; contextWindow: number } {
553
- const messages = this.session.messages ?? [];
554
- const contextWindow = this.session.model?.contextWindow ?? 0;
555
-
556
- // 1) Non-message tokens (system prompt + tools + skills). Refresh only
557
- // when the inputs identity fingerprint changes — usually never
558
- // during a streaming turn. ~10-30 ms when it does refresh.
559
- const inputsKey = this.#computeNonMessageInputsKey();
560
- if (this.#nonMessageTokensCache === undefined || this.#nonMessageInputsKey !== inputsKey) {
561
- this.#nonMessageTokensCache = computeNonMessageTokens(this.session);
562
- this.#nonMessageInputsKey = inputsKey;
563
- }
564
-
565
- // 2) Message tokens — incremental rolling total. The sidecar cache lives
566
- // on each stable message object (all but the current tail). Normal
567
- // streaming turns only recompute the last message and newly appended
568
- // entries. Full rebuild only when the message array is replaced,
569
- // shrinks, or the recently-promoted stable tail mutates in place.
570
- const messagesTokens = this.#getCachedMessageTokens(messages);
571
-
572
- const usedTokens = this.#nonMessageTokensCache + messagesTokens;
573
- return { usedTokens, contextWindow };
574
- }
575
-
576
- #getCachedMessageTokens(messages: readonly AgentMessage[]): number {
577
- const cache = this.#messageTokenTotalsCache;
578
- if (!cache || cache.messagesRef !== messages || messages.length <= cache.stableCount) {
579
- return this.#rebuildMessageTokenTotals(messages);
580
- }
581
-
582
- let stableTokens = cache.stableTokens;
583
- let stableCount = cache.stableCount;
584
- const stableLimit = Math.max(0, messages.length - 1);
518
+ getCachedContextBreakdown(): { usedTokens: number | null; contextWindow: number } {
519
+ const messages = this.session.messages ?? EMPTY_MESSAGES;
520
+ const modelContextWindow = this.session.model?.contextWindow ?? 0;
521
+ const length = messages.length;
522
+ const lastFingerprint = length > 0 ? messageFingerprint(messages[length - 1]!) : undefined;
585
523
 
524
+ const cache = this.#contextUsageCache;
586
525
  if (
587
- cache.lastStableMessage &&
588
- stableCount > 0 &&
589
- messages[stableCount - 1] === cache.lastStableMessage &&
590
- cache.lastStableFingerprint !== undefined &&
591
- cache.lastStableFingerprint !== messageFingerprint(cache.lastStableMessage)
526
+ cache &&
527
+ cache.messagesRef === messages &&
528
+ cache.length === length &&
529
+ cache.lastFingerprint === lastFingerprint &&
530
+ cache.modelContextWindow === modelContextWindow
592
531
  ) {
593
- return this.#rebuildMessageTokenTotals(messages);
594
- }
595
-
596
- while (stableCount < stableLimit) {
597
- const promoted = messages[stableCount]!;
598
- stableTokens += tokensForMessage(promoted);
599
- stableCount++;
600
- }
601
-
602
- const lastStableMessage = stableCount > 0 ? messages[stableCount - 1] : undefined;
603
- const lastStableFingerprint = lastStableMessage ? messageFingerprint(lastStableMessage) : undefined;
604
- const lastMessage = messages.at(-1);
605
- const lastTokens = lastMessage ? estimateTokens(lastMessage) : 0;
606
- this.#messageTokenTotalsCache = {
607
- messagesRef: messages,
608
- stableCount,
609
- stableTokens,
610
- lastStableMessage,
611
- lastStableFingerprint,
612
- };
613
- return stableTokens + lastTokens;
614
- }
615
-
616
- #rebuildMessageTokenTotals(messages: readonly AgentMessage[]): number {
617
- let stableTokens = 0;
618
- const stableLimit = Math.max(0, messages.length - 1);
619
- for (let i = 0; i < stableLimit; i++) {
620
- stableTokens += tokensForMessage(messages[i]!);
532
+ return { usedTokens: cache.usedTokens, contextWindow: cache.contextWindow };
621
533
  }
622
534
 
623
- const lastStableMessage = stableLimit > 0 ? messages[stableLimit - 1] : undefined;
624
- const lastStableFingerprint = lastStableMessage ? messageFingerprint(lastStableMessage) : undefined;
625
- const lastMessage = messages.at(-1);
626
- const lastTokens = lastMessage ? estimateTokens(lastMessage) : 0;
627
-
628
- this.#messageTokenTotalsCache = {
535
+ const usage = this.session.getContextUsage();
536
+ const usedTokens = usage?.tokens ?? null;
537
+ const contextWindow = usage?.contextWindow ?? modelContextWindow;
538
+ this.#contextUsageCache = {
629
539
  messagesRef: messages,
630
- stableCount: stableLimit,
631
- stableTokens,
632
- lastStableMessage,
633
- lastStableFingerprint,
540
+ length,
541
+ lastFingerprint,
542
+ modelContextWindow,
543
+ usedTokens,
544
+ contextWindow,
634
545
  };
635
- return stableTokens + lastTokens;
636
- }
637
-
638
- /**
639
- * Build an identity fingerprint for the non-message inputs (system prompt,
640
- * tools, skills). When this changes, the non-message token cache must be
641
- * recomputed. Cheap: just lengths + first-string-length. Doesn't need to
642
- * be cryptographically unique — only stable for the same inputs.
643
- */
644
- #computeNonMessageInputsKey(): string {
645
- const sp = this.session.systemPrompt ?? [];
646
- const tools = this.session.agent?.state?.tools ?? [];
647
- const skills = this.session.skills ?? [];
648
- const modelId = this.session.model?.id ?? "";
649
- return `${modelId}|${sp.length}:${sp[0]?.length ?? 0}|${tools.length}|${skills.length}`;
546
+ return { usedTokens, contextWindow };
650
547
  }
651
548
 
652
549
  #buildSegmentContext(
@@ -673,14 +570,14 @@ export class StatusLineComponent implements Component {
673
570
  tokensPerSecond: this.#getTokensPerSecond(),
674
571
  };
675
572
 
676
- let contextTokens = 0;
677
573
  let contextWindow = state.model?.contextWindow ?? this.session.model?.contextWindow ?? 0;
574
+ let contextPercent: number | null = 0;
678
575
  if (includeContext) {
679
576
  const breakdown = this.getCachedContextBreakdown();
680
- contextTokens = breakdown.usedTokens;
681
577
  contextWindow = breakdown.contextWindow || contextWindow;
578
+ contextPercent =
579
+ breakdown.usedTokens === null ? null : contextWindow > 0 ? (breakdown.usedTokens / contextWindow) * 100 : 0;
682
580
  }
683
- let contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
684
581
 
685
582
  // Collab guest: context comes from the host's state frames — the local
686
583
  // replica does no accounting of its own.
@@ -364,7 +364,7 @@ const contextPctSegment: StatusLineSegment = {
364
364
  const autoIcon = ctx.autoCompactEnabled && theme.icon.auto ? ` ${theme.icon.auto}` : "";
365
365
  const text = `${formatContextUsage(pct, window)}${autoIcon}`;
366
366
 
367
- const color = getContextUsageThemeColor(getContextUsageLevel(pct, window));
367
+ const color = getContextUsageThemeColor(getContextUsageLevel(pct ?? 0, window));
368
368
  const content = withIcon(theme.icon.context, theme.fg(color, text));
369
369
 
370
370
  return { content, visible: true };
@@ -71,7 +71,8 @@ export interface SegmentContext {
71
71
  cost: number;
72
72
  tokensPerSecond: number | null;
73
73
  };
74
- contextPercent: number;
74
+ /** Context usage percent, or null when unknown (e.g. right after compaction). */
75
+ contextPercent: number | null;
75
76
  contextWindow: number;
76
77
  autoCompactEnabled: boolean;
77
78
  subagentCount: number;
@@ -1026,11 +1026,6 @@ export class CommandController {
1026
1026
  }
1027
1027
  this.ctx.statusContainer.clear();
1028
1028
 
1029
- const originalOnEscape = this.ctx.editor.onEscape;
1030
- this.ctx.editor.onEscape = () => {
1031
- this.ctx.session.abortCompaction();
1032
- };
1033
-
1034
1029
  this.ctx.chatContainer.addChild(new Spacer(1));
1035
1030
  const label = isAuto ? "Auto-compacting context... (esc to cancel)" : "Compacting context... (esc to cancel)";
1036
1031
  const compactingLoader = new Loader(
@@ -1068,7 +1063,6 @@ export class CommandController {
1068
1063
  } finally {
1069
1064
  compactingLoader.stop();
1070
1065
  this.ctx.statusContainer.clear();
1071
- this.ctx.editor.onEscape = originalOnEscape;
1072
1066
  }
1073
1067
  await this.ctx.flushCompactionQueue({ willRetry: false });
1074
1068
  return outcome;
@@ -1089,11 +1083,6 @@ export class CommandController {
1089
1083
  }
1090
1084
  this.ctx.statusContainer.clear();
1091
1085
 
1092
- const originalOnEscape = this.ctx.editor.onEscape;
1093
- this.ctx.editor.onEscape = () => {
1094
- this.ctx.session.abortHandoff();
1095
- };
1096
-
1097
1086
  const handoffLoader = new Loader(
1098
1087
  this.ctx.ui,
1099
1088
  spinner => theme.fg("accent", spinner),
@@ -1138,7 +1127,6 @@ export class CommandController {
1138
1127
  } finally {
1139
1128
  handoffLoader.stop();
1140
1129
  this.ctx.statusContainer.clear();
1141
- this.ctx.editor.onEscape = originalOnEscape;
1142
1130
  }
1143
1131
  this.ctx.ui.requestRender();
1144
1132
  }
@@ -17,8 +17,9 @@ import { getSymbolTheme, theme } from "../../modes/theme/theme";
17
17
  import type { InteractiveModeContext, TodoPhase } from "../../modes/types";
18
18
  import type { PlanApprovalDetails } from "../../plan-mode/approved-plan";
19
19
  import type { AgentSessionEvent } from "../../session/agent-session";
20
- import { isSilentAbort, readPendingDisplayTag, resolveAbortLabel } from "../../session/messages";
20
+ import { isSilentAbort, readQueueChipText, resolveAbortLabel } from "../../session/messages";
21
21
  import type { ResolveToolDetails } from "../../tools/resolve";
22
+ import { hasVisibleThinking } from "../../utils/thinking-display";
22
23
  import { interruptHint } from "../shared";
23
24
  import { StreamingRevealController } from "./streaming-reveal";
24
25
  import { ToolArgsRevealController } from "./tool-args-reveal";
@@ -37,16 +38,6 @@ const IRC_MESSAGE_VISIBLE_TTL_MS = 10_000;
37
38
  */
38
39
  const MAX_LIVE_IRC_CARDS = 4;
39
40
 
40
- /**
41
- * Loader label shown the instant a user interrupt (Esc) is requested, kept until
42
- * the agent turn fully unwinds. Esc fires the abort synchronously, but the loop
43
- * only stops the spinner at `agent_end`, which it cannot reach until every
44
- * in-flight tool settles its abort in `executeToolCalls` (`Promise.allSettled`).
45
- * Swapping the steady "Working…" for this acknowledges the keypress instead of
46
- * reading as an ignored Esc for the seconds a slow tool takes to tear down.
47
- */
48
- export const INTERRUPTING_WORKING_MESSAGE = "Interrupting…";
49
-
50
41
  type AgentSessionEventHandlers = {
51
42
  [E in AgentSessionEventKind]: (event: Extract<AgentSessionEvent, { type: E }>) => Promise<void>;
52
43
  };
@@ -63,8 +54,6 @@ export class EventController {
63
54
  #renderedCustomMessages = new Set<string>();
64
55
  #lastIntent: string | undefined = undefined;
65
56
  #backgroundToolCallIds = new Set<string>();
66
- #agentTurnActive = false;
67
- #interrupting = false;
68
57
  #readToolCallArgs = new Map<string, Record<string, unknown>>();
69
58
  #readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
70
59
  #lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
@@ -193,7 +182,7 @@ export class EventController {
193
182
  return true;
194
183
  }
195
184
  #updateWorkingMessageFromIntent(intent: unknown): void {
196
- if (this.#interrupting) return;
185
+ if (this.ctx.session.isAborting) return;
197
186
  // Streamed JSON can deliver non-string `_i` (object, number, boolean) before
198
187
  // schema validation; `?.` only guards null/undefined, so guard the type too.
199
188
  if (typeof intent !== "string") return;
@@ -203,19 +192,6 @@ export class EventController {
203
192
  this.ctx.setWorkingMessage(`${trimmed}${interruptHint()}`);
204
193
  }
205
194
 
206
- /**
207
- * Acknowledge a user interrupt (Esc) immediately: switch the loader to
208
- * `INTERRUPTING_WORKING_MESSAGE` and freeze intent-driven working-message
209
- * updates for the rest of the turn so a late `tool_execution_start` intent
210
- * cannot repaint a "Working…/<intent>" line over the acknowledgment. Reset at
211
- * the next `agent_start`. No-op outside an active turn or if already set.
212
- */
213
- notifyInterrupting(): void {
214
- if (!this.#agentTurnActive || this.#interrupting) return;
215
- this.#interrupting = true;
216
- this.ctx.setWorkingMessage(INTERRUPTING_WORKING_MESSAGE);
217
- }
218
-
219
195
  subscribeToAgent(): void {
220
196
  this.ctx.unsubscribe = this.ctx.session.subscribe(async (event: AgentSessionEvent) => {
221
197
  await this.handleEvent(event);
@@ -233,8 +209,6 @@ export class EventController {
233
209
  this.#renderedCustomMessages.clear();
234
210
  this.#lastIntent = undefined;
235
211
  this.#backgroundToolCallIds.clear();
236
- this.#agentTurnActive = false;
237
- this.#interrupting = false;
238
212
  this.#readToolCallArgs.clear();
239
213
  this.#readToolCallAssistantComponents.clear();
240
214
  this.#lastAssistantComponent = undefined;
@@ -264,8 +238,6 @@ export class EventController {
264
238
  }
265
239
 
266
240
  async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
267
- this.#agentTurnActive = true;
268
- this.#interrupting = false;
269
241
  this.#lastIntent = undefined;
270
242
  this.#readToolCallArgs.clear();
271
243
  this.#readToolCallAssistantComponents.clear();
@@ -276,10 +248,6 @@ export class EventController {
276
248
  this.#pinnedErrorComponent?.setErrorPinned(false);
277
249
  this.#pinnedErrorComponent = undefined;
278
250
  this.ctx.clearPinnedError();
279
- if (this.ctx.retryEscapeHandler) {
280
- this.ctx.editor.onEscape = this.ctx.retryEscapeHandler;
281
- this.ctx.retryEscapeHandler = undefined;
282
- }
283
251
  if (this.ctx.retryLoader) {
284
252
  this.ctx.retryLoader.stop();
285
253
  this.ctx.retryLoader = undefined;
@@ -299,15 +267,10 @@ export class EventController {
299
267
  this.#renderedCustomMessages.add(signature);
300
268
  this.#resetReadGroup();
301
269
  this.ctx.addMessageToChat(event.message);
302
- // Tag-keyed pending-bar refresh: when AgentSession.#handleAgentEvent
303
- // spliced this dequeued custom message out of #steeringMessages /
304
- // #followUpMessages (it ran before this emit), the array state is
305
- // already correct pendingMessagesContainer just needs to be
306
- // re-rendered to match. Gated on tag presence so non-queued customs
307
- // (ttsr-injection, irc:*, async-result, hookMessage) skip the
308
- // rebuild; their dispatch path never registered a pending chip.
309
- // Mirrors the user-role refresh at the bottom of this function.
310
- if (event.message.role === "custom" && readPendingDisplayTag(event.message.details)) {
270
+ // Queued custom-message chips are derived from the agent queue; refresh the
271
+ // pending bar when the queued custom is consumed so the chip disappears
272
+ // immediately.
273
+ if (event.message.role === "custom" && readQueueChipText(event.message.details)) {
311
274
  this.ctx.updatePendingMessagesDisplay();
312
275
  }
313
276
  this.ctx.ui.requestRender();
@@ -475,7 +438,7 @@ export class EventController {
475
438
  const visibleBlockCount = this.ctx.streamingMessage.content.filter(
476
439
  content =>
477
440
  (content.type === "text" && content.text.trim().length > 0) ||
478
- (content.type === "thinking" && content.thinking.trim().length > 0),
441
+ (content.type === "thinking" && hasVisibleThinking(content)),
479
442
  ).length;
480
443
  if (visibleBlockCount > this.#lastVisibleBlockCount) {
481
444
  this.#resetReadGroup();
@@ -823,7 +786,21 @@ export class EventController {
823
786
  }
824
787
  }
825
788
  async #handleAgentEnd(_event: Extract<AgentSessionEvent, { type: "agent_end" }>): Promise<void> {
826
- this.#agentTurnActive = false;
789
+ // A superseded agent_end: the agent is already streaming a fresh turn, so
790
+ // this event belongs to a turn that has already been replaced. The session
791
+ // dispatches to listeners fire-and-forget across an async extension-emit hop
792
+ // (#emitSessionEvent), so an interrupted turn's agent_end can land AFTER the
793
+ // resumed turn's agent_start (e.g. any post-turn agent.continue()). Running
794
+ // the turn-end teardown now would stop the loader the live turn just created,
795
+ // leaving "Working…" gone while the agent keeps running. The live turn owns
796
+ // the loader and finalizes it at its own agent_end (isStreaming === false by
797
+ // then). Mirrors the collab guest's !isStreaming loader reconciler.
798
+ if (this.ctx.session.isStreaming) return;
799
+
800
+ await this.#finishAgentEnd();
801
+ }
802
+
803
+ async #finishAgentEnd(): Promise<void> {
827
804
  this.#streamingReveal.stop();
828
805
  this.#toolArgsReveal.flushAll();
829
806
  if (this.ctx.loadingAnimation) {
@@ -871,10 +848,6 @@ export class EventController {
871
848
  event: Extract<AgentSessionEvent, { type: "auto_compaction_start" }>,
872
849
  ): Promise<void> {
873
850
  this.#cancelIdleCompaction();
874
- this.ctx.autoCompactionEscapeHandler = this.ctx.editor.onEscape;
875
- this.ctx.editor.onEscape = () => {
876
- this.ctx.viewSession.abortCompaction();
877
- };
878
851
  this.ctx.statusContainer.clear();
879
852
  const reasonText =
880
853
  event.reason === "overflow"
@@ -903,10 +876,6 @@ export class EventController {
903
876
 
904
877
  async #handleAutoCompactionEnd(event: Extract<AgentSessionEvent, { type: "auto_compaction_end" }>): Promise<void> {
905
878
  this.#cancelIdleCompaction();
906
- if (this.ctx.autoCompactionEscapeHandler) {
907
- this.ctx.editor.onEscape = this.ctx.autoCompactionEscapeHandler;
908
- this.ctx.autoCompactionEscapeHandler = undefined;
909
- }
910
879
  if (this.ctx.autoCompactionLoader) {
911
880
  this.ctx.autoCompactionLoader.stop();
912
881
  this.ctx.autoCompactionLoader = undefined;
@@ -964,10 +933,6 @@ export class EventController {
964
933
  }
965
934
 
966
935
  async #handleAutoRetryStart(event: Extract<AgentSessionEvent, { type: "auto_retry_start" }>): Promise<void> {
967
- this.ctx.retryEscapeHandler = this.ctx.editor.onEscape;
968
- this.ctx.editor.onEscape = () => {
969
- this.ctx.viewSession.abortRetry();
970
- };
971
936
  this.ctx.statusContainer.clear();
972
937
  const delaySeconds = Math.round(event.delayMs / 1000);
973
938
  this.ctx.retryLoader = new Loader(
@@ -982,10 +947,6 @@ export class EventController {
982
947
  }
983
948
 
984
949
  async #handleAutoRetryEnd(event: Extract<AgentSessionEvent, { type: "auto_retry_end" }>): Promise<void> {
985
- if (this.ctx.retryEscapeHandler) {
986
- this.ctx.editor.onEscape = this.ctx.retryEscapeHandler;
987
- this.ctx.retryEscapeHandler = undefined;
988
- }
989
950
  if (this.ctx.retryLoader) {
990
951
  this.ctx.retryLoader.stop();
991
952
  this.ctx.retryLoader = undefined;