@gajae-code/coding-agent 0.8.2 → 0.9.1

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 (214) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/types/append-only-mode.d.ts +31 -0
  3. package/dist/types/capability/rule.d.ts +8 -0
  4. package/dist/types/cli/fixture-report.d.ts +20 -0
  5. package/dist/types/cli/fixture-report.test.d.ts +1 -0
  6. package/dist/types/cli/nofile-limit.d.ts +11 -0
  7. package/dist/types/cli/update-cli.d.ts +22 -0
  8. package/dist/types/config/settings-schema.d.ts +119 -1
  9. package/dist/types/discovery/index.d.ts +1 -0
  10. package/dist/types/export/html/template.generated.d.ts +1 -1
  11. package/dist/types/export/ttsr.d.ts +14 -3
  12. package/dist/types/gjc-runtime/deep-interview-state.d.ts +5 -2
  13. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +7 -0
  14. package/dist/types/gjc-runtime/state-writer.d.ts +3 -0
  15. package/dist/types/gjc-runtime/team-runtime.d.ts +26 -0
  16. package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
  17. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +42 -0
  18. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +119 -13
  19. package/dist/types/goals/runtime.d.ts +1 -0
  20. package/dist/types/hooks/native-skill-hook.d.ts +12 -0
  21. package/dist/types/modes/components/assistant-message.d.ts +5 -1
  22. package/dist/types/modes/components/status-line.d.ts +9 -0
  23. package/dist/types/modes/components/thinking-selector.d.ts +2 -2
  24. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  25. package/dist/types/modes/controllers/event-controller.d.ts +5 -0
  26. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  27. package/dist/types/modes/interactive-mode.d.ts +4 -0
  28. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
  29. package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
  30. package/dist/types/modes/rpc/rpc-types.d.ts +14 -0
  31. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +19 -0
  32. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +23 -1
  33. package/dist/types/modes/types.d.ts +3 -0
  34. package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
  35. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  36. package/dist/types/notifications/config-commands.d.ts +9 -0
  37. package/dist/types/notifications/config.d.ts +22 -0
  38. package/dist/types/notifications/index.d.ts +2 -0
  39. package/dist/types/notifications/lifecycle-commands.d.ts +1 -0
  40. package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
  41. package/dist/types/notifications/reply-sent-store.d.ts +53 -0
  42. package/dist/types/notifications/rich-draft.d.ts +68 -0
  43. package/dist/types/notifications/rich-render.d.ts +90 -0
  44. package/dist/types/notifications/telegram-daemon-cli.d.ts +3 -3
  45. package/dist/types/notifications/telegram-daemon.d.ts +30 -0
  46. package/dist/types/notifications/telegram-reference.d.ts +7 -0
  47. package/dist/types/notifications/threaded-render.d.ts +14 -0
  48. package/dist/types/sdk.d.ts +3 -0
  49. package/dist/types/session/agent-session.d.ts +43 -1
  50. package/dist/types/session/file-mention-pruning.d.ts +8 -0
  51. package/dist/types/session/messages.d.ts +4 -0
  52. package/dist/types/session/session-manager.d.ts +17 -1
  53. package/dist/types/skill-state/active-state.d.ts +18 -1
  54. package/dist/types/system-prompt.d.ts +18 -2
  55. package/dist/types/task/executor.d.ts +1 -0
  56. package/dist/types/task/fork-context-budget.d.ts +7 -0
  57. package/dist/types/task/token-log.d.ts +30 -0
  58. package/dist/types/task/token-log.test.d.ts +1 -0
  59. package/dist/types/tools/bisect.d.ts +81 -0
  60. package/dist/types/tools/index.d.ts +1 -0
  61. package/dist/types/tools/search.d.ts +2 -2
  62. package/dist/types/utils/file-mentions.d.ts +14 -0
  63. package/dist/types/utils/git.d.ts +32 -0
  64. package/dist/types/utils/pasted-image-path.d.ts +30 -0
  65. package/dist/types/web/scrapers/youtube.d.ts +8 -0
  66. package/package.json +8 -11
  67. package/src/append-only-mode.ts +58 -0
  68. package/src/autoresearch/prompt.md +1 -1
  69. package/src/autoresearch/tools/log-experiment.ts +1 -1
  70. package/src/capability/rule.ts +9 -0
  71. package/src/cli/config-cli.ts +27 -3
  72. package/src/cli/fixture-report.test.ts +230 -0
  73. package/src/cli/fixture-report.ts +171 -0
  74. package/src/cli/nofile-limit.ts +48 -0
  75. package/src/cli/notify-cli.ts +11 -9
  76. package/src/cli/update-cli.ts +85 -9
  77. package/src/cli.ts +26 -0
  78. package/src/commands/contribution-prep.ts +1 -1
  79. package/src/config/keybindings.ts +1 -1
  80. package/src/config/settings-schema.ts +76 -1
  81. package/src/defaults/gjc/skills/deep-interview/SKILL.md +12 -13
  82. package/src/defaults/gjc/skills/ralplan/SKILL.md +30 -93
  83. package/src/defaults/gjc/skills/team/SKILL.md +16 -13
  84. package/src/defaults/gjc/skills/ultragoal/SKILL.md +59 -4
  85. package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +30 -0
  86. package/src/defaults/gjc-defaults.ts +9 -0
  87. package/src/discovery/helpers.ts +10 -0
  88. package/src/discovery/index.ts +1 -0
  89. package/src/export/html/template.generated.ts +1 -1
  90. package/src/export/html/template.js +60 -2
  91. package/src/export/ttsr.ts +96 -18
  92. package/src/extensibility/extensions/runner.ts +181 -219
  93. package/src/extensibility/gjc-plugins/runtime-adapters.ts +156 -44
  94. package/src/gjc-runtime/deep-interview-state.ts +31 -2
  95. package/src/gjc-runtime/launch-tmux.ts +6 -0
  96. package/src/gjc-runtime/session-state-sidecar.ts +208 -13
  97. package/src/gjc-runtime/state-writer.ts +15 -0
  98. package/src/gjc-runtime/team-runtime.ts +175 -19
  99. package/src/gjc-runtime/tmux-common.ts +1 -0
  100. package/src/gjc-runtime/ultragoal-guard.ts +55 -66
  101. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +315 -0
  102. package/src/gjc-runtime/ultragoal-runtime.ts +1602 -143
  103. package/src/gjc-runtime/workflow-manifest.generated.json +113 -2
  104. package/src/gjc-runtime/workflow-manifest.ts +74 -1
  105. package/src/goals/runtime.ts +10 -2
  106. package/src/hindsight/backend.ts +1 -3
  107. package/src/hooks/native-skill-hook.ts +95 -4
  108. package/src/internal-urls/docs-index.generated.ts +18 -9
  109. package/src/lsp/client.ts +1 -0
  110. package/src/main.ts +43 -2
  111. package/src/memories/index.ts +12 -1
  112. package/src/modes/components/assistant-message.ts +241 -82
  113. package/src/modes/components/custom-editor.ts +1 -0
  114. package/src/modes/components/footer.ts +7 -2
  115. package/src/modes/components/status-line.ts +167 -54
  116. package/src/modes/components/thinking-selector.ts +10 -8
  117. package/src/modes/components/tool-execution.ts +44 -19
  118. package/src/modes/controllers/command-controller.ts +39 -2
  119. package/src/modes/controllers/event-controller.ts +77 -49
  120. package/src/modes/controllers/extension-ui-controller.ts +8 -1
  121. package/src/modes/controllers/input-controller.ts +13 -18
  122. package/src/modes/controllers/selector-controller.ts +41 -0
  123. package/src/modes/interactive-mode.ts +14 -1
  124. package/src/modes/rpc/rpc-mode.ts +32 -12
  125. package/src/modes/rpc/rpc-socket-security.ts +13 -1
  126. package/src/modes/rpc/rpc-types.ts +9 -0
  127. package/src/modes/shared/agent-wire/command-validation.ts +4 -0
  128. package/src/modes/shared/agent-wire/event-contract.ts +22 -0
  129. package/src/modes/shared/agent-wire/event-envelope.ts +82 -0
  130. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  131. package/src/modes/types.ts +3 -0
  132. package/src/modes/utils/injected-user-submission.ts +94 -0
  133. package/src/modes/utils/ui-helpers.ts +17 -4
  134. package/src/notifications/config-commands.ts +20 -0
  135. package/src/notifications/config.ts +43 -4
  136. package/src/notifications/index.ts +105 -9
  137. package/src/notifications/lifecycle-commands.ts +37 -16
  138. package/src/notifications/lifecycle-control-runtime.ts +8 -3
  139. package/src/notifications/reply-sent-store.ts +134 -0
  140. package/src/notifications/rich-draft.ts +107 -0
  141. package/src/notifications/rich-render.ts +142 -0
  142. package/src/notifications/telegram-daemon-cli.ts +54 -7
  143. package/src/notifications/telegram-daemon-control.ts +2 -2
  144. package/src/notifications/telegram-daemon.ts +390 -56
  145. package/src/notifications/telegram-reference.ts +17 -0
  146. package/src/notifications/threaded-render.ts +45 -2
  147. package/src/prompts/agents/architect.md +19 -10
  148. package/src/prompts/agents/critic.md +16 -18
  149. package/src/prompts/agents/executor.md +14 -11
  150. package/src/prompts/agents/planner.md +18 -13
  151. package/src/prompts/ci-green-request.md +3 -8
  152. package/src/prompts/goals/goal-continuation.md +0 -5
  153. package/src/prompts/goals/goal-mode-active.md +0 -5
  154. package/src/prompts/system/agent-creation-architect.md +2 -2
  155. package/src/prompts/system/plan-mode-active.md +1 -1
  156. package/src/prompts/system/plan-mode-subagent.md +3 -3
  157. package/src/prompts/system/project-prompt.md +0 -13
  158. package/src/prompts/system/subagent-system-prompt.md +3 -2
  159. package/src/prompts/system/system-prompt.md +26 -19
  160. package/src/prompts/system/volatile-project-context.md +10 -0
  161. package/src/prompts/system/web-search.md +4 -7
  162. package/src/prompts/tools/apply-patch.md +6 -0
  163. package/src/prompts/tools/ast-edit.md +1 -0
  164. package/src/prompts/tools/bash.md +1 -1
  165. package/src/prompts/tools/bisect.md +22 -0
  166. package/src/prompts/tools/browser.md +1 -1
  167. package/src/prompts/tools/cron.md +0 -2
  168. package/src/prompts/tools/eval.md +2 -2
  169. package/src/prompts/tools/find.md +1 -1
  170. package/src/prompts/tools/github.md +6 -6
  171. package/src/prompts/tools/goal.md +1 -3
  172. package/src/prompts/tools/image-gen.md +12 -5
  173. package/src/prompts/tools/irc.md +5 -4
  174. package/src/prompts/tools/job.md +2 -2
  175. package/src/prompts/tools/lsp.md +1 -1
  176. package/src/prompts/tools/monitor.md +2 -2
  177. package/src/prompts/tools/patch.md +2 -2
  178. package/src/prompts/tools/replace.md +2 -21
  179. package/src/prompts/tools/rewind.md +1 -1
  180. package/src/prompts/tools/search-tool-bm25.md +6 -11
  181. package/src/prompts/tools/search.md +1 -1
  182. package/src/prompts/tools/skill.md +3 -2
  183. package/src/prompts/tools/ssh.md +2 -1
  184. package/src/prompts/tools/subagent.md +2 -2
  185. package/src/prompts/tools/task.md +4 -5
  186. package/src/prompts/tools/todo-write.md +1 -1
  187. package/src/prompts/tools/vim.md +1 -0
  188. package/src/prompts/tools/web-search.md +2 -0
  189. package/src/prompts/tools/write.md +8 -2
  190. package/src/sdk.ts +68 -30
  191. package/src/session/agent-session.ts +734 -107
  192. package/src/session/file-mention-pruning.ts +64 -0
  193. package/src/session/messages.ts +4 -0
  194. package/src/session/session-manager.ts +59 -2
  195. package/src/skill-state/active-state.ts +141 -5
  196. package/src/skill-state/deep-interview-mutation-guard.ts +67 -3
  197. package/src/slash-commands/builtin-registry.ts +143 -1
  198. package/src/system-prompt.ts +57 -16
  199. package/src/task/executor.ts +125 -20
  200. package/src/task/fork-context-advisory.ts +2 -7
  201. package/src/task/fork-context-budget.ts +9 -0
  202. package/src/task/index.ts +4 -3
  203. package/src/task/output-manager.ts +4 -2
  204. package/src/task/token-log.test.ts +188 -0
  205. package/src/task/token-log.ts +179 -0
  206. package/src/tools/bisect.ts +367 -0
  207. package/src/tools/index.ts +3 -0
  208. package/src/tools/output-meta.ts +116 -4
  209. package/src/tools/search.ts +8 -2
  210. package/src/tools/skill.ts +15 -0
  211. package/src/utils/file-mentions.ts +39 -7
  212. package/src/utils/git.ts +73 -0
  213. package/src/utils/pasted-image-path.ts +170 -0
  214. package/src/web/scrapers/youtube.ts +24 -12
