@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
@@ -22,6 +22,25 @@ export interface WelcomeComponentOptions {
22
22
  collapseChangelog?: boolean;
23
23
  }
24
24
 
25
+ const WELCOME_STATIC_RIGHT_ROWS_EXCLUDING_DYNAMIC_SECTIONS = 15;
26
+ const DEFAULT_WHATS_NEW_ROWS = 3;
27
+ const MAX_WHATS_NEW_ROWS = 12;
28
+ const NEWLINE_FLOW_KEY =
29
+ process.platform === "win32" ? { key: "alt+enter", label: "newline" } : { key: "ctrl+j", label: "newline" };
30
+
31
+ const FLOW_KEY_ITEMS: ReadonlyArray<{ key: string; label: string }> = [
32
+ { key: "/", label: "commands" },
33
+ { key: "#", label: "actions" },
34
+ { key: "!", label: "shell" },
35
+ { key: "$", label: "python" },
36
+ { key: "?", label: "keymap" },
37
+ { key: "ctrl+l", label: "model" },
38
+ { key: "shift+tab", label: "reasoning" },
39
+ { key: "tab", label: "complete" },
40
+ NEWLINE_FLOW_KEY,
41
+ { key: "ctrl+c", label: "clear" },
42
+ ];
43
+
25
44
  /**
26
45
  * Sayknow-CLI launch surface: a blue-gradient SAYKNOW wordmark, compact
27
46
  * command affordances, and project signals — a distinct identity, not a
@@ -99,7 +118,6 @@ export class WelcomeComponent implements Component {
99
118
  }
100
119
  const targetContentRows = targetRows === undefined ? undefined : Math.max(0, targetRows - 2);
101
120
  const dualContentWidth = boxWidth - 3; // 3 = │ + │ + │
102
- const preferredLeftCol = 40;
103
121
  const minLeftCol = 20; // wordmark plus Sayknow identity labels
104
122
  const minRightCol = 24;
105
123
  const modelPill = this.#pill(theme.icon.model || "model", this.modelName, "statusLineModel");
@@ -113,10 +131,12 @@ export class WelcomeComponent implements Component {
113
131
  visibleWidth(modelPill),
114
132
  visibleWidth(providerPill),
115
133
  );
116
- const desiredLeftCol = Math.min(preferredLeftCol, Math.max(minLeftCol, Math.floor(dualContentWidth * 0.42)));
134
+ const evenLeftCol = Math.floor(dualContentWidth / 2);
135
+ const maxLeftColWithRightMinimum = Math.max(1, dualContentWidth - minRightCol);
136
+ const desiredLeftCol = Math.max(leftMinContentWidth, evenLeftCol);
117
137
  const dualLeftCol =
118
138
  dualContentWidth >= minRightCol + 1
119
- ? Math.min(desiredLeftCol, dualContentWidth - minRightCol)
139
+ ? Math.min(desiredLeftCol, maxLeftColWithRightMinimum)
120
140
  : Math.max(1, dualContentWidth - 1);
121
141
  const dualRightCol = Math.max(1, dualContentWidth - dualLeftCol);
122
142
  const showRightColumn = dualLeftCol >= leftMinContentWidth && dualRightCol >= minRightCol;
@@ -152,26 +172,6 @@ export class WelcomeComponent implements Component {
152
172
 
153
173
  const rightColumnWidth = showRightColumn ? rightCol : leftCol;
154
174
  const separator = buildSeparator(rightColumnWidth);
155
- const changelogBudget = Math.max(1, Math.min(6, Math.floor((targetContentRows ?? 18) * 0.3)));
156
-
157
- const sessionLines: string[] = [];
158
- if (this.recentSessions.length === 0) {
159
- sessionLines.push(` ${theme.fg("dim", t("welcome.noSessions"))}`);
160
- } else {
161
- const bulletPrefix = ` ${theme.md.bullet} `;
162
- const prefixWidth = visibleWidth(bulletPrefix);
163
- for (const session of this.recentSessions.slice(0, 3)) {
164
- const timeSuffixRaw = ` (${session.timeAgo})`;
165
- const timeWidth = visibleWidth(timeSuffixRaw);
166
- const nameBudget = Math.max(1, rightColumnWidth - prefixWidth - timeWidth);
167
- const nameVis = visibleWidth(session.name);
168
- const name = nameVis > nameBudget ? truncateToWidth(session.name, nameBudget) : session.name;
169
- sessionLines.push(
170
- `${theme.fg("dim", bulletPrefix)}${theme.fg("muted", name)}${theme.fg("dim", timeSuffixRaw)}`,
171
- );
172
- }
173
- }
174
-
175
175
  const lspLines: string[] = [];
176
176
  if (this.lspServers.length === 0) {
177
177
  lspLines.push(` ${theme.fg("dim", t("welcome.noLsp"))}`);
@@ -188,6 +188,24 @@ export class WelcomeComponent implements Component {
188
188
  }
189
189
  }
190
190
 
191
+ const flowPreferredRows = this.#flowKeyRows(rightColumnWidth).length;
192
+ const changelogRowLimit = this.#whatsNewRowLimit(targetContentRows, lspLines.length, flowPreferredRows);
193
+ const changelogLines = this.#whatsNewLines(rightColumnWidth, changelogRowLimit);
194
+ const flowRowLimit = this.#flowKeyRowLimit(
195
+ targetContentRows,
196
+ changelogLines.length,
197
+ lspLines.length,
198
+ rightColumnWidth,
199
+ );
200
+ const flowLines = this.#flowKeyLines(rightColumnWidth, flowRowLimit);
201
+ const sessionLimit = this.#sessionTrailLimit(
202
+ targetContentRows,
203
+ changelogLines.length,
204
+ lspLines.length,
205
+ flowLines.length,
206
+ );
207
+ const sessionLines = this.#sessionTrailLines(rightColumnWidth, sessionLimit);
208
+
191
209
  // Workflow affordances: pad the command so descriptions align.
192
210
  const wf = (cmd: string, desc: string): string => {
193
211
  const pad = " ".repeat(Math.max(1, 17 - visibleWidth(cmd)));
@@ -197,7 +215,7 @@ export class WelcomeComponent implements Component {
197
215
  const rightLines = [
198
216
  "",
199
217
  ` ${theme.bold(theme.fg("accent", "What's New"))}`,
200
- ...this.#whatsNewLines(rightColumnWidth, changelogBudget),
218
+ ...changelogLines,
201
219
  separator,
202
220
  ` ${theme.bold(theme.fg("accent", t("welcome.workflows")))}`,
203
221
  wf("/deep-interview", t("welcome.wf.deepInterview")),
@@ -206,19 +224,7 @@ export class WelcomeComponent implements Component {
206
224
  wf("/team", t("welcome.wf.team")),
207
225
  separator,
208
226
  ` ${theme.bold(theme.fg("accent", t("welcome.flowKeys")))}`,
209
- ` ${theme.fg("dim", "/")}${theme.fg("muted", ` ${t("welcome.commands")}`)} ${theme.fg("dim", "·")} ${theme.fg(
210
- "dim",
211
- "#",
212
- )}${theme.fg("muted", ` ${t("welcome.actions")}`)}`,
213
- ` ${theme.fg("dim", "!")}${theme.fg("muted", ` ${t("welcome.shell")}`)} ${theme.fg("dim", "·")} ${theme.fg("dim", "$")}${theme.fg(
214
- "muted",
215
- ` ${t("welcome.python")}`,
216
- )}`,
217
- ` ${theme.fg("dim", "?")}${theme.fg("muted", ` ${t("welcome.keymap")}`)} ${theme.fg("dim", "·")} ${theme.fg(
218
- "dim",
219
- "ctrl+l",
220
- )}${theme.fg("muted", ` ${t("welcome.model")}`)}`,
221
- ` ${theme.fg("dim", "shift+tab")}${theme.fg("muted", ` ${t("welcome.reasoning")}`)}`,
227
+ ...flowLines,
222
228
  separator,
223
229
  ` ${theme.bold(theme.fg("accent", t("welcome.projectPulse")))}`,
224
230
  ...lspLines,
@@ -337,7 +343,139 @@ export class WelcomeComponent implements Component {
337
343
  return [...Array.from({ length: topPad }, () => ""), ...clipped, ...Array.from({ length: bottomPad }, () => "")];
338
344
  }
339
345
 
340
- #whatsNewLines(width: number, maxItems: number): string[] {
346
+ #flowKeyItemText(item: { key: string; label: string }): string {
347
+ return `${theme.fg("dim", item.key)}${theme.fg("muted", ` ${this.#flowKeyLabel(item.label)}`)}`;
348
+ }
349
+
350
+ #flowKeyLabel(label: string): string {
351
+ switch (label) {
352
+ case "commands":
353
+ return t("welcome.commands");
354
+ case "actions":
355
+ return t("welcome.actions");
356
+ case "shell":
357
+ return t("welcome.shell");
358
+ case "python":
359
+ return t("welcome.python");
360
+ case "keymap":
361
+ return t("welcome.keymap");
362
+ case "model":
363
+ return t("welcome.model");
364
+ case "reasoning":
365
+ return t("welcome.reasoning");
366
+ default:
367
+ return label;
368
+ }
369
+ }
370
+
371
+ #flowKeyRows(width: number): string[] {
372
+ const contentWidth = Math.max(1, width - 1);
373
+ const separator = ` ${theme.fg("dim", "·")} `;
374
+ const rows: string[] = [];
375
+ let current = "";
376
+ for (const item of FLOW_KEY_ITEMS) {
377
+ const segment = this.#flowKeyItemText(item);
378
+ const next = current ? `${current}${separator}${segment}` : segment;
379
+ if (current && visibleWidth(next) > contentWidth) {
380
+ rows.push(` ${current}`);
381
+ current = segment;
382
+ } else {
383
+ current = next;
384
+ }
385
+ }
386
+ if (current) rows.push(` ${current}`);
387
+ return rows.length > 0 ? rows : [` ${theme.fg("dim", "No flow keys")}`];
388
+ }
389
+
390
+ #flowKeyLines(width: number, maxRows: number): string[] {
391
+ const rows = this.#flowKeyRows(width);
392
+ const rowLimit = Math.max(1, Math.floor(maxRows));
393
+ if (rows.length <= rowLimit) return rows;
394
+ if (rowLimit === 1) {
395
+ const firstItem = FLOW_KEY_ITEMS[0];
396
+ const firstSegment = firstItem ? this.#flowKeyItemText(firstItem) : theme.fg("dim", "keys");
397
+ return [this.#fitToWidth(` ${firstSegment} ${theme.fg("dim", "· … ")}${theme.bold("/help")}`, width)];
398
+ }
399
+ return [...rows.slice(0, rowLimit - 1), ` ${theme.fg("dim", `… ${theme.bold("/help")} for more`)}`];
400
+ }
401
+
402
+ #flowKeyRowLimit(
403
+ targetContentRows: number | undefined,
404
+ changelogLineCount: number,
405
+ lspLineCount: number,
406
+ rightColumnWidth: number,
407
+ ): number {
408
+ const preferredRows = this.#flowKeyRows(rightColumnWidth).length;
409
+ if (targetContentRows === undefined) return preferredRows;
410
+
411
+ const sessionBaselineRows = this.recentSessions.length === 0 ? 1 : Math.min(3, this.recentSessions.length);
412
+ const availableRows =
413
+ targetContentRows -
414
+ WELCOME_STATIC_RIGHT_ROWS_EXCLUDING_DYNAMIC_SECTIONS -
415
+ changelogLineCount -
416
+ lspLineCount -
417
+ sessionBaselineRows;
418
+ return Math.max(1, Math.min(preferredRows, availableRows));
419
+ }
420
+
421
+ #whatsNewRowLimit(targetContentRows: number | undefined, lspLineCount: number, flowLineCount: number): number {
422
+ if (targetContentRows === undefined) return 5;
423
+
424
+ const sessionBaselineRows = this.recentSessions.length === 0 ? 1 : Math.min(3, this.recentSessions.length);
425
+ const dynamicRows = Math.max(
426
+ 1,
427
+ targetContentRows - WELCOME_STATIC_RIGHT_ROWS_EXCLUDING_DYNAMIC_SECTIONS - flowLineCount - lspLineCount,
428
+ );
429
+ const rowsAfterBaselineSessions = Math.max(1, dynamicRows - sessionBaselineRows);
430
+ const spareRows = Math.max(0, rowsAfterBaselineSessions - DEFAULT_WHATS_NEW_ROWS);
431
+ return Math.max(
432
+ 1,
433
+ Math.min(MAX_WHATS_NEW_ROWS, rowsAfterBaselineSessions, DEFAULT_WHATS_NEW_ROWS + Math.floor(spareRows / 2)),
434
+ );
435
+ }
436
+
437
+ #sessionTrailLimit(
438
+ targetContentRows: number | undefined,
439
+ changelogLineCount: number,
440
+ lspLineCount: number,
441
+ flowLineCount: number,
442
+ ): number {
443
+ if (this.recentSessions.length === 0) return 0;
444
+
445
+ const defaultLimit = Math.min(3, this.recentSessions.length);
446
+ if (targetContentRows === undefined) return defaultLimit;
447
+
448
+ const rowsWithDefaultTrail =
449
+ WELCOME_STATIC_RIGHT_ROWS_EXCLUDING_DYNAMIC_SECTIONS +
450
+ changelogLineCount +
451
+ flowLineCount +
452
+ lspLineCount +
453
+ defaultLimit;
454
+ const extraRows = Math.max(0, targetContentRows - rowsWithDefaultTrail);
455
+ return Math.min(this.recentSessions.length, defaultLimit + extraRows);
456
+ }
457
+
458
+ #sessionTrailLines(rightColumnWidth: number, limit: number): string[] {
459
+ if (this.recentSessions.length === 0) {
460
+ return [` ${theme.fg("dim", t("welcome.noSessions"))}`];
461
+ }
462
+
463
+ const bulletPrefix = ` ${theme.md.bullet} `;
464
+ const prefixWidth = visibleWidth(bulletPrefix);
465
+ const lines: string[] = [];
466
+ for (const session of this.recentSessions.slice(0, limit)) {
467
+ const timeSuffixRaw = ` (${session.timeAgo})`;
468
+ const timeWidth = visibleWidth(timeSuffixRaw);
469
+ const nameBudget = Math.max(1, rightColumnWidth - prefixWidth - timeWidth);
470
+ const nameVis = visibleWidth(session.name);
471
+ const name = nameVis > nameBudget ? truncateToWidth(session.name, nameBudget) : session.name;
472
+ lines.push(`${theme.fg("dim", bulletPrefix)}${theme.fg("muted", name)}${theme.fg("dim", timeSuffixRaw)}`);
473
+ }
474
+ return lines;
475
+ }
476
+
477
+ #whatsNewLines(width: number, maxRows: number): string[] {
478
+ const rowLimit = Math.max(1, Math.floor(maxRows));
341
479
  const changelog = this.options.changelogMarkdown?.trim();
342
480
  if (!changelog) {
343
481
  return [` ${theme.fg("dim", "Ready for your next prompt")}`];
@@ -348,25 +486,25 @@ export class WelcomeComponent implements Component {
348
486
  return [
349
487
  ` ${theme.fg("muted", `Updated to v${version}`)}`,
350
488
  ` ${theme.fg("dim", `Use ${theme.bold("/changelog")} for details`)}`,
351
- ];
489
+ ].slice(0, rowLimit);
352
490
  }
353
491
 
354
- const itemLimit = Math.max(1, Math.floor(maxItems));
355
492
  const items = this.#changelogItems(changelog);
356
493
  if (items.length === 0) {
357
494
  return [
358
495
  ` ${theme.fg("muted", `Updated to v${version}`)}`,
359
496
  ` ${theme.fg("dim", `Use ${theme.bold("/changelog")} for details`)}`,
360
- ];
497
+ ].slice(0, rowLimit);
361
498
  }
362
499
 
363
500
  const prefix = ` ${theme.md.bullet} `;
364
501
  const textWidth = Math.max(1, width - visibleWidth(prefix));
365
- const visibleItems = items.slice(0, itemLimit).map(item => {
502
+ const visibleItemCount = items.length > rowLimit ? Math.max(1, rowLimit - 1) : rowLimit;
503
+ const visibleItems = items.slice(0, visibleItemCount).map(item => {
366
504
  const text = visibleWidth(item) > textWidth ? truncateToWidth(item, textWidth) : item;
367
505
  return `${theme.fg("dim", prefix)}${theme.fg("muted", text)}`;
368
506
  });
369
- if (items.length > visibleItems.length) {
507
+ if (items.length > visibleItems.length && visibleItems.length < rowLimit) {
370
508
  visibleItems.push(` ${theme.fg("dim", `… ${theme.bold("/changelog")} for full notes`)}`);
371
509
  }
372
510
  return visibleItems;
@@ -469,7 +607,6 @@ interface ShineConfig {
469
607
  /** Center of the shine band along the diagonal, in [0, 1]. */
