@sayknow-cli/coding-agent 0.3.7 → 0.3.9

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 (272) hide show
  1. package/dist/types/append-only-mode.d.ts +31 -0
  2. package/dist/types/capability/rule.d.ts +8 -0
  3. package/dist/types/cli/completion-cli.d.ts +41 -0
  4. package/dist/types/cli/config-cli.d.ts +1 -0
  5. package/dist/types/cli/fixture-report.d.ts +20 -0
  6. package/dist/types/cli/fixture-report.test.d.ts +1 -0
  7. package/dist/types/cli/nofile-limit.d.ts +11 -0
  8. package/dist/types/cli/notify-cli.d.ts +7 -0
  9. package/dist/types/cli/update-cli.d.ts +22 -0
  10. package/dist/types/cli.d.ts +124 -1
  11. package/dist/types/commands/completion.d.ts +29 -0
  12. package/dist/types/commands/config.d.ts +3 -0
  13. package/dist/types/commands/deep-interview.d.ts +3 -0
  14. package/dist/types/config/file-lock.d.ts +7 -6
  15. package/dist/types/config/keybindings.d.ts +2 -2
  16. package/dist/types/config/model-profile-activation.d.ts +6 -0
  17. package/dist/types/config/model-profiles.d.ts +1 -0
  18. package/dist/types/config/settings-schema.d.ts +119 -1
  19. package/dist/types/config/settings.d.ts +8 -0
  20. package/dist/types/discovery/index.d.ts +1 -0
  21. package/dist/types/export/html/template.generated.d.ts +1 -1
  22. package/dist/types/export/ttsr.d.ts +14 -3
  23. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  24. package/dist/types/extensibility/extensions/types.d.ts +8 -0
  25. package/dist/types/goals/runtime.d.ts +1 -0
  26. package/dist/types/hooks/native-skill-hook.d.ts +12 -0
  27. package/dist/types/main.d.ts +4 -1
  28. package/dist/types/modes/components/assistant-message.d.ts +5 -1
  29. package/dist/types/modes/components/model-selector.d.ts +4 -0
  30. package/dist/types/modes/components/queued-message-selector.d.ts +12 -0
  31. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  32. package/dist/types/modes/components/status-line.d.ts +9 -0
  33. package/dist/types/modes/components/thinking-selector.d.ts +2 -2
  34. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  35. package/dist/types/modes/controllers/event-controller.d.ts +5 -0
  36. package/dist/types/modes/controllers/input-controller.d.ts +1 -3
  37. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  38. package/dist/types/modes/interactive-mode.d.ts +4 -0
  39. package/dist/types/modes/print-mode.d.ts +13 -1
  40. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -1
  41. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
  42. package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
  43. package/dist/types/modes/rpc/rpc-types.d.ts +14 -0
  44. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +19 -0
  45. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +23 -1
  46. package/dist/types/modes/types.d.ts +3 -0
  47. package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
  48. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  49. package/dist/types/notifications/config-commands.d.ts +9 -0
  50. package/dist/types/notifications/config.d.ts +30 -1
  51. package/dist/types/notifications/daemon-paths.d.ts +1 -0
  52. package/dist/types/notifications/index.d.ts +8 -3
  53. package/dist/types/notifications/lifecycle-commands.d.ts +12 -3
  54. package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
  55. package/dist/types/notifications/reply-sent-store.d.ts +53 -0
  56. package/dist/types/notifications/rich-draft.d.ts +68 -0
  57. package/dist/types/notifications/rich-render.d.ts +90 -0
  58. package/dist/types/notifications/telegram-daemon.d.ts +45 -3
  59. package/dist/types/notifications/telegram-reference.d.ts +7 -5
  60. package/dist/types/notifications/threaded-render.d.ts +14 -0
  61. package/dist/types/runtime-api-key.d.ts +7 -0
  62. package/dist/types/sdk.d.ts +3 -0
  63. package/dist/types/session/agent-session.d.ts +61 -6
  64. package/dist/types/session/agent-storage.d.ts +1 -0
  65. package/dist/types/session/file-mention-pruning.d.ts +8 -0
  66. package/dist/types/session/messages.d.ts +4 -0
  67. package/dist/types/session/session-manager.d.ts +17 -1
  68. package/dist/types/skc-runtime/deep-interview-ambiguity.d.ts +39 -0
  69. package/dist/types/skc-runtime/deep-interview-recorder.d.ts +10 -1
  70. package/dist/types/skc-runtime/deep-interview-state.d.ts +17 -2
  71. package/dist/types/skc-runtime/session-state-sidecar.d.ts +7 -0
  72. package/dist/types/skc-runtime/state-writer.d.ts +3 -0
  73. package/dist/types/skc-runtime/tmux-common.d.ts +1 -0
  74. package/dist/types/skc-runtime/ultragoal-receipt-freshness.d.ts +42 -0
  75. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +120 -13
  76. package/dist/types/skill-state/active-state.d.ts +18 -1
  77. package/dist/types/system-prompt.d.ts +18 -2
  78. package/dist/types/task/executor.d.ts +3 -0
  79. package/dist/types/task/fork-context-budget.d.ts +7 -0
  80. package/dist/types/task/token-log.d.ts +30 -0
  81. package/dist/types/task/token-log.test.d.ts +1 -0
  82. package/dist/types/tools/bisect.d.ts +81 -0
  83. package/dist/types/tools/computer.d.ts +2 -2
  84. package/dist/types/tools/index.d.ts +1 -0
  85. package/dist/types/tools/search.d.ts +2 -2
  86. package/dist/types/tools/ultragoal-ask-guard.d.ts +9 -2
  87. package/dist/types/utils/file-mentions.d.ts +14 -0
  88. package/dist/types/utils/git.d.ts +32 -0
  89. package/dist/types/utils/pasted-image-path.d.ts +30 -0
  90. package/dist/types/web/scrapers/youtube.d.ts +8 -0
  91. package/dist/types/workflow/workflow-intent-diff.d.ts +8 -2
  92. package/dist/types/workflow/workflow-intent-report-consumer.d.ts +50 -0
  93. package/dist/types/workflow/workflow-intent-report-schema.d.ts +2 -0
  94. package/dist/types/workflow/workflow-intent-report.d.ts +48 -0
  95. package/package.json +8 -8
  96. package/src/append-only-mode.ts +58 -0
  97. package/src/autoresearch/prompt.md +1 -1
  98. package/src/autoresearch/tools/log-experiment.ts +1 -1
  99. package/src/capability/rule.ts +9 -0
  100. package/src/cli/completion-cli.ts +234 -0
  101. package/src/cli/config-cli.ts +61 -9
  102. package/src/cli/fast-help.ts +1 -0
  103. package/src/cli/fixture-report.test.ts +230 -0
  104. package/src/cli/fixture-report.ts +171 -0
  105. package/src/cli/nofile-limit.ts +48 -0
  106. package/src/cli/notify-cli.ts +103 -17
  107. package/src/cli/telegram-cli.ts +1 -1
  108. package/src/cli/update-cli.ts +148 -33
  109. package/src/cli.ts +29 -1
  110. package/src/commands/completion.ts +84 -0
  111. package/src/commands/config.ts +2 -0
  112. package/src/commands/contribution-prep.ts +1 -1
  113. package/src/commands/deep-interview.ts +2 -1
  114. package/src/config/file-lock.ts +73 -34
  115. package/src/config/keybindings.ts +3 -3
  116. package/src/config/model-profile-activation.ts +65 -0
  117. package/src/config/model-profiles.ts +31 -2
  118. package/src/config/settings-schema.ts +76 -1
  119. package/src/config/settings.ts +22 -0
  120. package/src/defaults/skc/skills/team/SKILL.md +17 -14
  121. package/src/defaults/skc/skills/ultragoal/SKILL.md +59 -4
  122. package/src/defaults/skc/skills/ultragoal/pipeline-validation-contracts.md +30 -0
  123. package/src/defaults/skc-defaults.ts +9 -0
  124. package/src/discovery/helpers.ts +10 -0
  125. package/src/discovery/index.ts +1 -0
  126. package/src/export/html/template.generated.ts +1 -1
  127. package/src/export/html/template.js +60 -2
  128. package/src/export/ttsr.ts +96 -18
  129. package/src/extensibility/extensions/runner.ts +183 -219
  130. package/src/extensibility/extensions/types.ts +9 -0
  131. package/src/extensibility/skc-plugins/runtime-adapters.ts +156 -44
  132. package/src/goals/runtime.ts +10 -2
  133. package/src/hindsight/backend.ts +1 -3
  134. package/src/hooks/native-skill-hook.ts +124 -4
  135. package/src/internal-urls/docs-index.generated.ts +17 -8
  136. package/src/lsp/client.ts +1 -0
  137. package/src/main.ts +95 -43
  138. package/src/memories/index.ts +12 -1
  139. package/src/modes/components/assistant-message.ts +241 -82
  140. package/src/modes/components/custom-editor.ts +1 -0
  141. package/src/modes/components/footer.ts +7 -2
  142. package/src/modes/components/model-selector.ts +54 -11
  143. package/src/modes/components/queued-message-selector.ts +92 -0
  144. package/src/modes/components/settings-selector.ts +4 -0
  145. package/src/modes/components/status-line.ts +167 -54
  146. package/src/modes/components/thinking-selector.ts +10 -8
  147. package/src/modes/components/tool-execution.ts +44 -19
  148. package/src/modes/components/welcome.ts +181 -44
  149. package/src/modes/controllers/command-controller.ts +40 -4
  150. package/src/modes/controllers/event-controller.ts +77 -49
  151. package/src/modes/controllers/extension-ui-controller.ts +12 -11
  152. package/src/modes/controllers/input-controller.ts +192 -35
  153. package/src/modes/controllers/selector-controller.ts +168 -24
  154. package/src/modes/interactive-mode.ts +56 -16
  155. package/src/modes/print-mode.ts +43 -4
  156. package/src/modes/prompt-action-autocomplete.ts +21 -20
  157. package/src/modes/rpc/rpc-mode.ts +32 -12
  158. package/src/modes/rpc/rpc-socket-security.ts +13 -1
  159. package/src/modes/rpc/rpc-types.ts +9 -0
  160. package/src/modes/shared/agent-wire/command-validation.ts +4 -0
  161. package/src/modes/shared/agent-wire/event-contract.ts +22 -0
  162. package/src/modes/shared/agent-wire/event-envelope.ts +82 -0
  163. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  164. package/src/modes/types.ts +3 -0
  165. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  166. package/src/modes/utils/injected-user-submission.ts +94 -0
  167. package/src/modes/utils/ui-helpers.ts +20 -7
  168. package/src/notifications/config-commands.ts +20 -0
  169. package/src/notifications/config.ts +52 -5
  170. package/src/notifications/daemon-paths.ts +2 -0
  171. package/src/notifications/index.ts +144 -18
  172. package/src/notifications/lifecycle-commands.ts +74 -23
  173. package/src/notifications/lifecycle-control-runtime.ts +8 -3
  174. package/src/notifications/lifecycle-orchestrator.ts +19 -6
  175. package/src/notifications/reply-sent-store.ts +134 -0
  176. package/src/notifications/rich-draft.ts +107 -0
  177. package/src/notifications/rich-render.ts +142 -0
  178. package/src/notifications/telegram-daemon-control.ts +5 -3
  179. package/src/notifications/telegram-daemon.ts +606 -98
  180. package/src/notifications/telegram-reference.ts +17 -16
  181. package/src/notifications/threaded-render.ts +45 -2
  182. package/src/prompts/agents/architect.md +19 -10
  183. package/src/prompts/agents/critic.md +16 -18
  184. package/src/prompts/agents/executor.md +14 -11
  185. package/src/prompts/agents/planner.md +18 -13
  186. package/src/prompts/ci-green-request.md +3 -8
  187. package/src/prompts/goals/goal-continuation.md +0 -5
  188. package/src/prompts/goals/goal-mode-active.md +0 -5
  189. package/src/prompts/system/agent-creation-architect.md +2 -2
  190. package/src/prompts/system/plan-mode-active.md +1 -1
  191. package/src/prompts/system/plan-mode-subagent.md +3 -3
  192. package/src/prompts/system/project-prompt.md +0 -13
  193. package/src/prompts/system/subagent-system-prompt.md +3 -2
  194. package/src/prompts/system/system-prompt.md +29 -17
  195. package/src/prompts/system/volatile-project-context.md +10 -0
  196. package/src/prompts/system/web-search.md +4 -7
  197. package/src/prompts/tools/apply-patch.md +6 -0
  198. package/src/prompts/tools/ast-edit.md +1 -0
  199. package/src/prompts/tools/bash.md +1 -1
  200. package/src/prompts/tools/bisect.md +22 -0
  201. package/src/prompts/tools/browser.md +1 -1
  202. package/src/prompts/tools/computer.md +2 -2
  203. package/src/prompts/tools/cron.md +0 -2
  204. package/src/prompts/tools/eval.md +2 -2
  205. package/src/prompts/tools/find.md +1 -1
  206. package/src/prompts/tools/github.md +6 -6
  207. package/src/prompts/tools/goal.md +1 -3
  208. package/src/prompts/tools/image-gen.md +12 -5
  209. package/src/prompts/tools/irc.md +5 -4
  210. package/src/prompts/tools/job.md +2 -2
  211. package/src/prompts/tools/lsp.md +1 -1
  212. package/src/prompts/tools/monitor.md +2 -2
  213. package/src/prompts/tools/patch.md +2 -2
  214. package/src/prompts/tools/replace.md +2 -21
  215. package/src/prompts/tools/rewind.md +1 -1
  216. package/src/prompts/tools/search-tool-bm25.md +6 -11
  217. package/src/prompts/tools/search.md +1 -1
  218. package/src/prompts/tools/skill.md +3 -2
  219. package/src/prompts/tools/ssh.md +2 -1
  220. package/src/prompts/tools/subagent.md +2 -2
  221. package/src/prompts/tools/task.md +4 -5
  222. package/src/prompts/tools/todo-write.md +1 -1
  223. package/src/prompts/tools/vim.md +1 -0
  224. package/src/prompts/tools/web-search.md +2 -0
  225. package/src/prompts/tools/write.md +8 -2
  226. package/src/runtime-api-key.ts +16 -0
  227. package/src/sdk.ts +88 -36
  228. package/src/session/agent-session.ts +1013 -141
  229. package/src/session/agent-storage.ts +4 -0
  230. package/src/session/file-mention-pruning.ts +64 -0
  231. package/src/session/messages.ts +4 -0
  232. package/src/session/session-manager.ts +103 -4
  233. package/src/skc-runtime/deep-interview-ambiguity.ts +206 -0
  234. package/src/skc-runtime/deep-interview-recorder.ts +62 -8
  235. package/src/skc-runtime/deep-interview-state.ts +44 -2
  236. package/src/skc-runtime/session-state-sidecar.ts +263 -20
  237. package/src/skc-runtime/state-runtime.ts +12 -2
  238. package/src/skc-runtime/state-writer.ts +15 -0
  239. package/src/skc-runtime/tmux-common.ts +1 -0
  240. package/src/skc-runtime/ultragoal-guard.ts +55 -66
  241. package/src/skc-runtime/ultragoal-receipt-freshness.ts +315 -0
  242. package/src/skc-runtime/ultragoal-runtime.ts +1672 -159
  243. package/src/skc-runtime/workflow-manifest.generated.json +113 -2
  244. package/src/skc-runtime/workflow-manifest.ts +74 -1
  245. package/src/skill-state/active-state.ts +141 -5
  246. package/src/skill-state/deep-interview-mutation-guard.ts +67 -3
  247. package/src/slash-commands/builtin-registry.ts +296 -72
  248. package/src/system-prompt.ts +57 -16
  249. package/src/task/executor.ts +132 -24
  250. package/src/task/fork-context-advisory.ts +2 -7
  251. package/src/task/fork-context-budget.ts +9 -0
  252. package/src/task/index.ts +13 -6
  253. package/src/task/output-manager.ts +20 -8
  254. package/src/task/receipt.ts +26 -7
  255. package/src/task/token-log.test.ts +188 -0
  256. package/src/task/token-log.ts +179 -0
  257. package/src/tools/bisect.ts +367 -0
  258. package/src/tools/computer.ts +246 -43
  259. package/src/tools/index.ts +3 -0
  260. package/src/tools/output-meta.ts +116 -4
  261. package/src/tools/search.ts +8 -2
  262. package/src/tools/skill.ts +15 -0
  263. package/src/tools/telegram-send.ts +6 -0
  264. package/src/tools/ultragoal-ask-guard.ts +39 -5
  265. package/src/utils/file-mentions.ts +39 -7
  266. package/src/utils/git.ts +73 -0
  267. package/src/utils/pasted-image-path.ts +170 -0
  268. package/src/web/scrapers/youtube.ts +24 -12
  269. package/src/workflow/workflow-intent-diff.ts +29 -6
  270. package/src/workflow/workflow-intent-report-consumer.ts +221 -0
  271. package/src/workflow/workflow-intent-report-schema.ts +129 -0
  272. package/src/workflow/workflow-intent-report.ts +135 -0