package/src/lsp/client.ts CHANGED
@@ -835,6 +835,7 @@ async function shutdownClientInstance(client: LspClient): Promise<void> {
835
835
  await Promise.race([shutdown, Bun.sleep(5_000)]);
836
836
  await client.owner?.dispose();
837
837
  await client.owner?.awaitExit({ timeoutMs: 1_000 });
838
+ await Promise.race([client.proc.exited.catch(() => undefined), Bun.sleep(1_000)]);
838
839
  }
839
840
 
840
841
  export async function shutdownClient(key: string): Promise<void> {
package/src/main.ts CHANGED
@@ -52,6 +52,7 @@ import { runStartupCredentialAutoImportIfNeeded } from "./setup/credential-auto-
52
52
  import { formatModelOnboardingGuidance } from "./setup/model-onboarding-guidance";
53
53
  import { executeBuiltinSlashCommand } from "./slash-commands/builtin-registry";
54
54
  import { resolvePromptInput } from "./system-prompt";
55
+ import { persistTaskTokenLog, resolveTaskTokenLogDir, taskTokenLogFromUsage } from "./task/token-log";
55
56
  import type { LspStartupServerInfo } from "./tools";
56
57
  import { getDisplayChangelogEntries, getInstalledVersionChangelogEntry, getNewEntries } from "./utils/changelog";
57
58
  import type { EventBus } from "./utils/event-bus";
@@ -513,8 +514,8 @@ export async function createSessionManager(
513
514
  }
514
515
  return manager;
515
516
  }
