@deepseek-harness-tui/dsh-tui 0.9.3 → 0.10.0-beta.2

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 (267) hide show
  1. package/README.md +24 -6
  2. package/cordis.patch.yml +117 -39
  3. package/cordis.yml +13 -10
  4. package/lib/types/api.d.ts +7 -0
  5. package/lib/types/api.d.ts.map +1 -0
  6. package/lib/types/api.js +1 -0
  7. package/lib/types/cc/hyperlink.d.ts.map +1 -1
  8. package/lib/types/cc/hyperlink.js +42 -4
  9. package/lib/types/cc/markdown.d.ts.map +1 -1
  10. package/lib/types/cc/markdown.js +15 -5
  11. package/lib/types/commands.d.ts +6 -0
  12. package/lib/types/commands.d.ts.map +1 -1
  13. package/lib/types/commands.js +12 -8
  14. package/lib/types/components/MessageList.d.ts +8 -4
  15. package/lib/types/components/MessageList.d.ts.map +1 -1
  16. package/lib/types/components/MessageList.js +94 -32
  17. package/lib/types/components/PermissionsPicker.d.ts +6 -7
  18. package/lib/types/components/PermissionsPicker.d.ts.map +1 -1
  19. package/lib/types/components/PermissionsPicker.js +20 -14
  20. package/lib/types/components/PromptEditor.d.ts +46 -0
  21. package/lib/types/components/PromptEditor.d.ts.map +1 -0
  22. package/lib/types/components/PromptEditor.js +60 -0
  23. package/lib/types/components/PromptInput.d.ts +12 -0
  24. package/lib/types/components/PromptInput.d.ts.map +1 -1
  25. package/lib/types/components/PromptInput.js +1240 -205
  26. package/lib/types/components/RewindPicker.js +1 -1
  27. package/lib/types/components/SplitDiffView.d.ts +11 -1
  28. package/lib/types/components/SplitDiffView.d.ts.map +1 -1
  29. package/lib/types/components/SplitDiffView.js +9 -2
  30. package/lib/types/components/StreamingMarkdown.d.ts.map +1 -1
  31. package/lib/types/components/StreamingMarkdown.js +140 -3
  32. package/lib/types/components/SubagentDashboard.d.ts.map +1 -1
  33. package/lib/types/components/SubagentDashboard.js +2 -2
  34. package/lib/types/components/SubagentDetailScene.js +1 -1
  35. package/lib/types/components/ThemePicker.d.ts +13 -15
  36. package/lib/types/components/ThemePicker.d.ts.map +1 -1
  37. package/lib/types/components/ThemePicker.js +24 -26
  38. package/lib/types/components/Tooltip.d.ts +67 -0
  39. package/lib/types/components/Tooltip.d.ts.map +1 -0
  40. package/lib/types/components/Tooltip.js +220 -0
  41. package/lib/types/components/approvals/ApprovalPanel.d.ts.map +1 -1
  42. package/lib/types/components/approvals/ApprovalPanel.js +1 -1
  43. package/lib/types/components/design-system/Divider.d.ts +14 -1
  44. package/lib/types/components/design-system/Divider.d.ts.map +1 -1
  45. package/lib/types/components/design-system/Divider.js +86 -8
  46. package/lib/types/components/design-system/ThemeProvider.d.ts +5 -2
  47. package/lib/types/components/design-system/ThemeProvider.d.ts.map +1 -1
  48. package/lib/types/components/design-system/ThemeProvider.js +52 -16
  49. package/lib/types/components/design-system/ThemedBox.d.ts +23 -2
  50. package/lib/types/components/design-system/ThemedBox.d.ts.map +1 -1
  51. package/lib/types/components/messages/AssistantThinkingMessage.d.ts +11 -6
  52. package/lib/types/components/messages/AssistantThinkingMessage.d.ts.map +1 -1
  53. package/lib/types/components/messages/AssistantThinkingMessage.js +10 -6
  54. package/lib/types/components/messages/AssistantToolUseMessage.d.ts +13 -1
  55. package/lib/types/components/messages/AssistantToolUseMessage.d.ts.map +1 -1
  56. package/lib/types/components/messages/AssistantToolUseMessage.js +37 -6
  57. package/lib/types/components/messages/UserPromptMessage.d.ts.map +1 -1
  58. package/lib/types/components/messages/UserPromptMessage.js +7 -1
  59. package/lib/types/components/questions/AskUserQuestionPanel.js +1 -1
  60. package/lib/types/components/questions/PlanReviewPanel.d.ts.map +1 -1
  61. package/lib/types/components/questions/PlanReviewPanel.js +29 -3
  62. package/lib/types/components/sessions/SessionListRow.d.ts +8 -1
  63. package/lib/types/components/sessions/SessionListRow.d.ts.map +1 -1
  64. package/lib/types/components/sessions/SessionListRow.js +9 -2
  65. package/lib/types/components/sessions/SessionPreview.d.ts.map +1 -1
  66. package/lib/types/components/sessions/SessionPreview.js +5 -3
  67. package/lib/types/components/sessions/WorkspaceListRow.d.ts +17 -0
  68. package/lib/types/components/sessions/WorkspaceListRow.d.ts.map +1 -0
  69. package/lib/types/components/sessions/WorkspaceListRow.js +27 -0
  70. package/lib/types/components/smoothReveal.d.ts +97 -0
  71. package/lib/types/components/smoothReveal.d.ts.map +1 -0
  72. package/lib/types/components/smoothReveal.js +279 -0
  73. package/lib/types/customTheme.d.ts +4 -0
  74. package/lib/types/customTheme.d.ts.map +1 -1
  75. package/lib/types/customTheme.js +9 -5
  76. package/lib/types/dsh-adapter/approvals.d.ts +114 -2
  77. package/lib/types/dsh-adapter/approvals.d.ts.map +1 -1
  78. package/lib/types/dsh-adapter/approvals.js +241 -3
  79. package/lib/types/dsh-adapter/channel.d.ts +59 -2
  80. package/lib/types/dsh-adapter/channel.d.ts.map +1 -1
  81. package/lib/types/dsh-adapter/channel.js +499 -80
  82. package/lib/types/dsh-adapter/childStderr.d.ts.map +1 -1
  83. package/lib/types/dsh-adapter/childStderr.js +9 -0
  84. package/lib/types/dsh-adapter/contract.d.ts +17 -13
  85. package/lib/types/dsh-adapter/contract.d.ts.map +1 -1
  86. package/lib/types/dsh-adapter/contract.js +36 -27
  87. package/lib/types/dsh-adapter/credentialRefGuard.d.ts +40 -0
  88. package/lib/types/dsh-adapter/credentialRefGuard.d.ts.map +1 -0
  89. package/lib/types/dsh-adapter/credentialRefGuard.js +43 -0
  90. package/lib/types/dsh-adapter/effect-ledger.d.ts +1 -1
  91. package/lib/types/dsh-adapter/effect-ledger.d.ts.map +1 -1
  92. package/lib/types/dsh-adapter/effect-ledger.js +1 -0
  93. package/lib/types/dsh-adapter/extensions.d.ts +4 -1
  94. package/lib/types/dsh-adapter/extensions.d.ts.map +1 -1
  95. package/lib/types/dsh-adapter/extensions.js +8 -1
  96. package/lib/types/dsh-adapter/index.d.ts +11 -1
  97. package/lib/types/dsh-adapter/index.d.ts.map +1 -1
  98. package/lib/types/dsh-adapter/index.js +2 -0
  99. package/lib/types/dsh-adapter/plugin-test-utils.d.ts +62 -0
  100. package/lib/types/dsh-adapter/plugin-test-utils.d.ts.map +1 -0
  101. package/lib/types/dsh-adapter/plugin-test-utils.js +61 -0
  102. package/lib/types/dsh-adapter/plugin.d.ts +20 -0
  103. package/lib/types/dsh-adapter/plugin.d.ts.map +1 -1
  104. package/lib/types/dsh-adapter/plugin.js +174 -39
  105. package/lib/types/dsh-adapter/plugins-info.d.ts.map +1 -1
  106. package/lib/types/dsh-adapter/plugins-info.js +12 -0
  107. package/lib/types/dsh-adapter/preset-resolution.d.ts +49 -0
  108. package/lib/types/dsh-adapter/preset-resolution.d.ts.map +1 -0
  109. package/lib/types/dsh-adapter/preset-resolution.js +42 -0
  110. package/lib/types/dsh-adapter/presets.d.ts +0 -26
  111. package/lib/types/dsh-adapter/presets.d.ts.map +1 -1
  112. package/lib/types/dsh-adapter/presets.js +4 -14
  113. package/lib/types/dsh-adapter/promptDebug.d.ts.map +1 -1
  114. package/lib/types/dsh-adapter/promptDebug.js +4 -1
  115. package/lib/types/dsh-adapter/providerGuard.d.ts +75 -0
  116. package/lib/types/dsh-adapter/providerGuard.d.ts.map +1 -0
  117. package/lib/types/dsh-adapter/providerGuard.js +104 -0
  118. package/lib/types/dsh-adapter/questions-answerer.d.ts +49 -0
  119. package/lib/types/dsh-adapter/questions-answerer.d.ts.map +1 -0
  120. package/lib/types/dsh-adapter/questions-answerer.js +61 -0
  121. package/lib/types/dsh-adapter/recap.d.ts.map +1 -1
  122. package/lib/types/dsh-adapter/recap.js +12 -6
  123. package/lib/types/dsh-adapter/sanitize.d.ts.map +1 -1
  124. package/lib/types/dsh-adapter/sanitize.js +6 -1
  125. package/lib/types/dsh-adapter/sessions/digest.d.ts +21 -1
  126. package/lib/types/dsh-adapter/sessions/digest.d.ts.map +1 -1
  127. package/lib/types/dsh-adapter/sessions/digest.js +229 -3
  128. package/lib/types/dsh-adapter/sessions/frames.d.ts +3 -0
  129. package/lib/types/dsh-adapter/sessions/frames.d.ts.map +1 -1
  130. package/lib/types/dsh-adapter/sessions/frames.js +34 -19
  131. package/lib/types/dsh-adapter/sessions/list.d.ts.map +1 -1
  132. package/lib/types/dsh-adapter/sessions/list.js +148 -31
  133. package/lib/types/dsh-adapter/sessions/store.d.ts +8 -0
  134. package/lib/types/dsh-adapter/sessions/store.d.ts.map +1 -1
  135. package/lib/types/dsh-adapter/sessions/store.js +24 -4
  136. package/lib/types/dsh-adapter/sessions/types.d.ts +3 -0
  137. package/lib/types/dsh-adapter/sessions/types.d.ts.map +1 -1
  138. package/lib/types/dsh-adapter/settings-sections.d.ts.map +1 -1
  139. package/lib/types/dsh-adapter/settings-sections.js +13 -1
  140. package/lib/types/dsh-adapter/settingsEditor.d.ts +6 -0
  141. package/lib/types/dsh-adapter/settingsEditor.d.ts.map +1 -1
  142. package/lib/types/dsh-adapter/settingsEditor.js +15 -3
  143. package/lib/types/dsh-adapter/subagents.d.ts +4 -0
  144. package/lib/types/dsh-adapter/subagents.d.ts.map +1 -1
  145. package/lib/types/dsh-adapter/subagents.js +8 -0
  146. package/lib/types/dsh-adapter/themes.d.ts +51 -0
  147. package/lib/types/dsh-adapter/themes.d.ts.map +1 -0
  148. package/lib/types/dsh-adapter/themes.js +272 -0
  149. package/lib/types/dsh-adapter/toast.d.ts +67 -0
  150. package/lib/types/dsh-adapter/toast.d.ts.map +1 -0
  151. package/lib/types/dsh-adapter/toast.js +141 -0
  152. package/lib/types/dsh-adapter/types.d.ts +1 -1
  153. package/lib/types/extensions.d.ts +4 -0
  154. package/lib/types/extensions.d.ts.map +1 -1
  155. package/lib/types/extensions.js +5 -1
  156. package/lib/types/history.d.ts.map +1 -1
  157. package/lib/types/history.js +7 -2
  158. package/lib/types/hooks/useRuntimeThemeSnapshot.d.ts +10 -0
  159. package/lib/types/hooks/useRuntimeThemeSnapshot.d.ts.map +1 -0
  160. package/lib/types/hooks/useRuntimeThemeSnapshot.js +32 -0
  161. package/lib/types/i18n.d.ts +199 -41
  162. package/lib/types/i18n.d.ts.map +1 -1
  163. package/lib/types/i18n.js +82 -20
  164. package/lib/types/ink/components/App.d.ts +20 -0
  165. package/lib/types/ink/components/App.d.ts.map +1 -1
  166. package/lib/types/ink/components/App.js +142 -13
  167. package/lib/types/ink/components/Box.d.ts +39 -2
  168. package/lib/types/ink/components/Box.d.ts.map +1 -1
  169. package/lib/types/ink/components/Box.js +61 -37
  170. package/lib/types/ink/events/context-menu-event.d.ts +19 -0
  171. package/lib/types/ink/events/context-menu-event.d.ts.map +1 -0
  172. package/lib/types/ink/events/context-menu-event.js +20 -0
  173. package/lib/types/ink/events/drag-event.d.ts +44 -0
  174. package/lib/types/ink/events/drag-event.d.ts.map +1 -0
  175. package/lib/types/ink/events/drag-event.js +39 -0
  176. package/lib/types/ink/events/event-handlers.d.ts +8 -0
  177. package/lib/types/ink/events/event-handlers.d.ts.map +1 -1
  178. package/lib/types/ink/events/event-handlers.js +8 -0
  179. package/lib/types/ink/hit-test.d.ts +69 -0
  180. package/lib/types/ink/hit-test.d.ts.map +1 -1
  181. package/lib/types/ink/hit-test.js +260 -6
  182. package/lib/types/ink/ink.d.ts +25 -0
  183. package/lib/types/ink/ink.d.ts.map +1 -1
  184. package/lib/types/ink/ink.js +74 -10
  185. package/lib/types/ink/parse-keypress.d.ts +20 -0
  186. package/lib/types/ink/parse-keypress.d.ts.map +1 -1
  187. package/lib/types/ink/parse-keypress.js +190 -22
  188. package/lib/types/ink/renderer.d.ts.map +1 -1
  189. package/lib/types/ink/renderer.js +15 -1
  190. package/lib/types/ink/screen.d.ts +7 -0
  191. package/lib/types/ink/screen.d.ts.map +1 -1
  192. package/lib/types/ink/screen.js +8 -1
  193. package/lib/types/ink/termio/osc.d.ts +2 -1
  194. package/lib/types/ink/termio/osc.d.ts.map +1 -1
  195. package/lib/types/ink/termio/osc.js +26 -5
  196. package/lib/types/plugin-spec/registry.d.ts +1 -1
  197. package/lib/types/plugin-spec/registry.js +1 -1
  198. package/lib/types/presetPrefs.d.ts +6 -5
  199. package/lib/types/presetPrefs.d.ts.map +1 -1
  200. package/lib/types/presetPrefs.js +10 -5
  201. package/lib/types/screens/Chat.d.ts +4 -1
  202. package/lib/types/screens/Chat.d.ts.map +1 -1
  203. package/lib/types/screens/Chat.js +169 -68
  204. package/lib/types/screens/SessionBrowser.d.ts +9 -4
  205. package/lib/types/screens/SessionBrowser.d.ts.map +1 -1
  206. package/lib/types/screens/SessionBrowser.js +483 -75
  207. package/lib/types/screens/SessionTree.d.ts.map +1 -1
  208. package/lib/types/screens/SessionTree.js +5 -1
  209. package/lib/types/screens/Settings.d.ts.map +1 -1
  210. package/lib/types/screens/Settings.js +5 -1
  211. package/lib/types/screens/StatusLine.d.ts.map +1 -1
  212. package/lib/types/screens/StatusLine.js +5 -1
  213. package/lib/types/screens/chatOverlay.d.ts +3 -2
  214. package/lib/types/screens/chatOverlay.d.ts.map +1 -1
  215. package/lib/types/screens/chatOverlay.js +2 -0
  216. package/lib/types/sessionPins.d.ts +33 -0
  217. package/lib/types/sessionPins.d.ts.map +1 -0
  218. package/lib/types/sessionPins.js +181 -0
  219. package/lib/types/sessions/format.d.ts +2 -0
  220. package/lib/types/sessions/format.d.ts.map +1 -1
  221. package/lib/types/sessions/format.js +9 -0
  222. package/lib/types/sessions/view.d.ts +42 -6
  223. package/lib/types/sessions/view.d.ts.map +1 -1
  224. package/lib/types/sessions/view.js +133 -35
  225. package/lib/types/test-utils.d.ts +2 -0
  226. package/lib/types/test-utils.d.ts.map +1 -0
  227. package/lib/types/test-utils.js +8 -0
  228. package/lib/types/theme.d.ts +22 -5
  229. package/lib/types/theme.d.ts.map +1 -1
  230. package/lib/types/theme.js +57 -7
  231. package/lib/types/themeCatalog.d.ts +36 -0
  232. package/lib/types/themeCatalog.d.ts.map +1 -0
  233. package/lib/types/themeCatalog.js +157 -0
  234. package/lib/types/tips.d.ts.map +1 -1
  235. package/lib/types/tips.js +114 -18
  236. package/lib/types/update.d.ts +159 -3
  237. package/lib/types/update.d.ts.map +1 -1
  238. package/lib/types/update.js +497 -16
  239. package/lib/types/utils/debug.d.ts.map +1 -1
  240. package/lib/types/utils/debug.js +2 -1
  241. package/lib/types/utils/keymap.d.ts +1 -1
  242. package/lib/types/utils/keymap.d.ts.map +1 -1
  243. package/lib/types/utils/keymap.js +19 -2
  244. package/lib/types/utils/urlGuard.d.ts +28 -0
  245. package/lib/types/utils/urlGuard.d.ts.map +1 -0
  246. package/lib/types/utils/urlGuard.js +21 -0
  247. package/node_modules/@deepseek-harness-tui/dsh-auth/README.md +3 -0
  248. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/credentials.d.ts +7 -11
  249. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/index.d.ts +16 -1
  250. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/index.js +23 -2
  251. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/interaction.d.ts +5 -5
  252. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/pi-ai.d.ts +23 -0
  253. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/pi-ai.js +27 -0
  254. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/profiles.d.ts +17 -3
  255. package/node_modules/@deepseek-harness-tui/dsh-auth/lib/profiles.js +36 -4
  256. package/node_modules/@deepseek-harness-tui/dsh-auth/package.json +11 -12
  257. package/package.json +114 -74
  258. package/lib/types/dsh-adapter/packaged-skills.d.ts +0 -19
  259. package/lib/types/dsh-adapter/packaged-skills.d.ts.map +0 -1
  260. package/lib/types/dsh-adapter/packaged-skills.js +0 -78
  261. package/skills/audit/SKILL.md +0 -24
  262. package/skills/bug/SKILL.md +0 -27
  263. package/skills/pr-comments/SKILL.md +0 -20
  264. package/skills/practice/SKILL.md +0 -22
  265. package/skills/release-notes/SKILL.md +0 -25
  266. package/skills/review/SKILL.md +0 -25
  267. package/skills/vuln-check/SKILL.md +0 -25