@@ -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", "sayknow")), 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;
@@ -98,6 +98,7 @@ export type ModelSelectorSelection =
98
98
  kind: "assignment";
99
99
  model: Model;
100
100
  role: SkcModelAssignmentTargetId | null;
101
+ roles?: readonly SkcModelAssignmentTargetId[];
101
102
  thinkingLevel?: ThinkingLevel;
102
103
  selector?: string;
103
104
  }
@@ -1374,7 +1375,11 @@ export class ModelSelectorComponent extends Container {
1374
1375
  for (let i = 0; i < actionCount; i++) {
1375
1376
  const prefix = i === this.#selectedActionIndex ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
1376
1377
  const role = SKC_MODEL_ASSIGNMENT_TARGET_IDS[i];
1377
- const label = `Set as ${SKC_MODEL_ASSIGNMENT_TARGETS[role].tag ?? role.toUpperCase()} (${SKC_MODEL_ASSIGNMENT_TARGETS[role].name})`;
1378
+ const label = role
1379
+ ? `Set as ${SKC_MODEL_ASSIGNMENT_TARGETS[role].tag ?? role.toUpperCase()} (${SKC_MODEL_ASSIGNMENT_TARGETS[role].name})`
1380
+ : i === SKC_MODEL_ASSIGNMENT_TARGET_IDS.length
1381
+ ? "Set for all role agents"
1382
+ : "Set for all targets";
1378
1383
  this.#listContainer.addChild(
1379
1384
  new Text(`${prefix}${i === this.#selectedActionIndex ? theme.fg("accent", label) : label}`, 0, 0),
1380
1385
  );
@@ -1411,7 +1416,7 @@ export class ModelSelectorComponent extends Container {
1411
1416
  return this.#roles[role]?.thinkingLevel ?? ThinkingLevel.Inherit;
1412
1417
  }
1413
1418
  #getActionCount(_model: Model): number {
1414
- return SKC_MODEL_ASSIGNMENT_TARGET_IDS.length;
1419
+ return SKC_MODEL_ASSIGNMENT_TARGET_IDS.length + 2;
1415
1420
  }