516
- // Default case (new session) returns undefined, SDK will create one
517
- return undefined;
517
+ const sessionDir = parsed.sessionDir ?? SessionManager.getDefaultSessionDir(cwd, activeSettings.getAgentDir());
518
+ return SessionManager.create(cwd, sessionDir);
518
519
  }
519
520
 
520
521
  async function maybeAutoChdir(parsed: Args): Promise<void> {
@@ -951,6 +952,46 @@ export async function runRootCommand(
951
952
  modelRegistry,
952
953
  settingsInstance,
953
954
  );
955
+ // Resolve the token-log dir lazily on the first chat-usage event: a fresh
956
+ // launch's session dir does not exist yet at this point (the SDK creates it),
957
+ // so eager resolution would miss it. Re-resolve when the SessionManager id
958
+ // changes mid-run (fork/new) so root turns keep landing in the SAME
959
+ // `<session>/token-logs` dir the task executor uses for subagent turns.
960
+ let rootTokenLogDir: string | undefined;
961
+ let rootTokenLogSessionId: string | undefined;
962
+ let rootTokenLogResolved = false;
963
+ let rootTokenTurn = 0;
964
+ const baseTelemetry = sessionOptions.telemetry;
965
+ sessionOptions.telemetry = {
966
+ ...(baseTelemetry ?? {}),
967
+ onChatUsage: async event => {
968
+ await baseTelemetry?.onChatUsage?.(event);
969
+ const currentSessionId = sessionManager?.getSessionId();
970
+ if (!rootTokenLogResolved || (currentSessionId && currentSessionId !== rootTokenLogSessionId)) {
971
+ rootTokenLogDir = await resolveTaskTokenLogDir(process.cwd(), sessionManager);
972
+ // Reset the per-session turn counter when the log dir moves to a new
973
+ // session so each session's log starts at turn 1.
974
+ if (rootTokenLogSessionId !== undefined && currentSessionId !== rootTokenLogSessionId) rootTokenTurn = 0;
975
+ rootTokenLogSessionId = currentSessionId;
976
+ rootTokenLogResolved = true;
977
+ }
978
+ if (!rootTokenLogDir) return;
979
+ rootTokenTurn += 1;
980
+ await persistTaskTokenLog(
981
+ taskTokenLogFromUsage(event.usage, {
982
+ subagentId: "root",
983
+ agent: event.agent?.name ?? "main",
984
+ // Monotonic 1-based sequence of persisted usage events for this
985
+ // session (event.stepNumber is 0-based and -1 for oneshot spans).
986
+ turn: rootTokenTurn,
987
+ at: new Date().toISOString(),
988
+ model: event.model,
989
+ cost: event.cost,
990
+ }),
991
+ { dir: rootTokenLogDir },
992
+ );
993
+ },
994
+ };
954
995
  sessionOptions.authStorage = authStorage;
955
996
  sessionOptions.modelRegistry = modelRegistry;
956
997
  sessionOptions.hasUI = isInteractive || mode === "rpc-ui";
@@ -1083,7 +1083,18 @@ async function resolveMemoryModel(options: {
1083
1083
  });
1084
1084
  if (resolved.model) return resolved.model;
1085
1085
  }