@@ -1,8 +1,9 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { assembleContextFor, installModelSelection } from '@deepseek-ai/dsh-agent';
3
- import { isUserInvocable, renderSkillContent } from '@deepseek-ai/dsh-skill';
4
- import { createUserMessage, isTokenDelta, MessageId, ReasoningEffortId, } from '@deepseek-ai/dsh-llm';
3
+ import { isModelInvocable, isUserInvocable, renderSkillContent } from '@deepseek-ai/dsh-skill';
4
+ import { createUserMessage, MessageId, ReasoningEffortId, } from '@deepseek-ai/dsh-llm';
5
5
  import { runSideQuestion, wrapSideQuestion } from './sideQuestion.js';
6
+ import { isReservedCredentialRef } from './credentialRefGuard.js';
6
7
  import { collectRecentActivity, parseRecapResponse, RECAP_RECENT_CHARS, wrapRecapPrompt } from './recap.js';
7
8
  import { SESSION_COLOR_NAMES } from '../cc/sessionColors.js';
8
9
  import { fetchBalance } from '../deepseekBalance.js';
@@ -11,7 +12,7 @@ import { SessionId } from '@deepseek-ai/dsh-session';
11
12
  import { renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt';
12
13
  import { loadBaselineInstructions } from '@deepseek-ai/dsh-agent-instructions';
13
14
  import { extname, isAbsolute, join } from 'node:path';
14
- import { completeCommands, HIDDEN_COMMAND_NAMES, isLocalCommandName, LOCAL_COMMANDS, parseCommandName } from '../commands.js';
15
+ import { completeCommands, HIDDEN_COMMAND_NAMES, isCommandCompletionToken, isLocalCommandName, LOCAL_COMMANDS, parseCommandName } from '../commands.js';
15
16
  import { clearResumeTarget, forgetSession, readResumeTarget, touchSession, writeResumeTarget } from '../sessionHistory.js';
16
17
  import { appendSessionTitle, defaultMaxScanned, deleteSessionLog, ensureLegacySessionEventTypes, readSessionEventsFromFile, readSessionEventsFromLog, sessionsRoots } from './compat/index.js';
17
18
  import { buildSessionTree, forkTarget, liveTailWindow, rewindTarget, turnUserText, } from './sessionTree.js';
@@ -22,16 +23,17 @@ import { isPathLikeQuery, rankFileCandidates } from '../utils/fileSuggestions.js
22
23
  import { readEffortPref, writeEffortPref } from '../effortPrefs.js';
23
24
  import { readModelPref, writeModelPref } from '../modelPrefs.js';
24
25
  import { explicitModelRoute, recordedModelRoute, resolveModelRoute, validateModelRoute } from '../modelRoute.js';
25
- import { readPresetPref, writePresetPref } from '../presetPrefs.js';
26
- import { composePreset, resolvePersistedPreset, resolvePersistedRoute, rosterOf, runningPresetOf, serviceForAgent } from './presets.js';
26
+ import { migratePresetPref, readPresetPref, writePresetPref } from '../presetPrefs.js';
27
+ import { composePreset, resolvePersistedPreset, resolvePersistedRoute, runningPresetOf, serviceForAgent } from './presets.js';
28
+ import { resolveCompatiblePreset, rosterOf } from './preset-resolution.js';
27
29
  import { isPresetName, PRESET_NAMES } from '../components/activityFrames.js';
28
30
  import { existsSync, statSync, writeFileSync } from 'node:fs';
29
31
  import { logForDebugging } from '../utils/debug.js';
30
32
  import { homeDir, LEGACY_DATA_DIR } from '../utils/paths.js';
31
33
  import { extractMentions } from '../utils/mentions.js';
32
34
  import { getLang, LANGS, t } from '../i18n.js';
33
- import { AUTO_THEME_NAME, THEME_NAMES } from '../theme.js';
34
- import { listCustomThemes } from '../customTheme.js';
35
+ import { AUTO_THEME_NAME } from '../theme.js';
36
+ import { listThemeCatalog } from '../themeCatalog.js';
35
37
  import { modeDisplayName, resolveSessionModes } from '../sessionModes.js';
36
38
  import { normalizeScrollGutter, normalizeStatusBar, normalizeToolBackground } from '../tuiDisplayPrefs.js';
37
39
  import { SubagentActivityStore } from './subagents.js';
@@ -45,6 +47,7 @@ import { getHostCommandTrees } from './command-trees.js';
45
47
  import { getHostSettingsSections, getLocalSettingsSectionsHost } from './settings-sections.js';
46
48
  import { getHostSceneRuntime } from './scenes.js';
47
49
  import { getHostRenderers } from './renderers.js';
50
+ import { getHostThemes } from './themes.js';
48
51
  import { getHostMessageObserver } from './message-observer.js';
49
52
  import { dispatchTuiDecision, dispatchTuiNotification, normalizeCancelDecision } from './extension-events.js';
50
53
  import { installDecisionGuard } from './decision-guard.js';
@@ -131,6 +134,119 @@ function normalizeRewindPromptDecision(result, warn) {
131
134
  /** Toast-bound plugin text (veto reasons, handled notices, rewind summaries)
132
135
  * is render-path data too: same sanitization, toast-width cap. */
133
136
  const NOTICE_CELLS = 200;
137
+ const PERMISSION_PRESET_CUSTOM = 'custom';
138
+ const PERMISSION_PRESET_NAME_CELLS = 120;
139
+ const PERMISSION_PRESET_DESCRIPTION_CELLS = 400;
140
+ function isRecord(value) {
141
+ return value !== null && typeof value === 'object';
142
+ }
143
+ function legacyPermissionPresetOptions() {
144
+ return [
145
+ {
146
+ value: 'read-only',
147
+ name: t('permission-preset-readonly'),
148
+ description: t('permission-preset-readonly-desc'),
149
+ },
150
+ {
151
+ value: 'workspace-write',
152
+ name: t('permission-preset-workspace-write'),
153
+ description: t('permission-preset-workspace-write-desc'),
154
+ },
155
+ {
156
+ value: 'danger-full-access',
157
+ name: t('permission-preset-full-access'),
158
+ description: t('permission-preset-full-access-desc'),
159
+ },
160
+ ];
161
+ }
162
+ function legacyPermissionPresetSnapshot(sandbox) {
163
+ const options = legacyPermissionPresetOptions();
164
+ const currentOption = sandbox === undefined ? undefined : options.find(option => option.value === sandbox);
165
+ return {
166
+ availability: 'legacy',
167
+ options,
168
+ ...(currentOption === undefined
169
+ ? {}
170
+ : { current: { ...currentOption, kind: 'preset' } }),
171
+ };
172
+ }
173
+ function unavailablePermissionPresetSnapshot() {
174
+ return { availability: 'unavailable', options: [] };
175
+ }
176
+ function normalizePermissionPresetOption(value) {
177
+ if (!isRecord(value) || typeof value.value !== 'string' || typeof value.name !== 'string')
178
+ return undefined;
179
+ const name = cleanRenderText(value.name, PERMISSION_PRESET_NAME_CELLS);
180
+ if (name === '')
181
+ return undefined;
182
+ if (value.description !== undefined && typeof value.description !== 'string')
183
+ return undefined;
184
+ const description = value.description === undefined
185
+ ? undefined
186
+ : cleanRenderText(value.description, PERMISSION_PRESET_DESCRIPTION_CELLS);
187
+ if (value.description !== undefined && description === '')
188
+ return undefined;
189
+ return {
190
+ value: value.value,
191
+ name,
192
+ ...(description === undefined || description === '' ? {} : { description }),
193
+ };
194
+ }
195
+ function permissionPresetSnapshotFromService(service, events) {
196
+ if (!isRecord(service))
197
+ return unavailablePermissionPresetSnapshot();
198
+ const runtime = service;
199
+ try {
200
+ const capturedNames = runtime.names;
201
+ const current = runtime.current;
202
+ const optionOf = runtime.optionOf;
203
+ if (!Array.isArray(capturedNames) || capturedNames.length === 0)
204
+ return unavailablePermissionPresetSnapshot();
205
+ if (typeof current !== 'function' || typeof optionOf !== 'function')
206
+ return unavailablePermissionPresetSnapshot();
207
+ const names = [...capturedNames];
208
+ const seen = new Set();
209
+ for (const name of names) {
210
+ if (typeof name !== 'string' || name.trim() === '' || name === PERMISSION_PRESET_CUSTOM || seen.has(name)) {
211
+ return unavailablePermissionPresetSnapshot();
212
+ }
213
+ seen.add(name);
214
+ }
215
+ const options = [];
216
+ for (const name of names) {
217
+ const option = normalizePermissionPresetOption(optionOf(name));
218
+ if (option === undefined || option.value !== name)
219
+ return unavailablePermissionPresetSnapshot();
220
+ options.push({ ...option });
221
+ }
222
+ const currentValue = current(events);
223
+ if (typeof currentValue !== 'string' || (currentValue !== PERMISSION_PRESET_CUSTOM && !seen.has(currentValue))) {
224
+ return unavailablePermissionPresetSnapshot();
225
+ }
226
+ const currentOption = normalizePermissionPresetOption(optionOf(currentValue));
227
+ if (currentOption === undefined || currentOption.value !== currentValue)
228
+ return unavailablePermissionPresetSnapshot();
229
+ if (currentValue !== PERMISSION_PRESET_CUSTOM) {
230
+ const rosterOption = options.find(option => option.value === currentValue);
231
+ if (rosterOption === undefined
232
+ || rosterOption.name !== currentOption.name
233
+ || rosterOption.description !== currentOption.description) {
234
+ return unavailablePermissionPresetSnapshot();
235
+ }
236
+ }
237
+ return {
238
+ availability: 'runtime',
239
+ options,
240
+ current: {
241
+ ...currentOption,
242
+ kind: currentValue === PERMISSION_PRESET_CUSTOM ? 'custom' : 'preset',
243
+ },
244
+ };
245
+ }
246
+ catch {
247
+ return unavailablePermissionPresetSnapshot();
248
+ }
249
+ }
134
250
  /** `tui/rewind-done` return normalization: the first non-empty STRING is the
135
251
  * summary; anything else is not a decision. */
136
252
  function normalizeRewindDoneSummary(result, warn) {
@@ -173,6 +289,22 @@ const SUBAGENT_TOOL_NAMES = new Set([
173
289
  function isSubagentToolName(name) {
174
290
  return SUBAGENT_TOOL_NAMES.has(name.toLowerCase());
175
291
  }
292
+ /** Narrow an optional plugin event without importing its module augmentation. */
293
+ function todoPanelItems(data) {
294
+ if (typeof data !== 'object' || data === null)
295
+ return undefined;
296
+ const todos = data.todos;
297
+ if (!Array.isArray(todos))
298
+ return undefined;
299
+ const valid = todos.every(item => {
300
+ if (typeof item !== 'object' || item === null)
301
+ return false;
302
+ const candidate = item;
303
+ return typeof candidate.content === 'string' &&
304
+ (candidate.status === 'pending' || candidate.status === 'in_progress' || candidate.status === 'completed');
305
+ });
306
+ return valid ? todos : undefined;
307
+ }
176
308
  const ARGS_PREVIEW_LIMIT = 160;
177
309
  const RESULT_PREVIEW_LIMIT = 240;
178
310
  /** Local `!`-command output cap (mirrors the result preview limit). */
@@ -508,6 +640,7 @@ async function waitForTurnEnd(session, fromSeq, timeoutMs) {
508
640
  export function createChannel(ctx, initialAgent, options) {
509
641
  let agent = initialAgent;
510
642
  let currentHandle = options.handle;
643
+ const themeHost = getHostThemes(ctx.get('tuiThemes'));
511
644
  const subagentControl = {
512
645
  interrupt(agentId) {
513
646
  const child = subagentStore.get(agentId);
@@ -662,6 +795,25 @@ export function createChannel(ctx, initialAgent, options) {
662
795
  state.subagents = subagentStore.snapshot();
663
796
  syncSubagentRows(state.subagents);
664
797
  };
798
+ /** Drop the subagent row map (transcript wipe): the next event for a still
799
+ * live subagent re-creates its card as a fresh row instead of feeding a
800
+ * row object no transcript holds (update-only orphan). */
801
+ const dropSubagentRows = () => {
802
+ subagentStreamDirty = false;
803
+ subagentRowsByAgentId.clear();
804
+ };
805
+ /** Full subagent reset for a session swap: the row map, the queued task
806
+ * descriptions and the store itself are all scoped to the OLD agent's
807
+ * session. Leaked into the adopted one, they would keep dead subagents in
808
+ * the dashboard snapshot until new events overwrite it, grow the row map
809
+ * without bound across swaps, and hand a stale queued description to the
810
+ * new session's first card. */
811
+ const resetSubagentProjection = () => {
812
+ dropSubagentRows();
813
+ pendingTaskDescriptions.length = 0;
814
+ subagentStore.reset();
815
+ state.subagents = [];
816
+ };
665
817
  // foldRows incremental cursor (see foldRows): rows only append past the
666
818
  // fold line, so each pass touches only newly-eligible rows.
667
819
  const foldCursor = { rows: null, index: 0 };
@@ -906,6 +1058,7 @@ export function createChannel(ctx, initialAgent, options) {
906
1058
  toolCards.clear();
907
1059
  nextRowId = 0;
908
1060
  state.rows.length = 0;
1061
+ resetSubagentProjection();
909
1062
  // Goal/todo/title are session-scoped; the replay re-derives them for
910
1063
  // the session being entered (or leaves them empty).
911
1064
  state.todos = [];
@@ -958,6 +1111,11 @@ export function createChannel(ctx, initialAgent, options) {
958
1111
  touchSession(childId);
959
1112
  state.emit();
960
1113
  void oldHandle?.dispose().catch(() => { });
1114
+ // The staged-image map is session-scoped (the same contract the
1115
+ // resumeTo/newSession tails enforce): tokens typed against the rewound
1116
+ // conversation must not ride into the fork's next send, and the epoch
1117
+ // bump fences saves still in flight for the old session.
1118
+ clearStagedImages();
961
1119
  return sourceSessionId;
962
1120
  };
963
1121
  /** Monotonic token: only the latest `interruptAndDeliver` re-queues, so a
@@ -1176,7 +1334,7 @@ export function createChannel(ctx, initialAgent, options) {
1176
1334
  }
1177
1335
  // `undefined` = not registered; a handler error surfaces as its
1178
1336
  // message so the user sees why the command failed.
1179
- return execution?.result.text ?? '';
1337
+ return execution === undefined ? undefined : execution.result.text ?? '';
1180
1338
  }
1181
1339
  catch (error) {
1182
1340
  return error instanceof Error ? error.message : String(error);
@@ -1389,6 +1547,39 @@ export function createChannel(ctx, initialAgent, options) {
1389
1547
  state.emit();
1390
1548
  }).catch(() => { });
1391
1549
  };
1550
+ // --- Manual-compaction lifecycle ---------------------------------------
1551
+ // The in-flight /compact transaction: its abort hook plus the settled
1552
+ // promise. Every path that replaces `agent` (rewind / rewind-node /
1553
+ // resume / new / model switch) must cancel and await it BEFORE snapshot-
1554
+ // ting the session. Without this, a slow summarizer keeps running against
1555
+ // the OLD session across the switch and can commit its replacement
1556
+ // checkpoint AFTER the fork snapshot — silently swapping the history the
1557
+ // user believed intact ("compaction failed → /model → context lost").
1558
+ let manualCompaction;
1559
+ /** Compactions cancelled by settleManualCompaction: their rejection is expected. */
1560
+ const cancelledCompactions = new WeakSet();
1561
+ /**
1562
+ * Cancel an in-flight manual compaction and wait for it to settle.
1563
+ * Aborting tears the summarizer stream down; dsh-compaction then closes
1564
+ * the transaction with an error end marker and rejects compactNow with
1565
+ * the `cancelled` class — no checkpoint is committed, the surface stays
1566
+ * whole. The settle race is capped so a stuck stream can never wedge the
1567
+ * session switch itself.
1568
+ */
1569
+ const settleManualCompaction = async () => {
1570
+ const active = manualCompaction;
1571
+ if (active === undefined)
1572
+ return;
1573
+ manualCompaction = undefined;
1574
+ cancelledCompactions.add(active.controller);
1575
+ active.controller.abort(new Error('session switch'));
1576
+ state.notify(t('compact-cancelled-switch'), { color: 'warning', timeoutMs: 4000 });
1577
+ await Promise.race([
1578
+ active.settled,
1579
+ new Promise(resolve => { setTimeout(resolve, 3000); }),
1580
+ ]);
1581
+ };
1582
+ let agentBindingGeneration = 0;
1392
1583
  const state = {
1393
1584
  effortLevels: undefined,
1394
1585
  version: 0,
@@ -1407,6 +1598,7 @@ export function createChannel(ctx, initialAgent, options) {
1407
1598
  return ns?.value?.recapOnOpen !== false;
1408
1599
  },
1409
1600
  agentId: agent.id,
1601
+ agentBindingGeneration: 0,
1410
1602
  model: options.model,
1411
1603
  provider: options.provider,
1412
1604
  tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, peak: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, idle: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } },
@@ -1442,6 +1634,8 @@ export function createChannel(ctx, initialAgent, options) {
1442
1634
  scrollGutter: normalizeScrollGutter(options.scrollGutter),
1443
1635
  foldTerminalCommand: options.foldTerminalCommand === true,
1444
1636
  promptSessionLabel: options.promptSessionLabel === true,
1637
+ expandEditor: options.expandEditor !== false,
1638
+ smoothStreaming: options.smoothStreaming !== false,
1445
1639
  statusBar: normalizeStatusBar(options.statusBar),
1446
1640
  whale: options.whale !== false,
1447
1641
  minimal: options.minimal === true,
@@ -1484,21 +1678,34 @@ export function createChannel(ctx, initialAgent, options) {
1484
1678
  ];
1485
1679
  }
1486
1680
  if (path.length === 1 && path[0] === 'theme') {
1681
+ const themeEntries = listThemeCatalog(themeHost);
1487
1682
  return [
1488
1683
  { name: 'status', description: 'Show the current theme', descriptionKey: 'sugg-status-desc' },
1489
1684
  { name: AUTO_THEME_NAME, description: 'Follow the terminal background', descriptionKey: 'sugg-theme-auto-desc' },
1490
- ...THEME_NAMES.map((name) => ({
1491
- name,
1492
- description: `Built-in theme ${name}`,
1493
- descriptionKey: 'sugg-theme-builtin-desc',
1494
- })),
1495
- ...listCustomThemes()
1496
- .filter((spec) => spec.name !== AUTO_THEME_NAME)
1497
- .map((spec) => ({
1498
- name: spec.name,
1499
- description: `User theme (${spec.base} base)`,
1500
- descriptionKey: 'sugg-theme-user-desc',
1501
- })),
1685
+ ...themeEntries
1686
+ .filter((entry) => entry.name !== AUTO_THEME_NAME)
1687
+ .map((entry) => {
1688
+ const base = entry.base ?? 'dark';
1689
+ if (entry.source === 'builtin') {
1690
+ return {
1691
+ name: entry.name,
1692
+ description: `Built-in theme ${entry.name}`,
1693
+ descriptionKey: 'sugg-theme-builtin-desc',
1694
+ };
1695
+ }
1696
+ if (entry.source === 'runtime') {
1697
+ return {
1698
+ name: entry.name,
1699
+ description: `Plugin theme (${base} base)`,
1700
+ descriptionKey: 'sugg-theme-plugin-desc',
1701
+ };
1702
+ }
1703
+ return {
1704
+ name: entry.name,
1705
+ description: `User theme (${base} base)`,
1706
+ descriptionKey: 'sugg-theme-user-desc',
1707
+ };
1708
+ }),
1502
1709
  ];
1503
1710
  }
1504
1711
  if (path.length === 1 && path[0] === 'color') {
@@ -1573,13 +1780,23 @@ export function createChannel(ctx, initialAgent, options) {
1573
1780
  ];
1574
1781
  }
1575
1782
  if (path.length === 1 && path[0] === 'permission') {
1576
- // Sandbox-preset vocabulary of the `/permission` picker — Tab
1577
- // completes it for keyboard users, Enter still dispatches.
1578
- return [
1579
- { name: 'read-only', description: 'Read-only session: no file writes, no commands', descriptionKey: 'permission-preset-readonly-desc' },
1580
- { name: 'workspace-write', description: 'Read/write inside the workspace; writes need a prior read', descriptionKey: 'permission-preset-workspace-write-desc' },
1581
- { name: 'danger-full-access', description: 'Unrestricted access, no approvals', descriptionKey: 'permission-preset-full-access-desc' },
1582
- ];
1783
+ const snapshot = state.permissionPresets();
1784
+ return snapshot.options
1785
+ .filter(option => isCommandCompletionToken(option.value))
1786
+ .map(option => ({
1787
+ name: option.value,
1788
+ description: option.description ?? option.name,
1789
+ ...(option.value === 'read-only'
1790
+ ? { descriptionKey: 'permission-preset-readonly-desc' }
1791
+ : option.value === 'workspace-write'
1792
+ ? { descriptionKey: 'permission-preset-workspace-write-desc' }
1793
+ : option.value === 'danger-full-access'
1794
+ ? { descriptionKey: 'permission-preset-full-access-desc' }
1795
+ : {}),
1796
+ ...(snapshot.current?.kind === 'preset' && snapshot.current.value === option.value
1797
+ ? { tag: 'current' }
1798
+ : {}),
1799
+ }));
1583
1800
  }
1584
1801
  if (path.length === 1 && path[0] === 'plan') {
1585
1802
  return [
@@ -1833,6 +2050,10 @@ export function createChannel(ctx, initialAgent, options) {
1833
2050
  return null;
1834
2051
  }
1835
2052
  }
2053
+ // An in-flight manual compaction must not straddle the fork: cancel it
2054
+ // and wait, or its checkpoint could commit right after the seed snapshot
2055
+ // below and quietly replace history the rewind was meant to preserve.
2056
+ await settleManualCompaction();
1836
2057
  const childId = SessionId(randomUUID());
1837
2058
  // DSH event order is `turn/start → user/message → … → turn/end`, so a
1838
2059
  // message's own seq always sits inside its turn — forking there would
@@ -2363,6 +2584,10 @@ export function createChannel(ctx, initialAgent, options) {
2363
2584
  state.notify(t('rewind-unavailable'), { color: 'error' });
2364
2585
  return null;
2365
2586
  }
2587
+ // An in-flight manual compaction must not straddle the snapshot below
2588
+ // (live branch) nor keep summarizing the current session while the
2589
+ // rewind targets another — cancel and await it first.
2590
+ await settleManualCompaction();
2366
2591
  // Pin the entry-time session: the awaits below (log load, preset
2367
2592
  // compose, agent create) are windows in which a queued switch
2368
2593
  // (/new, /resume, /model) can swap `agent` — the mutation queue only
@@ -2543,6 +2768,10 @@ export function createChannel(ctx, initialAgent, options) {
2543
2768
  state.notify(t('fork-while-working'), { color: 'warning' });
2544
2769
  return false;
2545
2770
  }
2771
+ // An in-flight manual compaction must not straddle the fork snapshot:
2772
+ // cancel and await it, or its checkpoint could commit right after the
2773
+ // seed copy below and quietly replace history the fork preserved.
2774
+ await settleManualCompaction();
2546
2775
  const source = agent.session;
2547
2776
  const childId = SessionId(randomUUID());
2548
2777
  // No boundary: the whole (turn-closed) log. Slice via sessions.fork for
@@ -2641,6 +2870,15 @@ export function createChannel(ctx, initialAgent, options) {
2641
2870
  // untouched.
2642
2871
  if (await sessionSwitchVetoed('resume', sessionId))
2643
2872
  return { ok: false, reason: 'cancelled' };
2873
+ // The live session's in-flight manual compaction must not keep running
2874
+ // (and commit its checkpoint) once we leave it for the target — cancel
2875
+ // and await it before any target read.
2876
+ await settleManualCompaction();
2877
+ // Identity pin for the rival-swap guard below: everything between here
2878
+ // and the adoption can await (veto, preset, route, agents.resume), and
2879
+ // an interrupt-queued /new or a second /resume may commit a different
2880
+ // swap in that window.
2881
+ const entrySession = agent.session;
2644
2882
  let handle;
2645
2883
  // Compat boundary: register vouched-for legacy event types (e.g.
2646
2884
  // activity/status from pre-#143 logs) in every reachable dsh-session
@@ -2692,6 +2930,17 @@ export function createChannel(ctx, initialAgent, options) {
2692
2930
  catch (error) {
2693
2931
  state.notify(t('resume-attach-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'warning', timeoutMs: 8000 });
2694
2932
  }
2933
+ // Rival-swap guard (rewindToNode's entrySession check, applied to the
2934
+ // resume path): the awaits above can straddle another session swap
2935
+ // committing first, and adopting now would stomp the newer session's
2936
+ // live transcript with this target's replay. Free the just-created
2937
+ // handle and bail — the live session stays exactly as the rival left
2938
+ // it, and the persisted target simply stays in /resume.
2939
+ if (agent.session !== entrySession) {
2940
+ void handle.dispose().catch(() => { });
2941
+ state.notify(t('resume-session-changed'), { color: 'error' });
2942
+ return { ok: false, reason: 'failed', error: 'live session changed during resume' };
2943
+ }
2695
2944
  // Replay the persisted history into a fresh transcript (same reset as
2696
2945
  // rewindTo, plus the context window which the replay re-derives).
2697
2946
  streaming = undefined;
@@ -2703,6 +2952,7 @@ export function createChannel(ctx, initialAgent, options) {
2703
2952
  toolCards.clear();
2704
2953
  nextRowId = 0;
2705
2954
  state.rows.length = 0;
2955
+ resetSubagentProjection();
2706
2956
  // Goal/todo/title are session-scoped; the replay re-derives them for
2707
2957
  // the session being entered (or leaves them empty).
2708
2958
  state.todos = [];
@@ -2805,12 +3055,20 @@ export function createChannel(ctx, initialAgent, options) {
2805
3055
  // happened yet — the session id below is not even allocated.
2806
3056
  if (await sessionSwitchVetoed('new'))
2807
3057
  return false;
3058
+ // Leaving the live session: its in-flight manual compaction must not
3059
+ // keep summarizing (and later commit a checkpoint the user believes
3060
+ // cancelled) — cancel and await it first.
3061
+ await settleManualCompaction();
2808
3062
  const sessionId = SessionId(randomUUID());
2809
3063
  let handle;
2810
3064
  // A fresh session composes the caller's DEFAULT preset: the cordis.yml
2811
3065
  // `preset` key wins over the persisted `/preset` choice, which wins
2812
3066
  // over the roster default (same precedence as activityFrames).
2813
- const newComposed = await composePreset(ctx, options.configuredPreset ?? readPresetPref());
3067
+ const presetPref = options.configuredPreset === undefined ? readPresetPref() : undefined;
3068
+ const newComposed = await composePreset(ctx, options.configuredPreset ?? presetPref);
3069
+ if (!migratePresetPref(presetPref, newComposed.agentPreset)) {
3070
+ state.notify(t('preset-switched-pref-failed', { id: newComposed.agentPreset ?? presetPref ?? 'unknown' }), { color: 'warning' });
3071
+ }
2814
3072
  // Same precedence for the route (issues #14/#30/#67): the pair resolves
2815
3073
  // atomically — a complete cordis.yml route wins whole, else the
2816
3074
  // persisted `/model` choice (a switch earlier in this run just wrote
@@ -2872,6 +3130,7 @@ export function createChannel(ctx, initialAgent, options) {
2872
3130
  lastTextDelta.clear();
2873
3131
  nextRowId = 0;
2874
3132
  state.rows.length = 0;
3133
+ resetSubagentProjection();
2875
3134
  // Goal/todo/title are session-scoped; the replay re-derives them for
2876
3135
  // the session being entered (or leaves them empty).
2877
3136
  state.todos = [];
@@ -3008,6 +3267,11 @@ export function createChannel(ctx, initialAgent, options) {
3008
3267
  }
3009
3268
  let seed;
3010
3269
  try {
3270
+ // An in-flight manual compaction must not straddle the fork: cancel
3271
+ // it first, or its checkpoint can commit right after this snapshot —
3272
+ // the model-switched child would start from the summary alone while
3273
+ // the user believes the full history carried over ("context lost").
3274
+ await settleManualCompaction();
3011
3275
  // No boundary = fork the whole log (continue the conversation).
3012
3276
  seed = sessions.fork(agent.session).events;
3013
3277
  }
@@ -3057,6 +3321,7 @@ export function createChannel(ctx, initialAgent, options) {
3057
3321
  toolCards.clear();
3058
3322
  nextRowId = 0;
3059
3323
  state.rows.length = 0;
3324
+ resetSubagentProjection();
3060
3325
  // Goal/todo/title are session-scoped; the replay re-derives them for
3061
3326
  // the session being entered (or leaves them empty).
3062
3327
  state.todos = [];
@@ -3111,7 +3376,7 @@ export function createChannel(ctx, initialAgent, options) {
3111
3376
  currentHandle = handle;
3112
3377
  bindAgent();
3113
3378
  // Model-switch quip rides the fresh tracker (pi parity).
3114
- activityTracker.onModelSwitch(model);
3379
+ updateWorkingActivity('model switch', () => activityTracker.onModelSwitch(model));
3115
3380
  refreshCommandList();
3116
3381
  void refreshLoadedContext();
3117
3382
  void refreshSkillCommands();
@@ -3119,6 +3384,9 @@ export function createChannel(ctx, initialAgent, options) {
3119
3384
  touchSession(childId);
3120
3385
  state.emit();
3121
3386
  void oldHandle?.dispose().catch(() => { });
3387
+ // Staged image tokens were typed against the pre-switch conversation;
3388
+ // resumeTo/newSession already drop theirs on the swap — same contract.
3389
+ clearStagedImages();
3122
3390
  // Persist the choice so the next boot and `/new` start on it (same
3123
3391
  // contract as /preset and /effort; issues #14/#30). A failed
3124
3392
  // write keeps the live switch but warns it will not survive a restart.
@@ -3138,6 +3406,11 @@ export function createChannel(ctx, initialAgent, options) {
3138
3406
  streaming = undefined;
3139
3407
  reasoning = undefined;
3140
3408
  toolCards.clear();
3409
+ // In-flight subagents keep streaming after the wipe; clearing the row
3410
+ // map lets their next event re-create the card as a fresh row instead
3411
+ // of feeding a row object no transcript holds (the store keeps live
3412
+ // tracking for the dashboard — same session, still running).
3413
+ dropSubagentRows();
3141
3414
  state.activeToolCount = 0;
3142
3415
  state.responseChars = 0;
3143
3416
  state.rows.push({
@@ -3214,6 +3487,18 @@ export function createChannel(ctx, initialAgent, options) {
3214
3487
  state.promptSessionLabel = enabled;
3215
3488
  state.emit();
3216
3489
  },
3490
+ setExpandEditor(enabled) {
3491
+ if (enabled === state.expandEditor)
3492
+ return;
3493
+ state.expandEditor = enabled;
3494
+ state.emit();
3495
+ },
3496
+ setSmoothStreaming(enabled) {
3497
+ if (enabled === state.smoothStreaming)
3498
+ return;
3499
+ state.smoothStreaming = enabled;
3500
+ state.emit();
3501
+ },
3217
3502
  setStatusBar(config) {
3218
3503
  const next = normalizeStatusBar({ ...state.statusBar, ...config });
3219
3504
  const changed = Object.keys(next).some(key => next[key] !== state.statusBar[key]);
@@ -3255,6 +3540,18 @@ export function createChannel(ctx, initialAgent, options) {
3255
3540
  state.notify(t('activity-indicator-switched', { name }));
3256
3541
  return true;
3257
3542
  },
3543
+ permissionPresets() {
3544
+ let service;
3545
+ try {
3546
+ service = ctx.get('permissionPresets');
3547
+ }
3548
+ catch {
3549
+ return unavailablePermissionPresetSnapshot();
3550
+ }
3551
+ if (service === undefined)
3552
+ return legacyPermissionPresetSnapshot(state.mode.sandbox);
3553
+ return permissionPresetSnapshotFromService(service, agent.session.events);
3554
+ },
3258
3555
  async listPresets() {
3259
3556
  const presets = rosterOf(ctx);
3260
3557
  if (presets === undefined)
@@ -3285,17 +3582,21 @@ export function createChannel(ctx, initialAgent, options) {
3285
3582
  }
3286
3583
  let target;
3287
3584
  try {
3288
- target = await presets.resolve(presetId);
3585
+ target = await resolveCompatiblePreset(presets, presetId);
3289
3586
  }
3290
3587
  catch (error) {
3291
3588
  state.notify(t('preset-not-found', { id: presetId, err: error instanceof Error ? error.message : String(error) }), { color: 'error', timeoutMs: 8000 });
3292
3589
  return false;
3293
3590
  }
3294
3591
  if (target.broken !== undefined) {
3295
- state.notify(t('preset-load-failed', { id: presetId, broken: target.broken }), { color: 'error', timeoutMs: 8000 });
3592
+ state.notify(t('preset-load-failed', { id: target.id, broken: target.broken }), { color: 'error', timeoutMs: 8000 });
3296
3593
  return false;
3297
3594
  }
3298
3595
  if (target.id === state.agentPreset) {
3596
+ if (!migratePresetPref(presetId, target.id)) {
3597
+ state.notify(t('preset-switched-pref-failed', { id: target.id }), { color: 'warning' });
3598
+ return true;
3599
+ }
3299
3600
  state.notify(t('preset-already-current', { id: target.id }), { color: 'success' });
3300
3601
  return true;
3301
3602
  }
@@ -3436,6 +3737,14 @@ export function createChannel(ctx, initialAgent, options) {
3436
3737
  async writeCredential(ref, value) {
3437
3738
  if (!credentials)
3438
3739
  throw new Error('credentials service unavailable');
3740
+ // Second layer of the secret-ref reservation guard: the
3741
+ // registration layer already rejects plugin sections with
3742
+ // host-owned refs, but this seam must not trust it — a stale
3743
+ // section (registered before the guard) or a direct call must not
3744
+ // reach the shared credentials. The host's own main-credential
3745
+ // writes go through providerSetup().writeCredential instead.
3746
+ if (isReservedCredentialRef(ref))
3747
+ throw new Error(t('settings-secret-ref-reserved', { ref }));
3439
3748
  await credentials.set(ref, value);
3440
3749
  },
3441
3750
  };
@@ -3751,18 +4060,43 @@ export function createChannel(ctx, initialAgent, options) {
3751
4060
  state.notify(t('compact-while-working'), { color: 'warning' });
3752
4061
  return;
3753
4062
  }
3754
- const signal = new AbortController().signal;
4063
+ const controller = new AbortController();
3755
4064
  state.notify(t('compact-working'));
3756
- void compactService
3757
- .compactNow(agent, signal)
3758
- .then((result) => {
3759
- state.notify(result ? t('compact-done') : t('compact-nothing'));
3760
- // Compaction quip rides the next thinking rotation (pi parity).
3761
- if (result)
3762
- activityTracker.onCompact('done');
3763
- })
3764
- .catch((error) => {
3765
- state.notify(t('compact-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error', timeoutMs: 8000 });
4065
+ // Register the in-flight transaction so any agent-replacing path
4066
+ // (rewind/resume/new/model switch) can cancel it before snapshotting
4067
+ // the session — see settleManualCompaction. `settled` never rejects:
4068
+ // every branch lands in a notification.
4069
+ const settled = (async () => {
4070
+ try {
4071
+ const result = await compactService.compactNow(agent, controller.signal);
4072
+ state.notify(result ? t('compact-done') : t('compact-nothing'));
4073
+ // Compaction quip rides the next thinking rotation (pi parity).
4074
+ if (result)
4075
+ updateWorkingActivity('compaction', () => activityTracker.onCompact('done'));
4076
+ }
4077
+ catch (error) {
4078
+ // ManualCompactionError('persistence'): the replacement checkpoint
4079
+ // is ALREADY committed — only the durability flush failed. The
4080
+ // surface is now the summary, so a plain "failed" toast here sent
4081
+ // users to /model expecting full history and finding only the
4082
+ // summary ("context lost"). Distinguish it, structurally — the
4083
+ // TUI must not import the error class across the adapter seam.
4084
+ if (error.code === 'persistence') {
4085
+ state.notify(t('compact-flush-failed'), { color: 'warning', timeoutMs: 12000 });
4086
+ return;
4087
+ }
4088
+ // A switch-initiated abort rejects compactNow with the abort reason;
4089
+ // the cancellation was already toasted above — a second generic
4090
+ // "failed" toast for the same, expected rejection would mislead.
4091
+ if (cancelledCompactions.has(controller))
4092
+ return;
4093
+ state.notify(t('compact-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error', timeoutMs: 8000 });
4094
+ }
4095
+ })();
4096
+ manualCompaction = { controller, settled };
4097
+ void settled.finally(() => {
4098
+ if (manualCompaction?.controller === controller)
4099
+ manualCompaction = undefined;
3766
4100
  });
3767
4101
  })().catch((error) => {
3768
4102
  // Sync throws from compactNow (e.g. runMaintenance rejecting a
@@ -4039,19 +4373,21 @@ export function createChannel(ctx, initialAgent, options) {
4039
4373
  ...(renderedInstructions?.truncated ?? []).map(file => file.displayPath),
4040
4374
  ]);
4041
4375
  files.push(...[...instructionPaths].map(displayPath => ({ displayPath })));
4042
- // The skills registry is host-plane but scope-layered: preset rows
4043
- // (skill-filesystem) register into the preset's layer, so the catalog
4044
- // must be read through the agent's scope chain (serviceForAgent falls
4045
- // back to the host context when no roster is mounted).
4046
- const skillsService = serviceForAgent(ctx, target, 'skills');
4047
- if (skillsService !== undefined) {
4048
- const catalog = await skillsService.list({});
4376
+ // A registry entry reaches the model only through dsh-tool-skill's
4377
+ // catalog, which is gated on that exact tool being visible to the agent.
4378
+ const skillsRegistry = tools.some(tool => tool.name === 'skill')
4379
+ ? skillRegistryFor(target)
4380
+ : undefined;
4381
+ if (skillsRegistry !== undefined) {
4382
+ const observation = await skillsRegistry.snapshot(skillViewOptions(target));
4049
4383
  if (target !== agent)
4050
4384
  return;
4051
- skills.push(...catalog.map(skill => ({
4052
- name: skill.name,
4053
- description: skill.description,
4054
- })));
4385
+ if (observation.complete) {
4386
+ skills.push(...observation.skills.filter(isModelInvocable).map(skill => ({
4387
+ name: skill.name,
4388
+ description: skill.description,
4389
+ })));
4390
+ }
4055
4391
  }
4056
4392
  }
4057
4393
  catch (error) {
@@ -4065,7 +4401,7 @@ export function createChannel(ctx, initialAgent, options) {
4065
4401
  * Rebuild the merged slash-command list: built-in locals, then registry
4066
4402
  * commands (plan/goal/…), then user-invocable skills from the DSH skill
4067
4403
  * registry (issue #86 — filesystem-discovered skills must appear in the
4068
- * `/` menu and Tab completion, like /audit and /review). Skill entries
4404
+ * `/` menu and Tab completion, like /my-skill). Skill entries
4069
4405
  * are completion-only: dispatch falls through to the model as plain text,
4070
4406
  * where dsh-tool-skill's pre-step hook injects the skill body — the same
4071
4407
  * path a hand-typed `/skill-name` takes. Registry and skill reads are
@@ -4538,7 +4874,7 @@ ${output}
4538
4874
  streaming = existing;
4539
4875
  return existing;
4540
4876
  }
4541
- streaming = { id: nextRowId, kind: 'assistant', text: '', streaming: true, ...seq !== undefined ? { seq } : {} };
4877
+ streaming = { id: nextRowId, kind: 'assistant', text: '', streaming: true, fresh: true, ...seq !== undefined ? { seq } : {} };
4542
4878
  nextRowId += 1;
4543
4879
  state.rows.push(streaming);
4544
4880
  return streaming;
@@ -4877,6 +5213,11 @@ ${output}
4877
5213
  if (text)
4878
5214
  row.text = text;
4879
5215
  row.streaming = false;
5216
+ // Live settles keep the smooth-reveal cursor alive (a one-shot
5217
+ // non-streaming delivery still paints as a flow); replayed
5218
+ // settles must not — the transcript would typewrite on open.
5219
+ if (!replaying && text)
5220
+ row.fresh = true;
4880
5221
  }
4881
5222
  streaming = undefined;
4882
5223
  if (reasoning !== undefined) {
@@ -5000,6 +5341,9 @@ ${output}
5000
5341
  kind: 'tool',
5001
5342
  text: '',
5002
5343
  seq: event.seq,
5344
+ // Smooth-reveal participation flag: live cards animate their body
5345
+ // in; replayed cards (resume/rewind) paint complete.
5346
+ fresh: !replaying,
5003
5347
  tool: {
5004
5348
  callId: event.data.callId,
5005
5349
  name: event.data.name,
@@ -5153,21 +5497,31 @@ ${output}
5153
5497
  case 'session/title':
5154
5498
  state.sessionTitle = event.data.title;
5155
5499
  break;
5156
- case 'todo/write':
5157
- // Whole-list snapshot — latest write wins; log-only UI state.
5158
- state.todos = event.data.todos;
5159
- break;
5160
5500
  default:
5501
+ // dsh-tool-todo owns this optional module augmentation in alpha.2.
5502
+ // Match by name so the TUI remains loadable without that plugin.
5503
+ if (event.type === 'todo/write') {
5504
+ const todos = todoPanelItems(event.data);
5505
+ if (todos !== undefined)
5506
+ state.todos = todos;
5507
+ break;
5508
+ }
5161
5509
  // Logged preset switch (blank sessions only, issue #8): a transcript
5162
5510
  // marker so a replayed log shows which composition produced the
5163
5511
  // turns after it. Not in dsh-session's typed union — matched here by
5164
5512
  // name, like the other plugin-defined events above.
5165
5513
  if (event.type === 'agent-preset/selected') {
5166
5514
  const data = event.data;
5515
+ const recordedPreset = typeof data.agentPreset === 'string' ? data.agentPreset : undefined;
5516
+ const renamedOfficialPreset = (recordedPreset === 'code' && state.agentPreset === 'ptc') ||
5517
+ (recordedPreset === 'ptc' && state.agentPreset === 'code');
5518
+ const preset = renamedOfficialPreset && state.agentPreset !== undefined
5519
+ ? state.agentPreset
5520
+ : recordedPreset ?? 'unknown';
5167
5521
  state.rows.push({
5168
5522
  id: nextRowId,
5169
5523
  kind: 'notice',
5170
- text: t('agent-preset-switched', { preset: data.agentPreset ?? 'unknown' }),
5524
+ text: t('agent-preset-switched', { preset }),
5171
5525
  });
5172
5526
  nextRowId += 1;
5173
5527
  break;
@@ -5264,17 +5618,60 @@ ${output}
5264
5618
  state.workingActivity = rendered;
5265
5619
  return rendered;
5266
5620
  };
5621
+ // Working Activity is an optional presentation sidecar. A malformed durable
5622
+ // event must never let it abort the authoritative channel projection (Cordis
5623
+ // contains the listener throw, but the rest of THIS callback would otherwise
5624
+ // be skipped — including turn/end and inbox retirement).
5625
+ let activityFailureReported = false;
5626
+ const updateWorkingActivity = (source, update) => {
5627
+ try {
5628
+ update?.();
5629
+ return renderWorkingActivity();
5630
+ }
5631
+ catch (error) {
5632
+ if (!activityFailureReported) {
5633
+ activityFailureReported = true;
5634
+ const detail = error instanceof Error ? error.message : String(error);
5635
+ ctx.logger.warn(`dsh-tui: working-activity ignored ${source} after a projection error: ${detail}`);
5636
+ }
5637
+ return undefined;
5638
+ }
5639
+ };
5640
+ /**
5641
+ * Release volatile UI gates when the bound driver is definitively quiescent
5642
+ * but its terminal session event did not reach this projection. This does not
5643
+ * invent a turn/end or any transcript fact; it only reconciles live controls
5644
+ * to the authoritative Agent status so Enter/Esc cannot remain latched.
5645
+ */
5646
+ const reconcileRetiredProjection = (status) => {
5647
+ if (!state.working)
5648
+ return;
5649
+ ctx.logger.warn(`dsh-tui: agent became ${status} while the channel still projected an open turn; releasing volatile UI gates`);
5650
+ cancelInFlight = false;
5651
+ state.cancelPending = false;
5652
+ state.working = false;
5653
+ state.activeToolCount = 0;
5654
+ settleStreaming();
5655
+ updateSpinnerMode();
5656
+ };
5267
5657
  const bindAgent = () => {
5658
+ agentBindingGeneration += 1;
5659
+ state.agentBindingGeneration = agentBindingGeneration;
5268
5660
  for (const dispose of agentSubscriptions)
5269
5661
  dispose();
5270
5662
  stopActivityTick();
5663
+ // Cancel state and deferred interrupt delivery belong to one bound agent.
5664
+ // A replacement must neither inherit the old latch nor receive its queued
5665
+ // microtask after the session identity changes.
5666
+ cancelInFlight = false;
5667
+ interruptSeq += 1;
5271
5668
  const prefs = activityPrefsSnapshot();
5272
5669
  activityTracker = new ActivityTracker(prefs.config, Date.now, prefs.customActions);
5273
- activityTracker.onAgentStatus(agent.status);
5274
- renderWorkingActivity();
5670
+ activityFailureReported = false;
5671
+ updateWorkingActivity('agent bind', () => activityTracker.onAgentStatus(agent.status));
5275
5672
  activityTickTimer = setInterval(() => {
5276
5673
  const previous = state.workingActivity;
5277
- const rendered = renderWorkingActivity();
5674
+ const rendered = updateWorkingActivity('activity tick');
5278
5675
  if (rendered === undefined)
5279
5676
  return;
5280
5677
  // Live phases deliberately wake at 500 ms even when the formatted line
@@ -5318,8 +5715,9 @@ ${output}
5318
5715
  if (subject !== agent)
5319
5716
  return;
5320
5717
  state.status = status;
5321
- activityTracker.onAgentStatus(status);
5322
- renderWorkingActivity();
5718
+ updateWorkingActivity(`agent/status:${status}`, () => activityTracker.onAgentStatus(status));
5719
+ if (status === 'idle')
5720
+ reconcileRetiredProjection('idle');
5323
5721
  state.emit();
5324
5722
  }),
5325
5723
  ctx.on('agent/disposed', ({ agent: subject }) => {
@@ -5327,6 +5725,7 @@ ${output}
5327
5725
  return;
5328
5726
  state.status = 'disposed';
5329
5727
  stopActivityTick();
5728
+ reconcileRetiredProjection('disposed');
5330
5729
  state.emit();
5331
5730
  }),
5332
5731
  // Pending delivery is driven by the agent inbox: a claimed message
@@ -5358,9 +5757,16 @@ ${output}
5358
5757
  };
5359
5758
  })(),
5360
5759
  ctx.on('session/event', (session, event) => {
5361
- // First check if this is a subagent session
5362
- const subagentId = subagentStore.getSubagentIdBySession(session);
5363
- if (subagentId) {
5760
+ // The currently bound main session always wins. SubagentActivityStore
5761
+ // intentionally retains Session-object mappings for completed cards;
5762
+ // if one of those sessions is later adopted/resumed as the main agent,
5763
+ // checking the stale child mapping first would swallow every main event
5764
+ // (including turn/end) and leave working/cancelPending latched forever.
5765
+ const isMainSession = session === agent.session;
5766
+ const subagentId = isMainSession
5767
+ ? undefined
5768
+ : subagentStore.getSubagentIdBySession(session);
5769
+ if (subagentId !== undefined) {
5364
5770
  subagentStore.onSessionEvent(subagentId, event);
5365
5771
  if (event.type === 'assistant/chunk') {
5366
5772
  // Token-rate path (100-300 events/s): the store append stays
@@ -5376,23 +5782,24 @@ ${output}
5376
5782
  }
5377
5783
  return;
5378
5784
  }
5379
- // Otherwise handle main agent session
5380
- if (session !== agent.session)
5785
+ // Otherwise handle the bound main-agent session.
5786
+ if (!isMainSession)
5381
5787
  return;
5382
5788
  // Observation broker (C-042): maps user/message + assistant/message
5383
5789
  // into grant-gated envelopes; every other event type is a no-op, and
5384
5790
  // publish never throws into this arm.
5385
5791
  messageObserver?.publish(session, event);
5386
- activityTracker.onSessionEvent(event);
5387
- // Interrupt quip: an aborted/interrupted turn ends the round; the
5388
- // comeback copy shows on the next thinking rotation (pi parity).
5389
- if (event.type === 'turn/end') {
5390
- const reason = event.data.reason;
5391
- if (reason?.kind === 'aborted' || reason?.kind === 'interrupted') {
5392
- activityTracker.onInterrupted();
5792
+ updateWorkingActivity(`session/event:${event.type}`, () => {
5793
+ activityTracker.onSessionEvent(event);
5794
+ // Interrupt quip: an aborted/interrupted turn ends the round; the
5795
+ // comeback copy shows on the next thinking rotation (pi parity).
5796
+ if (event.type === 'turn/end') {
5797
+ const reason = event.data.reason;
5798
+ if (reason?.kind === 'aborted' || reason?.kind === 'interrupted') {
5799
+ activityTracker.onInterrupted();
5800
+ }
5393
5801
  }
5394
- }
5395
- renderWorkingActivity();
5802
+ });
5396
5803
  // Mode-affecting atoms fold into the Shift+Tab mode indicator the
5397
5804
  // moment they land (whether appended by cycleMode or by hand).
5398
5805
  const eventType = event.type;
@@ -5519,7 +5926,7 @@ ${output}
5519
5926
  // is exactly what the column claims.
5520
5927
  noteBranch(agent.session.id, branch);
5521
5928
  // Feed the working line so git tools can show ` · git <branch>`.
5522
- activityTracker.onGitBranch(branch);
5929
+ updateWorkingActivity('git branch', () => activityTracker.onGitBranch(branch));
5523
5930
  state.emit();
5524
5931
  }
5525
5932
  })
@@ -5591,6 +5998,18 @@ export function sessionCwdMatches(stateCwd, headerCwd, caseInsensitive = process
5591
5998
  function estimateTokens(text) {
5592
5999
  return Math.ceil(text.length / 4);
5593
6000
  }
6001
+ /** Whether one stream chunk advances the first-token/decode boundary. */
6002
+ function isTokenDelta(chunk) {
6003
+ switch (chunk.type) {
6004
+ case 'text-delta':
6005
+ case 'reasoning-delta':
6006
+ return chunk.text !== '';
6007
+ case 'tool-call-delta':
6008
+ return chunk.argumentsDelta !== '' || chunk.name !== undefined;
6009
+ default:
6010
+ return false;
6011
+ }
6012
+ }
5594
6013
  /** Character payload of one token-bearing stream delta for the live fallback. */
5595
6014
  function tokenDeltaChars(chunk) {
5596
6015
  switch (chunk.type) {