1416
1421
 
1417
1422
  #getSelectedItem(): ModelItem | CanonicalModelItem | undefined {
@@ -1659,7 +1664,15 @@ export class ModelSelectorComponent extends Container {
1659
1664
  if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
1660
1665
  this.#pendingActionItem = undefined;
1661
1666
  const role = SKC_MODEL_ASSIGNMENT_TARGET_IDS[this.#selectedActionIndex];
1662
- if (role) this.#handleSelect(item, role);
1667
+ if (role) {
1668
+ this.#handleSelect(item, role);
1669
+ return;
1670
+ }
1671
+ const roles =
1672
+ this.#selectedActionIndex === SKC_MODEL_ASSIGNMENT_TARGET_IDS.length
1673
+ ? (["executor", "architect", "planner", "critic"] as const)
1674
+ : SKC_MODEL_ASSIGNMENT_TARGET_IDS;
1675
+ this.#handleSelect(item, "default", undefined, roles);
1663
1676
  return;
1664
1677
  }
1665
1678
  if (getKeybindings().matches(keyData, "tui.select.cancel")) {
@@ -1698,17 +1711,34 @@ export class ModelSelectorComponent extends Container {
1698
1711
  this.#updateList();
1699
1712
  }
1700
1713
  }
1714
+ #getInitialThinkingChoiceIndex(item: ModelItem | CanonicalModelItem, levels: ThinkingLevel[]): number {
1715
+ const preferred = this.#getPreferredThinkingLevel(item);
1716
+ if (preferred && preferred !== ThinkingLevel.Inherit) {
1717
+ const index = levels.indexOf(preferred);
1718
+ if (index !== -1) return index;
1719
+ }
1720
+ return 0;
1721
+ }
1722
+
1723
+ #getPreferredThinkingLevel(item: ModelItem | CanonicalModelItem): ThinkingLevel | undefined {
1724
+ if (item.thinkingLevel && item.thinkingLevel !== ThinkingLevel.Inherit) {
1725
+ return item.thinkingLevel;
1726
+ }
1727
+ return undefined;
1728
+ }
1701
1729
 