1086
- return session.model ?? modelRegistry.getAll()[0];
1086
+ if (session.model) return session.model;
1087
+ // No configured role and no active session model (e.g. background startup
1088
+ // before lazy model resolution): prefer the most recently used model over
1089
+ // raw registry order. `getAll()[0]` can be a retired model the provider
1090
+ // now rejects with 404, which permanently fails consolidation jobs.
1091
+ const allModels = modelRegistry.getAll();
1092
+ const usageOrder = session.settings.getStorage()?.getModelUsageOrder() ?? [];
1093
+ for (const usedKey of usageOrder) {
1094
+ const match = allModels.find(model => `${model.provider}/${model.id}` === usedKey);
1095
+ if (match) return match;
1096
+ }
1097
+ return allModels[0];
1087
1098
  }
1088
1099
 
1089
1100
  function loadMemoryConfig(settings: Settings): MemoryRuntimeConfig {
@@ -55,6 +55,15 @@ function elideRunawayThinkingRepetition(text: string): string {
55
55
  return text;
56
56
  }
57
57
 
58
+ interface AssistantMessageUpdateOptions {
59
+ streaming?: boolean;
60
+ }
61
+
62
+ type AssistantChildDescriptor = {
63
+ key: string;
64
+ component: Component;
65
+ };
66
+
58
67
  /**
59
68
  * Component that renders a complete assistant message
60
69
  */
@@ -67,6 +76,11 @@ export class AssistantMessageComponent extends Container {
67
76
  #kittyConversionsInFlight = new Set<string>();
68
77
  #responseHeader = new Text(theme.bold(theme.fg("statusLineModel", "gajae")), 1, 0);
69
78
  #contentBlocksCache = new WeakMap<object, { source: string; component: Component }>();
79
+ #lastStreaming = false;
80
+ #childComponents = new Map<string, Component>();
81
+ #contentBlockKeys = new WeakMap<object, string>();
82
+ #nextContentBlockKey = 0;
83
+ #reusableChildren = new WeakSet<Component>();
70
84
 
71
85
  constructor(
72
86
  message?: AssistantMessage,
@@ -87,7 +101,7 @@ export class AssistantMessageComponent extends Container {
87
101
  override invalidate(): void {
88
102
  super.invalidate();
89
103
  if (this.#lastMessage) {
90
- this.updateContent(this.#lastMessage);
104
+ this.updateContent(this.#lastMessage, { streaming: this.#lastStreaming });
91
105
  }
92
106
  }
93
107
 
@@ -115,7 +129,7 @@ export class AssistantMessageComponent extends Container {
115
129
  this.#convertToolImagesForKitty(toolCallId, validImages);
116
130
  }
117
131
  if (this.#lastMessage) {
118
- this.updateContent(this.#lastMessage);
132
+ this.updateContent(this.#lastMessage, { streaming: this.#lastStreaming });
119
133
  }
120
134
  }
121
135
 
@@ -138,7 +152,7 @@ export class AssistantMessageComponent extends Container {
138
152
  mimeType: "image/png",
139
153
  });
140
154
  if (this.#lastMessage) {
141
- this.updateContent(this.#lastMessage);
155
+ this.updateContent(this.#lastMessage, { streaming: this.#lastStreaming });
142
156
  }
143
157
  this.onImageUpdate?.();
144
158
  })
@@ -151,153 +165,298 @@ export class AssistantMessageComponent extends Container {
151
165
  setUsageInfo(usage: Usage): void {
152
166
  this.#usageInfo = usage;
153
167
  if (this.#lastMessage) {
154
- this.updateContent(this.#lastMessage);
168
+ this.updateContent(this.#lastMessage, { streaming: this.#lastStreaming });
169
+ }
170
+ }
171
+
172
+ #contentBlockKey(content: object): string {
173
+ let key = this.#contentBlockKeys.get(content);
174
+ if (!key) {
175
+ key = `block:${this.#nextContentBlockKey++}`;
176
+ this.#contentBlockKeys.set(content, key);
155
177
  }
178
+ return key;
156
179
  }
157
180
 
158
- #renderTextBlock(content: { text: string }): Component {
181
+ #cachedChild<T extends Component>(key: string, create: () => T): T {
182
+ const cached = this.#childComponents.get(key);
183
+ if (cached) return cached as T;
184
+ const component = create();
185
+ this.#childComponents.set(key, component);
186
+ this.#reusableChildren.add(component);
187
+ return component;
188
+ }
189
+
190
+ #renderTextBlock(content: { text: string }, streaming: boolean): Component {
159
191
  const cached = this.#contentBlocksCache.get(content);
160
- if (cached?.source === content.text) return cached.component;
192
+ if (cached?.source === content.text) {
193
+ if (cached.component instanceof Markdown) {
194
+ cached.component.setOnStaleThrottle(this.onImageUpdate);
195
+ cached.component.setStreaming(streaming);
196
+ }
197
+ return cached.component;
198
+ }
161
199
  const trimmed = content.text.trim();
162
200
  const deepInterview = renderDeepInterviewAssistantText(trimmed, theme);
163
201
  // Reuse the same Markdown instance across streaming chunks (update text in place)
164
202
  // instead of constructing a new one each chunk; combined with the markdown
165
203
  // per-code-block highlight cache, appends no longer re-highlight the whole prefix.
166
204
  if (!deepInterview && cached && cached.component instanceof Markdown) {
167
- cached.component.setText(trimmed);
205
+ cached.component.setOnStaleThrottle(this.onImageUpdate);
206
+ cached.component.setText(trimmed, { streaming });
168
207
  cached.source = content.text;
169
208
  return cached.component;
170
209
  }
171
210
  const component = deepInterview ?? new Markdown(trimmed, 1, 0, getMarkdownTheme());
211
+ if (component instanceof Markdown) {
212
+ component.setOnStaleThrottle(this.onImageUpdate);
213
+ component.setStreaming(streaming);
214
+ }
172
215
  this.#contentBlocksCache.set(content, { source: content.text, component });
216
+ this.#reusableChildren.add(component);
173
217
  return component;
174
218
  }
175
219
 
176
- #renderThinkingBlock(content: { thinking: string }): Markdown {
220
+ #renderThinkingBlock(content: { thinking: string }, streaming: boolean): Markdown {
177
221
  const cached = this.#contentBlocksCache.get(content);
178
- if (cached?.source === content.thinking) return cached.component as Markdown;
222
+ if (cached?.source === content.thinking) {
223
+ if (cached.component instanceof Markdown) {
224
+ cached.component.setOnStaleThrottle(this.onImageUpdate);
225
+ cached.component.setStreaming(streaming);
226
+ }
227
+ return cached.component as Markdown;
228
+ }
179
229
  const trimmed = elideRunawayThinkingRepetition(content.thinking.trim());
230
+ if (cached?.component instanceof Markdown) {
231
+ cached.component.setOnStaleThrottle(this.onImageUpdate);
232
+ cached.component.setText(trimmed, { streaming });
233
+ cached.source = content.thinking;
234
+ return cached.component;
235
+ }
180
236
  const component = new Markdown(trimmed, 1, 0, getMarkdownTheme(), {
181
237
  color: (text: string) => theme.fg("thinkingText", text),
182
238
  italic: true,
183
239
  });
240
+ component.setOnStaleThrottle(this.onImageUpdate);
241
+ component.setStreaming(streaming);
184
242
  this.#contentBlocksCache.set(content, { source: content.thinking, component });
243
+ this.#reusableChildren.add(component);
185
244
  return component;
186
245
  }
187
246
 
188
- #renderToolImages(): void {
247
+ #toolImageDescriptors(): AssistantChildDescriptor[] {
189
248
  const imageEntries = Array.from(this.#toolImagesByCallId.entries()).flatMap(([toolCallId, images]) =>
190
249
  images.map((image, index) => ({ image, key: `${toolCallId}:${index}` })),
191
250
  );
192
- if (imageEntries.length === 0) return;
251
+ if (imageEntries.length === 0) return [];
193
252
 
194
- this.#contentContainer.addChild(new Spacer(1));
253
+ const descriptors: AssistantChildDescriptor[] = [
254
+ { key: "tool-images:spacer", component: this.#cachedChild("tool-images:spacer", () => new Spacer(1)) },
255
+ ];
195
256
  for (const { image, key } of imageEntries) {
196
257
  const displayImage =
197
258
  TERMINAL.imageProtocol === ImageProtocol.Kitty && image.mimeType !== "image/png"
198
259
  ? this.#convertedKittyImages.get(key)
199
260
  : image;
200
261
  if (TERMINAL.imageProtocol && displayImage) {
201
- this.#contentContainer.addChild(
202
- new Image(
203
- displayImage.data,
204
- displayImage.mimeType,
205
- { fallbackColor: (text: string) => theme.fg("toolOutput", text) },
206
- resolveImageOptions(),
262
+ const imageKey = `tool-image:${key}:${displayImage.mimeType}:${displayImage.data}`;
263
+ descriptors.push({
264
+ key: imageKey,
265
+ component: this.#cachedChild(
266
+ imageKey,
267
+ () =>
268
+ new Image(
269
+ displayImage.data,
270
+ displayImage.mimeType,
271
+ { fallbackColor: (text: string) => theme.fg("toolOutput", text) },
272
+ resolveImageOptions(),
273
+ ),
207
274
  ),
208
- );
275
+ });
209
276
  continue;
210
277
  }
211
- this.#contentContainer.addChild(new Text(theme.fg("toolOutput", `[Image: ${image.mimeType}]`), 1, 0));
278
+ const fallbackKey = `tool-image-fallback:${key}:${image.mimeType}`;
279
+ descriptors.push({
280
+ key: fallbackKey,
281
+ component: this.#cachedChild(
282
+ fallbackKey,
283
+ () => new Text(theme.fg("toolOutput", `[Image: ${image.mimeType}]`), 1, 0),
284
+ ),
285
+ });
212
286
  }
287
+ return descriptors;
213
288
  }
214
289
 
215
- updateContent(message: AssistantMessage): void {
216
- this.#lastMessage = message;
290
+ #reconcileChildren(descriptors: AssistantChildDescriptor[]): void {
291
+ const nextChildren = descriptors.map(descriptor => descriptor.component);
292
+ if (
293
+ nextChildren.length === this.#contentContainer.children.length &&
294
+ nextChildren.every((child, index) => child === this.#contentContainer.children[index])
295
+ ) {
296
+ return;
297
+ }
217
298
 
218
- // Clear content container
219
- this.#contentContainer.clear();
299
+ const nextSet = new Set(nextChildren);
300
+ for (const child of this.#contentContainer.children) {
301
+ if (!nextSet.has(child) && !this.#reusableChildren.has(child)) {
302
+ child.dispose?.();
303
+ }
304
+ }
305
+ this.#contentContainer.children = nextChildren;
306
+ }
220
307
 
221
- const hasVisibleContent = message.content.some(
222
- c => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()),
223
- );
308
+ updateContent(message: AssistantMessage, options?: AssistantMessageUpdateOptions): void {
309
+ this.#lastMessage = message;
310
+ this.#lastStreaming = options?.streaming ?? false;
224
311
 
312
+ const visibleContentAfter = new Array<boolean>(message.content.length).fill(false);
313
+ let hasVisibleContentAfter = false;
314
+ let hasVisibleContent = false;
315
+ let activeContentIndex = -1;
316
+ for (let i = message.content.length - 1; i >= 0; i--) {
317
+ visibleContentAfter[i] = hasVisibleContentAfter;
318
+ const content = message.content[i];
319
+ const isVisible =
320
+ (content.type === "text" && Boolean(content.text.trim())) ||
321
+ (content.type === "thinking" && Boolean(content.thinking.trim()));
322
+ if (isVisible) {
323
+ hasVisibleContent = true;
324
+ hasVisibleContentAfter = true;
325
+ if (this.#lastStreaming && activeContentIndex === -1) {
326
+ activeContentIndex = i;
327
+ }
328
+ }
329
+ }
330
+
331
+ const streaming = this.#lastStreaming;
332
+ const descriptors: AssistantChildDescriptor[] = [];
225
333
  if (hasVisibleContent) {
226
- this.#contentContainer.addChild(new Spacer(1));
227
- this.#contentContainer.addChild(this.#responseHeader);
334
+ descriptors.push({
335
+ key: "response-header:spacer",
336
+ component: this.#cachedChild("response-header:spacer", () => new Spacer(1)),
337
+ });
338
+ descriptors.push({ key: "response-header", component: this.#responseHeader });
228
339
  }
229
340
 
230
341
  // Render content in order
231
342
  for (let i = 0; i < message.content.length; i++) {
232
343
  const content = message.content[i];
344
+ const blockKey = this.#contentBlockKey(content);
233
345
  if (content.type === "text" && content.text.trim()) {
234
- this.#contentContainer.addChild(this.#renderTextBlock(content));
346
+ descriptors.push({
347
+ key: `${blockKey}:text`,
348
+ component: this.#renderTextBlock(content, streaming && i === activeContentIndex),
349
+ });
235
350
  } else if (content.type === "thinking" && content.thinking.trim()) {
236
351
  // Add spacing only when another visible assistant content block follows.
237
352
  // This avoids a superfluous blank line before separately-rendered tool execution blocks.
238
- const hasVisibleContentAfter = message.content
239
- .slice(i + 1)
240
- .some(c => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()));
241
-
242
353
  if (this.hideThinkingBlock) {
243
354
  // Show static "Thinking..." label when hidden
244
- this.#contentContainer.addChild(new Text(theme.italic(theme.fg("thinkingText", "Thinking...")), 1, 0));
245
- if (hasVisibleContentAfter) {
246
- this.#contentContainer.addChild(new Spacer(1));
247
- }
355
+ descriptors.push({
356
+ key: `${blockKey}:thinking-hidden`,
357
+ component: this.#cachedChild(
358
+ `${blockKey}:thinking-hidden`,
359
+ () => new Text(theme.italic(theme.fg("thinkingText", "Thinking...")), 1, 0),
360
+ ),
361
+ });
248
362
  } else {
249
- this.#contentContainer.addChild(this.#renderThinkingBlock(content));
250
- if (hasVisibleContentAfter) {
251
- this.#contentContainer.addChild(new Spacer(1));
252
- }
363
+ descriptors.push({
364
+ key: `${blockKey}:thinking`,
365
+ component: this.#renderThinkingBlock(content, streaming && i === activeContentIndex),
366
+ });
367
+ }
368
+ if (visibleContentAfter[i]) {
369
+ descriptors.push({
370
+ key: `${blockKey}:thinking-spacer`,
371
+ component: this.#cachedChild(`${blockKey}:thinking-spacer`, () => new Spacer(1)),
372
+ });
253
373
  }
254
374
  }
255
375
  }
256
376
 
257
- this.#renderToolImages();
258
- // Check if aborted - show after partial content
259
- // But only if there are no tool calls (tool execution components will show the error)
260
- const hasToolCalls = message.content.some(c => c.type === "toolCall");
261
- if (!hasToolCalls) {
262
- if (message.stopReason === "aborted" && !isSilentAbort(message.errorMessage)) {
263
- const abortMessage =
264
- message.errorMessage && message.errorMessage !== "Request was aborted"
265
- ? message.errorMessage
266
- : "Operation aborted";
267
- if (hasVisibleContent) {
268
- this.#contentContainer.addChild(new Spacer(1));
269
- } else {
270
- this.#contentContainer.addChild(new Spacer(1));
377
+ descriptors.push(...this.#toolImageDescriptors());
378
+
379
+ const isTerminal = !streaming || Boolean(message.stopReason);
380
+ if (isTerminal) {
381
+ // Check if aborted - show after partial content
382
+ // But only if there are no tool calls (tool execution components will show the error)
383
+ const hasToolCalls = message.content.some(c => c.type === "toolCall");
384
+ if (!hasToolCalls) {
385
+ if (message.stopReason === "aborted" && !isSilentAbort(message.errorMessage)) {
386
+ const abortMessage =
387
+ message.errorMessage && message.errorMessage !== "Request was aborted"
388
+ ? message.errorMessage
389
+ : "Operation aborted";
390
+ descriptors.push({
391
+ key: "abort:spacer",
392
+ component: this.#cachedChild("abort:spacer", () => new Spacer(1)),
393
+ });
394
+ descriptors.push({
395
+ key: `abort:text:${abortMessage}`,
396
+ component: this.#cachedChild(
397
+ `abort:text:${abortMessage}`,
398
+ () => new Text(theme.fg("error", abortMessage), 1, 0),
399
+ ),
400
+ });
401
+ } else if (message.stopReason === "error") {
402
+ const errorMsg = message.errorMessage || "Unknown error";
403
+ descriptors.push({
404
+ key: "error:spacer",
405
+ component: this.#cachedChild("error:spacer", () => new Spacer(1)),
406
+ });
407
+ descriptors.push({
408
+ key: `error:text:${errorMsg}`,
409
+ component: this.#cachedChild(
410
+ `error:text:${errorMsg}`,
411
+ () => new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0),
412
+ ),
413
+ });
271
414
  }
272
- this.#contentContainer.addChild(new Text(theme.fg("error", abortMessage), 1, 0));
273
- } else if (message.stopReason === "error") {
274
- const errorMsg = message.errorMessage || "Unknown error";
275
- this.#contentContainer.addChild(new Spacer(1));
276
- this.#contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0));
277
415
  }
278
- }
279
- if (
280
- message.errorMessage &&
281
- !isSilentAbort(message.errorMessage) &&
282
- message.stopReason !== "aborted" &&
283
- message.stopReason !== "error"
284
- ) {
285
- this.#contentContainer.addChild(new Spacer(1));
286
- this.#contentContainer.addChild(new Text(theme.fg("error", `Error: ${message.errorMessage}`), 1, 0));
287
- }
416
+ if (
417
+ message.errorMessage &&
418
+ !isSilentAbort(message.errorMessage) &&
419
+ message.stopReason !== "aborted" &&
420
+ message.stopReason !== "error"
421
+ ) {
422
+ descriptors.push({
423
+ key: "message-error:spacer",
424
+ component: this.#cachedChild("message-error:spacer", () => new Spacer(1)),
425
+ });
426
+ descriptors.push({
427
+ key: `message-error:text:${message.errorMessage}`,
428
+ component: this.#cachedChild(
429
+ `message-error:text:${message.errorMessage}`,
430
+ () => new Text(theme.fg("error", `Error: ${message.errorMessage}`), 1, 0),
431
+ ),
432
+ });
433
+ }
288
434
 
289
- // Token usage metadata
290
- if (settings.get("display.showTokenUsage") && this.#usageInfo) {
291
- const usage = this.#usageInfo;
292
- const totalInput = usage.input + usage.cacheWrite;
293
- const parts: string[] = [];
294
- parts.push(`${theme.icon.input} ${formatNumber(totalInput)}`);
295
- parts.push(`${theme.icon.output} ${formatNumber(usage.output)}`);
296
- if (usage.cacheRead > 0) {
297
- parts.push(`cache: ${formatNumber(usage.cacheRead)}`);
435
+ // Token usage metadata
436
+ if (settings.get("display.showTokenUsage") && this.#usageInfo) {
437
+ const usage = this.#usageInfo;
438
+ const totalInput = usage.input + usage.cacheWrite;
439
+ const parts: string[] = [];
440
+ parts.push(`${theme.icon.input} ${formatNumber(totalInput)}`);
441
+ parts.push(`${theme.icon.output} ${formatNumber(usage.output)}`);
442
+ if (usage.cacheRead > 0) {
443
+ parts.push(`cache: ${formatNumber(usage.cacheRead)}`);
444
+ }
445
+ const usageText = parts.join(" ");
446
+ descriptors.push({
447
+ key: "usage:spacer",
448
+ component: this.#cachedChild("usage:spacer", () => new Spacer(1)),
449
+ });
450
+ descriptors.push({
451
+ key: `usage:text:${usageText}`,
452
+ component: this.#cachedChild(
453
+ `usage:text:${usageText}`,
454
+ () => new Text(theme.fg("dim", usageText), 1, 0),
455
+ ),
456
+ });
298
457
  }
299
- this.#contentContainer.addChild(new Spacer(1));
300
- this.#contentContainer.addChild(new Text(theme.fg("dim", parts.join(" ")), 1, 0));
301
458
  }
459
+
460
+ this.#reconcileChildren(descriptors);
302
461
  }
303
462
  }
@@ -182,6 +182,7 @@ export class CustomEditor extends Editor {
182
182
  dispose(): void {
183
183
  this.#clearPendingPasteState();
184
184
  this.#pasteHandler = new BracketedPasteHandler();
185
+ super.dispose();
185
186
  }
186
187
 
187
188
  #drainPendingPasteInput(initialInput?: string): void {
@@ -16,6 +16,7 @@ import { resolveCurrentBranch } from "./status-line/git-utils";
16
16
  export class FooterComponent implements Component {
17
17
  #cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name
18
18
  #gitWatcher: fs.FSWatcher | null = null;
19
+ #gitWatcherGeneration = 0;
19
20
  #onBranchChange: (() => void) | null = null;
20
21
  #autoCompactEnabled: boolean = true;
21
22
  #extensionStatuses: Map<string, string> = new Map();
@@ -51,7 +52,10 @@ export class FooterComponent implements Component {
51
52
  }
52
53
 
53
54
  #setupGitWatcher(): void {
54
- // Clean up existing watcher
55
+ // Clean up existing watcher. The generation guard below keeps a stale
56
+ // in-flight resolve — one that dispose() or a newer setup already
57
+ // superseded — from installing a watcher nothing would ever close.
58
+ const generation = ++this.#gitWatcherGeneration;
55
59
  if (this.#gitWatcher) {
56
60
  this.#gitWatcher.close();
57
61
  this.#gitWatcher = null;
@@ -60,7 +64,7 @@ export class FooterComponent implements Component {
60
64
  void git.head
61
65
  .resolve(getProjectDir())
62
66
  .then(head => {
63
- if (!head) {
67
+ if (!head || generation !== this.#gitWatcherGeneration) {
64
68
  return;
65
69
  }
66
70
 
@@ -84,6 +88,7 @@ export class FooterComponent implements Component {
84
88
  * Clean up the file watcher
85
89
  */
86
90
  dispose(): void {
91
+ this.#gitWatcherGeneration++;
87
92
  if (this.#gitWatcher) {
88
93
  this.#gitWatcher.close();
89
94
  this.#gitWatcher = null;