470
608
  pos: number;
471
609
  }
472
-
473
610
  /**
474
611
  * Apply a multi-stop diagonal gradient (bottom-left → top-right) plus an
475
612
  * optional sliding shine band across multi-line art. `phase` (0..1) shifts the
@@ -13,6 +13,7 @@ import {
13
13
  import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@sayknow-cli/tui";
14
14
  import { formatDuration, Snowflake, setProjectDir } from "@sayknow-cli/utils";
15
15
  import { $ } from "bun";
16
+ import { resolveAppendOnlyMode } from "../../append-only-mode";
16
17
  import { jobElapsedMs } from "../../async";
17
18
  import { reset as resetCapabilities } from "../../capability";
18
19
  import { clearClaudePluginRootsCache } from "../../discovery/helpers";
@@ -400,7 +401,7 @@ export class CommandController {
400
401
  {
401
402
  const setting = this.ctx.settings.get("provider.appendOnlyContext") ?? "auto";
402
403
  const provider = this.ctx.session.model?.provider;
403
- const mode = setting === "on" ? true : setting === "off" ? false : provider === "deepseek";
404
+ const mode = resolveAppendOnlyMode(setting, provider ?? "");
404
405
  const activeLabel = mode ? theme.fg("success", "active") : theme.fg("dim", "inactive");
405
406
  const settingLabel = setting === "auto" ? `${setting} (${provider ?? "?"})` : setting;
406
407
  info += `${theme.fg("dim", "Append-Only:")} ${activeLabel} (setting: ${settingLabel})\n`;
@@ -540,7 +541,7 @@ export class CommandController {
540
541
  const title = showFull ? "Full Changelog" : "Recent Changes";
541
542
  const hint = showFull
542
543
  ? ""
543
- : `\n\n${theme.fg("dim", "Use")} ${theme.bold("/changelog full")} ${theme.fg("dim", "to view the complete changelog.")}`;
544
+ : `\n\n${theme.fg("dim", "Use")} ${theme.bold("/changelog --full")} ${theme.fg("dim", "to view the complete changelog.")}`;
544
545
 
545
546
  this.ctx.chatContainer.addChild(new Spacer(1));
546
547
  this.ctx.chatContainer.addChild(new DynamicBorder());
@@ -926,8 +927,7 @@ export class CommandController {
926
927
  this.ctx.streamingMessage = undefined;
927
928
  this.ctx.pendingTools.clear();
928
929
 
929
- this.ctx.chatContainer.addChild(new Spacer(1));
930
- this.ctx.chatContainer.addChild(new Text(`${theme.fg("accent", `${theme.status.success} ${label}`)}`, 1, 1));
930
+ this.ctx.chatContainer.addChild(new Text(`${theme.fg("accent", `${theme.status.success} ${label}`)}`, 1, 0));
931
931
  await this.ctx.reloadTodos();
932
932
  this.ctx.ui.requestRender();
933
933
  }
@@ -936,6 +936,42 @@ export class CommandController {
936
936
  await this.#runNewSessionFlow();
937
937
  }
938
938
 
939
+ async handleContextClearCommand(): Promise<void> {
940
+ if (this.ctx.loadingAnimation) {
941
+ this.ctx.loadingAnimation.stop();
942
+ this.ctx.loadingAnimation = undefined;
943
+ }
944
+ this.ctx.statusContainer.clear();
945
+
946
+ if (this.ctx.session.isCompacting) {
947
+ this.ctx.session.abortCompaction();
948
+ while (this.ctx.session.isCompacting) {
949
+ await Bun.sleep(10);
950
+ }
951
+ }
952
+ if (!(await this.ctx.session.clearContext())) return;
953
+ setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd());
954
+
955
+ this.ctx.statusLine.invalidate();
956
+ this.ctx.statusLine.setSessionStartTime(Date.now());
957
+ this.ctx.updateEditorTopBorder();
958
+ this.ctx.updateEditorBorderColor();
959
+ this.ctx.ui.requestRender();
960
+
961
+ this.ctx.chatContainer.clear();
962
+ this.ctx.pendingMessagesContainer.clear();
963
+ this.ctx.compactionQueuedMessages = [];
964
+ this.ctx.streamingComponent = undefined;
965
+ this.ctx.streamingMessage = undefined;
966
+ this.ctx.pendingTools.clear();
967
+
968
+ this.ctx.chatContainer.addChild(
969
+ new Text(`${theme.fg("accent", `${theme.status.success} Context cleared`)}`, 1, 0),
970
+ );
971
+ await this.ctx.reloadTodos();
972
+ this.ctx.ui.requestRender();
973
+ }
974
+
939
975
  async handleDropCommand(): Promise<void> {
940
976
  if (!this.ctx.sessionManager.getSessionFile()) {
941
977
  this.ctx.showError("Nothing to drop (in-memory session)");
@@ -16,6 +16,7 @@ import { ToolExecutionComponent } from "../../modes/components/tool-execution";
16
16
  import { TtsrNotificationComponent } from "../../modes/components/ttsr-notification";
17
17
  import { getSymbolTheme, theme } from "../../modes/theme/theme";
18
18
  import type { InteractiveModeContext, TodoPhase } from "../../modes/types";
19
+ import { completionNotifyDisabledByEnv } from "../../notifications/config";
19
20
  import { summaryFromMessage } from "../../notifications/helpers";
20
21
  import type { PlanApprovalDetails } from "../../plan-mode/approved-plan";
21
22
  import type { AgentSessionEvent } from "../../session/agent-session";
@@ -23,11 +24,22 @@ import { isSilentAbort, readPendingDisplayTag } from "../../session/messages";
23
24
  import type { ResolveToolDetails } from "../../tools/resolve";
24
25
  import { interruptHint } from "../shared";
25
26
  import { buildAbortDisplayMessage } from "../utils/abort-message";
27
+ import { consumeInjectedOptimisticSignature } from "../utils/injected-user-submission";
26
28
  import { ringTerminalBell } from "../utils/terminal-bell";
29
+ import { argsWithPartialJson } from "../utils/ui-helpers";
27
30
 
28
31
  type AgentSessionEventKind = AgentSessionEvent["type"];
29
32
 
30
33
  const IRC_MESSAGE_VISIBLE_TTL_MS = 10_000;
34
+
35
+ /** Test-only performance counters for advisory baseline tests. */
36
+ export const __eventControllerPerfCounters = {
37
+ messageUpdateContentVisits: 0,
38
+ reset(): void {
39
+ this.messageUpdateContentVisits = 0;
40
+ },
41
+ };
42
+
31
43
  const COMPLETION_NOTIFY_COMMAND_TIMEOUT_MS = 10_000;