1702
1730
  #handleSelect(
1703
1731
  item: ModelItem | CanonicalModelItem,
1704
1732
  role: SkcModelAssignmentTargetId | null,
1705
1733
  thinkingLevel?: ThinkingLevel,
1734
+ roles?: readonly SkcModelAssignmentTargetId[],
1706
1735
  ): void {
1707
1736
  const itemThinkingLevel = thinkingLevel ?? item.thinkingLevel;
1708
1737
  const hasExplicitThinkingChoice = thinkingLevel !== undefined || item.explicitThinkingLevel === true;
1709
- if (!hasExplicitThinkingChoice && requiresExplicitThinkingChoice(item.model)) {
1710
- this.#pendingThinkingChoice = { item, role, levels: getSelectableThinkingLevels(item.model) };
1711
- this.#selectedThinkingIndex = 0;
1738
+ if (!hasExplicitThinkingChoice && requiresExplicitThinkingChoice(item.model, role)) {
1739
+ const levels = getSelectableThinkingLevels(item.model);
1740
+ this.#pendingThinkingChoice = { item, role, levels };
1741
+ this.#selectedThinkingIndex = this.#getInitialThinkingChoiceIndex(item, levels);
1712
1742
  this.#updateList();
1713
1743
  return;
1714
1744
  }
@@ -1726,17 +1756,23 @@ export class ModelSelectorComponent extends Container {
1726
1756
  }
1727
1757
 
1728
1758
  const selectedThinkingLevel = itemThinkingLevel ?? this.#getCurrentRoleThinkingLevel(role);
1729
- const selectorValue =
1730
- role === "default" ? item.selector : formatModelSelectorValue(item.selector, selectedThinkingLevel);
1759
+ const selectorValue = roles
1760
+ ? formatModelSelectorValue(item.selector, selectedThinkingLevel)
1761
+ : role === "default"
1762
+ ? item.selector
1763
+ : formatModelSelectorValue(item.selector, selectedThinkingLevel);
1731
1764
 
1732
1765
  // Update local state for UI
1733
- this.#roles[role] = { model: item.model, thinkingLevel: selectedThinkingLevel };
1766
+ for (const targetRole of roles ?? [role]) {
1767
+ this.#roles[targetRole] = { model: item.model, thinkingLevel: selectedThinkingLevel };
1768
+ }
1734
1769
 
1735
1770
  // Notify caller (for updating agent state if needed)
1736
1771
  this.#onSelectCallback({
1737
1772
  kind: "assignment",
1738
1773
  model: item.model,
1739
1774
  role,
1775
+ roles,
1740
1776
  thinkingLevel: selectedThinkingLevel,
1741
1777
  selector: selectorValue,
1742
1778
  });
@@ -1751,10 +1787,17 @@ export class ModelSelectorComponent extends Container {
1751
1787
  async __testSelectProfile(profileName: string, setDefault: boolean): Promise<void> {
1752
1788
  await this.#onSelectCallback({ kind: "profile", profileName, setDefault });
1753
1789
  }
1790
+ async __testSelectAssignment(
1791
+ selection: Omit<Extract<ModelSelectorSelection, { kind: "assignment" }>, "kind">,
1792
+ ): Promise<void> {
1793
+ await this.#onSelectCallback({ kind: "assignment", ...selection });
1794
+ }
1754
1795
  }
1755
1796
 
1756
- function requiresExplicitThinkingChoice(model: Model): boolean {
1757
- return model.reasoning === true && (model.provider === "openai" || model.provider === "openai-codex");
1797
+ function requiresExplicitThinkingChoice(model: Model, role: SkcModelAssignmentTargetId | null): boolean {
1798
+ if (model.reasoning !== true) return false;
1799
+ if (model.provider === "openai" || model.provider === "openai-codex") return true;
1800
+ return role !== null && SKC_MODEL_ASSIGNMENT_TARGETS[role].settingsPath === "task.agentModelOverrides";
1758
1801
  }
1759
1802
 
1760
1803
  function getSelectableThinkingLevels(model: Model): ThinkingLevel[] {