32
44
 
33
45
  interface CompletionNotifyPayload {
@@ -100,6 +112,8 @@ export class EventController {
100
112
  #lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
101
113
  #idleCompactionTimer?: NodeJS.Timeout;
102
114
  #ircExpiryTimers = new Map<string, NodeJS.Timeout>();
115
+ #toolIntentCache = new Map<string, { args: unknown; intent: string | undefined }>();
116
+ #thinkingContentIndices = new Set<number>();
103
117
  #handlers: AgentSessionEventHandlers;
104
118
 
105
119
  constructor(private ctx: InteractiveModeContext) {
@@ -279,12 +293,17 @@ export class EventController {
279
293
  const signature = `${textContent}\u0000${imageCount}`;
280
294
 
281
295
  this.#resetReadGroup();
282
- const wasOptimistic = this.ctx.optimisticUserMessageSignature === signature;
296
+ const wasSingleOptimistic = this.ctx.optimisticUserMessageSignature === signature;
297
+ const wasInjectedOptimistic = !wasSingleOptimistic && consumeInjectedOptimisticSignature(this.ctx, signature);
298
+ const wasOptimistic = wasSingleOptimistic || wasInjectedOptimistic;
283
299
  const wasLocallySubmitted = this.ctx.locallySubmittedUserSignatures.delete(signature) || wasOptimistic;
284
300
  if (!wasOptimistic) {
285
301
  this.ctx.addMessageToChat(event.message);
286
302
  }
287
- if (wasOptimistic) {
303
+ // Only clear the single local slot when IT matched; an injected match is
304
+ // consumed from the counting Map above and must not clobber a coexisting
305
+ // pending local optimistic signature.
306
+ if (wasSingleOptimistic) {
288
307
  this.ctx.optimisticUserMessageSignature = undefined;
289
308
  }
290
309
 
@@ -307,12 +326,14 @@ export class EventController {
307
326
  } else if (event.message.role === "assistant") {
308
327
  this.#lastThinkingCount = 0;
309
328
  this.#resetReadGroup();
329
+ this.#toolIntentCache.clear();
330
+ this.#thinkingContentIndices.clear();
310
331
  this.ctx.streamingComponent = new AssistantMessageComponent(undefined, this.ctx.hideThinkingBlock, () =>
311
332
  this.ctx.ui.requestRender(),
312
333
  );
313
334
  this.ctx.streamingMessage = event.message;
314
335
  this.ctx.chatContainer.addChild(this.ctx.streamingComponent);
315
- this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
336
+ this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage, { streaming: true });
316
337
  this.ctx.ui.requestRender();
317
338
  }
318
339
  }
@@ -375,46 +396,53 @@ export class EventController {
375
396
  async #handleMessageUpdate(event: Extract<AgentSessionEvent, { type: "message_update" }>): Promise<void> {
376
397
  if (this.ctx.streamingComponent && event.message.role === "assistant") {
377
398
  this.ctx.streamingMessage = event.message;
378
- this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
379
-
380
- const thinkingCount = this.ctx.streamingMessage.content.filter(
381
- content => content.type === "thinking" && content.thinking.trim(),
382
- ).length;
383
- if (thinkingCount > this.#lastThinkingCount) {
384
- this.#resetReadGroup();
385
- this.#lastThinkingCount = thinkingCount;
399
+ this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage, { streaming: true });
400
+ const contentIndex = event.assistantMessageEvent?.contentIndex;
401
+ const changedContent =
402
+ typeof contentIndex === "number" && contentIndex >= 0
403
+ ? this.ctx.streamingMessage.content[contentIndex]
404
+ : undefined;
405
+ const contentsToProcess = changedContent ? [changedContent] : this.ctx.streamingMessage.content;
406
+
407
+ if (changedContent?.type === "thinking" && changedContent.thinking.trim()) {
408
+ if (typeof contentIndex === "number") this.#thinkingContentIndices.add(contentIndex);
409
+ const thinkingCount = this.#thinkingContentIndices.size;
410
+ if (thinkingCount > this.#lastThinkingCount) {
411
+ this.#resetReadGroup();
412
+ this.#lastThinkingCount = thinkingCount;
413
+ }
414
+ } else if (!changedContent) {
415
+ const thinkingCount = this.ctx.streamingMessage.content.filter(
416
+ content => content.type === "thinking" && content.thinking.trim(),
417
+ ).length;
418
+ if (thinkingCount > this.#lastThinkingCount) {
419
+ this.#resetReadGroup();
420
+ this.#lastThinkingCount = thinkingCount;
421
+ }
386
422
  }
387
423
 
388
- for (const content of this.ctx.streamingMessage.content) {
424
+ for (const content of contentsToProcess) {
425
+ __eventControllerPerfCounters.messageUpdateContentVisits += 1;
389
426
  if (content.type !== "toolCall") continue;
390
427
  if (content.name === "read") {
391
- if (!readArgsHaveTarget(content.arguments)) {
392
- // Args still streaming — defer until path is parseable so we can route to the
393
- // read group (regular files) vs ToolExecutionComponent (internal URLs).
394
- // Creating either component now would lock the read into the wrong shape.
395
- continue;
396
- }
428
+ if (!readArgsHaveTarget(content.arguments)) continue;
397
429
  if (!readArgsTargetInternalUrl(content.arguments)) {
398
430
  this.#trackReadToolCall(content.id, content.arguments);
399
431
  const component = this.ctx.pendingTools.get(content.id);
400
- if (component) {
401
- component.updateArgs(content.arguments, content.id);
402
- } else {
432
+ if (component) component.updateArgs(content.arguments, content.id);
433
+ else {
403
434
  const group = this.#getReadGroup();
404
435
  group.updateArgs(content.arguments, content.id);
405
436
  this.ctx.pendingTools.set(content.id, group);
406
437
  }
407
438
  continue;
408
439
  }
409
- // Internal URL read falls through to ToolExecutionComponent below.
410
440
  }
411
441
 
412
- // Preserve the raw partial JSON for renderers that need to surface fields before the JSON object closes.
413
- // Bash uses this to show inline env assignments during streaming instead of popping them in at completion.
414
- const renderArgs =
415
- "partialJson" in content
416
- ? { ...content.arguments, __partialJson: content.partialJson }
417
- : content.arguments;
442
+ const renderArgs = argsWithPartialJson(
443
+ content.arguments,
444
+ "partialJson" in content ? content.partialJson : undefined,
445
+ );
418
446
  if (!this.ctx.pendingTools.has(content.id)) {
419
447
  this.#resetReadGroup();
420
448
  this.ctx.chatContainer.addChild(new Text("", 0, 0));
@@ -437,32 +465,30 @@ export class EventController {
437
465
  this.ctx.chatContainer.addChild(component);
438
466
  this.ctx.pendingTools.set(content.id, component);
439
467
  } else {
440
- const component = this.ctx.pendingTools.get(content.id);
441
- if (component) {
442
- component.updateArgs(renderArgs, content.id);
443
- }
468
+ this.ctx.pendingTools.get(content.id)?.updateArgs(renderArgs, content.id);
444
469
  }
445
- }
446
470
 
447
- // Update working message with intent from streamed tool arguments
448
- for (const content of this.ctx.streamingMessage.content) {
449
- if (content.type !== "toolCall") continue;
450
471
  const args = content.arguments;
451
472
  if (!args || typeof args !== "object") continue;
473
+ let intent: string | undefined;
452
474
  if (INTENT_FIELD in args) {
453
- this.#updateWorkingMessageFromIntent(args[INTENT_FIELD]);
454
- continue;
455
- }
456
- const tool = this.ctx.session.getToolByName(content.name);
457
- if (typeof tool?.intent !== "function") continue;
458
- try {
459
- const derived = tool.intent(args as never)?.trim();
460
- if (derived) {
461
- this.#updateWorkingMessageFromIntent(derived);
475
+ intent = String(args[INTENT_FIELD]);
476
+ } else {
477
+ const cached = this.#toolIntentCache.get(content.id);
478
+ if (cached?.args === args) intent = cached.intent;
479
+ else {
480
+ const tool = this.ctx.session.getToolByName(content.name);
481
+ if (typeof tool?.intent === "function") {
482
+ try {
483
+ intent = tool.intent(args as never)?.trim();
484
+ } catch {
485
+ // intent function must never break the UI
486
+ }
487
+ }
488
+ this.#toolIntentCache.set(content.id, { args, intent });
462
489
  }
463
- } catch {
464
- // intent function must never break the UI
465
490
  }
491
+ if (intent) this.#updateWorkingMessageFromIntent(intent);
466
492
  }
467
493
 
468
494
  this.ctx.ui.requestRender();
@@ -495,9 +521,9 @@ export class EventController {
495
521
  // display only — does NOT mutate the persisted message's stopReason
496
522
  // (the marker on errorMessage drives replay-side suppression).
497
523
  const msgWithoutAbort = { ...this.ctx.streamingMessage, stopReason: "stop" as const };
498
- this.ctx.streamingComponent.updateContent(msgWithoutAbort);
524
+ this.ctx.streamingComponent.updateContent(msgWithoutAbort, { streaming: false });
499
525
  } else {
500
- this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
526
+ this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage, { streaming: false });
501
527
  }
502
528
 
503
529
  if (this.ctx.streamingMessage.stopReason !== "aborted" && this.ctx.streamingMessage.stopReason !== "error") {
@@ -941,6 +967,8 @@ export class EventController {
941
967
  }
942
968
 
943
969
  sendCompletionNotification(): void {
970
+ // Per-run hard opt-out (env, config-untouched, child-inheritable): SKC_NOTIFY=off.
971
+ if (completionNotifyDisabledByEnv(process.env)) return;
944
972
  const isBackgrounded = this.ctx.isBackgrounded !== false;
945
973
  const notify = settings.get("completion.notify");
946
974
  if (notify === "off") return;