mixdog 0.9.0 → 0.9.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 (240) hide show
  1. package/package.json +10 -3
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/background-task-meta-smoke.mjs +1 -1
  6. package/scripts/bench-run.mjs +262 -0
  7. package/scripts/compact-smoke.mjs +12 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  9. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  10. package/scripts/internal-comms-bench.mjs +727 -0
  11. package/scripts/internal-comms-smoke.mjs +75 -0
  12. package/scripts/lead-workflow-smoke.mjs +4 -4
  13. package/scripts/live-worker-smoke.mjs +9 -9
  14. package/scripts/output-style-bench.mjs +285 -0
  15. package/scripts/output-style-smoke.mjs +13 -10
  16. package/scripts/patch-replay.mjs +90 -0
  17. package/scripts/provider-stream-stall-test.mjs +276 -0
  18. package/scripts/provider-toolcall-test.mjs +599 -1
  19. package/scripts/routing-corpus.mjs +281 -0
  20. package/scripts/session-bench.mjs +1526 -0
  21. package/scripts/session-diag.mjs +595 -0
  22. package/scripts/session-ingest-smoke.mjs +2 -2
  23. package/scripts/task-bench.mjs +207 -0
  24. package/scripts/tool-failures.mjs +6 -6
  25. package/scripts/tool-smoke.mjs +306 -66
  26. package/scripts/toolcall-args-test.mjs +81 -0
  27. package/src/agents/debugger/AGENT.md +4 -4
  28. package/src/agents/heavy-worker/AGENT.md +4 -2
  29. package/src/agents/reviewer/AGENT.md +4 -4
  30. package/src/agents/worker/AGENT.md +4 -2
  31. package/src/app.mjs +10 -6
  32. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  33. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  34. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  35. package/src/headless-role.mjs +14 -14
  36. package/src/help.mjs +1 -0
  37. package/src/lib/mixdog-debug.cjs +0 -22
  38. package/src/lib/plugin-paths.cjs +1 -7
  39. package/src/lib/rules-builder.cjs +34 -56
  40. package/src/mixdog-session-runtime.mjs +710 -319
  41. package/src/output-styles/default.md +12 -7
  42. package/src/output-styles/minimal.md +25 -0
  43. package/src/output-styles/oneline.md +21 -0
  44. package/src/output-styles/simple.md +10 -9
  45. package/src/repl.mjs +12 -4
  46. package/src/rules/agent/00-common.md +7 -5
  47. package/src/rules/agent/30-explorer.md +7 -8
  48. package/src/rules/lead/01-general.md +3 -1
  49. package/src/rules/lead/lead-tool.md +7 -0
  50. package/src/rules/shared/01-tool.md +17 -12
  51. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  52. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  53. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  54. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  55. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  56. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  57. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  58. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  59. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  60. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  61. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  62. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  63. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  64. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  65. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +359 -106
  66. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +63 -51
  67. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  68. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  70. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  71. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  72. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +86 -30
  73. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +254 -280
  74. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +191 -50
  75. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  76. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  77. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  78. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +265 -1
  79. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  80. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  81. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  82. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  83. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  84. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  85. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  86. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  87. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +1 -1
  88. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -32
  89. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  90. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -44
  91. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  92. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  93. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  94. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  95. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  100. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  101. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  102. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  103. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  104. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  105. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  106. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  107. package/src/runtime/channels/backends/discord.mjs +99 -9
  108. package/src/runtime/channels/backends/telegram.mjs +501 -0
  109. package/src/runtime/channels/index.mjs +441 -1254
  110. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  111. package/src/runtime/channels/lib/config.mjs +54 -3
  112. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  113. package/src/runtime/channels/lib/executor.mjs +0 -3
  114. package/src/runtime/channels/lib/format.mjs +4 -2
  115. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  116. package/src/runtime/channels/lib/output-forwarder.mjs +77 -71
  117. package/src/runtime/channels/lib/runtime-paths.mjs +29 -6
  118. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  119. package/src/runtime/channels/lib/session-discovery.mjs +0 -4
  120. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  121. package/src/runtime/channels/lib/tool-format.mjs +1 -2
  122. package/src/runtime/channels/lib/transcript-discovery.mjs +20 -11
  123. package/src/runtime/channels/lib/webhook.mjs +59 -31
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  126. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  127. package/src/runtime/memory/index.mjs +187 -43
  128. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  129. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  130. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  131. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  132. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  133. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  134. package/src/runtime/memory/lib/memory-ops-policy.mjs +0 -1
  135. package/src/runtime/memory/lib/memory.mjs +101 -4
  136. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  137. package/src/runtime/memory/lib/runtime-fetcher.mjs +43 -18
  138. package/src/runtime/memory/lib/session-ingest.mjs +116 -7
  139. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  140. package/src/runtime/memory/tool-defs.mjs +6 -3
  141. package/src/runtime/search/index.mjs +2 -7
  142. package/src/runtime/search/lib/config.mjs +0 -4
  143. package/src/runtime/search/lib/state.mjs +1 -15
  144. package/src/runtime/search/lib/web-tools.mjs +0 -1
  145. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  146. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  147. package/src/runtime/shared/child-spawn-gate.mjs +0 -6
  148. package/src/runtime/shared/config.mjs +9 -0
  149. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  150. package/src/runtime/shared/schedules-store.mjs +21 -19
  151. package/src/runtime/shared/tool-surface.mjs +98 -13
  152. package/src/runtime/shared/transcript-writer.mjs +129 -0
  153. package/src/runtime/shared/update-checker.mjs +214 -0
  154. package/src/standalone/agent-tool.mjs +255 -109
  155. package/src/standalone/channel-admin.mjs +133 -40
  156. package/src/standalone/channel-worker.mjs +8 -291
  157. package/src/standalone/explore-tool.mjs +2 -2
  158. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  159. package/src/standalone/provider-admin.mjs +11 -0
  160. package/src/standalone/seeds.mjs +1 -11
  161. package/src/standalone/usage-dashboard.mjs +1 -1
  162. package/src/tui/App.jsx +2137 -750
  163. package/src/tui/components/ConfirmBar.jsx +47 -0
  164. package/src/tui/components/ContextPanel.jsx +5 -3
  165. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  166. package/src/tui/components/Markdown.jsx +22 -98
  167. package/src/tui/components/Message.jsx +14 -35
  168. package/src/tui/components/Picker.jsx +87 -12
  169. package/src/tui/components/PromptInput.jsx +146 -9
  170. package/src/tui/components/QueuedCommands.jsx +1 -1
  171. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  172. package/src/tui/components/Spinner.jsx +7 -7
  173. package/src/tui/components/StatusLine.jsx +40 -21
  174. package/src/tui/components/TextEntryPanel.jsx +51 -7
  175. package/src/tui/components/ToolExecution.jsx +177 -100
  176. package/src/tui/components/TurnDone.jsx +4 -4
  177. package/src/tui/components/UsagePanel.jsx +1 -1
  178. package/src/tui/components/tool-output-format.mjs +312 -40
  179. package/src/tui/components/tool-output-format.test.mjs +180 -1
  180. package/src/tui/display-width.mjs +69 -0
  181. package/src/tui/display-width.test.mjs +35 -0
  182. package/src/tui/dist/index.mjs +7324 -2393
  183. package/src/tui/engine.mjs +287 -126
  184. package/src/tui/index.jsx +117 -7
  185. package/src/tui/keyboard-protocol.mjs +42 -0
  186. package/src/tui/lib/voice-recorder.mjs +453 -0
  187. package/src/tui/markdown/format-token.mjs +354 -142
  188. package/src/tui/markdown/format-token.test.mjs +155 -17
  189. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  190. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  191. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  192. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  193. package/src/tui/markdown/table-layout.mjs +9 -9
  194. package/src/tui/paste-attachments.mjs +0 -11
  195. package/src/tui/prompt-history-store.mjs +129 -0
  196. package/src/tui/prompt-history-store.test.mjs +52 -0
  197. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  198. package/src/tui/theme.mjs +41 -647
  199. package/src/tui/themes/base.mjs +86 -0
  200. package/src/tui/themes/basic.mjs +85 -0
  201. package/src/tui/themes/catppuccin.mjs +72 -0
  202. package/src/tui/themes/dracula.mjs +70 -0
  203. package/src/tui/themes/everforest.mjs +71 -0
  204. package/src/tui/themes/gruvbox.mjs +71 -0
  205. package/src/tui/themes/index.mjs +71 -0
  206. package/src/tui/themes/indigo.mjs +78 -0
  207. package/src/tui/themes/kanagawa.mjs +80 -0
  208. package/src/tui/themes/light.mjs +81 -0
  209. package/src/tui/themes/nord.mjs +72 -0
  210. package/src/tui/themes/onedark.mjs +16 -0
  211. package/src/tui/themes/rosepine.mjs +70 -0
  212. package/src/tui/themes/teal.mjs +81 -0
  213. package/src/tui/themes/tokyonight.mjs +79 -0
  214. package/src/tui/themes/utils.mjs +106 -0
  215. package/src/tui/themes/warm.mjs +79 -0
  216. package/src/tui/transcript-tool-failures.mjs +13 -2
  217. package/src/ui/markdown.mjs +1 -1
  218. package/src/ui/model-display.mjs +2 -2
  219. package/src/ui/statusline.mjs +26 -27
  220. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -623
  221. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  222. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  223. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  224. package/src/workflows/default/WORKFLOW.md +39 -12
  225. package/src/workflows/sequential/WORKFLOW.md +46 -0
  226. package/src/workflows/solo/WORKFLOW.md +7 -0
  227. package/vendor/ink/build/display-width.js +62 -0
  228. package/vendor/ink/build/ink.js +154 -20
  229. package/vendor/ink/build/measure-text.js +4 -1
  230. package/vendor/ink/build/output.js +115 -9
  231. package/vendor/ink/build/render-node-to-output.js +4 -1
  232. package/vendor/ink/build/render.js +4 -0
  233. package/src/hooks/lib/permission-rules.cjs +0 -170
  234. package/src/hooks/lib/settings-loader.cjs +0 -112
  235. package/src/lib/hook-pipe-path.cjs +0 -10
  236. package/src/output-styles/extreme-simple.md +0 -20
  237. package/src/rules/lead/04-workflow.md +0 -51
  238. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  239. package/src/workflows/default/workflow.json +0 -13
  240. package/src/workflows/solo/workflow.json +0 -7
package/src/tui/App.jsx CHANGED
@@ -22,17 +22,22 @@ import React, { useState, useCallback, useEffect, useLayoutEffect, useMemo, useR
22
22
  import { Box, Text, useApp, useInput, useStdin, useStdout } from 'ink';
23
23
  import stringWidth from 'string-width';
24
24
  import stripAnsi from 'strip-ansi';
25
- import { theme, TURN_MARKER, RESULT_GUTTER } from './theme.mjs';
25
+ import { theme, surfaceBackground, TURN_MARKER, RESULT_GUTTER } from './theme.mjs';
26
26
  import { useEngine } from './hooks/useEngine.mjs';
27
- import { renderTokenAnsiSegments } from './markdown/render-ansi.mjs';
28
- import { assistantBodyWidth, measureMarkdownTableRows } from './markdown/table-layout.mjs';
27
+ import {
28
+ measureMarkdownRenderedRows,
29
+ measureStreamingMarkdownRenderedRows,
30
+ } from './markdown/measure-rendered-rows.mjs';
31
+ import { streamingLayoutText } from './markdown/streaming-markdown.mjs';
32
+ import { displayWidth } from './display-width.mjs';
29
33
  import { classifyToolCategory, formatToolSurface, normalizeToolName, parseToolArgs, summarizeAgentSurfaceBrief } from '../runtime/shared/tool-surface.mjs';
30
34
  import { isBackgroundErrorOnlyBody } from '../runtime/shared/err-text.mjs';
31
- import { AssistantMessage, UserMessage, ThinkingMessage, NoticeMessage } from './components/Message.jsx';
35
+ import { AssistantMessage, UserMessage, NoticeMessage } from './components/Message.jsx';
32
36
  import { ToolExecution } from './components/ToolExecution.jsx';
33
37
  import { formatExpandedResult, wrapExpandedResultLines } from './components/tool-output-format.mjs';
34
38
  import { Spinner } from './components/Spinner.jsx';
35
39
  import { StatusDone, TurnDone } from './components/TurnDone.jsx';
40
+ import { ItemRightHintOverprint } from './components/ItemRightHintOverprint.jsx';
36
41
  import { StatusLine } from './components/StatusLine.jsx';
37
42
  import { PromptInput } from './components/PromptInput.jsx';
38
43
  import { QueuedCommands } from './components/QueuedCommands.jsx';
@@ -67,13 +72,30 @@ import {
67
72
  shouldSuppressFullyFailedToolItem,
68
73
  toolItemResultText,
69
74
  } from './transcript-tool-failures.mjs';
75
+ import {
76
+ toggleVoice,
77
+ isVoiceEnabled,
78
+ getRecorderState,
79
+ startRecording,
80
+ stopRecording,
81
+ cancelRecording,
82
+ disposeRecorder,
83
+ } from './lib/voice-recorder.mjs';
70
84
 
71
85
  import { displayModelName } from '../ui/model-display.mjs';
86
+ import { supportsExtendedKeys, ENABLE_KITTY_KEYBOARD, ENABLE_MODIFY_OTHER_KEYS } from './keyboard-protocol.mjs';
72
87
 
73
88
  const MOUSE_TRACKING_ON = '\x1b[?1000h\x1b[?1002h\x1b[?1006h';
74
89
  const MOUSE_TRACKING_OFF = '\x1b[?1006l\x1b[?1002l\x1b[?1000l';
75
90
  const MOUSE_MODIFIER_MASK = 4 | 8 | 16;
76
91
  const MOUSE_CTRL_MASK = 16;
92
+ // SEARCH_DEFAULT marker — mirrors backend SEARCH_DEFAULT_PROVIDER/MODEL
93
+ // (mixdog-session-runtime.mjs 1167-1168). A search route of {provider:'default',
94
+ // model:'default'} means "follow the Main Model" at runtime (nativeSearchRoutes).
95
+ const SEARCH_DEFAULT_ROUTE = Object.freeze({ provider: 'default', model: 'default' });
96
+ const isSearchDefaultRoute = (route) =>
97
+ String(route?.provider || '').toLowerCase() === 'default'
98
+ && String(route?.model || '').toLowerCase() === 'default';
77
99
 
78
100
  const SLASH_COMMANDS = [
79
101
  { name: 'clear', usage: '/clear', aliases: ['new'], aliasUsage: ['new'], description: 'Start a fresh chat' },
@@ -97,10 +119,13 @@ const SLASH_COMMANDS = [
97
119
  { name: 'hooks', usage: '/hooks', description: 'Manage before-tool hook rules and events' },
98
120
  { name: 'providers', usage: '/providers', description: 'Manage auth, API keys, OAuth, and local endpoints' },
99
121
  { name: 'channels', usage: '/channels', description: 'Manage Discord, channels, schedules, webhooks' },
122
+ { name: 'remote', usage: '/remote', description: 'Toggle Discord remote mode for this session' },
100
123
  { name: 'schedules', usage: '/schedules', description: 'Manage schedules' },
101
124
  { name: 'webhooks', usage: '/webhooks', description: 'Manage inbound webhooks' },
102
125
  { name: 'settings', usage: '/setting', aliases: ['setting', 'config'], aliasUsage: ['settings', 'config'], showAliasUsage: false, description: 'Open runtime settings' },
103
126
  { name: 'profile', usage: '/profile', description: 'Set your title and response language' },
127
+ { name: 'update', usage: '/update', description: 'Check version and update mixdog' },
128
+ { name: 'voice', usage: '/voice', description: 'Toggle voice input (Ctrl+Space to record)' },
104
129
  { name: 'quit', usage: '/quit', aliases: ['exit', 'q'], aliasUsage: ['exit', 'q'], description: 'Quit the TUI' },
105
130
  ];
106
131
 
@@ -174,6 +199,42 @@ function terminalSize(stdout) {
174
199
  };
175
200
  }
176
201
 
202
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
203
+
204
+ function wrappedTextRows(value, width) {
205
+ const w = Math.max(1, Math.floor(Number(width) || 1));
206
+ let row = 0;
207
+ let col = 0;
208
+ for (const { segment } of graphemeSegmenter.segment(String(value ?? ''))) {
209
+ if (segment === '\n') {
210
+ row += 1;
211
+ col = 0;
212
+ continue;
213
+ }
214
+ const segmentWidth = stringWidth(segment);
215
+ if (segmentWidth === 0) continue;
216
+ if (col > 0 && col + segmentWidth > w) {
217
+ row += 1;
218
+ col = 0;
219
+ }
220
+ col += segmentWidth;
221
+ if (col >= w) {
222
+ row += Math.floor(col / w);
223
+ col %= w;
224
+ }
225
+ }
226
+ return row + 1;
227
+ }
228
+
229
+ function promptContentRows(value, contentColumns) {
230
+ // PromptInput appends a blank trailing cell when the caret is at end-of-input
231
+ // so the native cursor always has a rendered cell. App does not own the cursor
232
+ // offset, so reserve for the worst common case: caret at the end. This can
233
+ // over-reserve by one row at exact wrap boundaries, but never under-reserves
234
+ // and therefore prevents transcript rows from bleeding into the prompt box.
235
+ return wrappedTextRows(`${String(value ?? '')} `, contentColumns);
236
+ }
237
+
177
238
  function clean(value) {
178
239
  return String(value ?? '').trim();
179
240
  }
@@ -195,14 +256,6 @@ function modelSwitchNotice() {
195
256
  return 'Model updated · new sessions';
196
257
  }
197
258
 
198
- function systemShellDescription(shell = {}) {
199
- const command = clean(shell.command);
200
- if (command) return `${command} · config`;
201
- const effective = clean(shell.effective);
202
- if (effective) return `${effective} · ${shell.source || 'env'}`;
203
- return 'auto';
204
- }
205
-
206
259
  function compactJson(value, max = 180) {
207
260
  let text = '';
208
261
  try {
@@ -251,14 +304,6 @@ function providerKindLabel(provider = {}) {
251
304
  return 'API key';
252
305
  }
253
306
 
254
- function summarizeTags(tags, limit = 3) {
255
- const values = [...new Set((Array.isArray(tags) ? tags : [])
256
- .map((tag) => clean(tag))
257
- .filter(Boolean))];
258
- if (values.length <= limit) return values.join(', ');
259
- return `${values.slice(0, limit).join(', ')}, +${values.length - limit}`;
260
- }
261
-
262
307
  function formatSessionUpdatedAt(value) {
263
308
  const n = Number(value);
264
309
  if (!Number.isFinite(n) || n <= 0) return '--:--';
@@ -278,50 +323,6 @@ function formatSessionMessageCount(count) {
278
323
  return `${Number.isFinite(n) ? Math.max(0, Math.round(n)) : 0} msg${n === 1 ? '' : 's'}`;
279
324
  }
280
325
 
281
- function parseAgentControl(text) {
282
- const parts = String(text || '').trim().split(/\s+/).filter(Boolean);
283
- const action = (parts[0] || '').toLowerCase();
284
- if (!['spawn', 'send', 'list', 'status', 'read', 'cleanup', 'cancel', 'close'].includes(action)) return null;
285
- const value = parts[1] || '';
286
- if (action === 'list' || action === 'cleanup') return { type: action };
287
- if (action === 'spawn') {
288
- const agent = value;
289
- if (!agent) return { error: 'usage: /agent spawn <agent> <prompt>' };
290
- const parsed = parseAgentFreeform(parts.slice(2));
291
- if (!parsed.message) return { error: 'usage: /agent spawn <agent> <prompt>' };
292
- return { type: 'spawn', agent, ...parsed };
293
- }
294
- if (action === 'send') {
295
- if (!value) return { error: 'usage: /agent send <target> <message>' };
296
- const parsed = parseAgentFreeform(parts.slice(2));
297
- if (!parsed.message) return { error: 'usage: /agent send <target> <message>' };
298
- return value.startsWith('sess_')
299
- ? { type: 'send', sessionId: value, ...parsed }
300
- : { type: 'send', tag: value, ...parsed };
301
- }
302
- if (!value) return { error: `usage: /agent ${action} <target>` };
303
- if (action === 'status' || action === 'read') return { type: action, task_id: value };
304
- if (value.startsWith('task_')) return { type: action, task_id: value };
305
- if (value.startsWith('sess_')) return { type: action, sessionId: value };
306
- return { type: action, tag: value };
307
- }
308
-
309
- function parseAgentFreeform(parts) {
310
- const out = {};
311
- let i = 0;
312
- for (; i < parts.length; i += 1) {
313
- const token = parts[i];
314
- const kv = /^([a-zA-Z][\w-]*)=(.+)$/.exec(token);
315
- if (kv && ['tag', 'preset', 'provider', 'model', 'effort', 'cwd'].includes(kv[1])) {
316
- out[kv[1]] = kv[2];
317
- continue;
318
- }
319
- break;
320
- }
321
- out.message = parts.slice(i).join(' ').trim();
322
- return out;
323
- }
324
-
325
326
  function parseHookRuleInput(text) {
326
327
  const parts = String(text || '').split('|').map((part) => part.trim());
327
328
  const [tool, actionRaw, match, reason, patchText] = parts;
@@ -352,7 +353,7 @@ function parseMcpServerInput(text) {
352
353
  const parts = String(text || '').split('|').map((part) => part.trim());
353
354
  const [name, commandOrUrl, argsText = '', cwd = ''] = parts;
354
355
  if (!name || !commandOrUrl) return { error: 'usage: name | command-or-url | args(optional) | cwd(optional)' };
355
- if (/^https?:\/\//i.test(commandOrUrl)) return { server: { name, url: commandOrUrl } };
356
+ if (/^(?:https?|wss?):\/\//i.test(commandOrUrl)) return { server: { name, url: commandOrUrl } };
356
357
  return {
357
358
  server: {
358
359
  name,
@@ -448,6 +449,68 @@ function centerLine(value, columns, reserve = 0) {
448
449
  return `${' '.repeat(pad)}${text}`;
449
450
  }
450
451
 
452
+ const WELCOME_PROMPT_HINTS = [
453
+ 'Tip: /setting · Tune the runtime before the run.',
454
+ 'Tip: /model · Pick the right brain for the job.',
455
+ 'Tip: /workflow · Change how work gets routed.',
456
+ 'Tip: Ctrl+O · Expand tool output when you need details.',
457
+ 'Tip: PageUp/PageDown · Scroll the transcript.',
458
+ 'Tip: Esc · Close panels or interrupt work.',
459
+ 'Tip: /usage · Check quota before a long run.',
460
+ 'Tip: /agents · See who can help.',
461
+ 'Tip: /theme · Change the terminal mood.',
462
+ 'Tip: /search · Set web search routing.',
463
+ 'Paste an error. I’ll trace it.',
464
+ 'Tell me the goal. I’ll handle the steps.',
465
+ 'Start with a task, a bug, or a wild idea.',
466
+ 'Good fixes start with a repro.',
467
+ 'Ask for a plan, then let the agents work.',
468
+ 'Small prompt, sharp result.',
469
+ 'Drop in a file path and ask what changed.',
470
+ 'Describe the outcome, not just the command.',
471
+ 'Ready when you are.',
472
+ 'One clear goal beats ten vague tasks.',
473
+ ];
474
+
475
+ const CONDITIONAL_WELCOME_PROMPT_HINTS = {
476
+ noProvider: 'Tip: /providers · Connect a provider before your first turn.',
477
+ noModel: 'Tip: /model · Choose a model before your first turn.',
478
+ soloWorkflow: 'Tip: /workflow · Switch from Solo when you want agents.',
479
+ searchDefaultUnsupported: 'Tip: /search · Choose a native search model for this main model.',
480
+ error: 'Tip: /doctor · Check setup health. (coming soon)',
481
+ };
482
+
483
+ function randomWelcomePromptHint() {
484
+ const index = Math.floor(Math.random() * WELCOME_PROMPT_HINTS.length);
485
+ return WELCOME_PROMPT_HINTS[index] || WELCOME_PROMPT_HINTS[0] || '';
486
+ }
487
+
488
+ function providerSetupHasUsableProvider(setup = {}) {
489
+ const rows = [
490
+ ...(Array.isArray(setup.api) ? setup.api : []),
491
+ ...(Array.isArray(setup.oauth) ? setup.oauth : []),
492
+ ...(Array.isArray(setup.local) ? setup.local : []),
493
+ ];
494
+ return rows.some((row) => (
495
+ row?.authenticated === true
496
+ || row?.enabled === true
497
+ || row?.stored === true
498
+ || row?.env === true
499
+ || row?.detected === true
500
+ ));
501
+ }
502
+
503
+ function activeWorkflowSummaryForStore(store, workflow = {}) {
504
+ try {
505
+ const workflows = store.listWorkflows?.() || [];
506
+ return workflows.find((item) => item.active)
507
+ || workflows.find((item) => item.id === workflow?.id)
508
+ || null;
509
+ } catch {
510
+ return null;
511
+ }
512
+ }
513
+
451
514
  function promptStatusColor(tone) {
452
515
  if (tone === 'error') return theme.error;
453
516
  if (tone === 'warn' || tone === 'cancel') return theme.warning;
@@ -542,25 +605,34 @@ function isAgentResponseResultText(text) {
542
605
  function ToolHookDenialCard({ item, columns = 80 }) {
543
606
  const { label, summary } = formatToolSurface(item.name, item.args);
544
607
  const detail = formatHookDenialDetail(toolItemResultText(item));
545
- const summaryText = summary ? ` (${summary})` : '';
608
+ const safeLabel = stripAnsi(String(label || '')).replace(/[\u0000-\u001F\u007F]/g, ' ').replace(/\s+/g, ' ').trim();
609
+ const safeSummary = stripAnsi(String(summary || '')).replace(/[\u0000-\u001F\u007F]/g, ' ').replace(/\s+/g, ' ').trim();
610
+ const safeDetail = stripAnsi(String(detail || '')).replace(/[\u0000-\u001F\u007F]/g, ' ').replace(/\s+/g, ' ').trim();
611
+ const summaryText = safeSummary ? ` (${safeSummary})` : '';
612
+ const rowWidth = Math.max(1, Number(columns || 80));
613
+ const detailWidth = Math.max(1, rowWidth - stringWidth(RESULT_GUTTER));
546
614
  return (
547
- <Box flexDirection="column" marginTop={1}>
548
- <Box flexDirection="row">
615
+ <Box flexDirection="column" marginTop={1} width={rowWidth} overflow="hidden">
616
+ <Box flexDirection="row" width={rowWidth} overflow="hidden">
549
617
  <Box flexShrink={0} minWidth={2}>
550
618
  <Text color={theme.error}>{TURN_MARKER}</Text>
551
619
  </Box>
552
- <Text wrap="truncate">
553
- <Text bold color={theme.text}>{label}</Text>
554
- {summaryText ? <Text color={theme.text}>{summaryText}</Text> : null}
555
- <Text color={theme.error}> · Denied</Text>
556
- </Text>
620
+ <Box flexGrow={1} flexShrink={1} overflow="hidden" minWidth={0}>
621
+ <Text wrap="truncate">
622
+ <Text bold color={theme.text}>{safeLabel}</Text>
623
+ {summaryText ? <Text color={theme.text}>{summaryText}</Text> : null}
624
+ <Text color={theme.error}> · Denied</Text>
625
+ </Text>
626
+ </Box>
557
627
  </Box>
558
- {detail ? (
559
- <Box flexDirection="row">
560
- <Box flexShrink={0}>
628
+ {safeDetail ? (
629
+ <Box flexDirection="row" width={rowWidth} overflow="hidden">
630
+ <Box flexShrink={0} width={stringWidth(RESULT_GUTTER)}>
561
631
  <Text color={theme.subtle}>{RESULT_GUTTER}</Text>
562
632
  </Box>
563
- <Text color={theme.error} wrap="truncate">{detail}</Text>
633
+ <Box flexShrink={0} width={detailWidth} overflow="hidden">
634
+ <Text color={theme.error} wrap="truncate">{safeDetail}</Text>
635
+ </Box>
564
636
  </Box>
565
637
  ) : null}
566
638
  </Box>
@@ -573,21 +645,46 @@ function ToolHookDenialCard({ item, columns = 80 }) {
573
645
  // epoch through Item → AssistantMessage/UserMessage/ToolExecution breaks
574
646
  // React.memo's shallow equality on a theme change without a broad refactor.
575
647
  const Item = React.memo(function Item({ item, prevKind, columns, toolOutputExpanded, rightMessage = '', rightTone = 'info', rightMessageWidth = 24, themeEpoch = 0 }) {
648
+ const hintOnTurnDoneRow = item.kind === 'turndone' || item.kind === 'statusdone';
649
+ let node = null;
576
650
  switch (item.kind) {
577
- case 'user': return <UserMessage text={item.text} attached={prevKind === 'user'} columns={columns} themeEpoch={themeEpoch} />;
578
- case 'assistant': return <AssistantMessage text={item.text} streaming={item.streaming} columns={columns} themeEpoch={themeEpoch} />;
651
+ case 'user':
652
+ node = <UserMessage text={item.text} attached={prevKind === 'user'} columns={columns} themeEpoch={themeEpoch} />;
653
+ break;
654
+ case 'assistant':
655
+ node = <AssistantMessage text={item.text} streaming={item.streaming} columns={columns} themeEpoch={themeEpoch} assistantId={item.id} />;
656
+ break;
579
657
  case 'tool': {
580
658
  if (shouldSuppressFullyFailedToolItem(item)) return null;
581
659
  if (isHookApprovalDenialToolItem(item)) {
582
- return <ToolHookDenialCard item={item} columns={columns} />;
660
+ node = <ToolHookDenialCard item={item} columns={columns} />;
661
+ break;
583
662
  }
584
- return <ToolExecution name={item.name} args={item.args} result={item.result} rawResult={item.rawResult} isError={item.isError} errorCount={item.errorCount} expanded={toolOutputExpanded || item.expanded} globalExpanded={toolOutputExpanded} columns={columns} attached={false} count={item.count} completedCount={item.completedCount} startedAt={item.startedAt} completedAt={item.completedAt} aggregate={item.aggregate} categories={item.categories} headerFinalized={item.headerFinalized} deferredDisplayReady={item.deferredDisplayReady} themeEpoch={themeEpoch} />;
663
+ node = <ToolExecution name={item.name} args={item.args} result={item.result} rawResult={item.rawResult} isError={item.isError} errorCount={item.errorCount} expanded={toolOutputExpanded || item.expanded} columns={columns} attached={false} count={item.count} completedCount={item.completedCount} startedAt={item.startedAt} completedAt={item.completedAt} aggregate={item.aggregate} categories={item.categories} headerFinalized={item.headerFinalized} deferredDisplayReady={item.deferredDisplayReady} themeEpoch={themeEpoch} />;
664
+ break;
585
665
  }
586
- case 'notice': return <NoticeMessage text={item.text} tone={item.tone} columns={columns} />;
587
- case 'turndone': return <TurnDone elapsedMs={item.elapsedMs} status={item.status} outputTokens={item.outputTokens} thinkingElapsedMs={item.thinkingElapsedMs} verb={item.verb} rightMessage={rightMessage} rightTone={rightTone} rightMessageWidth={rightMessageWidth} />;
588
- case 'statusdone': return <StatusDone label={item.label} detail={item.detail} rightMessage={rightMessage} rightTone={rightTone} rightMessageWidth={rightMessageWidth} />;
589
- default: return null;
666
+ case 'notice':
667
+ node = <NoticeMessage text={item.text} tone={item.tone} columns={columns} />;
668
+ break;
669
+ case 'turndone':
670
+ node = <TurnDone elapsedMs={item.elapsedMs} status={item.status} outputTokens={item.outputTokens} thinkingElapsedMs={item.thinkingElapsedMs} verb={item.verb} rightMessage={rightMessage} rightTone={rightTone} rightMessageWidth={rightMessageWidth} />;
671
+ break;
672
+ case 'statusdone':
673
+ node = <StatusDone label={item.label} detail={item.detail} rightMessage={rightMessage} rightTone={rightTone} rightMessageWidth={rightMessageWidth} />;
674
+ break;
675
+ default:
676
+ return null;
590
677
  }
678
+ if (!node || hintOnTurnDoneRow || !rightMessage) return node;
679
+ return (
680
+ <ItemRightHintOverprint
681
+ rightMessage={rightMessage}
682
+ rightTone={rightTone}
683
+ rightMessageWidth={rightMessageWidth}
684
+ >
685
+ {node}
686
+ </ItemRightHintOverprint>
687
+ );
591
688
  });
592
689
 
593
690
  function positiveIntEnv(name, fallback) {
@@ -665,6 +762,15 @@ function shiftSelectionRectY(rect, deltaY) {
665
762
  return { ...rect, y1: rect.y1 + dy, y2: rect.y2 + dy };
666
763
  }
667
764
 
765
+ // Reading-order compare (row then col): -1 if a<b, 1 if a>b, 0 equal. Shared by
766
+ // buildSpanRect (word/line drag-extension). Module scope so both the mouse
767
+ // handler and the auto-scroll path can reach it.
768
+ function comparePoints(a, b) {
769
+ if (a.y !== b.y) return a.y < b.y ? -1 : 1;
770
+ if (a.x !== b.x) return a.x < b.x ? -1 : 1;
771
+ return 0;
772
+ }
773
+
668
774
  // Count how many terminal rows ONE logical line (no '\n') occupies once ink
669
775
  // word-wraps it. ink/Yoga break on whitespace (wrap-ansi wordWrap), NOT on a
670
776
  // hard column count: a word that does not fit the remaining space is pushed
@@ -676,14 +782,14 @@ function shiftSelectionRectY(rect, deltaY) {
676
782
  // word-wrap so the row estimate is never lower than what ink actually renders.
677
783
  function wrappedLineRows(line, width) {
678
784
  const text = String(line);
679
- const full = stringWidth(text);
785
+ const full = displayWidth(text);
680
786
  if (full === 0) return 1;
681
787
  if (full <= width) return 1;
682
788
  let rows = 1;
683
789
  let col = 0;
684
790
  for (const token of text.split(/(\s+)/)) {
685
791
  if (!token) continue;
686
- const tw = stringWidth(token);
792
+ const tw = displayWidth(token);
687
793
  if (tw === 0) continue;
688
794
  if (tw > width) {
689
795
  // Over-long unbreakable token: ink hard-splits it across rows.
@@ -724,38 +830,6 @@ function estimateWrappedRows(text, columns, reserve = 4) {
724
830
  // equals MarkdownTable's real line count (horizontal box OR vertical fallback)
725
831
  // with zero drift, including on win32 where stdout.columns ≠ frameColumns.
726
832
  //
727
- // StreamingMarkdown splits the body into stable+unstable <Markdown> children
728
- // under one gap={1} parent; the boundary gap exactly replaces the natural
729
- // segment-boundary gap, so measuring the whole text in one pass yields the same
730
- // total row count (no streaming-specific correction needed).
731
- function measureMarkdownRenderedRows(text, columns) {
732
- const value = String(text ?? '');
733
- if (!value) return 1;
734
- const bodyWidth = assistantBodyWidth(columns);
735
- let segments;
736
- try {
737
- segments = renderTokenAnsiSegments(value, { width: bodyWidth });
738
- } catch {
739
- // Never throw into the scroll math — fall back to the raw wrapped count.
740
- return Math.max(1, estimateWrappedRows(value, columns, 3));
741
- }
742
- if (!segments.length) return 1;
743
- let rows = 0;
744
- for (const seg of segments) {
745
- if (seg.type === 'table') {
746
- rows += Math.max(1, measureMarkdownTableRows(seg.token, bodyWidth));
747
- continue;
748
- }
749
- const plain = stripAnsi(String(seg.ansi ?? ''));
750
- for (const line of plain.split('\n')) {
751
- rows += wrappedLineRows(line, bodyWidth);
752
- }
753
- }
754
- // Markdown.jsx wraps the segments in <Box gap={1}>: one blank row per boundary.
755
- rows += segments.length - 1;
756
- return Math.max(1, rows);
757
- }
758
-
759
833
  const BACKGROUND_TASK_TOOL_NAMES = new Set(['explore', 'search', 'shell', 'bash', 'bash_session', 'shell_command', 'task']);
760
834
 
761
835
  function isBackgroundTaskToolName(normalizedName) {
@@ -817,6 +891,14 @@ function toolItemPendingForRows(item) {
817
891
  }
818
892
 
819
893
  // Mirror ToolExecution displayedResultText for row estimates (agent brief, etc.).
894
+ const LEADING_STATUS_MARKER_LINE_RE = /^\[status:\s*[^\]]*\]\s*$/i;
895
+
896
+ function stripLeadingStatusMarkerFromTextForRows(text) {
897
+ const lines = String(text || '').split('\n');
898
+ if (lines.length > 0 && LEADING_STATUS_MARKER_LINE_RE.test(String(lines[0] ?? '').trim())) lines.shift();
899
+ return lines.join('\n');
900
+ }
901
+
820
902
  function toolDisplayedResultTextForRows(item) {
821
903
  const rt = item?.result == null ? '' : String(item.result).replace(/\s+$/, '');
822
904
  const bgArgs = backgroundArgsForRows(item?.args);
@@ -825,9 +907,11 @@ function toolDisplayedResultTextForRows(item) {
825
907
  const normalizedName = String(normalizeToolName(item?.name) || '').toLowerCase();
826
908
  if (!toolItemPendingForRows(item) && isBackgroundTaskToolName(normalizedName)) {
827
909
  const meta = parseBackgroundTaskResultForRows(rt);
828
- if (meta?.hasResponse && String(meta.body || '').trim()) return String(meta.body);
910
+ if (meta?.hasResponse && String(meta.body || '').trim()) {
911
+ return stripLeadingStatusMarkerFromTextForRows(String(meta.body));
912
+ }
829
913
  }
830
- return errorOnlyResult ? '' : (rt || '');
914
+ return stripLeadingStatusMarkerFromTextForRows(errorOnlyResult ? '' : (rt || ''));
831
915
  }
832
916
 
833
917
  function toolHasDisplayResultForRows(item) {
@@ -844,6 +928,14 @@ function toolHasDisplayResultForRows(item) {
844
928
  return true;
845
929
  }
846
930
 
931
+ // Mirror ToolExecution expanded ResultBody rawText selection (aggregate always rawResult).
932
+ function toolExpandedRawTextForRows(item, rawRt) {
933
+ if (item?.aggregate) return rawRt;
934
+ const hasDisplayResult = toolHasDisplayResultForRows(item);
935
+ if (hasDisplayResult) return toolDisplayedResultTextForRows(item);
936
+ return stripLeadingStatusMarkerFromTextForRows(rawRt || '');
937
+ }
938
+
847
939
  function toolHeaderFailureOnlyForRows(item, normalizedName, hasDisplayResult) {
848
940
  if (hasDisplayResult) return false;
849
941
  const bgArgs = backgroundArgsForRows(item.args);
@@ -876,10 +968,16 @@ function toolArgPathForRows(item) {
876
968
  return a?.path ?? a?.file_path ?? a?.file ?? '';
877
969
  }
878
970
 
879
- function isShellSurfaceForRows(normalizedName) {
971
+ function isShellSurfaceForRows(normalizedName, label = '') {
880
972
  const n = String(normalizedName || '').toLowerCase();
973
+ const l = String(label || '').toLowerCase();
881
974
  return n === 'shell' || n === 'bash' || n === 'bash_session'
882
- || n === 'shell_command' || n === 'job_wait';
975
+ || n === 'shell_command' || n === 'job_wait' || l === 'run';
976
+ }
977
+
978
+ function isShellSurfaceForToolItem(item, normalizedName) {
979
+ const label = formatToolSurface(item?.name, item?.args)?.label || '';
980
+ return isShellSurfaceForRows(normalizedName, label);
883
981
  }
884
982
 
885
983
  // EXPANDED tool bodies are post-processed by formatExpandedResult (JSON pretty,
@@ -892,7 +990,7 @@ function estimateToolRenderedResultRows(value, { pathArg = '', isShell = false,
892
990
  if (!text) return 1;
893
991
  try {
894
992
  const logical = formatExpandedResult(text, { pathArg, isShell });
895
- const rows = wrapExpandedResultLines(logical, columns).length;
993
+ const rows = wrapExpandedResultLines(logical, columns, { isShell }).length;
896
994
  return Math.max(1, rows);
897
995
  } catch {
898
996
  return Math.max(1, text.split('\n').length);
@@ -905,11 +1003,12 @@ function estimateTranscriptItemRows(item, columns, toolOutputExpanded) {
905
1003
  case 'user':
906
1004
  return 1 + estimateWrappedRows(item.text, columns, 4);
907
1005
  case 'assistant':
908
- // marginTop={1} (AssistantMessage <Box>) + the exact rendered body height.
909
- // measureMarkdownRenderedRows mirrors the <Markdown> segment/gap/table
910
- // layout at the real body width (columns-3), so the reserved height equals
911
- // what ink draws — no phantom blank band (over) and no top-clip (under).
912
- return 1 + measureMarkdownRenderedRows(item.text, columns);
1006
+ // marginTop={1} (AssistantMessage <Box>) + rendered body height.
1007
+ // Streaming uses measureStreamingMarkdownRenderedRows (shared layout with
1008
+ // StreamingMarkdown); settled assistant uses measureMarkdownRenderedRows.
1009
+ return 1 + (item.streaming
1010
+ ? measureStreamingMarkdownRenderedRows(item.text, columns, item.id)
1011
+ : measureMarkdownRenderedRows(item.text, columns));
913
1012
  case 'tool': {
914
1013
  const TOOL_MARGIN_TOP = 1;
915
1014
  if (shouldSuppressFullyFailedToolItem(item)) return 0;
@@ -961,10 +1060,11 @@ function estimateTranscriptItemRows(item, columns, toolOutputExpanded) {
961
1060
  // Aggregate cards pass no pathArg/isShell to ResultBody; non-aggregate
962
1061
  // cards do. Mirror that here so the formatExpandedResult row count matches
963
1062
  // the rendered body exactly (JSON pretty / line-number split can shift it).
1063
+ const estimateText = toolExpandedRawTextForRows(item, rawRt);
964
1064
  const rawOpts = item.aggregate
965
1065
  ? {}
966
- : { pathArg: toolArgPathForRows(item), isShell: isShellSurfaceForRows(normalizedName) };
967
- return TOOL_MARGIN_TOP + 1 + estimateToolRenderedResultRows(rawRt, { ...rawOpts, columns });
1066
+ : { pathArg: toolArgPathForRows(item), isShell: isShellSurfaceForToolItem(item, normalizedName) };
1067
+ return TOOL_MARGIN_TOP + 1 + estimateToolRenderedResultRows(estimateText, { ...rawOpts, columns });
968
1068
  }
969
1069
  // Expanded agent card with no raw body to reveal: ToolExecution still
970
1070
  // shows one agent-brief detail line, so margin + header + one detail row.
@@ -999,7 +1099,7 @@ function estimateTranscriptItemRows(item, columns, toolOutputExpanded) {
999
1099
  const resultText = backgroundMeta?.hasResponse ? backgroundMeta.body : rt;
1000
1100
  const resultRows = estimateToolRenderedResultRows(resultText, {
1001
1101
  pathArg: toolArgPathForRows(item),
1002
- isShell: isShellSurfaceForRows(normalizedName),
1102
+ isShell: isShellSurfaceForToolItem(item, normalizedName),
1003
1103
  columns,
1004
1104
  });
1005
1105
  return TOOL_MARGIN_TOP + 1 + resultRows;
@@ -1255,7 +1355,7 @@ function measuredTranscriptRows(item, columns, toolOutputExpanded) {
1255
1355
  const STREAMING_ROW_QUANTUM = 1;
1256
1356
 
1257
1357
  function assistantTextForStreamingRowEstimate(text) {
1258
- return String(text ?? '').replace(/^\n+|\n+$/g, '');
1358
+ return streamingLayoutText(text);
1259
1359
  }
1260
1360
 
1261
1361
  function streamingEstimateRows(item, columns, toolOutputExpanded) {
@@ -1450,9 +1550,13 @@ function transcriptRenderWindow(items, { scrollOffset = 0, viewportHeight = 24,
1450
1550
  };
1451
1551
  }
1452
1552
 
1453
- export function App({ store, initialStatusLine = '' }) {
1553
+ export function App({ store, initialStatusLine = '', forceOnboarding = false }) {
1454
1554
  const state = useEngine(store);
1455
1555
  const [toolOutputExpanded, setToolOutputExpanded] = useState(false);
1556
+ // True for the entire first-run onboarding wizard (every step + nested depth)
1557
+ // so the welcome banner stays reserved and the layout doesn't jump when the
1558
+ // step pickers mount. Cleared on finish/cancel.
1559
+ const [onboardingActive, setOnboardingActive] = useState(false);
1456
1560
  const { exit } = useApp();
1457
1561
  // internal_eventEmitter is ink's parsed-input bus. ink 7 consumes stdin via
1458
1562
  // the 'readable' event + stdin.read() (see ink's App.js), draining the buffer
@@ -1469,6 +1573,13 @@ export function App({ store, initialStatusLine = '' }) {
1469
1573
  const [tuiReady, setTuiReady] = useState(false);
1470
1574
  const exitRequestedRef = useRef(false);
1471
1575
  const [resizeState, setResizeState] = useState(() => ({ ...terminalSize(stdout), epoch: 0 }));
1576
+ // Windows Terminal/conhost scrolls the alt-screen (auto-wrap/DECAWM) when the
1577
+ // bottom-right cell is written. WT_SESSION is also set when the UI runs under
1578
+ // a Unix-ish shell hosted by Windows Terminal, where process.platform is not
1579
+ // necessarily win32 but the terminal behavior is still Windows-like.
1580
+ const windowsLikeTerminal = process.platform === 'win32' || Boolean(process.env.WT_SESSION);
1581
+ const rightSafetyColumns = windowsLikeTerminal ? 1 : 0;
1582
+ const frameColumns = Math.max(1, resizeState.columns - rightSafetyColumns);
1472
1583
  // scrollOffset = how many transcript ROWS we've scrolled UP from the bottom
1473
1584
  // (0 = pinned to the latest, showing the newest content). Mouse wheel adjusts
1474
1585
  // it; accepted prompts only arm bottom-follow; the snap happens when the
@@ -1477,6 +1588,7 @@ export function App({ store, initialStatusLine = '' }) {
1477
1588
  const scrollPositionRef = useRef(0);
1478
1589
  const scrollTargetRef = useRef(0);
1479
1590
  const maxScrollRowsRef = useRef(0);
1591
+ const transcriptBottomSlackRowsRef = useRef(0);
1480
1592
  const scrollAnimationRef = useRef(null);
1481
1593
  const transcriptTotalRowsRef = useRef(0);
1482
1594
  const preservedScrollDeltaRef = useRef(0);
@@ -1652,6 +1764,8 @@ export function App({ store, initialStatusLine = '' }) {
1652
1764
  const toolApproval = state.toolApproval || null;
1653
1765
  const [promptDraft, setPromptDraft] = useState('');
1654
1766
  const [promptDraftOverride, setPromptDraftOverride] = useState(null);
1767
+ const promptLayoutValueRef = useRef('');
1768
+ const [, setPromptLayoutRows] = useState(1);
1655
1769
  const [, setPastedImages] = useState({});
1656
1770
  const pastedImagesRef = useRef({});
1657
1771
  const nextPastedImageIdRef = useRef(1);
@@ -1668,20 +1782,40 @@ export function App({ store, initialStatusLine = '' }) {
1668
1782
  const promptHistoryDraftChangeRef = useRef(false);
1669
1783
  const [promptHint, setPromptHint] = useState('');
1670
1784
  const [promptHintTone, setPromptHintTone] = useState('info');
1785
+ const [welcomePromptHintDismissed, setWelcomePromptHintDismissed] = useState(false);
1786
+ const [conditionalWelcomePromptHint, setConditionalWelcomePromptHint] = useState('');
1787
+ const welcomePromptHintRef = useRef(null);
1788
+ if (welcomePromptHintRef.current === null) {
1789
+ welcomePromptHintRef.current = randomWelcomePromptHint();
1790
+ }
1791
+ const dismissWelcomePromptHint = useCallback(() => {
1792
+ setWelcomePromptHintDismissed((dismissed) => dismissed || true);
1793
+ }, []);
1794
+ const toastErrorSignature = useMemo(() => (
1795
+ (state.toasts || [])
1796
+ .filter((toast) => toast?.tone === 'error')
1797
+ .map((toast) => `${toast.id || ''}:${toast.text || ''}`)
1798
+ .join('|')
1799
+ ), [state.toasts]);
1671
1800
  const [slashIndex, setSlashIndex] = useState(0);
1672
1801
  const [slashDismissedFor, setSlashDismissedFor] = useState('');
1673
1802
  const [disabledSkills, setDisabledSkills] = useState(() => new Set());
1674
1803
  const slashPaletteRef = useRef({ open: false, count: 0 });
1675
1804
  const scrollFocusRef = useRef({});
1676
1805
  const onboardingStartedRef = useRef(false);
1677
- const onboardingRef = useRef({ defaultRoute: null, workflowRoutes: {}, providerModels: [] });
1806
+ const onboardingRef = useRef({ defaultRoute: null, searchRoute: null, agentRoutes: {}, agents: [], providerModels: [] });
1678
1807
  const providerModelsCacheRef = useRef({ models: null, at: 0 });
1679
1808
  const searchModelsCacheRef = useRef({ models: null, at: 0 });
1680
1809
  const modelPickerRequestRef = useRef(0);
1810
+ // Generation guard for the Step 1 background prefetch: bumped on every
1811
+ // provider-scope cache clear (e.g. after auth) so a stale in-flight
1812
+ // listProviderModels() cannot repopulate the ref after invalidation.
1813
+ const onboardingPrefetchSeqRef = useRef(0);
1681
1814
  const clearModelCaches = useCallback((scope = 'all') => {
1682
1815
  if (scope === 'all' || scope === 'provider') {
1683
1816
  providerModelsCacheRef.current = { models: null, at: 0 };
1684
1817
  onboardingRef.current.providerModels = [];
1818
+ onboardingPrefetchSeqRef.current += 1;
1685
1819
  }
1686
1820
  if (scope === 'all' || scope === 'search') {
1687
1821
  searchModelsCacheRef.current = { models: null, at: 0 };
@@ -1695,7 +1829,11 @@ export function App({ store, initialStatusLine = '' }) {
1695
1829
  // region: which surface the in-progress (or last) selection belongs to —
1696
1830
  // 'transcript' | 'status' (both ink-grid) | 'prompt' (PromptInput's own engine)
1697
1831
  // | null. Press decides it; motion/release stay in that region.
1698
- const dragRef = useRef({ anchor: null, anchorScroll: 0, last: null, active: false, rect: null, region: null });
1832
+ // anchorSpan: for word/line multi-click selections, the initial word/line
1833
+ // bounds ({ lo:{x,y}, hi:{x,y}, kind:'word'|'line' }) so a subsequent drag
1834
+ // extends the selection whole-word/whole-line from that span (see selection.ts
1835
+ // extendSelection). Null ⇔ ordinary char-drag selection.
1836
+ const dragRef = useRef({ anchor: null, anchorScroll: 0, last: null, active: false, rect: null, region: null, anchorSpan: null });
1699
1837
  const selectionPaintRef = useRef({ t: 0, rect: null, pending: null, timer: null });
1700
1838
  const transcriptViewportRef = useRef({ top: 0, bottom: 0 });
1701
1839
  // [mixdog] Latest terminal row count + the statusline band (bottom rows),
@@ -1704,12 +1842,85 @@ export function App({ store, initialStatusLine = '' }) {
1704
1842
  // region. STATUSLINE_ROWS mirrors the layout reserve below.
1705
1843
  const frameRowsRef = useRef(24);
1706
1844
  const STATUSLINE_BAND_ROWS = 3;
1845
+ const promptContentColumns = Math.max(1, frameColumns - 4);
1846
+ const syncPromptLayoutRows = useCallback((value) => {
1847
+ const text = String(value ?? '');
1848
+ promptLayoutValueRef.current = text;
1849
+ const nextRows = promptContentRows(text, promptContentColumns);
1850
+ setPromptLayoutRows((prev) => (prev === nextRows ? prev : nextRows));
1851
+ }, [promptContentColumns]);
1852
+ useEffect(() => {
1853
+ syncPromptLayoutRows(promptLayoutValueRef.current);
1854
+ }, [syncPromptLayoutRows]);
1855
+ useEffect(() => {
1856
+ let alive = true;
1857
+ const refreshConditionalWelcomeHint = async () => {
1858
+ let next = '';
1859
+ try {
1860
+ const setup = await store.getProviderSetup?.();
1861
+ if (setup && !providerSetupHasUsableProvider(setup)) {
1862
+ next = CONDITIONAL_WELCOME_PROMPT_HINTS.noProvider;
1863
+ }
1864
+ } catch {
1865
+ // If provider setup probing fails, let the generic/error tip path decide.
1866
+ }
1867
+ if (!next) {
1868
+ const activeProvider = String(state.provider || '').trim();
1869
+ const activeModel = String(state.model || '').trim();
1870
+ if (!activeProvider || !activeModel) {
1871
+ next = CONDITIONAL_WELCOME_PROMPT_HINTS.noModel;
1872
+ } else {
1873
+ try {
1874
+ const models = await Promise.resolve(store.listProviderModels?.({ quick: true }) || []);
1875
+ if (Array.isArray(models) && models.length === 0) {
1876
+ next = CONDITIONAL_WELCOME_PROMPT_HINTS.noModel;
1877
+ }
1878
+ } catch {
1879
+ // Model probing is advisory only; avoid replacing the random hint on failure.
1880
+ }
1881
+ }
1882
+ }
1883
+ const activeWorkflow = activeWorkflowSummaryForStore(store, state.workflow || {});
1884
+ if (!next && String(activeWorkflow?.id || state.workflow?.id || '').toLowerCase() === 'solo') {
1885
+ next = CONDITIONAL_WELCOME_PROMPT_HINTS.soloWorkflow;
1886
+ }
1887
+ if (!next) {
1888
+ const searchRoute = store.getSearchRoute?.() || null;
1889
+ const searchProvider = String(searchRoute?.provider || '').trim();
1890
+ const searchModel = String(searchRoute?.model || '').trim();
1891
+ const defaultSearchRoute = searchProvider.toLowerCase() === 'default' && searchModel.toLowerCase() === 'default';
1892
+ if (defaultSearchRoute) {
1893
+ try {
1894
+ const models = await Promise.resolve(store.listProviderModels?.({ quick: true }) || []);
1895
+ const current = Array.isArray(models)
1896
+ ? models.find((model) => model?.provider === state.provider && model?.id === state.model)
1897
+ : null;
1898
+ if (current && current.supportsWebSearch !== true) {
1899
+ next = CONDITIONAL_WELCOME_PROMPT_HINTS.searchDefaultUnsupported;
1900
+ }
1901
+ } catch {
1902
+ // Search default probing is advisory only.
1903
+ }
1904
+ }
1905
+ }
1906
+ if (!next && toastErrorSignature) {
1907
+ next = CONDITIONAL_WELCOME_PROMPT_HINTS.error;
1908
+ }
1909
+ if (alive) setConditionalWelcomePromptHint((prev) => (prev === next ? prev : next));
1910
+ };
1911
+ void refreshConditionalWelcomeHint();
1912
+ return () => { alive = false; };
1913
+ }, [store, state.provider, state.model, state.workflow?.id, toastErrorSignature]);
1707
1914
  const selectionLayoutRef = useRef(null);
1708
1915
  const selectionTextRef = useRef('');
1709
1916
  const selectionTextTimerRef = useRef(null);
1710
1917
  // lastClickRef tracks the previous left-press cell + time so the mouse handler
1711
- // can detect a double-click (same cell within 400ms) for word selection.
1712
- const lastClickRef = useRef({ x: -1, y: -1, t: 0 });
1918
+ // can detect a double-click (same cell within 500ms) for word selection.
1919
+ // count = consecutive qualifying presses on the same cell (1=single,
1920
+ // 2=double/word, 3=triple/line). A 4th qualifying press restarts the
1921
+ // sequence at 1 (claude-code cycles; simplest reset). Any non-qualifying
1922
+ // press resets to a fresh single.
1923
+ const lastClickRef = useRef({ x: -1, y: -1, t: 0, count: 0 });
1713
1924
 
1714
1925
  const showSelectionCopyHint = useCallback((text, tone = 'plain') => {
1715
1926
  if (promptHintTimerRef.current) clearTimeout(promptHintTimerRef.current);
@@ -1806,6 +2017,90 @@ export function App({ store, initialStatusLine = '' }) {
1806
2017
  }, 2200);
1807
2018
  }, []);
1808
2019
 
2020
+ // Voice recorder status hint — reuses the SAME promptHint state as
2021
+ // showPromptHint/clearPromptHint, but bypasses their 2200ms auto-clear
2022
+ // timer: '● REC' / '… transcribing' must stay visible for the ENTIRE
2023
+ // recording/transcribing duration (which can run well past 2.2s), not
2024
+ // vanish on a fixed timer. Also cancels any pending showPromptHint timer
2025
+ // so a stale auto-clear can never stomp the live recording status.
2026
+ const setVoiceStatusHint = useCallback((text, tone = 'info') => {
2027
+ if (promptHintTimerRef.current) {
2028
+ clearTimeout(promptHintTimerRef.current);
2029
+ promptHintTimerRef.current = null;
2030
+ }
2031
+ promptHintActiveRef.current = !!text;
2032
+ setPromptHint(String(text || ''));
2033
+ setPromptHintTone(tone);
2034
+ }, []);
2035
+
2036
+ // Ctrl+Space handler wired to PromptInput's onVoiceToggle. Dispatches on the
2037
+ // recorder's CURRENT state (idle/recording/transcribing) — see
2038
+ // src/tui/lib/voice-recorder.mjs for the state machine itself.
2039
+ const handleVoiceToggle = useCallback(async () => {
2040
+ if (!isVoiceEnabled()) {
2041
+ store.pushNotice('Voice is off — run /voice to turn it on', 'warn');
2042
+ return;
2043
+ }
2044
+ const recState = getRecorderState();
2045
+ if (recState === 'transcribing') {
2046
+ store.pushNotice('Voice: already transcribing…', 'info');
2047
+ return;
2048
+ }
2049
+ if (recState === 'recording') {
2050
+ setVoiceStatusHint('… transcribing', 'info');
2051
+ let result;
2052
+ try {
2053
+ result = await stopRecording();
2054
+ } catch (e) {
2055
+ result = { ok: false, reason: e?.message || String(e) };
2056
+ }
2057
+ setVoiceStatusHint('', 'info');
2058
+ if (!result) return; // race: recorder was no longer RECORDING
2059
+ if (!result.ok) {
2060
+ store.pushNotice(`Voice: ${result.reason || 'transcription failed'}`, 'error');
2061
+ return;
2062
+ }
2063
+ const text = String(result.text || '').trim();
2064
+ if (!text) {
2065
+ store.pushNotice('Voice: no speech detected', 'warn');
2066
+ return;
2067
+ }
2068
+ // End-of-draft insertion via promptDraftOverride (approved design —
2069
+ // no cursor-position insert; see gamerscroll skill's out-of-scope note
2070
+ // on PromptInput's imperative surface).
2071
+ // Med-4: promptValueRef.current is read HERE — after `await
2072
+ // stopRecording()` has already resolved — not captured earlier before
2073
+ // the await. PromptInput keeps valueRef.current live on every
2074
+ // keystroke (commitDraft), so this always reflects whatever the user
2075
+ // typed during the recording+transcribe window; nothing typed in that
2076
+ // gap is overwritten.
2077
+ const current = promptValueRef.current || '';
2078
+ const next = current ? `${current}${/\s$/.test(current) ? '' : ' '}${text}` : text;
2079
+ syncPromptLayoutRows(next);
2080
+ setPromptDraftOverride({ id: Date.now(), value: next });
2081
+ return;
2082
+ }
2083
+ // idle -> recording
2084
+ let result;
2085
+ try {
2086
+ result = await startRecording();
2087
+ } catch (e) {
2088
+ result = { ok: false, reason: e?.message || String(e) };
2089
+ }
2090
+ if (!result?.ok) {
2091
+ store.pushNotice(`Voice: ${result?.reason || 'failed to start recording'}`, 'error');
2092
+ return;
2093
+ }
2094
+ setVoiceStatusHint('● REC', 'error');
2095
+ }, [store, setVoiceStatusHint, syncPromptLayoutRows]);
2096
+
2097
+ // Best-effort recorder teardown on unmount (component-level cleanup;
2098
+ // index.jsx's runTui() also disposes via store.dispose on exit/signal, but
2099
+ // that path doesn't know about the TUI-local recorder singleton).
2100
+ useEffect(() => () => {
2101
+ disposeRecorder();
2102
+ }, []);
2103
+
1809
2104
  const installPastedImages = useCallback((images, { merge = true } = {}) => {
1810
2105
  if (!images || typeof images !== 'object' || Object.keys(images).length === 0) return;
1811
2106
  const next = merge ? { ...pastedImagesRef.current, ...images } : { ...images };
@@ -1929,6 +2224,8 @@ export function App({ store, initialStatusLine = '' }) {
1929
2224
  // long transcript jump to the bottom, then jump again when the new row is
1930
2225
  // appended. Keep the current viewport stable and let the row-delta effect
1931
2226
  // perform the single bottom-follow when the transcript actually grows.
2227
+ transcriptAnchorRef.current = null;
2228
+ transcriptAnchorDirtyRef.current = false;
1932
2229
  followingRef.current = true;
1933
2230
  stopSmoothScroll();
1934
2231
  }, [stopSmoothScroll]);
@@ -1965,18 +2262,28 @@ export function App({ store, initialStatusLine = '' }) {
1965
2262
  ...rect,
1966
2263
  clipY1: clip.y1,
1967
2264
  clipY2: Math.max(clip.y1, clip.y2),
2265
+ selectionForeground: theme.selectionHighlightText || theme.selectionText,
2266
+ selectionBackground: theme.selectionHighlightBackground || theme.selectionBackground,
1968
2267
  };
1969
2268
  if (options.captureText === false) clipped.captureText = false;
1970
2269
  return clipped;
1971
2270
  }, [selectionClip]);
1972
2271
 
1973
- const paintSelectionRect = useCallback((clippedRect, { rememberText = true } = {}) => {
2272
+ const paintSelectionRect = useCallback((clippedRect, { rememberText = true, immediate = false } = {}) => {
1974
2273
  const nextRect = clippedRect || null;
1975
2274
  const state = selectionPaintRef.current;
1976
- if (selectionRectsEqual(state.rect, nextRect)) return false;
2275
+ if (selectionRectsEqual(state.rect, nextRect)) {
2276
+ const needsCapture = nextRect && rememberText && nextRect.captureText !== false;
2277
+ if (!immediate && !needsCapture) return false;
2278
+ if (immediate || needsCapture) {
2279
+ store.setRenderSelection?.(nextRect, { immediate: true });
2280
+ }
2281
+ if (needsCapture) rememberSelectionTextSoon();
2282
+ return true;
2283
+ }
1977
2284
  state.rect = nextRect;
1978
2285
  state.t = Date.now();
1979
- store.setRenderSelection?.(nextRect);
2286
+ store.setRenderSelection?.(nextRect, immediate ? { immediate: true } : undefined);
1980
2287
  if (nextRect && rememberText && nextRect.captureText !== false) rememberSelectionTextSoon();
1981
2288
  return true;
1982
2289
  }, [store, rememberSelectionTextSoon]);
@@ -1991,7 +2298,7 @@ export function App({ store, initialStatusLine = '' }) {
1991
2298
  state.timer = null;
1992
2299
  state.pending = null;
1993
2300
  }
1994
- paintSelectionRect(clippedRect, { rememberText: true });
2301
+ paintSelectionRect(clippedRect, { rememberText: true, immediate: true });
1995
2302
  }, [paintSelectionRect, withSelectionClip]);
1996
2303
 
1997
2304
  const applySelectionRectThrottled = useCallback((rect) => {
@@ -2032,6 +2339,124 @@ export function App({ store, initialStatusLine = '' }) {
2032
2339
  };
2033
2340
  }, []);
2034
2341
 
2342
+ // Port of selection.ts extendSelection onto the linear-rect model, hoisted to
2343
+ // component scope so BOTH the mouse handler (motion/release) AND the
2344
+ // auto-scroll path (scrollTranscriptRows) can rebuild a span-aware rect. Grows
2345
+ // a word/line multi-click selection from its anchor span to the word/line under
2346
+ // the cursor: target ends before the span → extend backward (span.hi→targetLo);
2347
+ // target starts after → extend forward (span.lo→targetHi); overlapping → the
2348
+ // span. The moving end snaps to the word (getWordRectAt) or line (getLineRectAt)
2349
+ // at the cursor; a miss (blank/gutter) falls back to the raw cell. spanScroll
2350
+ // re-anchors the span to the current transcript scroll (status never scrolls) so
2351
+ // the original word/line tracks the content while dragging/auto-scrolling.
2352
+ const buildSpanRect = useCallback((span, x, y, region, spanScroll = 0) => {
2353
+ const conv = (pt) => (region === 'status' ? pt : selectionPointAtCurrentScroll(pt, spanScroll));
2354
+ const spanLo = conv(span.lo);
2355
+ const spanHi = conv(span.hi);
2356
+ let mLo;
2357
+ let mHi;
2358
+ if (span.kind === 'word') {
2359
+ const wr = store.getWordRectAt?.(x, y);
2360
+ if (wr) { mLo = { x: wr.x1, y: wr.y1 }; mHi = { x: wr.x2, y: wr.y2 }; }
2361
+ else { mLo = { x, y }; mHi = { x, y }; }
2362
+ } else {
2363
+ const lr = store.getLineRectAt?.(y);
2364
+ if (lr) { mLo = { x: lr.x1, y: lr.y1 }; mHi = { x: lr.x2, y: lr.y2 }; }
2365
+ else { mLo = { x: 0, y }; mHi = { x, y }; }
2366
+ }
2367
+ const rect = (a, b) => ({ mode: 'linear', x1: a.x, y1: a.y, x2: b.x, y2: b.y });
2368
+ if (comparePoints(mHi, spanLo) < 0) return rect(spanHi, mLo);
2369
+ if (comparePoints(mLo, spanHi) > 0) return rect(spanLo, mHi);
2370
+ return rect(spanLo, spanHi);
2371
+ }, [store, selectionPointAtCurrentScroll]);
2372
+
2373
+ const transcriptViewportRows = useCallback(() => {
2374
+ const top = Math.max(0, Number(transcriptViewportRef.current?.top) || 0);
2375
+ const bottom = Math.max(top, Number(transcriptViewportRef.current?.bottom) || top);
2376
+ return { top, bottom };
2377
+ }, []);
2378
+
2379
+ const statusBandRows = useCallback(() => {
2380
+ const rows = Math.max(1, Number(frameRowsRef.current) || 24);
2381
+ const top = Math.max(0, rows - STATUSLINE_BAND_ROWS);
2382
+ return { top, bottom: Math.max(top, rows - 1) };
2383
+ }, []);
2384
+
2385
+ const selectionMaxColAtRow = useCallback((row) => {
2386
+ const lr = store.getLineRectAt?.(row);
2387
+ if (lr != null && Number.isFinite(lr.x2)) return Math.max(0, lr.x2);
2388
+ return Math.max(0, frameColumns - 1);
2389
+ }, [store, frameColumns]);
2390
+
2391
+ const moveSelectionFocus = useCallback((move) => {
2392
+ const drag = dragRef.current;
2393
+ if (drag.active) return false;
2394
+ const region = drag.region;
2395
+ if (region !== 'transcript' && region !== 'status') return false;
2396
+ const rect = drag.rect;
2397
+ if (!rect) return false;
2398
+ if (rect.x1 === rect.x2 && rect.y1 === rect.y2) return false;
2399
+
2400
+ const anchor = { x: rect.x1, y: rect.y1 };
2401
+ let col = rect.x2;
2402
+ let row = rect.y2;
2403
+ const beforeCol = col;
2404
+ const beforeRow = row;
2405
+
2406
+ const { top, bottom } = region === 'status' ? statusBandRows() : transcriptViewportRows();
2407
+
2408
+ switch (move) {
2409
+ case 'left':
2410
+ if (col > 0) col -= 1;
2411
+ else if (row > top) {
2412
+ row -= 1;
2413
+ col = selectionMaxColAtRow(row);
2414
+ }
2415
+ break;
2416
+ case 'right': {
2417
+ const maxCol = selectionMaxColAtRow(row);
2418
+ if (col < maxCol) col += 1;
2419
+ else if (row < bottom) {
2420
+ row += 1;
2421
+ col = 0;
2422
+ }
2423
+ break;
2424
+ }
2425
+ case 'up':
2426
+ if (row > top) row -= 1;
2427
+ break;
2428
+ case 'down':
2429
+ if (row < bottom) row += 1;
2430
+ break;
2431
+ case 'lineStart':
2432
+ col = 0;
2433
+ break;
2434
+ case 'lineEnd':
2435
+ col = selectionMaxColAtRow(row);
2436
+ break;
2437
+ default:
2438
+ return false;
2439
+ }
2440
+
2441
+ row = Math.max(top, Math.min(bottom, row));
2442
+ col = Math.max(0, Math.min(selectionMaxColAtRow(row), col));
2443
+
2444
+ if (col === beforeCol && row === beforeRow) return false;
2445
+
2446
+ if (drag.anchorSpan) drag.anchorSpan = null;
2447
+
2448
+ const focus = { x: col, y: row };
2449
+ applySelectionRect({
2450
+ mode: 'linear',
2451
+ x1: anchor.x,
2452
+ y1: anchor.y,
2453
+ x2: focus.x,
2454
+ y2: focus.y,
2455
+ });
2456
+ drag.last = { x: focus.x, y: focus.y };
2457
+ return true;
2458
+ }, [applySelectionRect, statusBandRows, transcriptViewportRows, selectionMaxColAtRow]);
2459
+
2035
2460
  useEffect(() => () => {
2036
2461
  const paintState = selectionPaintRef.current;
2037
2462
  if (paintState.timer) clearTimeout(paintState.timer);
@@ -2055,7 +2480,7 @@ export function App({ store, initialStatusLine = '' }) {
2055
2480
  // streaming growth could lurch the view. At/over the bottom, drop the anchor
2056
2481
  // so the bottom-follow path owns the viewport again.
2057
2482
  if (appliedDelta !== 0) {
2058
- if (target <= 0) {
2483
+ if (target <= Math.max(0, Number(transcriptBottomSlackRowsRef.current) || 0)) {
2059
2484
  transcriptAnchorRef.current = null;
2060
2485
  transcriptAnchorDirtyRef.current = false;
2061
2486
  } else {
@@ -2087,9 +2512,16 @@ export function App({ store, initialStatusLine = '' }) {
2087
2512
  if (appliedDelta !== 0 && dragRef.current.rect) {
2088
2513
  let rect;
2089
2514
  if (dragRef.current.active) {
2090
- const { anchor, anchorScroll, last } = dragRef.current;
2091
- const currentAnchor = selectionPointAtCurrentScroll(anchor, anchorScroll);
2092
- rect = currentAnchor && last ? { mode: 'linear', x1: currentAnchor.x, y1: currentAnchor.y, x2: last.x, y2: last.y } : null;
2515
+ const { anchor, anchorScroll, last, anchorSpan, region } = dragRef.current;
2516
+ if (anchorSpan && last) {
2517
+ // Word/line multi-click drag that reached the edge: keep extending by
2518
+ // whole words/lines from the span to the word/line at the current cell,
2519
+ // NOT collapsing to a char {anchor->last} rect. Mirrors the motion path.
2520
+ rect = buildSpanRect(anchorSpan, last.x, last.y, region, anchorScroll);
2521
+ } else {
2522
+ const currentAnchor = selectionPointAtCurrentScroll(anchor, anchorScroll);
2523
+ rect = currentAnchor && last ? { mode: 'linear', x1: currentAnchor.x, y1: currentAnchor.y, x2: last.x, y2: last.y } : null;
2524
+ }
2093
2525
  } else {
2094
2526
  rect = shiftSelectionRectY(dragRef.current.rect, appliedDelta);
2095
2527
  }
@@ -2104,7 +2536,7 @@ export function App({ store, initialStatusLine = '' }) {
2104
2536
  stopSmoothScroll();
2105
2537
  scrollPositionRef.current = target;
2106
2538
  setScrollOffset(Math.round(target));
2107
- }, [startSmoothScroll, stopSmoothScroll, paintSelectionRect, selectionPointAtCurrentScroll, withSelectionClip, cancelTranscriptFollow]);
2539
+ }, [startSmoothScroll, stopSmoothScroll, paintSelectionRect, selectionPointAtCurrentScroll, withSelectionClip, cancelTranscriptFollow, buildSpanRect]);
2108
2540
 
2109
2541
  const passthroughCtrlWheelZoom = useCallback(() => {
2110
2542
  if (!stdout?.write) return;
@@ -2161,6 +2593,8 @@ export function App({ store, initialStatusLine = '' }) {
2161
2593
  y2: b.y,
2162
2594
  };
2163
2595
  };
2596
+ // Word/line multi-click drag-extension uses the hoisted buildSpanRect (same
2597
+ // logic reachable from the auto-scroll path in scrollTranscriptRows).
2164
2598
  const transcriptViewport = () => {
2165
2599
  const top = Math.max(0, Number(transcriptViewportRef.current?.top) || 0);
2166
2600
  const bottom = Math.max(top, Number(transcriptViewportRef.current?.bottom) || top);
@@ -2221,6 +2655,7 @@ export function App({ store, initialStatusLine = '' }) {
2221
2655
  // arrives whole. Guard so non-mouse keystrokes fall through untouched.
2222
2656
  const s = typeof data === 'string' ? data : String(data ?? '');
2223
2657
  if (s.indexOf('\x1b[<') === -1) return;
2658
+ dismissWelcomePromptHint();
2224
2659
  let up = 0;
2225
2660
  let down = 0;
2226
2661
  let m;
@@ -2254,7 +2689,7 @@ export function App({ store, initialStatusLine = '' }) {
2254
2689
  applySelectionRect(null);
2255
2690
  const offset = promptOffsetAt(x, y);
2256
2691
  stopSmoothScroll();
2257
- dragRef.current = { anchor: { x, y }, anchorScroll: 0, last: { x, y }, active: true, rect: null, region: 'prompt' };
2692
+ dragRef.current = { anchor: { x, y }, anchorScroll: 0, last: { x, y }, active: true, rect: null, region: 'prompt', anchorSpan: null };
2258
2693
  if (offset != null) promptMouseSelectionRef.current?.anchorAt?.(offset);
2259
2694
  continue;
2260
2695
  }
@@ -2264,45 +2699,65 @@ export function App({ store, initialStatusLine = '' }) {
2264
2699
  lastClickRef.current = { x: -1, y: -1, t: 0 };
2265
2700
  dragRef.current.active = false;
2266
2701
  dragRef.current.region = null;
2702
+ dragRef.current.anchorSpan = null;
2267
2703
  clearAllSelections();
2268
2704
  continue;
2269
2705
  }
2270
2706
  const region = inTranscript ? 'transcript' : 'status';
2271
2707
  // A press always clears the prompt-box selection (single active region).
2272
2708
  promptMouseSelectionRef.current?.clear?.();
2273
- // Double-click on a word: select just that word and copy it, reusing
2274
- // the existing selection/copy pipeline, then advance to the next event.
2275
- // Works for transcript AND status rows since getWordRectAt is grid-based.
2709
+ // Multi-click sequence: 2nd consecutive press = word (double-click),
2710
+ // 3rd = whole line (triple-click). Each press must land near the prior
2711
+ // one within 500ms up to 2 columns and 1 row of drift (terminals
2712
+ // often report a shifted cell on repeat clicks); tighter matching made
2713
+ // word selection unreliable. A 4th qualifying press restarts the
2714
+ // sequence at 1 (claude-code cycles; simplest: reset). Works for
2715
+ // transcript AND status rows since getWordRectAt/getLineRectAt are
2716
+ // grid-based. Copy still happens on Ctrl+C, never here.
2276
2717
  const now = Date.now();
2277
2718
  const lc = lastClickRef.current;
2278
- // Treat a second press as a double-click when it lands near the first
2279
- // press within 500ms: up to 2 columns and 1 row of drift (terminals
2280
- // often report a shifted cell on the second click). Tighter matching
2281
- // made double-click word selection unreliable.
2282
- const isDouble = (now - lc.t) < 500
2719
+ const qualifies = (now - lc.t) < 500
2283
2720
  && Math.abs(lc.y - y) <= 1
2284
2721
  && Math.abs(lc.x - x) <= 2;
2285
- if (isDouble) {
2286
- // Consume this click so a following press is not mistaken for another
2287
- // double-click (avoids a stray single-cell selection flicker).
2288
- lastClickRef.current = { x: -1, y: -1, t: 0 };
2289
- const wr = store.getWordRectAt?.(x, y);
2722
+ let clickCount = qualifies ? (lc.count || 1) + 1 : 1;
2723
+ if (clickCount > 3) clickCount = 1;
2724
+ if (clickCount === 2 || clickCount === 3) {
2725
+ // Word (2) or line (3) select. Snap to the word/line under the cell
2726
+ // and record the span on dragRef so a following drag extends by whole
2727
+ // words/lines from this span (see buildSpanRect). Leave the drag
2728
+ // ARMED (active:true): a release without motion keeps this highlight
2729
+ // (buildSpanRect returns the span for an in-span target), while
2730
+ // any motion extends it. Mirrors selectWordAt/selectLineAt setting
2731
+ // isDragging=true + anchorSpan; the mouse-up finalizes.
2732
+ const kind = clickCount === 2 ? 'word' : 'line';
2733
+ const wr = kind === 'word' ? store.getWordRectAt?.(x, y) : store.getLineRectAt?.(y);
2290
2734
  if (wr) {
2291
- const rect = linearSelection({ x: wr.x1, y: wr.y1 }, { x: wr.x2, y: wr.y2 });
2735
+ const lo = { x: wr.x1, y: wr.y1 };
2736
+ const hi = { x: wr.x2, y: wr.y2 };
2737
+ const rect = linearSelection(lo, hi);
2292
2738
  stopSmoothScroll();
2293
- dragRef.current = { anchor: null, anchorScroll: 0, last: null, active: false, rect: null, region };
2739
+ dragRef.current = {
2740
+ anchor: { x, y },
2741
+ anchorScroll: region === 'transcript' ? scrollTargetRef.current : 0,
2742
+ last: { x, y },
2743
+ active: true,
2744
+ rect: null,
2745
+ region,
2746
+ anchorSpan: { lo, hi, kind },
2747
+ };
2294
2748
  applySelectionRect(rect);
2295
- // Selection only copy happens on Ctrl+C (see useInput), not here.
2749
+ lastClickRef.current = { x, y, t: now, count: clickCount };
2296
2750
  continue;
2297
2751
  }
2298
2752
  }
2299
- lastClickRef.current = { x, y, t: now };
2753
+ lastClickRef.current = { x, y, t: now, count: 1 };
2300
2754
  // Left-button press: begin a new selection anchored here.
2301
2755
  // Anchor the drag but do NOT paint a zero-width selection yet; a plain
2302
2756
  // single click should not flash a one-cell highlight. The selection is
2303
2757
  // only rendered once a drag actually extends past the anchor.
2304
2758
  // Status-band selections do NOT scroll, so anchorScroll is irrelevant
2305
2759
  // there; keep the transcript scroll anchor only for the transcript.
2760
+ // Plain single press clears any word/line anchorSpan (char-drag mode).
2306
2761
  stopSmoothScroll();
2307
2762
  dragRef.current = {
2308
2763
  anchor: { x, y },
@@ -2311,6 +2766,7 @@ export function App({ store, initialStatusLine = '' }) {
2311
2766
  active: true,
2312
2767
  rect: null,
2313
2768
  region,
2769
+ anchorSpan: null,
2314
2770
  };
2315
2771
  } else if (baseButton === 0 && isMotion && dragRef.current.active) {
2316
2772
  const region = dragRef.current.region;
@@ -2327,11 +2783,19 @@ export function App({ store, initialStatusLine = '' }) {
2327
2783
  // current cell, clamped to the owning region's band.
2328
2784
  const selectionY = region === 'status' ? clampToStatusBand(y) : clampToTranscriptViewport(y);
2329
2785
  dragRef.current.last = { x, y: selectionY };
2330
- const anchor = region === 'status'
2331
- ? dragRef.current.anchor
2332
- : selectionPointAtCurrentScroll(dragRef.current.anchor, dragRef.current.anchorScroll);
2333
- const rect = linearSelection(anchor, { x, y: selectionY });
2334
- applySelectionRectThrottled(rect);
2786
+ const span = dragRef.current.anchorSpan;
2787
+ if (span) {
2788
+ // Word/line multi-click drag: extend by whole words/lines from the
2789
+ // anchor span to the word/line under the cursor (see buildSpanRect).
2790
+ const rect = buildSpanRect(span, x, selectionY, region, dragRef.current.anchorScroll);
2791
+ applySelectionRectThrottled(rect);
2792
+ } else {
2793
+ const anchor = region === 'status'
2794
+ ? dragRef.current.anchor
2795
+ : selectionPointAtCurrentScroll(dragRef.current.anchor, dragRef.current.anchorScroll);
2796
+ const rect = linearSelection(anchor, { x, y: selectionY });
2797
+ applySelectionRectThrottled(rect);
2798
+ }
2335
2799
  // Auto-scroll-while-dragging is transcript-only (the status band does
2336
2800
  // not scroll).
2337
2801
  if (region === 'transcript') {
@@ -2348,25 +2812,35 @@ export function App({ store, initialStatusLine = '' }) {
2348
2812
  // Finalize the prompt selection; highlight persists (copy on Ctrl+C).
2349
2813
  const offset = promptOffsetAt(x, y);
2350
2814
  dragRef.current.active = false;
2351
- if (offset != null) promptMouseSelectionRef.current?.extendTo?.(offset);
2815
+ promptMouseSelectionRef.current?.extendTo?.(offset, true);
2352
2816
  continue;
2353
2817
  }
2354
2818
  // Button release while dragging: finalize with the release coordinate
2355
2819
  // (the SGR release event carries col/row) and keep the selection
2356
2820
  // visible. Copy is NOT automatic — the user presses Ctrl+C to copy.
2357
2821
  // The highlight stays until ESC or a plain click.
2358
- const anchor = region === 'status'
2359
- ? dragRef.current.anchor
2360
- : selectionPointAtCurrentScroll(dragRef.current.anchor, dragRef.current.anchorScroll);
2361
- dragRef.current.active = false;
2822
+ const span = dragRef.current.anchorSpan;
2362
2823
  const releaseY = region === 'status' ? clampToStatusBand(y) : clampToTranscriptViewport(y);
2363
- const rect = linearSelection(anchor, { x, y: releaseY });
2364
- const empty = rect.x1 === rect.x2 && rect.y1 === rect.y2;
2365
- if (empty) {
2366
- applySelectionRect(null); // a plain click clears any prior highlight
2367
- } else {
2368
- // Push the final rect so ink re-renders the visible selection.
2824
+ dragRef.current.active = false;
2825
+ if (span) {
2826
+ // Word/line multi-click release: finalize from the span to the
2827
+ // word/line at the release cell. A release-without-motion resolves
2828
+ // to the span itself (in-span target), so the original word/line
2829
+ // highlight stays never cleared as "empty" like a bare click.
2830
+ const rect = buildSpanRect(span, x, releaseY, region, dragRef.current.anchorScroll);
2369
2831
  applySelectionRect(rect);
2832
+ } else {
2833
+ const anchor = region === 'status'
2834
+ ? dragRef.current.anchor
2835
+ : selectionPointAtCurrentScroll(dragRef.current.anchor, dragRef.current.anchorScroll);
2836
+ const rect = linearSelection(anchor, { x, y: releaseY });
2837
+ const empty = rect.x1 === rect.x2 && rect.y1 === rect.y2;
2838
+ if (empty) {
2839
+ applySelectionRect(null); // a plain click clears any prior highlight
2840
+ } else {
2841
+ // Push the final rect so ink re-renders the visible selection.
2842
+ applySelectionRect(rect);
2843
+ }
2370
2844
  }
2371
2845
  // Drag is over: the measured-height harvest was skipped for every
2372
2846
  // motion commit (see the harvest effect's dragRef guard), so force one
@@ -2392,7 +2866,28 @@ export function App({ store, initialStatusLine = '' }) {
2392
2866
  };
2393
2867
  inkInput.on('input', onData);
2394
2868
  return () => { inkInput.off('input', onData); };
2395
- }, [inkInput, isRawModeSupported, store, passthroughCtrlWheelZoom, resizeState.rows, scrollTranscriptRows, applySelectionRect, applySelectionRectThrottled, selectionPointAtCurrentScroll]);
2869
+ }, [inkInput, isRawModeSupported, store, passthroughCtrlWheelZoom, resizeState.rows, scrollTranscriptRows, applySelectionRect, applySelectionRectThrottled, selectionPointAtCurrentScroll, buildSpanRect, dismissWelcomePromptHint]);
2870
+
2871
+ // Enable extended keyboard reporting (kitty + xterm modifyOtherKeys) the same
2872
+ // way Claude Code does: SYNCHRONOUSLY, ONCE, with NO query/round-trip. ink
2873
+ // turns raw mode on during the first useInput mount (synchronously, inside
2874
+ // render); this mount effect runs in the same commit phase, right after — i.e.
2875
+ // before the user can realistically press a key. We write BOTH enables
2876
+ // unconditionally (the terminal honors whichever it implements; Windows
2877
+ // Terminal 1.24 has no kitty but DOES honor modifyOtherKeys), gated only by the
2878
+ // supportsExtendedKeys() allowlist. Because the enable lands before the first
2879
+ // keypress is read, the FIRST Ctrl+Enter already arrives as a distinguishable
2880
+ // \x1b[27;5;13~ (or kitty \x1b[13;5u) instead of a bare \r — fixing the old
2881
+ // "first Ctrl+Enter submits, second works" race. Teardown lives in index.jsx's
2882
+ // restoreTerminal(). The empty dep array makes this run exactly once on mount.
2883
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2884
+ useEffect(() => {
2885
+ if (!isRawModeSupported || !stdout?.write) return;
2886
+ if (!supportsExtendedKeys()) return;
2887
+ try {
2888
+ stdout.write(ENABLE_KITTY_KEYBOARD + ENABLE_MODIFY_OTHER_KEYS);
2889
+ } catch { /* terminal may be closing */ }
2890
+ }, []);
2396
2891
 
2397
2892
  // Item-count changes are the only time we can arm follow before row totals are
2398
2893
  // recomputed. Pure streaming height growth is handled in the row-delta effect.
@@ -2405,7 +2900,7 @@ export function App({ store, initialStatusLine = '' }) {
2405
2900
  return;
2406
2901
  }
2407
2902
  if (count === previousCount || dragRef.current.active) return;
2408
- if (scrollTargetRef.current <= 0 || followingRef.current) followingRef.current = true;
2903
+ if (scrollTargetRef.current <= transcriptBottomSlackRows || followingRef.current) followingRef.current = true;
2409
2904
  }, [state.items.length, resetTranscriptScroll]);
2410
2905
 
2411
2906
  // `exiting` removes the inline caret (PromptInput draws none when disabled) and
@@ -2445,6 +2940,7 @@ export function App({ store, initialStatusLine = '' }) {
2445
2940
  }
2446
2941
  if (restoreDraft) {
2447
2942
  if (restored.pastedImages) installPastedImages(restored.pastedImages, { merge: true });
2943
+ syncPromptLayoutRows(restored.text);
2448
2944
  setPromptDraftOverride({ id: Date.now(), value: restored.text });
2449
2945
  }
2450
2946
  if (showHint) {
@@ -2453,7 +2949,7 @@ export function App({ store, initialStatusLine = '' }) {
2453
2949
  clearPromptHint();
2454
2950
  }
2455
2951
  return true;
2456
- }, [store, promptDraft, showPromptHint, clearPromptHint, installPastedImages]);
2952
+ }, [store, promptDraft, showPromptHint, clearPromptHint, installPastedImages, syncPromptLayoutRows]);
2457
2953
 
2458
2954
  const recentPromptHistory = useMemo(() => {
2459
2955
  const items = Array.isArray(state.items) ? state.items : [];
@@ -2539,6 +3035,18 @@ export function App({ store, initialStatusLine = '' }) {
2539
3035
  // the active turn on the same Esc press.
2540
3036
  // - empty prompt + active turn interrupts the active turn.
2541
3037
  const handlePromptEscape = useCallback((text = '', meta = {}) => {
3038
+ // Recording takes priority over every other Esc branch (usage/context
3039
+ // panels, slash-clear, queue-restore, turn-interrupt): a live mic capture
3040
+ // must never silently keep running because Esc was consumed by something
3041
+ // else first. Only consumes Esc while actually RECORDING — transcribing
3042
+ // (already stopped, awaiting the HTTP round-trip) and idle fall through
3043
+ // to the existing branches unchanged.
3044
+ if (getRecorderState() === 'recording') {
3045
+ cancelRecording();
3046
+ setVoiceStatusHint('', 'info');
3047
+ store.pushNotice('Voice: recording cancelled', 'plain');
3048
+ return true;
3049
+ }
2542
3050
  if (usagePanel) { closeUsagePanel(); return true; }
2543
3051
  if (contextPanel) { setContextPanel(null); return true; }
2544
3052
 
@@ -2553,19 +3061,20 @@ export function App({ store, initialStatusLine = '' }) {
2553
3061
  // Idle + empty + nothing to restore: nothing (double-press from empty
2554
3062
  // opens message selector, but we don't have that feature yet).
2555
3063
  return false;
2556
- }, [contextPanel, usagePanel, closeUsagePanel, restoreQueuedToPrompt, clearPromptHint, clearPastedImagesSnapshot]);
3064
+ }, [contextPanel, usagePanel, closeUsagePanel, restoreQueuedToPrompt, clearPromptHint, clearPastedImagesSnapshot, setVoiceStatusHint, store]);
2557
3065
 
2558
3066
  const handlePromptInterrupt = useCallback((currentText = '') => {
2559
3067
  const result = store.abort?.();
2560
3068
  if (result?.aborted === false) return undefined;
2561
3069
  if (result?.pastedImages) installPastedImages(result.pastedImages, { merge: true });
3070
+ if (result?.discardPastedImages) clearPastedImagesSnapshot(result.discardPastedImages);
2562
3071
  const restoreText = String(result?.restoreText || '').trim();
2563
3072
  if (!restoreText) return undefined;
2564
3073
  const existingText = String(currentText || '').trim();
2565
3074
  const nextText = [restoreText, existingText].filter(Boolean).join('\n');
2566
3075
  clearPromptHint();
2567
3076
  return nextText;
2568
- }, [store, clearPromptHint, installPastedImages]);
3077
+ }, [store, clearPromptHint, installPastedImages, clearPastedImagesSnapshot]);
2569
3078
 
2570
3079
  // Ctrl+O toggles the global tool-output expansion, matching common terminal-chat
2571
3080
  // expectation that this is a view mode rather than a per-card hidden state.
@@ -2574,6 +3083,7 @@ export function App({ store, initialStatusLine = '' }) {
2574
3083
  }, []);
2575
3084
 
2576
3085
  useInput((input, key) => {
3086
+ if (!welcomePromptHintDismissed) dismissWelcomePromptHint();
2577
3087
  if (toolApproval) {
2578
3088
  const value = String(input || '').trim().toLowerCase();
2579
3089
  if (key.escape || value === 'd' || value === 'n') {
@@ -2615,6 +3125,20 @@ export function App({ store, initialStatusLine = '' }) {
2615
3125
  toggleExpand();
2616
3126
  return;
2617
3127
  }
3128
+ if (
3129
+ !picker
3130
+ && key.shift
3131
+ && (key.leftArrow || key.rightArrow || key.upArrow || key.downArrow || key.home || key.end)
3132
+ ) {
3133
+ let move = null;
3134
+ if (key.leftArrow) move = 'left';
3135
+ else if (key.rightArrow) move = 'right';
3136
+ else if (key.upArrow) move = 'up';
3137
+ else if (key.downArrow) move = 'down';
3138
+ else if (key.home) move = 'lineStart';
3139
+ else if (key.end) move = 'lineEnd';
3140
+ if (move && moveSelectionFocus(move)) return;
3141
+ }
2618
3142
  if (key.escape && usagePanel && !picker) {
2619
3143
  closeUsagePanel();
2620
3144
  return;
@@ -2642,6 +3166,7 @@ export function App({ store, initialStatusLine = '' }) {
2642
3166
  if (key.escape && !picker) {
2643
3167
  dragRef.current.active = false;
2644
3168
  dragRef.current.region = null;
3169
+ dragRef.current.anchorSpan = null;
2645
3170
  // Clear whichever region's selection is active. PromptInput's own ESC also
2646
3171
  // clears its selection when focused/enabled; this covers the disabled case
2647
3172
  // and a status/transcript ink-grid selection in one press.
@@ -3451,6 +3976,10 @@ export function App({ store, initialStatusLine = '' }) {
3451
3976
 
3452
3977
  const openOutputStylePicker = (options = {}) => {
3453
3978
  const returnTo = typeof options.returnTo === 'function' ? options.returnTo : null;
3979
+ // Onboarding mode: Enter (row select) and ConfirmBar Next must both persist
3980
+ // the chosen style, then advance. `onboarding.onAdvance/onBack` drive the
3981
+ // wizard; the confirm bar is built here so both paths share `saveStyle`.
3982
+ const onboarding = options.onboarding || null;
3454
3983
  let status = null;
3455
3984
  try {
3456
3985
  status = store.listOutputStyles?.() || null;
@@ -3464,6 +3993,7 @@ export function App({ store, initialStatusLine = '' }) {
3464
3993
  return;
3465
3994
  }
3466
3995
  const currentId = status?.current?.id || 'default';
3996
+ let highlightedStyleId = currentId;
3467
3997
  const items = styles.map((style) => ({
3468
3998
  value: style.id,
3469
3999
  label: style.label || style.id,
@@ -3478,30 +4008,55 @@ export function App({ store, initialStatusLine = '' }) {
3478
4008
  setSettingsPrompt(null);
3479
4009
  setContextPanel(null);
3480
4010
  closeUsagePanel();
4011
+ const saveStyle = (styleId, { advance = false } = {}) => {
4012
+ if (!styleId) return;
4013
+ setPicker(null);
4014
+ void store.setOutputStyle?.(styleId)
4015
+ .then((result) => {
4016
+ if (!result) {
4017
+ store.pushNotice('Output style switch is already running.', 'warn');
4018
+ } else {
4019
+ store.pushNotice(outputStyleNotice(result), 'info');
4020
+ }
4021
+ if (advance && onboarding) onboarding.onAdvance?.();
4022
+ else if (returnTo) returnTo();
4023
+ })
4024
+ .catch((e) => store.pushNotice(`Couldn’t switch output style: ${e?.message || e}`, 'error'));
4025
+ };
3481
4026
  setPicker({
3482
4027
  title: 'Output Style',
3483
4028
  description: 'Select response style.',
3484
- help: returnTo ? '↑/↓ Select · Enter Choose · Esc Settings' : '↑/↓ Select · Enter Choose · Esc Back',
4029
+ // Onboarding uses a ConfirmBar (←/→ = Back/Next); let the Picker supply
4030
+ // its ConfirmBar help instead of a stale ←/→ hint.
4031
+ help: onboarding ? undefined : (returnTo ? '↑/↓ Select · Enter Choose · Esc Settings' : '↑/↓ Select · Enter Choose · Esc Back'),
3485
4032
  labelWidth: 18,
3486
4033
  items,
4034
+ confirmBar: onboarding ? {
4035
+ buttons: [
4036
+ { value: 'back', label: '◀ Back' },
4037
+ { value: 'next', label: 'Next ▶' },
4038
+ ],
4039
+ onConfirm: (button) => {
4040
+ if (button.value === 'back') {
4041
+ setPicker(null);
4042
+ onboarding.onBack?.();
4043
+ return;
4044
+ }
4045
+ saveStyle(highlightedStyleId, { advance: true });
4046
+ },
4047
+ } : (options.confirmBar || null),
4048
+ onHighlight: onboarding ? (_value, item) => {
4049
+ if (item?._style?.id) highlightedStyleId = item._style.id;
4050
+ } : undefined,
3487
4051
  onSelect: (_value, item) => {
3488
4052
  const style = item?._style;
3489
4053
  if (!style) return;
3490
- setPicker(null);
3491
- void store.setOutputStyle?.(style.id)
3492
- .then((result) => {
3493
- if (!result) {
3494
- store.pushNotice('Output style switch is already running.', 'warn');
3495
- return;
3496
- }
3497
- store.pushNotice(outputStyleNotice(result), 'info');
3498
- if (returnTo) returnTo();
3499
- })
3500
- .catch((e) => store.pushNotice(`Couldn’t switch output style: ${e?.message || e}`, 'error'));
4054
+ saveStyle(style.id, { advance: Boolean(onboarding) });
3501
4055
  },
3502
4056
  onCancel: () => {
3503
4057
  setPicker(null);
3504
- if (returnTo) returnTo();
4058
+ if (onboarding) onboarding.onCancel?.();
4059
+ else if (returnTo) returnTo();
3505
4060
  },
3506
4061
  });
3507
4062
  };
@@ -3510,6 +4065,7 @@ export function App({ store, initialStatusLine = '' }) {
3510
4065
 
3511
4066
  const openThemePicker = (options = {}) => {
3512
4067
  const returnTo = typeof options.returnTo === 'function' ? options.returnTo : null;
4068
+ const onboarding = options.onboarding || null;
3513
4069
  let themes = [];
3514
4070
  try {
3515
4071
  themes = store.listThemes?.() || [];
@@ -3522,6 +4078,7 @@ export function App({ store, initialStatusLine = '' }) {
3522
4078
  return;
3523
4079
  }
3524
4080
  const currentId = store.getTheme?.() || themes.find((t) => t.current)?.id || themes[0]?.id;
4081
+ let highlightedThemeId = currentId;
3525
4082
  const items = themes.map((entry) => ({
3526
4083
  value: entry.id,
3527
4084
  label: entry.label || entry.id,
@@ -3543,31 +4100,57 @@ export function App({ store, initialStatusLine = '' }) {
3543
4100
  return null;
3544
4101
  }
3545
4102
  };
4103
+ // Onboarding: Enter (row) and ConfirmBar Next both persist the highlighted
4104
+ // theme, then advance; Back restores the original palette then steps back.
4105
+ const saveTheme = (id, { advance = false } = {}) => {
4106
+ setPicker(null);
4107
+ const applied = applyTheme(id, { persist: true });
4108
+ store.pushNotice(themeNotice(applied || { id }), 'info');
4109
+ if (advance && onboarding) onboarding.onAdvance?.();
4110
+ else if (returnTo) returnTo();
4111
+ };
3546
4112
  setPicker({
3547
4113
  title: 'Theme',
3548
4114
  description: 'Choose the color theme that looks best with your terminal.',
3549
- help: returnTo ? '↑/↓ Preview · Enter Choose · Esc Settings' : '↑/↓ Preview · Enter Choose · Esc Back',
4115
+ help: onboarding ? undefined : (returnTo ? '↑/↓ Preview · Enter Choose · Esc Settings' : '↑/↓ Preview · Enter Choose · Esc Back'),
3550
4116
  labelWidth: 22,
3551
4117
  initialIndex: Math.max(0, items.findIndex((item) => item.value === currentId)),
3552
4118
  items,
4119
+ confirmBar: onboarding ? {
4120
+ buttons: [
4121
+ { value: 'back', label: '◀ Back' },
4122
+ { value: 'next', label: 'Next ▶' },
4123
+ ],
4124
+ onConfirm: (button) => {
4125
+ if (button.value === 'back') {
4126
+ setPicker(null);
4127
+ // Restore the palette active before the picker opened, then step back.
4128
+ if (currentId) applyTheme(currentId, { persist: false });
4129
+ onboarding.onBack?.();
4130
+ return;
4131
+ }
4132
+ saveTheme(highlightedThemeId, { advance: true });
4133
+ },
4134
+ } : (options.confirmBar || null),
3553
4135
  // Live preview while moving: apply (no persist) so the surface re-tones
3554
4136
  // as the selection moves. Enter persists; Esc restores the original.
3555
4137
  onHighlight: (_value, item) => {
3556
- if (item?._theme?.id) applyTheme(item._theme.id, { persist: false });
4138
+ if (item?._theme?.id) {
4139
+ highlightedThemeId = item._theme.id;
4140
+ applyTheme(item._theme.id, { persist: false });
4141
+ }
3557
4142
  },
3558
4143
  onSelect: (_value, item) => {
3559
4144
  const entry = item?._theme;
3560
4145
  if (!entry) return;
3561
- setPicker(null);
3562
- const applied = applyTheme(entry.id, { persist: true });
3563
- store.pushNotice(themeNotice(applied || entry), 'info');
3564
- if (returnTo) returnTo();
4146
+ saveTheme(entry.id, { advance: Boolean(onboarding) });
3565
4147
  },
3566
4148
  onCancel: () => {
3567
4149
  setPicker(null);
3568
4150
  // Restore the theme that was active before the picker opened.
3569
4151
  if (currentId) applyTheme(currentId, { persist: false });
3570
- if (returnTo) returnTo();
4152
+ if (onboarding) onboarding.onCancel?.();
4153
+ else if (returnTo) returnTo();
3571
4154
  },
3572
4155
  });
3573
4156
  };
@@ -3815,7 +4398,7 @@ export function App({ store, initialStatusLine = '' }) {
3815
4398
  const pct = (value, total = windowTokens) => {
3816
4399
  const n = Number(value || 0);
3817
4400
  const d = Number(total || 0);
3818
- if (!d) return 'n/a';
4401
+ if (!d) return 'N/A';
3819
4402
  return `${((n / d) * 100).toFixed(n > 0 && n < d / 100 ? 1 : 0)}%`;
3820
4403
  };
3821
4404
  const fmt = (value) => {
@@ -3827,11 +4410,17 @@ export function App({ store, initialStatusLine = '' }) {
3827
4410
  return `${Math.round(n)}`;
3828
4411
  };
3829
4412
  const cachedRead = Number(usage.lastCachedReadTokens || 0);
3830
- const freshInput = Number(usage.lastInputTokens || 0);
3831
- const cacheDenom = cachedRead + freshInput;
4413
+ const cacheWrite = Number(usage.lastCacheWriteTokens || 0);
4414
+ const freshInput = Number(
4415
+ usage.lastUncachedInputTokens != null
4416
+ ? usage.lastUncachedInputTokens
4417
+ : Math.max(Number(usage.lastInputTokens || 0) - cachedRead - cacheWrite, 0)
4418
+ );
4419
+ const cacheDenom = Number(usage.lastContextTokens || 0) || (cachedRead + freshInput + cacheWrite);
3832
4420
  const cacheHitRate = cacheDenom > 0
3833
4421
  ? `${((cachedRead / cacheDenom) * 100).toFixed(0)}%`
3834
- : 'n/a';
4422
+ : 'N/A';
4423
+ const cacheWriteLabel = cacheWrite > 0 ? ` · ${fmt(cacheWrite)} write` : '';
3835
4424
  const contextSource = context.usedSource === 'last_api_request' ? 'last API request' : 'estimated';
3836
4425
  const lastApiLabel = context.lastApiRequestStale ? 'last API request (pre-compact)' : 'last API request';
3837
4426
  const compactElapsed = (value) => {
@@ -3899,13 +4488,13 @@ export function App({ store, initialStatusLine = '' }) {
3899
4488
  {
3900
4489
  value: 'last-api',
3901
4490
  label: 'Last API usage',
3902
- description: `${fmt(usage.lastContextTokens)} context · ${fmt(usage.lastInputTokens)} input · ${fmt(usage.lastOutputTokens)} output · ${lastApiLabel}`,
4491
+ description: `${fmt(usage.lastContextTokens)} context · ${fmt(freshInput)} uncached input · ${fmt(usage.lastOutputTokens)} output · ${lastApiLabel}`,
3903
4492
  _action: 'last-api',
3904
4493
  },
3905
4494
  {
3906
4495
  value: 'cache',
3907
4496
  label: 'Prompt cache',
3908
- description: `${cacheHitRate} hit · ${fmt(usage.lastCachedReadTokens)} read · ${fmt(usage.lastInputTokens)} new (last request)`,
4497
+ description: `${cacheHitRate} hit · ${fmt(usage.lastCachedReadTokens)} read${cacheWriteLabel} · ${fmt(freshInput)} new (last request)`,
3909
4498
  _action: 'cache',
3910
4499
  },
3911
4500
  {
@@ -3970,12 +4559,14 @@ export function App({ store, initialStatusLine = '' }) {
3970
4559
  },
3971
4560
  lastApi: {
3972
4561
  contextTokens: usage.lastContextTokens,
3973
- inputTokens: usage.lastInputTokens,
4562
+ inputTokens: freshInput,
4563
+ rawInputTokens: usage.lastInputTokens,
3974
4564
  outputTokens: usage.lastOutputTokens,
3975
4565
  },
3976
4566
  cache: {
3977
4567
  hitRate: cacheHitRate,
3978
4568
  readTokens: usage.lastCachedReadTokens,
4569
+ writeTokens: cacheWrite,
3979
4570
  },
3980
4571
  extensions: {
3981
4572
  skills: skills.count,
@@ -4025,6 +4616,17 @@ export function App({ store, initialStatusLine = '' }) {
4025
4616
  const plugins = store.pluginsStatus?.() || { count: 0 };
4026
4617
  const skills = store.skillsStatus?.() || { count: 0 };
4027
4618
  const channelWorker = store.getChannelWorkerStatus?.();
4619
+ let channelBackend = 'discord';
4620
+ try {
4621
+ channelBackend = store.getChannelSetup?.()?.backend || 'discord';
4622
+ } catch {
4623
+ channelBackend = 'discord';
4624
+ }
4625
+ const channelBackendLabel = channelBackend === 'telegram' ? 'Telegram' : 'Discord';
4626
+ const remoteEnabled = store.isRemoteEnabled?.() === true;
4627
+ const remoteRuntimeDescription = channelWorker?.running
4628
+ ? `runtime running · pid ${channelWorker.pid}`
4629
+ : 'runtime stopped';
4028
4630
  const compactType = compaction.compactType || compaction.type || 'semantic';
4029
4631
  const compactTypeLabel = compactType === 'recall-fasttrack' ? 'Fast-track' : 'Default';
4030
4632
  const outputStyleLabel = outputStyle?.current?.label || outputStyle?.current?.id || outputStyle?.configured || 'Default';
@@ -4151,6 +4753,31 @@ export function App({ store, initialStatusLine = '' }) {
4151
4753
  }
4152
4754
  openSettingsPicker();
4153
4755
  };
4756
+ const applyRemoteRuntime = () => {
4757
+ const enabled = store.toggleRemote?.() === true;
4758
+ store.pushNotice(enabled ? 'Remote mode ON' : 'Remote mode OFF', 'info');
4759
+ openSettingsPicker();
4760
+ };
4761
+ const cycleChannelBackend = (direction = 1) => {
4762
+ const backends = ['discord', 'telegram'];
4763
+ const currentIndex = Math.max(0, backends.indexOf(channelBackend));
4764
+ const chosen = backends[(currentIndex + direction + backends.length) % backends.length];
4765
+ if (chosen === channelBackend) {
4766
+ openSettingsPicker();
4767
+ return;
4768
+ }
4769
+ try {
4770
+ store.setBackend(chosen);
4771
+ const label = chosen === 'telegram' ? 'Telegram' : 'Discord';
4772
+ const restartHint = (store.isRemoteEnabled?.() === true || channelWorker?.running)
4773
+ ? `Channel set to ${label}. Restart remote to apply.`
4774
+ : `Channel set to ${label}.`;
4775
+ store.pushNotice(restartHint, 'info');
4776
+ } catch (e) {
4777
+ store.pushNotice(`channel backend failed: ${e?.message || e}`, 'error');
4778
+ }
4779
+ openSettingsPicker();
4780
+ };
4154
4781
  const items = [
4155
4782
  {
4156
4783
  value: 'profile',
@@ -4211,10 +4838,24 @@ export function App({ store, initialStatusLine = '' }) {
4211
4838
  _action: 'channels',
4212
4839
  },
4213
4840
  {
4214
- value: 'channels-setup',
4215
- label: 'Channels setup',
4216
- description: channelWorker?.running ? `runtime running · pid ${channelWorker.pid}` : 'runtime stopped',
4217
- _action: 'channels-setup',
4841
+ value: 'remote-runtime',
4842
+ label: 'Remote Runtime',
4843
+ meta: boolLabel(remoteEnabled),
4844
+ description: remoteRuntimeDescription,
4845
+ _action: 'remote-runtime',
4846
+ },
4847
+ {
4848
+ value: 'channel-backend',
4849
+ label: 'Channel',
4850
+ meta: channelBackendLabel,
4851
+ description: 'Left/Right or Enter changes channel type (Discord or Telegram).',
4852
+ _action: 'channel-backend',
4853
+ },
4854
+ {
4855
+ value: 'channel-setting',
4856
+ label: 'Setting',
4857
+ description: 'Configure credentials and main channel/chat for the active type.',
4858
+ _action: 'channel-setting',
4218
4859
  },
4219
4860
  {
4220
4861
  value: 'output-style',
@@ -4287,6 +4928,21 @@ export function App({ store, initialStatusLine = '' }) {
4287
4928
  description: `${skills.count || 0} available`,
4288
4929
  _action: 'skills',
4289
4930
  },
4931
+ {
4932
+ value: 'update',
4933
+ label: 'Update',
4934
+ meta: (() => {
4935
+ try {
4936
+ const upd = store.getUpdateSettings?.() || {};
4937
+ const current = upd.currentVersion || 'unknown';
4938
+ if (upd.updateAvailable && upd.latestVersion) return `${current} → ${upd.latestVersion}`;
4939
+ if (!upd.currentVersion) return 'unknown';
4940
+ return `${current} (latest)`;
4941
+ } catch { return 'unknown'; }
4942
+ })(),
4943
+ description: 'Check version and update mixdog.',
4944
+ _action: 'update',
4945
+ },
4290
4946
  ];
4291
4947
  setProviderPrompt(null);
4292
4948
  setChannelPrompt(null);
@@ -4310,6 +4966,8 @@ export function App({ store, initialStatusLine = '' }) {
4310
4966
  }
4311
4967
  else if (item?._action === 'memory') applyMemory(!(memory.enabled !== false));
4312
4968
  else if (item?._action === 'channels') applyChannels(!(channels.enabled !== false));
4969
+ else if (item?._action === 'remote-runtime') applyRemoteRuntime();
4970
+ else if (item?._action === 'channel-backend') cycleChannelBackend(-1);
4313
4971
  else if (item?._action === 'output-style') cycleOutputStyle(-1);
4314
4972
  else if (item?._action === 'theme') cycleTheme(-1);
4315
4973
  else if (item?._action === 'workflow') cycleWorkflow(-1);
@@ -4323,6 +4981,8 @@ export function App({ store, initialStatusLine = '' }) {
4323
4981
  }
4324
4982
  else if (item?._action === 'memory') applyMemory(!(memory.enabled !== false));
4325
4983
  else if (item?._action === 'channels') applyChannels(!(channels.enabled !== false));
4984
+ else if (item?._action === 'remote-runtime') applyRemoteRuntime();
4985
+ else if (item?._action === 'channel-backend') cycleChannelBackend(1);
4326
4986
  else if (item?._action === 'output-style') cycleOutputStyle(1);
4327
4987
  else if (item?._action === 'theme') cycleTheme(1);
4328
4988
  else if (item?._action === 'workflow') cycleWorkflow(1);
@@ -4339,7 +4999,9 @@ export function App({ store, initialStatusLine = '' }) {
4339
4999
  else if (item._action === 'memory') applyMemory(!(memory.enabled !== false));
4340
5000
  else if (item._action === 'memory-dashboard') openMemoryPicker();
4341
5001
  else if (item._action === 'channels') applyChannels(!(channels.enabled !== false));
4342
- else if (item._action === 'channels-setup') void openChannelSetupPicker('all');
5002
+ else if (item._action === 'remote-runtime') applyRemoteRuntime();
5003
+ else if (item._action === 'channel-backend') cycleChannelBackend(1);
5004
+ else if (item._action === 'channel-setting') openChannelSettingTypePicker({ returnTo: openSettingsPicker });
4343
5005
  else if (item._action === 'output-style') openOutputStylePicker({ returnTo: openSettingsPicker });
4344
5006
  else if (item._action === 'theme') openThemePicker({ returnTo: openSettingsPicker });
4345
5007
  else if (item._action === 'workflow') openWorkflowPicker({ returnTo: openSettingsPicker });
@@ -4364,6 +5026,7 @@ export function App({ store, initialStatusLine = '' }) {
4364
5026
  else if (item._action === 'plugins') openPluginsPicker();
4365
5027
  else if (item._action === 'hooks') openHooksPicker();
4366
5028
  else if (item._action === 'skills') openSkillsPicker();
5029
+ else if (item._action === 'update') openUpdatePicker({ returnTo: openSettingsPicker });
4367
5030
  },
4368
5031
  onCancel: () => {
4369
5032
  setPicker(null);
@@ -4379,44 +5042,51 @@ export function App({ store, initialStatusLine = '' }) {
4379
5042
  setChannelPrompt(null);
4380
5043
  setHookPrompt(null);
4381
5044
  setSettingsPrompt(null);
4382
- setPicker({
4383
- title: options.title || 'Providers',
4384
- description: options.description || 'Choose a provider to configure.',
4385
- labelWidth: 18,
4386
- metaWidth: 10,
4387
- pickerKey: 'providers-loading',
4388
- initialIndex: 0,
4389
- items: [{
4390
- value: 'checking',
4391
- label: 'Checking Providers',
4392
- meta: '',
4393
- description: 'please wait',
4394
- _type: 'loading',
4395
- }],
4396
- onSelect: () => {},
4397
- onCancel: () => {
4398
- setPicker(null);
4399
- if (onCancel) onCancel();
4400
- },
4401
- });
4402
- let setup;
4403
- try {
4404
- await new Promise((resolve) => setTimeout(resolve, 0));
4405
- setup = await store.getProviderSetup();
4406
- } catch (e) {
4407
- store.pushNotice(`providers failed: ${e?.message || e}`, 'error');
4408
- return;
4409
- }
4410
-
4411
- const items = [];
4412
- if (returnTo || onContinue) {
4413
- items.push({
4414
- value: 'continue-setup',
4415
- label: options.continueLabel || 'Continue setup',
4416
- description: options.continueDescription || 'return to setup',
4417
- _type: 'continue',
4418
- });
4419
- }
5045
+ // Onboarding (and any caller) can pass a preloaded provider setup so we skip
5046
+ // the "Checking Providers" placeholder frame that otherwise flashes before
5047
+ // the real list — that swap is what looked like a jump on Step 1 entry.
5048
+ let setup = options.preloadedSetup && typeof options.preloadedSetup === 'object'
5049
+ ? options.preloadedSetup
5050
+ : null;
5051
+ if (!setup) {
5052
+ setPicker({
5053
+ title: options.title || 'Providers',
5054
+ description: options.description || 'Choose a provider to configure.',
5055
+ labelWidth: 18,
5056
+ metaWidth: 10,
5057
+ pickerKey: 'providers-loading',
5058
+ initialIndex: 0,
5059
+ items: [{
5060
+ value: 'checking',
5061
+ label: 'Checking Providers',
5062
+ meta: '',
5063
+ description: 'please wait',
5064
+ _type: 'loading',
5065
+ }],
5066
+ onSelect: () => {},
5067
+ onCancel: () => {
5068
+ setPicker(null);
5069
+ if (onCancel) onCancel();
5070
+ },
5071
+ });
5072
+ try {
5073
+ await new Promise((resolve) => setTimeout(resolve, 0));
5074
+ setup = await store.getProviderSetup();
5075
+ } catch (e) {
5076
+ store.pushNotice(`providers failed: ${e?.message || e}`, 'error');
5077
+ return;
5078
+ }
5079
+ }
5080
+
5081
+ const items = [];
5082
+ if ((returnTo || onContinue) && !options.confirmBar) {
5083
+ items.push({
5084
+ value: 'continue-setup',
5085
+ label: options.continueLabel || 'Continue setup',
5086
+ description: options.continueDescription || 'return to setup',
5087
+ _type: 'continue',
5088
+ });
5089
+ }
4420
5090
  const providerFooter = (item) => {
4421
5091
  const provider = item?._provider;
4422
5092
  if (!provider) return '';
@@ -4805,14 +5475,15 @@ export function App({ store, initialStatusLine = '' }) {
4805
5475
  title: options.title || 'Providers',
4806
5476
  description: options.description || 'Choose a provider. Enter opens provider actions.',
4807
5477
  footer: providerFooter,
4808
- footerGapRows: 0,
4809
- help: '↑/↓ Select · Enter Open · Esc Back',
5478
+ footerGapRows: 1,
5479
+ help: options.confirmBar ? undefined : '↑/↓ Select · Enter Open · Esc Back',
4810
5480
  indexMode: 'always',
4811
5481
  labelWidth: 18,
4812
5482
  metaWidth: 12,
4813
5483
  pickerKey: `providers-main:${options.highlightProviderValue || 'root'}`,
4814
5484
  initialIndex: providerMainInitialIndex(),
4815
5485
  items,
5486
+ confirmBar: options.confirmBar || null,
4816
5487
  onHighlight: (_value, item) => {
4817
5488
  if (item?._providerId) rememberProviderSelection(item);
4818
5489
  },
@@ -4842,70 +5513,274 @@ export function App({ store, initialStatusLine = '' }) {
4842
5513
  });
4843
5514
  };
4844
5515
 
4845
- const openOnboardingAuthStep = () => {
5516
+ // First-run onboarding is a 5-step wizard. Each step's ROOT screen carries a
5517
+ // ConfirmBar (Back/Next, Finish on the last step); the Picker owns the bar
5518
+ // focus and only fires onConfirm from the bar. Nested depths (API-key entry,
5519
+ // model route picker, channel setting/webhook) render without a ConfirmBar so
5520
+ // their own key semantics are untouched and step-switching is disabled there.
5521
+ // Esc/cancel during onboarding = confirm skip. Mark onboarding complete
5522
+ // (routes/agents/provider untouched) so it does not reopen next launch;
5523
+ // `mixdog --onboarding` (forceOnboarding) still reopens regardless.
5524
+ const onboardingWarnReopen = () => {
5525
+ setOnboardingActive(false);
5526
+ try {
5527
+ store.skipOnboarding?.();
5528
+ store.pushNotice('Setup skipped. Run `mixdog --onboarding` to set up later.', 'info');
5529
+ } catch (e) {
5530
+ store.pushNotice(`Couldn’t save skip: ${e?.message || e}`, 'error');
5531
+ }
5532
+ };
5533
+
5534
+ // Warm the Step 2 data (provider models + agent roster) in the background as
5535
+ // soon as Step 1 opens, so advancing to Step 2 renders instantly instead of
5536
+ // flashing an empty panel while the async load runs.
5537
+ const prefetchOnboardingStep2 = () => {
5538
+ if (!Array.isArray(onboardingRef.current.providerModels) || onboardingRef.current.providerModels.length === 0) {
5539
+ const seq = onboardingPrefetchSeqRef.current;
5540
+ void Promise.resolve(store.listProviderModels?.())
5541
+ .then((models) => {
5542
+ // Drop a stale result: if the provider cache was invalidated (auth
5543
+ // change) while this load was in flight, its generation moved on.
5544
+ if (seq !== onboardingPrefetchSeqRef.current) return;
5545
+ if (Array.isArray(models) && models.length) {
5546
+ onboardingRef.current.providerModels = models;
5547
+ providerModelsCacheRef.current = { models, at: Date.now() };
5548
+ }
5549
+ })
5550
+ .catch(() => { /* Step 2 falls back to its own load on entry. */ });
5551
+ }
5552
+ if (!Array.isArray(onboardingRef.current.agents) || onboardingRef.current.agents.length === 0) {
5553
+ try {
5554
+ const roster = (store.listAgents?.() || []).map((a) => ({ id: a.id, label: a.label || a.id, description: a.description || '' }));
5555
+ if (roster.length) onboardingRef.current.agents = roster;
5556
+ } catch { /* Step 2 retries on entry. */ }
5557
+ }
5558
+ };
5559
+
5560
+ const openOnboardingAuthStep = async () => {
5561
+ prefetchOnboardingStep2();
5562
+ // Load the provider setup BEFORE opening the picker so Step 1 renders the
5563
+ // real list in one frame instead of flashing the "Checking Providers"
5564
+ // placeholder (that swap is what looked like a jump on entry). On failure,
5565
+ // fall back to the picker's own in-panel loading path.
5566
+ let preloadedSetup = null;
5567
+ try {
5568
+ preloadedSetup = await store.getProviderSetup?.();
5569
+ } catch { /* openProviderSetupPicker will show its loading frame + error. */ }
4846
5570
  void openProviderSetupPicker({
4847
- title: 'First Run · Step 1/2 · Provider Auth',
4848
- continueLabel: 'Continue to model setup',
4849
- continueDescription: 'choose the default and workflow models',
5571
+ title: 'First Run · Step 1/5 · Provider Auth',
4850
5572
  returnTo: () => openOnboardingAuthStep(),
4851
- onContinue: () => void openOnboardingWorkflowStep(),
4852
- onCancel: () => store.pushNotice('first-run setup will open again next launch', 'warn'),
5573
+ preloadedSetup,
5574
+ confirmBar: {
5575
+ buttons: [{ value: 'next', label: 'Next ▶' }],
5576
+ onConfirm: () => { setPicker(null); void openOnboardingWorkflowStep(); },
5577
+ },
5578
+ onCancel: onboardingWarnReopen,
4853
5579
  });
4854
5580
  };
4855
5581
 
4856
- const openOnboardingRoleModelPicker = (slot) => {
4857
- const models = normalizeModelOptions(onboardingRef.current.providerModels || []);
4858
- const fallbackRoute = onboardingRef.current.defaultRoute || null;
4859
- if (models.length === 0) {
4860
- store.pushNotice('no provider models available; open /providers to sign in', 'warn');
4861
- openOnboardingAuthStep();
5582
+ const openOnboardingThemeStep = () => {
5583
+ openThemePicker({
5584
+ onboarding: {
5585
+ onAdvance: () => openOnboardingOutputStyleStep(),
5586
+ onBack: () => void openOnboardingWorkflowStep(),
5587
+ onCancel: onboardingWarnReopen,
5588
+ },
5589
+ });
5590
+ };
5591
+
5592
+ const openOnboardingOutputStyleStep = () => {
5593
+ openOutputStylePicker({
5594
+ onboarding: {
5595
+ onAdvance: () => void openOnboardingRemoteStep(),
5596
+ onBack: () => openOnboardingThemeStep(),
5597
+ onCancel: onboardingWarnReopen,
5598
+ },
5599
+ });
5600
+ };
5601
+
5602
+ const openOnboardingRemoteStep = () => {
5603
+ void openChannelSetupPicker('all', {
5604
+ onboarding: true,
5605
+ confirmBar: {
5606
+ buttons: [
5607
+ { value: 'back', label: '◀ Back' },
5608
+ { value: 'finish', label: 'Finish ✓' },
5609
+ ],
5610
+ onConfirm: (button) => {
5611
+ if (button.value === 'back') {
5612
+ setPicker(null);
5613
+ openOnboardingOutputStyleStep();
5614
+ return;
5615
+ }
5616
+ finishOnboarding();
5617
+ },
5618
+ },
5619
+ });
5620
+ };
5621
+
5622
+ const finishOnboarding = () => {
5623
+ const defaultRoute = onboardingRef.current.defaultRoute;
5624
+ const searchRoute = onboardingRef.current.searchRoute || null;
5625
+ const overrides = onboardingRef.current.agentRoutes || {};
5626
+ const hasOverrides = Object.keys(overrides).length > 0;
5627
+ setPicker(null);
5628
+ setOnboardingActive(false);
5629
+ const done = () => store.pushNotice('First-run setup complete.', 'info');
5630
+ const failed = (e) => store.pushNotice(`Couldn’t save setup: ${e?.message || e}`, 'error');
5631
+ // Branch 1 — Main Model set: full persist. Agents without an explicit
5632
+ // override are sent; untouched agents are left out so the backend never
5633
+ // overwrites them (they follow the Main Model dynamically at runtime).
5634
+ if (defaultRoute) {
5635
+ void store.completeOnboarding?.({
5636
+ defaultRoute,
5637
+ defaultProvider: defaultRoute.provider,
5638
+ ...(hasOverrides ? { agentRoutes: { ...overrides } } : {}),
5639
+ ...(searchRoute ? { searchRoute } : {}),
5640
+ }).then(done).catch(failed);
4862
5641
  return;
4863
5642
  }
4864
- const recommendedRoute = chooseRecommendedModel(models, slot, fallbackRoute);
4865
- const items = [
4866
- {
4867
- value: 'recommended',
4868
- label: 'Use recommended',
4869
- description: routeLabel(recommendedRoute),
4870
- _action: 'recommended',
4871
- },
4872
- ...models.map((m) => ({
4873
- value: `${m.provider}:${m.id}`,
4874
- label: m.display || m.id,
4875
- description: modelDescription(m),
4876
- _action: 'select-model',
4877
- _model: m,
4878
- })),
4879
- ...(fallbackRoute ? [{
4880
- value: 'fallback',
4881
- label: 'Use lead model',
4882
- description: routeLabel(fallbackRoute),
4883
- _action: 'fallback',
4884
- }] : []),
4885
- ];
5643
+ // Branch 2 Main unset but some Search/agent picks exist: partial persist.
5644
+ // Only the explicit overrides are sent (no defaultRoute/defaultProvider); the
5645
+ // backend skips agents lacking a route and marks onboarding complete.
5646
+ if (hasOverrides || searchRoute) {
5647
+ void store.completeOnboarding?.({
5648
+ ...(hasOverrides ? { agentRoutes: { ...overrides } } : {}),
5649
+ ...(searchRoute ? { searchRoute } : {}),
5650
+ }).then(done).catch(failed);
5651
+ return;
5652
+ }
5653
+ // Branch 3 — nothing configured: mark done only, leave config untouched.
5654
+ try {
5655
+ store.skipOnboarding?.();
5656
+ } catch (e) {
5657
+ failed(e);
5658
+ return;
5659
+ }
5660
+ done();
5661
+ };
5662
+
5663
+ // Onboarding Step 2 per-target model picker. `target` is either the pseudo
5664
+ // slot 'lead' (Main Model) or a real agent id from listAgents(). No
5665
+ // recommendation logic: the current effective route is pre-highlighted, and
5666
+ // the plain model list is shown. Selecting Main Model updates defaultRoute;
5667
+ // agents that have no explicit override keep inheriting it.
5668
+ const openOnboardingRoleModelPicker = async (target) => {
5669
+ const isLead = target === 'lead';
5670
+ const isSearch = target === 'search';
5671
+ // Search uses the search-capable model list; lead/agent use provider models.
5672
+ let models;
5673
+ if (isSearch) {
5674
+ let searchModels = [];
5675
+ try {
5676
+ searchModels = await Promise.resolve(store.listSearchModels?.() || []);
5677
+ } catch (e) {
5678
+ store.pushNotice(`could not list search models: ${e?.message || e}`, 'warn');
5679
+ }
5680
+ models = normalizeModelOptions(searchModels || []);
5681
+ if (models.length === 0) {
5682
+ store.pushNotice('no native search models available; connect OpenAI, Grok, Gemini, or Anthropic', 'warn');
5683
+ void openOnboardingWorkflowStep();
5684
+ return;
5685
+ }
5686
+ } else {
5687
+ models = normalizeModelOptions(onboardingRef.current.providerModels || []);
5688
+ if (models.length === 0) {
5689
+ store.pushNotice('no provider models available; open /providers to sign in', 'warn');
5690
+ openOnboardingAuthStep();
5691
+ return;
5692
+ }
5693
+ }
5694
+ const overrides = onboardingRef.current.agentRoutes || {};
5695
+ // Current effective route for pre-marking: Main/Search show their own stored
5696
+ // route (or none); agents show their explicit override only (unset = none,
5697
+ // so we don't falsely mark the Main Model row on an untouched agent).
5698
+ const currentRoute = isLead
5699
+ ? (onboardingRef.current.defaultRoute || null)
5700
+ : isSearch
5701
+ ? (onboardingRef.current.searchRoute || null)
5702
+ : (overrides[target] || null);
5703
+ const routeMatchesModel = (route, m) => route?.provider === m.provider && route?.model === m.id;
5704
+ // Non-lead targets get a leading "Default" row that makes the target follow
5705
+ // the Main Model at runtime. For agents this clears the override (null);
5706
+ // for search this stores the SEARCH_DEFAULT marker route. "Default" is the
5707
+ // pre-marked row when the target is unset (agent) or on the marker (search).
5708
+ const isDefaultSelected = isSearch
5709
+ ? (!currentRoute || isSearchDefaultRoute(currentRoute))
5710
+ : !currentRoute;
5711
+ const isUnset = isDefaultSelected;
5712
+ const modelItems = models.map((m) => ({
5713
+ value: `${m.provider}:${m.id}`,
5714
+ label: m.display || m.id,
5715
+ marker: routeMatchesModel(currentRoute, m) ? '✓' : '',
5716
+ markerColor: theme.success,
5717
+ description: modelDescription(m),
5718
+ _model: m,
5719
+ }));
5720
+ const items = isLead
5721
+ ? modelItems
5722
+ : [
5723
+ {
5724
+ value: '__default__',
5725
+ label: 'Default',
5726
+ marker: isUnset ? '✓' : '',
5727
+ markerColor: theme.success,
5728
+ description: 'follows Main Model',
5729
+ _default: true,
5730
+ },
5731
+ ...modelItems,
5732
+ ];
5733
+ const matchIdx = models.findIndex((m) => routeMatchesModel(currentRoute, m));
5734
+ const initialIndex = isLead
5735
+ ? Math.max(0, matchIdx)
5736
+ : (isUnset || matchIdx < 0 ? 0 : matchIdx + 1);
5737
+ const label = isLead
5738
+ ? 'Main'
5739
+ : isSearch
5740
+ ? 'Search'
5741
+ : (onboardingRef.current.agents || []).find((a) => a.id === target)?.label || target;
4886
5742
  setPicker({
4887
- title: `First Run · ${slot} model`,
4888
- description: 'Pick the model route for this workflow role.',
5743
+ title: `First Run · ${label}`,
5744
+ description: isLead
5745
+ ? 'Pick the main model. Agents inherit this unless individually changed.'
5746
+ : isSearch
5747
+ ? 'Pick the native web-search model, or Default to follow the Main Model.'
5748
+ : `Pick the model for ${label}, or Default to follow the Main Model.`,
5749
+ initialIndex,
4889
5750
  items,
4890
5751
  onSelect: (_value, item) => {
4891
- const next = item._action === 'select-model'
4892
- ? routeFromModel(item._model)
4893
- : item._action === 'recommended'
4894
- ? recommendedRoute
4895
- : fallbackRoute;
5752
+ // "Default" clear the override so this target follows the Main Model.
5753
+ if (item?._default) {
5754
+ if (isSearch) {
5755
+ // Store the SEARCH_DEFAULT marker so finish persists it and the
5756
+ // runtime follows the Main Model (not a null that drops the field).
5757
+ onboardingRef.current.searchRoute = { ...SEARCH_DEFAULT_ROUTE };
5758
+ } else {
5759
+ const nextOverrides = { ...(onboardingRef.current.agentRoutes || {}) };
5760
+ delete nextOverrides[target];
5761
+ onboardingRef.current.agentRoutes = nextOverrides;
5762
+ }
5763
+ setPicker(null);
5764
+ void openOnboardingWorkflowStep();
5765
+ return;
5766
+ }
5767
+ const next = item?._model ? routeFromModel(item._model) : null;
4896
5768
  if (!next) {
4897
5769
  store.pushNotice('select a provider model first', 'warn');
4898
5770
  setPicker(null);
4899
- openOnboardingAuthStep();
5771
+ void openOnboardingWorkflowStep();
4900
5772
  return;
4901
5773
  }
4902
- if (slot === 'lead') {
5774
+ if (isLead) {
4903
5775
  onboardingRef.current.defaultRoute = next;
5776
+ } else if (isSearch) {
5777
+ onboardingRef.current.searchRoute = next;
5778
+ } else {
5779
+ onboardingRef.current.agentRoutes = {
5780
+ ...(onboardingRef.current.agentRoutes || {}),
5781
+ [target]: next,
5782
+ };
4904
5783
  }
4905
- onboardingRef.current.workflowRoutes = {
4906
- ...(onboardingRef.current.workflowRoutes || {}),
4907
- [slot]: next,
4908
- };
4909
5784
  setPicker(null);
4910
5785
  void openOnboardingWorkflowStep();
4911
5786
  },
@@ -4929,90 +5804,86 @@ export function App({ store, initialStatusLine = '' }) {
4929
5804
  const models = onboardingRef.current.providerModels || [];
4930
5805
  if (models.length === 0) {
4931
5806
  onboardingRef.current.defaultRoute = null;
4932
- onboardingRef.current.workflowRoutes = {};
5807
+ onboardingRef.current.agentRoutes = {};
4933
5808
  store.pushNotice('no provider models available; open /providers to sign in', 'warn');
4934
5809
  openOnboardingAuthStep();
4935
5810
  return;
4936
5811
  }
4937
- if (!onboardingRef.current.defaultRoute) {
4938
- onboardingRef.current.defaultRoute = chooseRecommendedModel(models, 'lead', null);
4939
- }
4940
- if (!onboardingRef.current.workflowRoutes || Object.keys(onboardingRef.current.workflowRoutes).length === 0) {
4941
- onboardingRef.current.workflowRoutes = buildWorkflowDefaults(models, onboardingRef.current.defaultRoute);
5812
+ // Main Model stays unset until the user picks one; no auto-recommendation.
5813
+ // Load the real agent roster once (explore/maintainer/worker/heavy-worker/
5814
+ // reviewer/debugger). Each agent defaults to the Main Model unless the user
5815
+ // set an explicit override in agentRoutes.
5816
+ if (!Array.isArray(onboardingRef.current.agents) || onboardingRef.current.agents.length === 0) {
5817
+ try {
5818
+ onboardingRef.current.agents = (store.listAgents?.() || []).map((a) => ({ id: a.id, label: a.label || a.id, description: a.description || '' }));
5819
+ } catch (e) {
5820
+ onboardingRef.current.agents = [];
5821
+ store.pushNotice(`could not list agents: ${e?.message || e}`, 'warn');
5822
+ }
4942
5823
  }
4943
- onboardingRef.current.workflowRoutes = {
4944
- ...(onboardingRef.current.workflowRoutes || {}),
4945
- lead: onboardingRef.current.defaultRoute,
4946
- };
4947
- const routes = onboardingRef.current.workflowRoutes || {};
4948
- const slots = [
4949
- ['agent', 'Agent', 'agent dispatch route'],
4950
- ['explorer', 'Explorer', 'code graph, file reading, repo exploration'],
4951
- ['memory', 'Memory', 'memory cycles and curation'],
4952
- ];
5824
+ const defaultRoute = onboardingRef.current.defaultRoute;
5825
+ const searchRoute = onboardingRef.current.searchRoute || null;
5826
+ const overrides = onboardingRef.current.agentRoutes || {};
5827
+ const agents = onboardingRef.current.agents || [];
4953
5828
  setProviderPrompt(null);
4954
5829
  setChannelPrompt(null);
4955
5830
  setHookPrompt(null);
4956
5831
  setSettingsPrompt(null);
4957
5832
  setPicker({
4958
- title: 'First Run · Step 2/2 · Workflow Routes',
4959
- description: 'Assign lead and workflow routes, then finish setup.',
5833
+ title: 'First Run · Step 2/5 · Models',
5834
+ description: 'Set the Main Model; each agent inherits it unless changed.',
5835
+ indexMode: 'always',
5836
+ labelWidth: 18,
5837
+ metaWidth: 33,
4960
5838
  items: [
4961
5839
  {
4962
- value: 'finish',
4963
- label: 'Finish setup',
4964
- description: 'save model and workflow route mapping',
4965
- _action: 'finish',
5840
+ value: 'main-model',
5841
+ label: 'Main',
5842
+ metaParts: agentModelParts(defaultRoute),
5843
+ description: 'main chat, planning, and agent default',
5844
+ _action: 'slot',
5845
+ _target: 'lead',
4966
5846
  },
4967
5847
  {
4968
- value: 'lead',
4969
- label: 'Default model',
4970
- description: `${routeLabel(onboardingRef.current.defaultRoute)} · main chat and planning route`,
5848
+ value: 'search-model',
5849
+ label: 'Search',
5850
+ // Marker route = follow Main Model → show a hint, not 'default/default'.
5851
+ metaParts: isSearchDefaultRoute(searchRoute)
5852
+ ? [{ text: '(follows main)', width: 17 }, { text: '', width: 6 }, { text: '', width: 4 }]
5853
+ : agentModelParts(searchRoute),
5854
+ description: 'native search model',
4971
5855
  _action: 'slot',
4972
- _slot: 'lead',
5856
+ _target: 'search',
4973
5857
  },
4974
- ...slots.map(([slot, label, description]) => ({
4975
- value: slot,
4976
- label,
4977
- description: `${routeLabel(routes[slot])} · ${description}`,
5858
+ ...agents.map((agent) => ({
5859
+ value: `agent:${agent.id}`,
5860
+ label: agent.label,
5861
+ metaParts: agentModelParts(overrides[agent.id] || null),
5862
+ description: agent.description || '',
4978
5863
  _action: 'slot',
4979
- _slot: slot,
5864
+ _target: agent.id,
4980
5865
  })),
4981
- {
4982
- value: 'back',
4983
- label: 'Back to provider auth',
4984
- description: 'change API keys, OAuth, or local endpoints',
4985
- _action: 'back',
4986
- },
4987
5866
  ],
5867
+ confirmBar: {
5868
+ buttons: [
5869
+ { value: 'back', label: '◀ Back' },
5870
+ { value: 'next', label: 'Next ▶' },
5871
+ ],
5872
+ onConfirm: (button) => {
5873
+ setPicker(null);
5874
+ if (button.value === 'back') openOnboardingAuthStep();
5875
+ else openOnboardingThemeStep();
5876
+ },
5877
+ },
4988
5878
  onSelect: (_value, item) => {
4989
5879
  setPicker(null);
4990
- if (item._action === 'finish') {
4991
- const defaultRoute = onboardingRef.current.defaultRoute;
4992
- if (!defaultRoute) {
4993
- store.pushNotice('select a provider model before finishing setup', 'warn');
4994
- openOnboardingAuthStep();
4995
- return;
4996
- }
4997
- void store.completeOnboarding?.({
4998
- defaultRoute,
4999
- workflowRoutes: onboardingRef.current.workflowRoutes || {},
5000
- })
5001
- .then(() => store.pushNotice('First-run setup complete.', 'info'))
5002
- .catch((e) => store.pushNotice(`Couldn’t save setup: ${e?.message || e}`, 'error'));
5003
- return;
5004
- }
5005
- if (item._action === 'back') {
5006
- openOnboardingAuthStep();
5007
- return;
5008
- }
5009
5880
  if (item._action === 'slot') {
5010
- openOnboardingRoleModelPicker(item._slot);
5881
+ openOnboardingRoleModelPicker(item._target);
5011
5882
  }
5012
5883
  },
5013
5884
  onCancel: () => {
5014
5885
  setPicker(null);
5015
- store.pushNotice('first-run setup will open again next launch', 'warn');
5886
+ onboardingWarnReopen();
5016
5887
  },
5017
5888
  });
5018
5889
  };
@@ -5022,8 +5893,9 @@ export function App({ store, initialStatusLine = '' }) {
5022
5893
  let canceled = false;
5023
5894
  try {
5024
5895
  const status = store.getOnboardingStatus?.();
5025
- if (status?.completed === true) return undefined;
5896
+ if (status?.completed === true && !forceOnboarding) return undefined;
5026
5897
  onboardingStartedRef.current = true;
5898
+ setOnboardingActive(true);
5027
5899
  setTimeout(() => {
5028
5900
  if (!canceled) openOnboardingAuthStep();
5029
5901
  }, 0);
@@ -5033,9 +5905,176 @@ export function App({ store, initialStatusLine = '' }) {
5033
5905
  return () => {
5034
5906
  canceled = true;
5035
5907
  };
5036
- }, [store]);
5908
+ }, [store, forceOnboarding]);
5037
5909
 
5038
- const openChannelSetupPicker = async (focus = 'all') => {
5910
+ const openChannelTypeActionsPicker = (backend, options = {}) => {
5911
+ const parentReturn = typeof options.returnTo === 'function'
5912
+ ? options.returnTo
5913
+ : () => openChannelSettingTypePicker();
5914
+ setProviderPrompt(null);
5915
+ setHookPrompt(null);
5916
+ setSettingsPrompt(null);
5917
+ setContextPanel(null);
5918
+ let setup;
5919
+ try {
5920
+ setup = store.getChannelSetup();
5921
+ } catch (e) {
5922
+ store.pushNotice(`channels failed: ${e?.message || e}`, 'error');
5923
+ return;
5924
+ }
5925
+ const isTelegram = backend === 'telegram';
5926
+ const activeBackend = setup.backend || 'discord';
5927
+ const tokenDescription = isTelegram
5928
+ ? `${setup.telegram?.status ?? 'Off'}${setup.telegram?.problem ? ' · Invalid' : ''}`
5929
+ : `${setup.discord.status}${setup.discord.problem ? ' · Invalid' : ''}`;
5930
+ const mainEntry = (setup.channels || []).find((ch) => ch.main)
5931
+ || (setup.channels || []).find((ch) => ch.name === 'main');
5932
+ const mainTarget = isTelegram
5933
+ ? (mainEntry?.telegramChatId || (activeBackend === 'telegram' ? mainEntry?.channelId : ''))
5934
+ : (mainEntry?.discordChannelId || (activeBackend === 'discord' ? mainEntry?.channelId : ''));
5935
+ const mainDescription = isTelegram
5936
+ ? (mainTarget ? `Chat ID ${mainTarget}` : 'Not set · Enter Telegram chat ID')
5937
+ : (mainTarget ? `Channel ID ${mainTarget}` : 'Not set · Enter Discord channel ID');
5938
+
5939
+ const openChannelPrompt = (prompt) => {
5940
+ setPicker(null);
5941
+ setContextPanel(null);
5942
+ setChannelPrompt({
5943
+ ...prompt,
5944
+ afterSave: () => openChannelTypeActionsPicker(backend, options),
5945
+ });
5946
+ };
5947
+
5948
+ setPicker({
5949
+ title: isTelegram ? 'Telegram' : 'Discord',
5950
+ description: activeBackend === backend
5951
+ ? 'Active channel type · token and main target'
5952
+ : 'Token and main target settings',
5953
+ help: '↑/↓ Select · Enter Edit · Esc Back',
5954
+ indexMode: 'always',
5955
+ labelWidth: 18,
5956
+ items: [
5957
+ {
5958
+ value: 'token',
5959
+ label: 'Bot token',
5960
+ description: tokenDescription,
5961
+ _action: isTelegram ? 'telegram-token' : 'discord-token',
5962
+ },
5963
+ {
5964
+ value: 'main',
5965
+ label: isTelegram ? 'Main chat' : 'Main channel',
5966
+ description: mainDescription,
5967
+ _action: 'main-target',
5968
+ },
5969
+ ],
5970
+ onSelect: (_value, item) => {
5971
+ try {
5972
+ if (item._action === 'discord-token') {
5973
+ openChannelPrompt({
5974
+ kind: 'discord-token',
5975
+ label: 'Discord bot token',
5976
+ hint: 'Paste the Discord bot token. It is stored in the OS keychain.',
5977
+ });
5978
+ return;
5979
+ }
5980
+ if (item._action === 'telegram-token') {
5981
+ openChannelPrompt({
5982
+ kind: 'telegram-token',
5983
+ label: 'Telegram bot token',
5984
+ hint: 'Paste the Telegram bot token from @BotFather. Stored in the OS keychain.',
5985
+ });
5986
+ return;
5987
+ }
5988
+ if (item._action === 'main-target') {
5989
+ openChannelPrompt({
5990
+ kind: 'channel-add',
5991
+ backend,
5992
+ label: isTelegram ? 'Main chat' : 'Main channel',
5993
+ hint: isTelegram
5994
+ ? 'Format: main | Telegram chat ID | mode(interactive/broadcast) | main'
5995
+ : 'Format: main | Discord channel ID | mode(interactive/broadcast) | main',
5996
+ });
5997
+ }
5998
+ } catch (e) {
5999
+ store.pushNotice(`channels update failed: ${e?.message || e}`, 'error');
6000
+ }
6001
+ },
6002
+ onCancel: () => {
6003
+ parentReturn();
6004
+ },
6005
+ });
6006
+ };
6007
+
6008
+ const openChannelSettingTypePicker = (options = {}) => {
6009
+ const returnTo = typeof options.returnTo === 'function' ? options.returnTo : () => {};
6010
+ setProviderPrompt(null);
6011
+ setChannelPrompt(null);
6012
+ setHookPrompt(null);
6013
+ setSettingsPrompt(null);
6014
+ setContextPanel(null);
6015
+ let setup;
6016
+ try {
6017
+ setup = store.getChannelSetup();
6018
+ } catch (e) {
6019
+ store.pushNotice(`channels failed: ${e?.message || e}`, 'error');
6020
+ return;
6021
+ }
6022
+ const activeBackend = setup.backend || 'discord';
6023
+ const mainEntry = (setup.channels || []).find((ch) => ch.main)
6024
+ || (setup.channels || []).find((ch) => ch.name === 'main');
6025
+ const typeDescription = (backend) => {
6026
+ const selected = activeBackend === backend;
6027
+ const hasToken = backend === 'telegram'
6028
+ ? setup.telegram?.authenticated === true
6029
+ : setup.discord?.authenticated === true;
6030
+ const hasTarget = backend === 'telegram'
6031
+ ? Boolean(mainEntry?.telegramChatId || (activeBackend === 'telegram' && mainEntry?.channelId))
6032
+ : Boolean(mainEntry?.discordChannelId || (activeBackend === 'discord' && mainEntry?.channelId));
6033
+ const needs = [
6034
+ ...(hasToken ? [] : ['token']),
6035
+ ...(hasTarget ? [] : [backend === 'telegram' ? 'chat ID' : 'channel ID']),
6036
+ ];
6037
+ return [
6038
+ ...(selected ? ['Selected'] : []),
6039
+ needs.length ? `Needs ${needs.join(' + ')}` : 'Ready',
6040
+ ].join(' · ');
6041
+ };
6042
+ setPicker({
6043
+ title: 'Channel Type Settings',
6044
+ description: 'Choose a type. Selected is the active backend; Ready means token and main target are set.',
6045
+ help: '↑/↓ Select · Enter Open · Esc Back',
6046
+ indexMode: 'always',
6047
+ labelWidth: 18,
6048
+ items: [
6049
+ {
6050
+ value: 'discord',
6051
+ label: 'Discord',
6052
+ description: typeDescription('discord'),
6053
+ _backend: 'discord',
6054
+ },
6055
+ {
6056
+ value: 'telegram',
6057
+ label: 'Telegram',
6058
+ description: typeDescription('telegram'),
6059
+ _backend: 'telegram',
6060
+ },
6061
+ ],
6062
+ onSelect: (value, item) => {
6063
+ const backend = item?._backend || (value === 'telegram' ? 'telegram' : value === 'discord' ? 'discord' : null);
6064
+ if (!backend) return;
6065
+ setPicker(null);
6066
+ openChannelTypeActionsPicker(backend, {
6067
+ returnTo: () => openChannelSettingTypePicker(options),
6068
+ });
6069
+ },
6070
+ onCancel: () => {
6071
+ setPicker(null);
6072
+ returnTo();
6073
+ },
6074
+ });
6075
+ };
6076
+
6077
+ const openChannelSetupPicker = async (focus = 'all', options = {}) => {
5039
6078
  setProviderPrompt(null);
5040
6079
  setChannelPrompt(null);
5041
6080
  setHookPrompt(null);
@@ -5055,23 +6094,19 @@ export function App({ store, initialStatusLine = '' }) {
5055
6094
  setChannelPrompt(prompt);
5056
6095
  };
5057
6096
 
6097
+ const channelRemoteEnabled = store.isRemoteEnabled?.() === true;
6098
+
5058
6099
  if (focus === 'schedules') {
5059
6100
  const schedules = setup.schedules || [];
5060
6101
  const items = [
5061
- {
5062
- value: 'schedule-add',
5063
- label: 'Add schedule',
5064
- description: 'name | cron | instructions | optional channel | optional model',
5065
- _action: 'schedule-add',
5066
- },
5067
6102
  ...(schedules.length ? schedules.map((schedule) => {
5068
6103
  const enabled = schedule.enabled !== false;
5069
6104
  return {
5070
6105
  value: `schedule:${schedule.name}`,
5071
6106
  label: schedule.name,
5072
6107
  marker: enabled ? '●' : '○',
5073
- markerColor: enabled ? theme.success : theme.inactive,
5074
- description: `${schedule.time || '(no cron)'} · ${schedule.route}${schedule.model ? ` · ${schedule.model}` : ''}`,
6108
+ markerColor: channelRemoteEnabled ? (enabled ? theme.success : theme.inactive) : theme.inactive,
6109
+ description: `${schedule.time || '(no cron)'} · ${schedule.route}${schedule.model ? ` · ${schedule.model}` : ''}${channelRemoteEnabled ? '' : ' · channel off'}`,
5075
6110
  _action: 'schedule-toggle',
5076
6111
  _name: schedule.name,
5077
6112
  _enabled: enabled,
@@ -5082,40 +6117,87 @@ export function App({ store, initialStatusLine = '' }) {
5082
6117
  description: 'no schedules configured',
5083
6118
  _action: 'noop',
5084
6119
  }]),
6120
+ ];
6121
+ const toggleSchedule = (item) => {
6122
+ if (item._action !== 'schedule-toggle') return;
6123
+ if (!channelRemoteEnabled) {
6124
+ store.pushNotice('enable channel first', 'warn');
6125
+ return;
6126
+ }
6127
+ try {
6128
+ store.setScheduleEnabled?.(item._name, !item._enabled);
6129
+ void openChannelSetupPicker('schedules', { highlightValue: `schedule:${item._name}` });
6130
+ } catch (e) {
6131
+ store.pushNotice(`schedule toggle failed: ${e?.message || e}`, 'error');
6132
+ }
6133
+ };
6134
+ setPicker({
6135
+ title: 'Schedules',
6136
+ description: channelRemoteEnabled ? 'Enable or disable cron schedules.' : 'Enable channel to toggle schedules.',
6137
+ initialIndex: Math.max(0, items.findIndex((entry) => entry.value === options.highlightValue)),
6138
+ items,
6139
+ onSelect: (_value, item) => toggleSchedule(item),
6140
+ onLeft: (item) => toggleSchedule(item),
6141
+ onRight: (item) => toggleSchedule(item),
6142
+ onCancel: () => {
6143
+ setPicker(null);
6144
+ },
6145
+ });
6146
+ return;
6147
+ }
6148
+
6149
+ if (focus === 'webhook-endpoint') {
6150
+ const returnTo = typeof options.returnTo === 'function'
6151
+ ? options.returnTo
6152
+ : () => setPicker(null);
6153
+ const domain = setup.webhook?.ngrokDomain || setup.webhook?.domain || '';
6154
+ const items = [
6155
+ {
6156
+ value: 'endpoint-domain',
6157
+ label: 'ngrok domain',
6158
+ description: domain ? domain : 'Not set · Enter ngrok domain',
6159
+ _action: 'endpoint-domain',
6160
+ },
5085
6161
  {
5086
- value: 'back',
5087
- label: 'Back',
5088
- description: 'return to channel setup',
5089
- _action: 'back',
6162
+ value: 'endpoint-authtoken',
6163
+ label: 'authtoken',
6164
+ description: setup.webhook?.authenticated === true ? 'Set' : 'Not set · Enter authtoken',
6165
+ _action: 'endpoint-authtoken',
5090
6166
  },
5091
6167
  ];
5092
6168
  setPicker({
5093
- title: 'Schedules',
5094
- description: 'Add, enable, or disable cron schedules.',
6169
+ title: 'Webhook endpoint',
6170
+ description: 'ngrok domain and authtoken. Toggle individual webhooks in /webhooks.',
6171
+ help: '↑/↓ Select · Enter Edit · Esc Back',
6172
+ indexMode: 'always',
6173
+ labelWidth: 18,
5095
6174
  items,
5096
6175
  onSelect: (_value, item) => {
5097
6176
  try {
5098
- if (item._action === 'schedule-add') {
6177
+ if (item._action === 'endpoint-domain') {
5099
6178
  openChannelPrompt({
5100
- kind: 'schedule-add',
5101
- label: 'Add schedule',
5102
- hint: 'Format: name | cron (5 or 6 fields) | instructions | channel(optional) | model(required with channel)',
6179
+ kind: 'webhook-domain',
6180
+ label: 'ngrok domain',
6181
+ hint: 'Paste the reserved ngrok domain (e.g. my-app.ngrok-free.app).',
6182
+ afterSave: () => void openChannelSetupPicker('webhook-endpoint', options),
5103
6183
  });
5104
6184
  return;
5105
6185
  }
5106
- if (item._action === 'back') {
5107
- void openChannelSetupPicker('all');
5108
- return;
6186
+ if (item._action === 'endpoint-authtoken') {
6187
+ openChannelPrompt({
6188
+ kind: 'webhook-token',
6189
+ label: 'Webhook/ngrok authtoken',
6190
+ hint: 'Paste the webhook/ngrok authtoken. It is stored in the OS keychain.',
6191
+ afterSave: () => void openChannelSetupPicker('webhook-endpoint', options),
6192
+ });
5109
6193
  }
5110
- if (item._action !== 'schedule-toggle') return;
5111
- store.setScheduleEnabled?.(item._name, !item._enabled);
5112
- void openChannelSetupPicker('schedules');
5113
6194
  } catch (e) {
5114
- store.pushNotice(`schedule toggle failed: ${e?.message || e}`, 'error');
6195
+ store.pushNotice(`webhook endpoint failed: ${e?.message || e}`, 'error');
5115
6196
  }
5116
6197
  },
5117
6198
  onCancel: () => {
5118
6199
  setPicker(null);
6200
+ returnTo();
5119
6201
  },
5120
6202
  });
5121
6203
  return;
@@ -5123,31 +6205,15 @@ export function App({ store, initialStatusLine = '' }) {
5123
6205
 
5124
6206
  if (focus === 'webhooks') {
5125
6207
  const hooks = setup.webhooks || [];
5126
- const serverEnabled = setup.webhook.enabled !== false;
5127
6208
  const items = [
5128
- {
5129
- value: 'webhook-add',
5130
- label: 'Add webhook',
5131
- description: 'name | instructions | optional channel | optional model | parser',
5132
- _action: 'webhook-add',
5133
- },
5134
- {
5135
- value: 'webhook-server',
5136
- label: 'Webhook server',
5137
- marker: serverEnabled ? '●' : '○',
5138
- markerColor: serverEnabled ? theme.success : theme.inactive,
5139
- description: `port ${setup.webhook.port || 3333} · auth ${setup.webhook.status}`,
5140
- _action: 'server-toggle',
5141
- _enabled: serverEnabled,
5142
- },
5143
6209
  ...(hooks.length ? hooks.map((hook) => {
5144
6210
  const enabled = hook.enabled !== false;
5145
6211
  return {
5146
6212
  value: `webhook:${hook.name}`,
5147
6213
  label: hook.name,
5148
6214
  marker: enabled ? '●' : '○',
5149
- markerColor: enabled ? theme.success : theme.inactive,
5150
- description: `${hook.parser || 'github'} · ${hook.route} · secret:${hook.secretSet ? 'set' : 'missing'}`,
6215
+ markerColor: channelRemoteEnabled ? (enabled ? theme.success : theme.inactive) : theme.inactive,
6216
+ description: `${hook.parser || 'github'} · ${hook.route} · secret:${hook.secretSet ? 'set' : 'missing'}${channelRemoteEnabled ? '' : ' · channel off'}`,
5151
6217
  _action: 'webhook-toggle',
5152
6218
  _name: hook.name,
5153
6219
  _enabled: enabled,
@@ -5158,44 +6224,28 @@ export function App({ store, initialStatusLine = '' }) {
5158
6224
  description: 'no webhook endpoints configured',
5159
6225
  _action: 'noop',
5160
6226
  }]),
5161
- {
5162
- value: 'back',
5163
- label: 'Back',
5164
- description: 'return to channel setup',
5165
- _action: 'back',
5166
- },
5167
6227
  ];
6228
+ const toggleWebhook = (item) => {
6229
+ if (item._action !== 'webhook-toggle') return;
6230
+ if (!channelRemoteEnabled) {
6231
+ store.pushNotice('enable channel first', 'warn');
6232
+ return;
6233
+ }
6234
+ try {
6235
+ store.setWebhookEnabled?.(item._name, !item._enabled);
6236
+ void openChannelSetupPicker('webhooks', { highlightValue: `webhook:${item._name}` });
6237
+ } catch (e) {
6238
+ store.pushNotice(`webhook toggle failed: ${e?.message || e}`, 'error');
6239
+ }
6240
+ };
5168
6241
  setPicker({
5169
6242
  title: 'Webhooks',
5170
- description: 'Manage inbound webhook endpoints and server settings.',
6243
+ description: channelRemoteEnabled ? 'Enable or disable inbound webhook endpoints.' : 'Enable channel to toggle webhooks.',
6244
+ initialIndex: Math.max(0, items.findIndex((entry) => entry.value === options.highlightValue)),
5171
6245
  items,
5172
- onSelect: (_value, item) => {
5173
- try {
5174
- if (item._action === 'webhook-add') {
5175
- openChannelPrompt({
5176
- kind: 'webhook-add',
5177
- label: 'Add webhook',
5178
- hint: 'Format: name | instructions | channel(optional) | model(required with channel) | parser(github/generic/stripe/sentry)',
5179
- });
5180
- return;
5181
- }
5182
- if (item._action === 'back') {
5183
- void openChannelSetupPicker('all');
5184
- return;
5185
- }
5186
- if (item._action === 'server-toggle') {
5187
- store.setWebhookConfig?.({ enabled: !item._enabled });
5188
- void openChannelSetupPicker('webhooks');
5189
- return;
5190
- }
5191
- if (item._action === 'webhook-toggle') {
5192
- store.setWebhookEnabled?.(item._name, !item._enabled);
5193
- void openChannelSetupPicker('webhooks');
5194
- }
5195
- } catch (e) {
5196
- store.pushNotice(`webhook toggle failed: ${e?.message || e}`, 'error');
5197
- }
5198
- },
6246
+ onSelect: (_value, item) => toggleWebhook(item),
6247
+ onLeft: (item) => toggleWebhook(item),
6248
+ onRight: (item) => toggleWebhook(item),
5199
6249
  onCancel: () => {
5200
6250
  setPicker(null);
5201
6251
  },
@@ -5204,144 +6254,128 @@ export function App({ store, initialStatusLine = '' }) {
5204
6254
  }
5205
6255
 
5206
6256
  const worker = store.getChannelWorkerStatus?.();
5207
- const serverEnabled = setup.webhook.enabled !== false;
6257
+ const activeBackend = setup.backend === 'telegram' ? 'telegram' : 'discord';
6258
+ const backendLabel = activeBackend === 'telegram' ? 'Telegram' : 'Discord';
6259
+ const remoteEnabled = store.isRemoteEnabled?.() === true;
6260
+ const boolLabel = (enabled) => enabled ? 'On' : 'Off';
6261
+ // Onboarding Step 5 reuses this root picker with a ConfirmBar. Reopens after
6262
+ // a toggle must carry the onboarding context (confirmBar + flag) forward so
6263
+ // Back/Finish stay wired and the general /channels path keeps its own opts.
6264
+ const reopenRoot = (extra = {}) => {
6265
+ const preserved = options.onboarding
6266
+ ? { onboarding: true, confirmBar: options.confirmBar || null }
6267
+ : {};
6268
+ void openChannelSetupPicker('all', { ...preserved, ...extra });
6269
+ };
6270
+ const applyRemoteRuntime = (highlightValue = 'remote-runtime') => {
6271
+ const enabled = store.toggleRemote?.() === true;
6272
+ store.pushNotice(enabled ? 'Remote mode ON' : 'Remote mode OFF', 'info');
6273
+ reopenRoot({ highlightValue });
6274
+ };
6275
+ const cycleChannelBackend = (direction = 1, highlightValue = 'channel-backend') => {
6276
+ const backends = ['discord', 'telegram'];
6277
+ const currentIndex = Math.max(0, backends.indexOf(activeBackend));
6278
+ const chosen = backends[(currentIndex + direction + backends.length) % backends.length];
6279
+ if (chosen === activeBackend) {
6280
+ reopenRoot({ highlightValue, backendOverride: activeBackend });
6281
+ return;
6282
+ }
6283
+ try {
6284
+ store.setBackend(chosen);
6285
+ const label = chosen === 'telegram' ? 'Telegram' : 'Discord';
6286
+ const restartHint = (store.isRemoteEnabled?.() === true || worker?.running)
6287
+ ? `Channel set to ${label}. Restart remote to apply.`
6288
+ : `Channel set to ${label}.`;
6289
+ store.pushNotice(restartHint, 'info');
6290
+ } catch (e) {
6291
+ store.pushNotice(`channel backend failed: ${e?.message || e}`, 'error');
6292
+ }
6293
+ reopenRoot({ highlightValue, backendOverride: chosen });
6294
+ };
5208
6295
  const items = [
5209
6296
  {
5210
- value: 'worker-status',
5211
- label: 'Channel runtime',
5212
- description: worker?.running ? `running · pid ${worker.pid}` : 'stopped',
5213
- _action: 'worker-status',
5214
- },
5215
- {
5216
- value: 'discord-token',
5217
- label: 'Discord token',
5218
- description: `Bot token · ${setup.discord.status}${setup.discord.problem ? ' · invalid' : ''}`,
5219
- _action: 'discord-token',
6297
+ value: 'remote-runtime',
6298
+ label: 'Remote Runtime',
6299
+ meta: boolLabel(remoteEnabled),
6300
+ description: worker?.running ? `Running · pid ${worker.pid}` : 'Stopped',
6301
+ _action: 'remote-runtime',
5220
6302
  },
5221
6303
  {
5222
- value: 'channel-add',
5223
- label: 'Add channel',
5224
- description: 'name | channelId | mode(interactive/broadcast)',
5225
- _action: 'channel-add',
6304
+ value: 'channel-backend',
6305
+ label: 'Channel',
6306
+ meta: backendLabel,
6307
+ description: 'Select Discord or Telegram',
6308
+ _action: 'channel-backend',
5226
6309
  },
5227
6310
  {
5228
- value: 'schedules',
5229
- label: 'Schedules',
5230
- description: `${(setup.schedules || []).length} configured`,
5231
- _action: 'schedules',
6311
+ value: 'channel-setting',
6312
+ label: 'Setting',
6313
+ description: 'Token and main target',
6314
+ _action: 'channel-setting',
5232
6315
  },
5233
6316
  {
5234
- value: 'schedule-add',
5235
- label: 'Add schedule',
5236
- description: 'name | cron | instructions | optional channel | optional model',
5237
- _action: 'schedule-add',
5238
- },
5239
- {
5240
- value: 'webhook-token',
5241
- label: 'Webhook auth',
5242
- description: `ngrok/webhook authtoken · ${setup.webhook.status}`,
5243
- _action: 'webhook-token',
5244
- },
5245
- {
5246
- value: 'webhook-toggle',
5247
- label: 'Webhook server',
5248
- marker: serverEnabled ? '' : '',
5249
- markerColor: serverEnabled ? theme.success : theme.inactive,
5250
- description: `${serverEnabled ? 'enabled' : 'disabled'} · port ${setup.webhook.port || 3333}`,
5251
- _action: 'webhook-toggle',
5252
- _enabled: serverEnabled,
5253
- },
5254
- {
5255
- value: 'webhooks',
5256
- label: 'Webhooks',
5257
- description: `${(setup.webhooks || []).length} configured`,
5258
- _action: 'webhooks',
5259
- },
5260
- {
5261
- value: 'webhook-add',
5262
- label: 'Add webhook',
5263
- description: 'name | instructions | optional channel | optional model | parser',
5264
- _action: 'webhook-add',
6317
+ value: 'webhook-endpoint',
6318
+ label: 'Webhook endpoint',
6319
+ meta: (() => {
6320
+ const hasDomain = Boolean(setup.webhook?.ngrokDomain || setup.webhook?.domain);
6321
+ const hasAuth = setup.webhook?.authenticated === true;
6322
+ return (hasDomain && hasAuth) ? 'On' : 'Off';
6323
+ })(),
6324
+ description: (() => {
6325
+ const hasDomain = Boolean(setup.webhook?.ngrokDomain || setup.webhook?.domain);
6326
+ const hasAuth = setup.webhook?.authenticated === true;
6327
+ const needs = [
6328
+ ...(hasDomain ? [] : ['domain']),
6329
+ ...(hasAuth ? [] : ['authtoken']),
6330
+ ];
6331
+ return needs.length ? `Needs ${needs.join(' + ')}` : 'ngrok domain and authtoken set';
6332
+ })(),
6333
+ _action: 'webhook-endpoint',
5265
6334
  },
5266
- ...((setup.channels || []).map((ch) => ({
5267
- value: `channel:${ch.name}`,
5268
- label: `# ${ch.name}`,
5269
- description: `${ch.channelId || '(unset)'} · ${ch.mode}${ch.main ? ' · main' : ''} · edit`,
5270
- _action: 'channel-edit',
5271
- _channel: ch,
5272
- }))),
5273
6335
  ];
5274
6336
 
5275
6337
  setPicker({
5276
6338
  title: 'Channels',
5277
- description: 'Discord token, channels, schedules, and webhooks.',
6339
+ description: 'Remote access and channel setup.',
6340
+ // Onboarding overlays a ConfirmBar (←/→ drive Back/Finish, not toggles),
6341
+ // so drop the ←/→ Change hint there and use the Picker's ConfirmBar help.
6342
+ help: options.confirmBar ? undefined : '↑/↓ Select · ←/→ Change · Enter Choose/Toggle · Esc Back',
6343
+ indexMode: 'always',
6344
+ labelWidth: 18,
6345
+ metaWidth: 12,
6346
+ pickerKey: `channels:${activeBackend}:${remoteEnabled ? 'on' : 'off'}:${options.highlightValue || 'root'}`,
6347
+ initialIndex: Math.max(0, items.findIndex((entry) => entry.value === options.highlightValue)),
5278
6348
  items,
6349
+ confirmBar: options.confirmBar || null,
6350
+ onLeft: options.confirmBar ? undefined : (item) => {
6351
+ if (item?._action === 'remote-runtime') applyRemoteRuntime(item.value);
6352
+ else if (item?._action === 'channel-backend') cycleChannelBackend(-1, item.value);
6353
+ },
6354
+ onRight: options.confirmBar ? undefined : (item) => {
6355
+ if (item?._action === 'remote-runtime') applyRemoteRuntime(item.value);
6356
+ else if (item?._action === 'channel-backend') cycleChannelBackend(1, item.value);
6357
+ },
5279
6358
  onSelect: (_value, item) => {
5280
6359
  try {
5281
- if (item._action === 'worker-status') {
5282
- store.pushNotice(worker?.running ? `channel runtime running: pid ${worker.pid}` : 'channel runtime stopped', 'info');
6360
+ if (item._action === 'remote-runtime') {
6361
+ applyRemoteRuntime(item.value);
5283
6362
  return;
5284
6363
  }
5285
- if (item._action === 'discord-token') {
5286
- openChannelPrompt({
5287
- kind: 'discord-token',
5288
- label: 'Discord bot token',
5289
- hint: 'Paste the Discord bot token. It is stored in the OS keychain.',
5290
- });
6364
+ if (item._action === 'channel-backend') {
6365
+ cycleChannelBackend(1, item.value);
5291
6366
  return;
5292
6367
  }
5293
- if (item._action === 'webhook-token') {
5294
- openChannelPrompt({
5295
- kind: 'webhook-token',
5296
- label: 'Webhook/ngrok authtoken',
5297
- hint: 'Paste the webhook/ngrok authtoken. It is stored in the OS keychain.',
6368
+ if (item._action === 'channel-setting') {
6369
+ openChannelSettingTypePicker({
6370
+ returnTo: () => reopenRoot({ highlightValue: 'channel-setting' }),
5298
6371
  });
5299
6372
  return;
5300
6373
  }
5301
- if (item._action === 'channel-add') {
5302
- openChannelPrompt({
5303
- kind: 'channel-add',
5304
- label: 'Add channel',
5305
- hint: 'Format: name | Discord channel ID | mode(interactive/broadcast) | main(optional)',
6374
+ if (item._action === 'webhook-endpoint') {
6375
+ void openChannelSetupPicker('webhook-endpoint', {
6376
+ ...(options.onboarding ? { onboarding: true, confirmBar: options.confirmBar || null } : {}),
6377
+ returnTo: () => reopenRoot({ highlightValue: 'webhook-endpoint' }),
5306
6378
  });
5307
- return;
5308
- }
5309
- if (item._action === 'channel-edit') {
5310
- const ch = item._channel || {};
5311
- openChannelPrompt({
5312
- kind: 'channel-add',
5313
- label: `Edit channel · ${ch.name}`,
5314
- hint: `Format: ${ch.name} | ${ch.channelId || '<channel-id>'} | ${ch.mode || 'interactive'} | ${ch.main ? 'main' : 'main(optional)'}`,
5315
- });
5316
- return;
5317
- }
5318
- if (item._action === 'schedule-add') {
5319
- openChannelPrompt({
5320
- kind: 'schedule-add',
5321
- label: 'Add schedule',
5322
- hint: 'Format: name | cron (5 or 6 fields) | instructions | channel(optional) | model(required with channel)',
5323
- });
5324
- return;
5325
- }
5326
- if (item._action === 'webhook-add') {
5327
- openChannelPrompt({
5328
- kind: 'webhook-add',
5329
- label: 'Add webhook',
5330
- hint: 'Format: name | instructions | channel(optional) | model(required with channel) | parser(github/generic/stripe/sentry)',
5331
- });
5332
- return;
5333
- }
5334
- if (item._action === 'webhook-toggle') {
5335
- store.setWebhookConfig?.({ enabled: !item._enabled });
5336
- void openChannelSetupPicker('all');
5337
- return;
5338
- }
5339
- if (item._action === 'schedules') {
5340
- void openChannelSetupPicker('schedules');
5341
- return;
5342
- }
5343
- if (item._action === 'webhooks') {
5344
- void openChannelSetupPicker('webhooks');
5345
6379
  }
5346
6380
  } catch (e) {
5347
6381
  store.pushNotice(`channels update failed: ${e?.message || e}`, 'error');
@@ -5349,6 +6383,8 @@ export function App({ store, initialStatusLine = '' }) {
5349
6383
  },
5350
6384
  onCancel: () => {
5351
6385
  setPicker(null);
6386
+ // Onboarding Step 5 Esc mirrors the other steps' reopen-next-launch warning.
6387
+ if (options.onboarding) onboardingWarnReopen();
5352
6388
  },
5353
6389
  });
5354
6390
  };
@@ -5364,46 +6400,7 @@ export function App({ store, initialStatusLine = '' }) {
5364
6400
  return { ...status, servers: status.servers || [] };
5365
6401
  };
5366
6402
 
5367
- const openMcpServerPicker = (server) => {
5368
- if (server?.error) store.pushNotice(`${server.name}: ${server.error}`, 'warn');
5369
- const enabled = server?.enabled !== false;
5370
- const items = [
5371
- {
5372
- value: enabled ? 'disable' : 'enable',
5373
- label: enabled ? 'Disable server' : 'Enable server',
5374
- description: server?.configured ? `${server?.status || 'unknown'} · ${server?.transport || 'unknown'}` : 'server is not configured',
5375
- _action: server?.configured ? (enabled ? 'disable' : 'enable') : 'noop',
5376
- },
5377
- {
5378
- value: 'reconnect',
5379
- label: 'Reconnect server',
5380
- description: 'refresh configured MCP servers',
5381
- _action: 'reconnect',
5382
- },
5383
- ];
5384
- setPicker({
5385
- title: `MCP · ${server?.name || 'server'}`,
5386
- description: 'Enable, disable, or reconnect this MCP server.',
5387
- items,
5388
- onSelect: (_toolValue, toolItem) => {
5389
- setPicker(null);
5390
- if (toolItem._action === 'enable' || toolItem._action === 'disable') {
5391
- void store.setMcpServerEnabled?.(server.name, toolItem._action === 'enable')
5392
- .then(() => openMcpServersPicker())
5393
- .catch((e) => store.pushNotice(`mcp toggle failed: ${e?.message || e}`, 'error'));
5394
- return;
5395
- }
5396
- if (toolItem._action === 'reconnect') {
5397
- void store.reconnectMcp?.()
5398
- .then(() => openMcpServersPicker())
5399
- .catch((e) => store.pushNotice(`mcp reconnect failed: ${e?.message || e}`, 'error'));
5400
- }
5401
- },
5402
- onCancel: () => openMcpServersPicker(),
5403
- });
5404
- };
5405
-
5406
- const openMcpServersPicker = () => {
6403
+ const openMcpServersPicker = (options = {}) => {
5407
6404
  const status = mcpStatus();
5408
6405
  if (!status) return;
5409
6406
  const servers = status.servers || [];
@@ -5417,27 +6414,36 @@ export function App({ store, initialStatusLine = '' }) {
5417
6414
  });
5418
6415
  }
5419
6416
  for (const server of servers) {
6417
+ const enabled = server.enabled !== false;
5420
6418
  items.push({
5421
6419
  value: `server:${server.name}`,
5422
- label: server.enabled === false ? `${server.name} (off)` : server.name,
6420
+ label: server.name,
6421
+ marker: enabled ? '●' : '○',
6422
+ markerColor: enabled ? theme.success : theme.inactive,
5423
6423
  description: `${server.status || 'unknown'} · ${server.transport || 'unknown'} · ${server.toolCount || 0} tools${server.error ? ` · ${server.error}` : ''}`,
5424
6424
  _action: 'server',
5425
6425
  _server: server,
6426
+ _enabled: enabled,
5426
6427
  });
5427
6428
  }
5428
6429
  setProviderPrompt(null);
5429
6430
  setChannelPrompt(null);
5430
6431
  setHookPrompt(null);
5431
6432
  setSettingsPrompt(null);
6433
+ const toggleServer = (item) => {
6434
+ if (item._action !== 'server' || !item._server?.name) return;
6435
+ void store.setMcpServerEnabled?.(item._server.name, !item._enabled)
6436
+ .then(() => openMcpServersPicker({ highlightValue: `server:${item._server.name}` }))
6437
+ .catch((e) => store.pushNotice(`mcp toggle failed: ${e?.message || e}`, 'error'));
6438
+ };
5432
6439
  setPicker({
5433
6440
  title: 'MCP servers',
5434
- description: 'Configured MCP servers and connection status.',
6441
+ description: 'Enable or disable configured MCP servers.',
6442
+ initialIndex: Math.max(0, items.findIndex((entry) => entry.value === options?.highlightValue)),
5435
6443
  items,
5436
- onSelect: (_value, item) => {
5437
- setPicker(null);
5438
- if (item._action !== 'server') return;
5439
- openMcpServerPicker(item._server);
5440
- },
6444
+ onSelect: (_value, item) => toggleServer(item),
6445
+ onLeft: (item) => toggleServer(item),
6446
+ onRight: (item) => toggleServer(item),
5441
6447
  onCancel: () => {
5442
6448
  setPicker(null);
5443
6449
  },
@@ -5501,10 +6507,11 @@ export function App({ store, initialStatusLine = '' }) {
5501
6507
  });
5502
6508
  };
5503
6509
 
5504
- const openSkillsPicker = () => {
6510
+ const openSkillsPicker = (options = {}) => {
5505
6511
  const status = skillsStatus();
5506
6512
  if (!status) return;
5507
6513
  const skills = status.skills || [];
6514
+ const disabledSet = options.disabledOverride instanceof Set ? options.disabledOverride : disabledSkills;
5508
6515
  const items = [];
5509
6516
  if (skills.length === 0) {
5510
6517
  items.push({
@@ -5515,28 +6522,39 @@ export function App({ store, initialStatusLine = '' }) {
5515
6522
  });
5516
6523
  }
5517
6524
  for (const skill of skills) {
5518
- const disabled = disabledSkills.has(skill.name);
6525
+ const enabled = !disabledSet.has(skill.name);
5519
6526
  items.push({
5520
6527
  value: skill.name,
5521
- label: disabled ? `${skill.name} (disabled)` : skill.name,
5522
- description: `${disabled ? 'disabled · ' : ''}${skill.source || 'skill'} · ${skill.description || skill.filePath || ''}`,
6528
+ label: skill.name,
6529
+ marker: enabled ? '' : '',
6530
+ markerColor: enabled ? theme.success : theme.inactive,
6531
+ description: `${skill.source || 'skill'} · ${skill.description || skill.filePath || ''}`,
5523
6532
  _action: 'skill',
5524
6533
  _skill: skill,
6534
+ _enabled: enabled,
5525
6535
  });
5526
6536
  }
5527
6537
  setProviderPrompt(null);
5528
6538
  setChannelPrompt(null);
5529
6539
  setHookPrompt(null);
5530
6540
  setSettingsPrompt(null);
6541
+ const toggleSkill = (item) => {
6542
+ if (item._action !== 'skill' || !item._skill?.name) return;
6543
+ const name = item._skill.name;
6544
+ const next = new Set(disabledSet);
6545
+ if (item._enabled) next.add(name); else next.delete(name);
6546
+ setDisabledSkills(next);
6547
+ store.pushNotice(`skill ${item._enabled ? 'disabled' : 'enabled'}: ${name}`, 'info');
6548
+ openSkillsPicker({ highlightValue: name, disabledOverride: next });
6549
+ };
5531
6550
  setPicker({
5532
6551
  title: 'Skills',
5533
- description: 'Project skills available to the assistant.',
6552
+ description: 'Enable or disable project skills.',
6553
+ initialIndex: Math.max(0, items.findIndex((entry) => entry.value === options.highlightValue)),
5534
6554
  items,
5535
- onSelect: (_value, item) => {
5536
- setPicker(null);
5537
- if (item._action !== 'skill' || !item._skill?.name) return;
5538
- openSkillDetailPicker(item._skill);
5539
- },
6555
+ onSelect: (_value, item) => toggleSkill(item),
6556
+ onLeft: (item) => toggleSkill(item),
6557
+ onRight: (item) => toggleSkill(item),
5540
6558
  onCancel: () => {
5541
6559
  setPicker(null);
5542
6560
  },
@@ -5818,21 +6836,22 @@ export function App({ store, initialStatusLine = '' }) {
5818
6836
  setChannelPrompt(null);
5819
6837
  setHookPrompt(null);
5820
6838
  setSettingsPrompt(null);
6839
+ const toggleRule = (item) => {
6840
+ if (item._action !== 'rule') return;
6841
+ try {
6842
+ store.setHookRuleEnabled?.(item._rule.index, !item._rule.enabled);
6843
+ void openHooksPicker();
6844
+ } catch (e) {
6845
+ store.pushNotice(`hook toggle failed: ${e?.message || e}`, 'error');
6846
+ }
6847
+ };
5821
6848
  setPicker({
5822
6849
  title: 'Hooks',
5823
6850
  description: 'Before-tool hook rules; Enter toggles a rule.',
5824
6851
  items,
5825
- onSelect: (_value, item) => {
5826
- setPicker(null);
5827
- if (item._action === 'rule') {
5828
- try {
5829
- store.setHookRuleEnabled?.(item._rule.index, !item._rule.enabled);
5830
- void openHooksPicker();
5831
- } catch (e) {
5832
- store.pushNotice(`hook toggle failed: ${e?.message || e}`, 'error');
5833
- }
5834
- }
5835
- },
6852
+ onSelect: (_value, item) => toggleRule(item),
6853
+ onLeft: (item) => toggleRule(item),
6854
+ onRight: (item) => toggleRule(item),
5836
6855
  onCancel: () => {
5837
6856
  setPicker(null);
5838
6857
  },
@@ -5902,6 +6921,126 @@ export function App({ store, initialStatusLine = '' }) {
5902
6921
  .catch((e) => store.pushNotice(`memory failed: ${e?.message || e}`, 'error'));
5903
6922
  };
5904
6923
 
6924
+ const openUpdatePicker = (options = {}) => {
6925
+ const returnTo = typeof options.returnTo === 'function' ? options.returnTo : null;
6926
+ const readSettings = () => {
6927
+ try { return store.getUpdateSettings?.() || {}; } catch { return {}; }
6928
+ };
6929
+ const readStatus = () => {
6930
+ try { return store.getUpdateStatus?.() || { phase: 'idle' }; } catch { return { phase: 'idle' }; }
6931
+ };
6932
+ const render = ({ checking = false } = {}) => {
6933
+ const upd = readSettings();
6934
+ const status = readStatus();
6935
+ const current = upd.currentVersion || 'unknown';
6936
+ const latestMeta = checking || status.phase === 'checking'
6937
+ ? 'checking…'
6938
+ : (upd.latestVersion || 'unknown');
6939
+ const items = [
6940
+ {
6941
+ value: 'current',
6942
+ label: 'Current version',
6943
+ meta: current,
6944
+ description: 'Installed mixdog version.',
6945
+ _action: 'current',
6946
+ },
6947
+ {
6948
+ value: 'latest',
6949
+ label: 'Latest version',
6950
+ meta: latestMeta,
6951
+ description: 'Enter to re-check now.',
6952
+ _action: 'latest',
6953
+ },
6954
+ {
6955
+ value: 'auto-update',
6956
+ label: 'Auto-update',
6957
+ meta: upd.autoUpdate ? 'On' : 'Off',
6958
+ description: '←/→ or Enter to toggle automatic updates.',
6959
+ _action: 'auto-update',
6960
+ },
6961
+ {
6962
+ value: 'update-now',
6963
+ label: 'Update now',
6964
+ description: upd.updateAvailable
6965
+ ? `Install ${upd.latestVersion || 'latest'}.`
6966
+ : 'Check and install the latest version.',
6967
+ _action: 'update-now',
6968
+ },
6969
+ ];
6970
+ setProviderPrompt(null);
6971
+ setChannelPrompt(null);
6972
+ setHookPrompt(null);
6973
+ setSettingsPrompt(null);
6974
+ setPicker({
6975
+ title: 'Update',
6976
+ description: 'Check version and update mixdog.',
6977
+ help: '↑/↓ Select · ←/→ Change · Enter Open/Toggle · Esc Close',
6978
+ indexMode: 'always',
6979
+ labelWidth: 16,
6980
+ metaWidth: 16,
6981
+ fillAvailable: true,
6982
+ items,
6983
+ onLeft: (item) => {
6984
+ if (item?._action === 'auto-update') toggleAutoUpdate(!upd.autoUpdate);
6985
+ },
6986
+ onRight: (item) => {
6987
+ if (item?._action === 'auto-update') toggleAutoUpdate(!upd.autoUpdate);
6988
+ },
6989
+ onSelect: (_value, item) => {
6990
+ if (item?._action === 'latest') {
6991
+ recheck();
6992
+ } else if (item?._action === 'auto-update') {
6993
+ toggleAutoUpdate(!upd.autoUpdate);
6994
+ } else if (item?._action === 'update-now') {
6995
+ runUpdate();
6996
+ }
6997
+ },
6998
+ onCancel: () => {
6999
+ setPicker(null);
7000
+ if (returnTo) returnTo();
7001
+ },
7002
+ });
7003
+ };
7004
+ const toggleAutoUpdate = (enabled) => {
7005
+ try {
7006
+ void Promise.resolve(store.setAutoUpdate?.(enabled)).finally(() => render());
7007
+ store.pushNotice(`Auto-update ${enabled ? 'on' : 'off'}`, 'info');
7008
+ } catch (e) {
7009
+ store.pushNotice(`auto-update failed: ${e?.message || e}`, 'error');
7010
+ }
7011
+ render();
7012
+ };
7013
+ const recheck = () => {
7014
+ render({ checking: true });
7015
+ void Promise.resolve(store.checkForUpdate?.({ force: true }))
7016
+ .then(() => render())
7017
+ .catch((e) => {
7018
+ store.pushNotice(`update check failed: ${e?.message || e}`, 'error');
7019
+ render();
7020
+ });
7021
+ };
7022
+ const runUpdate = () => {
7023
+ store.pushNotice('Updating…', 'info');
7024
+ void Promise.resolve(store.runUpdateNow?.())
7025
+ .then((result) => {
7026
+ if (result?.ok) {
7027
+ store.pushNotice(`v${result.version} installed — restart to apply`, 'info');
7028
+ } else {
7029
+ store.pushNotice(`Update failed: ${result?.error || 'unknown error'}`, 'error');
7030
+ }
7031
+ render();
7032
+ })
7033
+ .catch((e) => {
7034
+ store.pushNotice(`Update failed: ${e?.message || e}`, 'error');
7035
+ render();
7036
+ });
7037
+ };
7038
+ render({ checking: true });
7039
+ void Promise.resolve(store.checkForUpdate?.({}))
7040
+ .then(() => render())
7041
+ .catch(() => render());
7042
+ };
7043
+
5905
7044
  const openMemoryPicker = () => {
5906
7045
  setProviderPrompt(null);
5907
7046
  setChannelPrompt(null);
@@ -6302,6 +7441,19 @@ export function App({ store, initialStatusLine = '' }) {
6302
7441
  .then(ok => store.pushNotice(ok ? modelSwitchNotice() : 'Model switch is already running.', ok ? 'info' : 'warn'))
6303
7442
  .catch((e) => store.pushNotice(`Couldn’t switch model: ${e?.message || e}`, 'error'));
6304
7443
  return true;
7444
+ case 'remote': {
7445
+ const enabled = store.toggleRemote?.() === true;
7446
+ store.pushNotice(enabled ? 'Remote mode ON' : 'Remote mode OFF', 'info');
7447
+ return true;
7448
+ }
7449
+ case 'voice': {
7450
+ // Step1 only: toggleVoice() owns config persistence (voice.enabled) +
7451
+ // the missing-component install sequence + its own notices (OFF/ON/
7452
+ // progress/failure). We don't push a redundant notice here; a null
7453
+ // return means "already running" or "failed", both already noticed.
7454
+ void toggleVoice({ pushNotice: store.pushNotice });
7455
+ return true;
7456
+ }
6305
7457
  case 'search':
6306
7458
  if (state.busy) {
6307
7459
  store.pushNotice('wait for the current turn to finish before /search', 'warn');
@@ -6600,6 +7752,9 @@ export function App({ store, initialStatusLine = '' }) {
6600
7752
  case 'profile':
6601
7753
  openProfilePicker();
6602
7754
  return true;
7755
+ case 'update':
7756
+ openUpdatePicker();
7757
+ return true;
6603
7758
  case 'quit':
6604
7759
  requestExit();
6605
7760
  return true;
@@ -6727,26 +7882,42 @@ export function App({ store, initialStatusLine = '' }) {
6727
7882
  return false;
6728
7883
  }
6729
7884
  try {
7885
+ const resumeAfterChannelPrompt = (prompt) => {
7886
+ const afterSave = prompt?.afterSave;
7887
+ setChannelPrompt(null);
7888
+ if (typeof afterSave === 'function') afterSave();
7889
+ else void openChannelSetupPicker('all');
7890
+ };
6730
7891
  if (channelPrompt.kind === 'discord-token') {
6731
7892
  if (!commandText) return false;
6732
7893
  store.saveDiscordToken(commandText);
6733
- setChannelPrompt(null);
6734
- void openChannelSetupPicker('all');
7894
+ resumeAfterChannelPrompt(channelPrompt);
7895
+ return true;
7896
+ }
7897
+ if (channelPrompt.kind === 'telegram-token') {
7898
+ if (!commandText) return false;
7899
+ store.saveTelegramToken(commandText);
7900
+ resumeAfterChannelPrompt(channelPrompt);
6735
7901
  return true;
6736
7902
  }
6737
7903
  if (channelPrompt.kind === 'webhook-token') {
6738
7904
  if (!commandText) return false;
6739
7905
  store.saveWebhookAuthtoken(commandText);
6740
- setChannelPrompt(null);
6741
- void openChannelSetupPicker('all');
7906
+ resumeAfterChannelPrompt(channelPrompt);
7907
+ return true;
7908
+ }
7909
+ if (channelPrompt.kind === 'webhook-domain') {
7910
+ if (!commandText) return false;
7911
+ store.setWebhookConfig?.({ ngrokDomain: commandText });
7912
+ resumeAfterChannelPrompt(channelPrompt);
6742
7913
  return true;
6743
7914
  }
6744
7915
  const parts = commandText.split('|').map((part) => part.trim());
6745
7916
  if (channelPrompt.kind === 'channel-add') {
6746
- const [name, channelId, mode] = parts;
6747
- store.saveChannel({ name, channelId, mode });
6748
- setChannelPrompt(null);
6749
- void openChannelSetupPicker('all');
7917
+ const [name, channelId, mode, mainFlag] = parts;
7918
+ const main = String(mainFlag || '').toLowerCase() === 'main' || String(name || '').toLowerCase() === 'main';
7919
+ store.saveChannel({ name, channelId, mode, main, backend: channelPrompt.backend });
7920
+ resumeAfterChannelPrompt(channelPrompt);
6750
7921
  return true;
6751
7922
  }
6752
7923
  if (channelPrompt.kind === 'schedule-add') {
@@ -6992,6 +8163,8 @@ export function App({ store, initialStatusLine = '' }) {
6992
8163
  }, [slashCommands.length, activeSlashQuery]);
6993
8164
 
6994
8165
  const onPromptDraftChange = useCallback((value) => {
8166
+ if (String(value ?? '').length > 0) dismissWelcomePromptHint();
8167
+ syncPromptLayoutRows(value);
6995
8168
  const suppressPromptHint = promptHistoryDraftChangeRef.current;
6996
8169
  promptHistoryDraftChangeRef.current = false;
6997
8170
  const historyNav = promptHistoryNavRef.current;
@@ -7030,7 +8203,7 @@ export function App({ store, initialStatusLine = '' }) {
7030
8203
  if (slashDismissedFor) {
7031
8204
  setSlashDismissedFor((dismissed) => (dismissed && dismissed !== value ? '' : dismissed));
7032
8205
  }
7033
- }, [clearPromptHint, resetPromptHistoryNav, showPromptHint, slashDismissedFor]);
8206
+ }, [clearPromptHint, dismissWelcomePromptHint, resetPromptHistoryNav, showPromptHint, slashDismissedFor, syncPromptLayoutRows]);
7034
8207
 
7035
8208
  const cancelProviderPrompt = useCallback(() => {
7036
8209
  try { providerPrompt?.login?.cancel?.(); } catch {}
@@ -7043,8 +8216,12 @@ export function App({ store, initialStatusLine = '' }) {
7043
8216
  }, [providerPrompt, showPromptHint]);
7044
8217
 
7045
8218
  const cancelChannelPrompt = useCallback(() => {
8219
+ const onCancel = channelPrompt?.onCancel;
8220
+ const afterSave = channelPrompt?.afterSave;
7046
8221
  setChannelPrompt(null);
7047
- }, [showPromptHint]);
8222
+ if (typeof onCancel === 'function') onCancel();
8223
+ else if (typeof afterSave === 'function') afterSave();
8224
+ }, [channelPrompt, showPromptHint]);
7048
8225
 
7049
8226
  const cancelHookPrompt = useCallback(() => {
7050
8227
  setHookPrompt(null);
@@ -7222,7 +8399,8 @@ export function App({ store, initialStatusLine = '' }) {
7222
8399
  // − welcome header (empty transcript only)
7223
8400
  // − live status (thinking / spinner / TurnDone)
7224
8401
  // − queued prompts (marginTop 1 + N rows, only when queued)
7225
- // − input box (marginTop 1 + 2 border + 1 content)
8402
+ // − prompt meta (spinner / transient message / queued)
8403
+ // − input box (2 border + wrapped content)
7226
8404
  // − statusline (reserved L1 + L2 + outer gap; total 3 rows)
7227
8405
  //
7228
8406
  // Every sibling outside the viewport must be accounted for here; otherwise
@@ -7240,24 +8418,39 @@ export function App({ store, initialStatusLine = '' }) {
7240
8418
  // Slash search floats above the normal prompt. Actual option panels own the
7241
8419
  // prompt/status area, so they hide those rows and expand into that space.
7242
8420
  const inputBoxHidden = expandedOptionPanel;
7243
- const showWelcomeBanner = (state.items.length === 0 && !hasFloatingPanel) || projectSelectionActive;
8421
+ const showWelcomeBanner = (state.items.length === 0 && !hasFloatingPanel) || projectSelectionActive || onboardingActive;
7244
8422
  const WELCOME_ROWS = showWelcomeBanner ? 11 : 0;
7245
8423
  const liveSpinner = state.spinner?.active ? state.spinner : (state.commandStatus?.active ? state.commandStatus : null);
7246
8424
  const latestToast = state.toasts?.length ? state.toasts[state.toasts.length - 1] : null;
7247
8425
  const toastHint = latestToast ? latestToast.text : '';
7248
8426
  const inputHint = promptHint || toastHint;
7249
8427
  const inputHintTone = promptHint ? promptHintTone : (latestToast?.tone || 'info');
7250
- // While the slash palette is open it owns the area above the prompt, so the
7251
- // live spinner/meta row is suppressed entirely no reservation and no render.
7252
- const promptMetaRows = !inputBoxHidden && liveSpinner && !slashPaletteOpen ? 2 : 0;
8428
+ const latestTranscriptItem = state.items[state.items.length - 1] || null;
8429
+ const latestDoneAtTail = latestTranscriptItem?.kind === 'turndone' || latestTranscriptItem?.kind === 'statusdone';
8430
+ // Bottom meta band ownership is LIVE-SPINNER ONLY. A finished turn's done row
8431
+ // (turndone/statusdone) is a normal transcript item and flows into scrollback
8432
+ // like anything else, so the area directly above the prompt is CLEAR when the
8433
+ // user is idle. Earlier this row was pinned in the meta band until the next
8434
+ // transcript item was appended (to dodge an autowrap overprint/bleed), which
8435
+ // left the completed status row stuck above the prompt while the user typed or
8436
+ // sat idle. That bleed is now fixed at the source by the tool-output width
8437
+ // clamp, so the pin is no longer needed. Kept as a named null const so the
8438
+ // downstream meta-band/hint logic collapses cleanly to the spinner-only path.
8439
+ const latestDoneItem = null;
7253
8440
  const SCROLL_HINT_ROWS = 0;
7254
8441
  const LIVE_STATUS_ROWS = 0;
7255
- // The standalone prompt box is 3 rows (round border + one input line). Normal
7256
- // mode keeps a one-row top gap, but slash mode pins the command palette flush
7257
- // to the prompt, so reserve only the actual prompt height there. Otherwise an
7258
- // extra reserved row remains below the statusline and the prompt appears one
7259
- // row too high.
7260
- const INPUT_BOX_ROWS = inputBoxHidden ? 0 : (slashPaletteOpen ? 3 : 4) + promptMetaRows;
8442
+ // The standalone prompt box is 2 border rows + the wrapped PromptInput body.
8443
+ // The one-row scroll baseline gap ABOVE the prompt is owned by
8444
+ // transcriptGuardRows, not the prompt box itself. That keeps the scroll
8445
+ // reference at "textbox + 1" while the prompt/statusline bottom stays fixed.
8446
+ //
8447
+ // This must track the prompt draft's REAL wrapped height. Reserving a constant
8448
+ // one-line prompt lets long/multiline input grow the bottom cluster after the
8449
+ // transcript viewport has already claimed those rows, which makes transcript
8450
+ // body text overprint the textbox or slash command window.
8451
+ const currentPromptLayoutRows = promptContentRows(promptLayoutValueRef.current, promptContentColumns);
8452
+ const promptInputRows = inputBoxHidden ? 0 : currentPromptLayoutRows;
8453
+ const promptBoxRows = inputBoxHidden ? 0 : 2 + promptInputRows;
7261
8454
  const STATUSLINE_ROWS = 3;
7262
8455
  // Shared panel chrome math. Every floating panel follows the same vertical
7263
8456
  // rhythm INSIDE its round border: title row, blank, description/hint row,
@@ -7274,11 +8467,26 @@ export function App({ store, initialStatusLine = '' }) {
7274
8467
  const TEXT_ENTRY_ROWS = PANEL_CHROME_ROWS + 1;
7275
8468
  const OPTION_PANEL_EXTRA_ROWS = expandedOptionPanel ? 3 : 0;
7276
8469
  const queuedVisible = !hasFloatingPanel && !inputBoxHidden && state.queued?.length > 0;
7277
- // QueuedCommands has its own top margin, and the prompt box drops its normal
7278
- // top margin while a queue is visible. Net extra height is therefore only the
7279
- // queued rows themselves; counting the queue margin as an extra row over-
7280
- // reserves by one and makes the bottom input cluster float upward.
8470
+ // While the slash palette is open it owns the area above the prompt, so the
8471
+ // live spinner/meta row is suppressed entirely no reservation and no render.
8472
+ // Normalize the spinner TurnDone handoff by making them occupy the SAME
8473
+ // two-row slot. Engine appends turndone/statusdone before clearing spinner, so
8474
+ // a transient frame can otherwise contain BOTH: transcript grows by two rows
8475
+ // while the bottom spinner still reserves two rows, making the viewport visibly
8476
+ // jump. As soon as the done row is the transcript tail, drop the spinner slot;
8477
+ // the new done row replaces that height in the same frame, with no ms timer.
8478
+ const promptMetaVisible = !inputBoxHidden && !slashPaletteOpen && !!liveSpinner && !latestDoneAtTail;
8479
+ const promptMetaRows = promptMetaVisible ? 2 : 0;
8480
+ // Toast/error text without a live spinner uses the existing transcript guard
8481
+ // row directly above the prompt. Do NOT reserve another row here: that made a
8482
+ // transient hint add a visible newline/prompt jump whenever no spinner was
8483
+ // active.
8484
+ const overlayHintRequested = !inputBoxHidden && !hasFloatingPanel && !liveSpinner && !!inputHint && !queuedVisible;
8485
+ const overlayHintRows = 0;
8486
+ // QueuedCommands renders one row per queued command, pinned above the prompt
8487
+ // box (no extra top-margin row).
7281
8488
  const queuedRows = queuedVisible ? state.queued.length : 0;
8489
+ const INPUT_BOX_ROWS = promptBoxRows + promptMetaRows + overlayHintRows;
7282
8490
  const baseReserve = WELCOME_ROWS + SCROLL_HINT_ROWS + LIVE_STATUS_ROWS + INPUT_BOX_ROWS + STATUSLINE_ROWS + queuedRows;
7283
8491
  const maxFloatingPanelRows = Math.max(0, resizeState.rows - baseReserve - 1);
7284
8492
  const desiredFloatingPanelRows = toolApproval
@@ -7292,19 +8500,51 @@ export function App({ store, initialStatusLine = '' }) {
7292
8500
  : slashPaletteOpen
7293
8501
  ? PANEL_MAX_VISIBLE + PANEL_CHROME_ROWS
7294
8502
  : hasTextEntryPrompt
7295
- ? TEXT_ENTRY_ROWS + OPTION_PANEL_EXTRA_ROWS
8503
+ ? TEXT_ENTRY_ROWS
7296
8504
  : 0;
7297
8505
  const floatingPanelRows = desiredFloatingPanelRows > 0
7298
8506
  ? Math.min(desiredFloatingPanelRows, maxFloatingPanelRows)
7299
8507
  : 0;
8508
+ // Give the list every content row the panel exposes. The panel already grew
8509
+ // by OPTION_PANEL_EXTRA_ROWS; previously that growth was subtracted back out
8510
+ // here, so the rows leaked into an empty flexGrow gap instead of the list.
8511
+ // Reserving only PICKER_CHROME_ROWS lets the list occupy the full interior
8512
+ // (the footer's own reservation is handled inside Picker).
7300
8513
  const pickerVisibleRows = picker
7301
- ? Math.max(1, floatingPanelRows - PICKER_CHROME_ROWS - (picker.fillAvailable ? 0 : OPTION_PANEL_EXTRA_ROWS))
8514
+ ? Math.max(1, floatingPanelRows - PICKER_CHROME_ROWS)
7302
8515
  : PANEL_MAX_VISIBLE;
7303
8516
  const bottomReserve = baseReserve + floatingPanelRows;
7304
8517
  const viewportHeight = Math.max(1, resizeState.rows - bottomReserve);
8518
+ // Keep one physical row between the transcript clip and the bottom cluster
8519
+ // even when pinned to the live tail. Windows Terminal/conhost can still
8520
+ // surface one clipped/off-by-one transcript row below the statusline during
8521
+ // rapid tool-card updates; a permanent guard row makes that row blank instead
8522
+ // of a tool header/detail.
8523
+ const baseGuardRows = viewportHeight > 1 ? 1 : 0;
8524
+ // ── Scroll-time overprint guard ───────────────────────────────────────────
8525
+ // Wheel/manual scroll pushes the transcript column DOWN via a negative
8526
+ // marginBottom (see the viewport render). Under conhost/Windows Terminal the
8527
+ // incremental redraw can leave the row that slid past the clip edge painted
8528
+ // OVER the bottom cluster (input box / statusline) for a frame — the reported
8529
+ // "scrolled text shows on the statusline row" bug. One guard row is enough
8530
+ // while pinned to the live tail, but during an active scroll the slid row can
8531
+ // still bleed one line further, so widen the guard to TWO rows whenever the
8532
+ // viewport is genuinely scrolled up. The extra blank row absorbs the stray
8533
+ // paint instead of the statusline. Requires a viewport tall enough to spare
8534
+ // the row, and never shrinks below the base guard.
8535
+ const transcriptGuardRows = baseGuardRows;
8536
+ const transcriptContentHeight = Math.max(1, viewportHeight - transcriptGuardRows);
8537
+ // Bottom-follow / pin semantics must NOT widen with the scroll-time guard, or
8538
+ // the "pinned to tail" threshold would drift and streaming could freeze a row
8539
+ // above bottom. Keep the slack anchored to the BASE (single) guard: if a stale
8540
+ // pre-guard offset of 1 survives while no reading anchor is active, still treat
8541
+ // the viewport as pinned to the live tail so streaming/tool output continues
8542
+ // to auto-follow instead of freezing one row above bottom.
8543
+ const transcriptBottomSlackRows = Math.max(0, baseGuardRows);
8544
+ transcriptBottomSlackRowsRef.current = transcriptBottomSlackRows;
7305
8545
  transcriptViewportRef.current = {
7306
8546
  top: WELCOME_ROWS,
7307
- bottom: Math.max(WELCOME_ROWS, WELCOME_ROWS + viewportHeight - 1),
8547
+ bottom: Math.max(WELCOME_ROWS, WELCOME_ROWS + transcriptContentHeight - 1),
7308
8548
  };
7309
8549
  // [mixdog] Keep the live terminal row count current for the mouse handler's
7310
8550
  // region routing + status-band selection clip (see onData).
@@ -7313,24 +8553,6 @@ export function App({ store, initialStatusLine = '' }) {
7313
8553
  // bottom area), drop its stale measured rect so the mouse handler does not
7314
8554
  // route presses to a prompt box that is not on screen.
7315
8555
  if (inputBoxHidden) promptBoxRectRef.current = null;
7316
- // Windows Terminal/conhost scrolls the alt-screen (auto-wrap/DECAWM) when the
7317
- // bottom-right cell is written, so reserve one cell on win32. Other platforms
7318
- // render at full width.
7319
- const rightSafetyColumns = process.platform === 'win32' ? 1 : 0;
7320
- const frameColumns = Math.max(1, resizeState.columns - rightSafetyColumns);
7321
- const promptMetaVisible = !inputBoxHidden && !!liveSpinner && !slashPaletteOpen;
7322
- // Same-row done hint: when the newest transcript row is a TurnDone/StatusDone
7323
- // and there is a transient hint to show (and no live spinner owns the status
7324
- // band), attach the hint to that done row's right side instead of reserving a
7325
- // separate overlay row. overlayHintVisible excludes this case so the hint is
7326
- // not double-painted.
7327
- const lastTranscriptItem = (state.items || []).at(-1) ?? null;
7328
- const lastItemIsDoneRow = lastTranscriptItem?.kind === 'turndone'
7329
- || lastTranscriptItem?.kind === 'statusdone';
7330
- const attachInputHintToTurnDone = !inputBoxHidden
7331
- && !liveSpinner
7332
- && !!inputHint
7333
- && lastItemIsDoneRow;
7334
8556
  // Toast/error text has two mutually exclusive placements:
7335
8557
  // - while a live status row exists (thinking/compacting/responding), attach it
7336
8558
  // to that row so the bottom cluster reserves exactly one status band;
@@ -7338,13 +8560,32 @@ export function App({ store, initialStatusLine = '' }) {
7338
8560
  // the blank spacer instead of reserving an extra row. This keeps late errors
7339
8561
  // from pushing the prompt/statusline upward, and when thinking starts the
7340
8562
  // hint moves into the live row on the same render instead of double-painting.
7341
- const overlayHintVisible = !inputBoxHidden && !liveSpinner && !!inputHint && !queuedVisible && floatingPanelRows <= 0 && !attachInputHintToTurnDone;
7342
- const transientStatusWidth = inputHint
8563
+ // Transient hint placement while no spinner owns the band is resolved after
8564
+ // transcript windowing (see overlayHintOnLastItem / overlayHintFallbackRow).
8565
+ const spinnerHintWidth = inputHint
7343
8566
  ? Math.max(1, Math.min(Math.max(1, frameColumns - 4), Math.max(12, Math.floor(frameColumns * 0.42))))
7344
8567
  : 0;
8568
+ // When no live spinner owns a status band, the transient hint/error is drawn
8569
+ // into the existing transcript guard row directly above the prompt. Mirror the
8570
+ // spinner-row placement: a fixed-width right slot, not a full-width left box.
8571
+ const guardHintWidth = inputHint
8572
+ ? Math.max(1, Math.min(Math.max(1, frameColumns - 4), Math.max(12, Math.floor(frameColumns * 0.42))))
8573
+ : 0;
8574
+ const transientStatusWidth = liveSpinner ? spinnerHintWidth : guardHintWidth;
7345
8575
  const promptSpinnerColumns = liveSpinner && inputHint
7346
- ? Math.max(1, frameColumns - transientStatusWidth - 1)
8576
+ ? Math.max(1, frameColumns - spinnerHintWidth - 1)
7347
8577
  : frameColumns;
8578
+ const welcomePromptHintText = conditionalWelcomePromptHint || welcomePromptHintRef.current || '';
8579
+ const welcomePromptHintVisible = Boolean(
8580
+ welcomePromptHintText
8581
+ && !welcomePromptHintDismissed
8582
+ && state.items.length === 0
8583
+ && !hasFloatingPanel
8584
+ && !inputBoxHidden
8585
+ && !queuedVisible
8586
+ && !liveSpinner
8587
+ && !inputHint
8588
+ );
7348
8589
  // Key the heavy O(n) row-index + windowing memos on a STRUCTURE signature
7349
8590
  // instead of the `state.items` array identity. The engine swaps `state.items`
7350
8591
  // for a new array on every streaming flush (~8ms) while only the final
@@ -7378,40 +8619,111 @@ export function App({ store, initialStatusLine = '' }) {
7378
8619
  // SAME render, so the completed line fills its space with no one-frame gap.
7379
8620
  // Falls back to the live scrollOffset state when there is no active anchor
7380
8621
  // (bottom-follow / pinned) or it cannot be aligned (anchor item gone).
7381
- const anchorLockActive = !!transcriptAnchorRef.current
8622
+ const hasReadingAnchor = !!transcriptAnchorRef.current && !transcriptAnchorDirtyRef.current;
8623
+ const scrolledUpRows = Math.max(0, Number(scrollTargetRef.current) || 0);
8624
+ // "Genuinely scrolled up" = the viewport is above the bottom slack band. The
8625
+ // bottom-follow / pinned path owns everything at-or-below the slack; anything
8626
+ // above it is the user reading older transcript.
8627
+ const scrolledUp = scrolledUpRows > transcriptBottomSlackRows;
8628
+ // A genuine reading anchor wins even if followingRef is stale-true while the
8629
+ // user is scrolled up. The follow-arm SHOULD have been cleared when the anchor
8630
+ // was captured, but if it lingers true we must not fall through to the stale-
8631
+ // offset render path (that is one half of the newline-jump bug). Keep the
8632
+ // plain !following gate for the anchor-less follow case.
8633
+ const anchorLockActive = hasReadingAnchor && !followingRef.current && scrolledUp;
8634
+ const targetNearBottom = followingRef.current || !scrolledUp;
8635
+ const nearBottomWithoutAnchor = !transcriptAnchorRef.current
7382
8636
  && !transcriptAnchorDirtyRef.current
7383
- && !followingRef.current;
7384
- let renderScrollOffset = scrollOffset;
8637
+ && targetNearBottom;
8638
+ let renderScrollOffset = targetNearBottom ? 0 : scrollOffset;
8639
+ const lockViewRows = Math.max(1, Number(transcriptContentHeight) || 1);
8640
+ const lockTotalRows = Math.max(0, Number(transcriptRowIndex?.totalRows) || 0);
8641
+ const lockMaxRows = Math.max(0, lockTotalRows - lockViewRows);
8642
+ const curPrefixForLock = transcriptRowIndex?.prefixRows || null;
7385
8643
  if (anchorLockActive) {
7386
- const lockViewRows = Math.max(1, Number(viewportHeight) || 1);
7387
- const lockTotalRows = Math.max(0, Number(transcriptRowIndex?.totalRows) || 0);
7388
- const lockMaxRows = Math.max(0, lockTotalRows - lockViewRows);
7389
8644
  const locked = resolveAnchorScrollOffset({
7390
8645
  anchor: transcriptAnchorRef.current,
7391
8646
  items: state.items,
7392
- curPrefix: transcriptRowIndex?.prefixRows || null,
8647
+ curPrefix: curPrefixForLock,
7393
8648
  totalRows: lockTotalRows,
7394
8649
  viewRows: lockViewRows,
7395
8650
  maxRows: lockMaxRows,
7396
8651
  });
7397
8652
  if (locked != null) renderScrollOffset = locked;
8653
+ } else if (!followingRef.current && !nearBottomWithoutAnchor && scrolledUp) {
8654
+ // ── Same-frame anchor CAPTURE for the missing/dirty-anchor case ─────────
8655
+ // The viewport is genuinely scrolled up but there is NO usable same-frame
8656
+ // anchor: it is missing, or dirtied by a manual scroll whose synchronous
8657
+ // capture failed, or a stale-true follow-arm already dropped it. Without an
8658
+ // anchor this frame would render with the STALE bottom-relative
8659
+ // scrollOffset, so any row growth THIS frame (a streaming newline
8660
+ // completing a line, a transcript row expanding) shifts the visible top by
8661
+ // the delta BEFORE the post-commit rowDelta effect can capture/correct — and
8662
+ // that effect would then capture from the ALREADY-shifted totalRows. That is
8663
+ // the confirmed one-frame jump/jitter.
8664
+ //
8665
+ // Fix it at render time: capture an anchor from the PREVIOUS published
8666
+ // geometry (transcriptGeomRef still holds the prior frame here — THIS
8667
+ // frame's geom is published a few lines below), identifying the item id +
8668
+ // row offset that sat at the previous visible-TOP edge, then resolve the
8669
+ // offset against the CURRENT prefix table with the same pure helper so that
8670
+ // exact top row stays put THIS frame. Persist the captured anchor so the
8671
+ // post-commit effect keeps the identical anchor stable instead of
8672
+ // re-deriving one from the already-shifted totalRows.
8673
+ const geom = transcriptGeomRef.current || {};
8674
+ const prevPrefix = geom.prefixRows;
8675
+ if (Array.isArray(prevPrefix) && prevPrefix.length > 1) {
8676
+ const prevTotal = Math.max(0, Number(geom.totalRows) || 0);
8677
+ const prevView = Math.max(1, Number(geom.viewRows) || 1);
8678
+ const prevOffset = Math.max(0, Number(geom.renderOffset) || 0);
8679
+ const prevItems = geom.items || [];
8680
+ // Bottom-relative window math (same as transcriptRenderWindow): the top
8681
+ // edge sits `offset + viewRows` rows up from the previous total.
8682
+ const prevTopRow = Math.max(0, Math.min(prevTotal, prevTotal - prevOffset - prevView));
8683
+ let idx = upperBound(prevPrefix, prevTopRow) - 1;
8684
+ if (idx < 0) idx = 0;
8685
+ if (idx > prevPrefix.length - 2) idx = prevPrefix.length - 2;
8686
+ const anchorItem = prevItems[idx];
8687
+ if (anchorItem && anchorItem.id != null) {
8688
+ const captured = { id: anchorItem.id, offset: Math.max(0, prevTopRow - (prevPrefix[idx] || 0)) };
8689
+ const locked = resolveAnchorScrollOffset({
8690
+ anchor: captured,
8691
+ items: state.items,
8692
+ curPrefix: curPrefixForLock,
8693
+ totalRows: lockTotalRows,
8694
+ viewRows: lockViewRows,
8695
+ maxRows: lockMaxRows,
8696
+ });
8697
+ if (locked != null) {
8698
+ renderScrollOffset = locked;
8699
+ transcriptAnchorRef.current = captured;
8700
+ transcriptAnchorDirtyRef.current = false;
8701
+ }
8702
+ }
8703
+ }
7398
8704
  }
7399
8705
  const transcriptWindow = useMemo(() => transcriptRenderWindow(state.items, {
7400
8706
  scrollOffset: renderScrollOffset,
7401
- viewportHeight,
8707
+ viewportHeight: transcriptContentHeight,
7402
8708
  columns: frameColumns,
7403
8709
  toolOutputExpanded,
7404
8710
  rowIndex: transcriptRowIndex,
7405
8711
  // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: sig+scroll/viewport capture the relevant changes
7406
- }), [transcriptStructureSig, renderScrollOffset, viewportHeight, transcriptRowIndex]);
8712
+ }), [transcriptStructureSig, renderScrollOffset, transcriptContentHeight, transcriptRowIndex]);
7407
8713
  maxScrollRowsRef.current = transcriptWindow.maxScrollRows;
7408
8714
  // Publish this frame's geometry so a manual scroll can capture the reading
7409
8715
  // anchor synchronously (see captureTranscriptAnchorAt).
7410
8716
  transcriptGeomRef.current = {
7411
8717
  prefixRows: transcriptRowIndex?.prefixRows || null,
7412
8718
  totalRows: Math.max(0, Number(transcriptWindow.totalRows) || 0),
7413
- viewRows: Math.max(1, Number(viewportHeight) || 1),
8719
+ viewRows: Math.max(1, Number(transcriptContentHeight) || 1),
7414
8720
  items: state.items || null,
8721
+ // The offset THIS frame actually rendered with. The same-frame anchor
8722
+ // CAPTURE for a missing/dirty anchor (above) reads this from the PREVIOUS
8723
+ // frame to reconstruct the exact top-edge row that was on screen, so the
8724
+ // capture matches what the user saw rather than the stale scrollOffset
8725
+ // state. Bottom-relative, matching transcriptRenderWindow's window math.
8726
+ renderOffset: Math.max(0, Number(renderScrollOffset) || 0),
7415
8727
  };
7416
8728
  // The window memo is keyed on a structure signature that intentionally
7417
8729
  // ignores per-character growth of the streaming assistant text, so its
@@ -7423,8 +8735,27 @@ export function App({ store, initialStatusLine = '' }) {
7423
8735
  transcriptWindow.startIndex,
7424
8736
  transcriptWindow.endIndex,
7425
8737
  );
7426
- // (attachInputHintToTurnDone / lastTranscriptItem computed earlier, before
7427
- // overlayHintVisible, so the overlay can exclude the same-row attach case.)
8738
+ // The bottom meta band is spinner-only, so nothing is pulled out of the
8739
+ // transcript for it. A finished turn's done row (turndone/statusdone) renders
8740
+ // inline in scrollback like any other item — no filtering, no double-paint.
8741
+ const renderedTranscriptItems = transcriptVisibleItems;
8742
+ let overlayHintAttachItemIndex = -1;
8743
+ for (let i = renderedTranscriptItems.length - 1; i >= 0; i--) {
8744
+ const item = renderedTranscriptItems[i];
8745
+ if (item?.kind === 'tool' && shouldSuppressFullyFailedToolItem(item)) continue;
8746
+ overlayHintAttachItemIndex = i;
8747
+ break;
8748
+ }
8749
+ const transcriptTailPinned = Math.max(0, Number(transcriptWindow.effectiveScrollOffset) || 0) <= transcriptBottomSlackRows;
8750
+ const overlayHintOnLastItem = overlayHintRequested
8751
+ && floatingPanelRows <= 0
8752
+ && transcriptWindow.bottomSpacerRows === 0
8753
+ && transcriptTailPinned
8754
+ && overlayHintAttachItemIndex >= 0;
8755
+ const overlayHintFallbackRow = overlayHintRequested
8756
+ && floatingPanelRows <= 0
8757
+ && transcriptGuardRows > 0
8758
+ && !overlayHintOnLastItem;
7428
8759
  // ── App-level measured height harvest (ScrollBox/useVirtualScroll-inspired) ─
7429
8760
  // Runs after EVERY commit (no deps): Yoga has just laid out the mounted rows,
7430
8761
  // so each tracked item Box's getComputedHeight() is its REAL terminal height.
@@ -7514,8 +8845,18 @@ export function App({ store, initialStatusLine = '' }) {
7514
8845
  const currentPosition = Math.max(0, Number(scrollPositionRef.current) || 0);
7515
8846
  const currentOffset = Math.max(0, Number(scrollOffset) || 0);
7516
8847
  const maxRows = Math.max(0, Number(transcriptWindow.maxScrollRows) || 0);
7517
- const pinnedToBottom = currentTarget <= 0 && currentPosition <= 0 && currentOffset <= 0;
7518
- const followOnGrowth = followingRef.current && rowDelta > 0;
8848
+ const nearBottom = followingRef.current || currentTarget <= transcriptBottomSlackRows;
8849
+ const pinnedToBottom = nearBottom;
8850
+ // A genuine reading anchor must win over ordinary stream growth, but an
8851
+ // explicit follow arm (prompt submit / pinned bottom) must win over stale
8852
+ // anchor state. Manual scroll cancels followingRef before anchor capture, so
8853
+ // deliberate reading still stays anchored while automatic bottom-follow
8854
+ // stays armed across spinner/tool/stream height corrections.
8855
+ const activeReadingAnchor = !!transcriptAnchorRef.current
8856
+ && !transcriptAnchorDirtyRef.current
8857
+ && !followingRef.current
8858
+ && !nearBottom;
8859
+ const followOnGrowth = followingRef.current && rowDelta > 0 && !activeReadingAnchor;
7519
8860
  const shouldFollowBottom = rowDelta > 0 && (followOnGrowth || pinnedToBottom);
7520
8861
  if (shouldFollowBottom) {
7521
8862
  // Bottom follow: while pinned to the newest output,
@@ -7524,7 +8865,18 @@ export function App({ store, initialStatusLine = '' }) {
7524
8865
  // during streaming makes the transcript jump down/up and can clip the
7525
8866
  // currently generated assistant text. Keep all scroll refs at zero so
7526
8867
  // character generation stays visually stable.
7527
- followingRef.current = false;
8868
+ stopSmoothScroll();
8869
+ scrollTargetRef.current = 0;
8870
+ scrollPositionRef.current = 0;
8871
+ transcriptAnchorRef.current = null;
8872
+ transcriptAnchorDirtyRef.current = false;
8873
+ if (currentOffset !== 0) setScrollOffset(0);
8874
+ return;
8875
+ }
8876
+
8877
+ // Viewport-only changes, such as swapping TextEntryPanel for a picker, must
8878
+ // not turn a bottom-pinned transcript into a reading-anchor lock.
8879
+ if (rowDelta <= 0 && nearBottom) {
7528
8880
  stopSmoothScroll();
7529
8881
  scrollTargetRef.current = 0;
7530
8882
  scrollPositionRef.current = 0;
@@ -7546,12 +8898,12 @@ export function App({ store, initialStatusLine = '' }) {
7546
8898
  // only moves the bottom — the top is rock-stable. Changes ABOVE the anchor
7547
8899
  // move the item's prefix start and are absorbed the same way. No deltas, no
7548
8900
  // fallback, no drift.
7549
- const viewRows = Math.max(1, Number(viewportHeight) || 1);
8901
+ const viewRows = Math.max(1, Number(transcriptContentHeight) || 1);
7550
8902
  const items = state.items || [];
7551
8903
  let anchor = transcriptAnchorRef.current;
7552
8904
  // (Re)capture the anchor from the current viewport-top edge when missing or
7553
8905
  // invalidated by a manual scroll. anchorRow = absolute row at the top edge.
7554
- if (!anchor || transcriptAnchorDirtyRef.current) {
8906
+ if (!followingRef.current && (!anchor || transcriptAnchorDirtyRef.current)) {
7555
8907
  if (curPrefix && curPrefix.length > 1) {
7556
8908
  const anchorRow = Math.max(0, Math.min(totalRows, totalRows - currentTarget - viewRows));
7557
8909
  let idx = upperBound(curPrefix, anchorRow) - 1;
@@ -7591,12 +8943,25 @@ export function App({ store, initialStatusLine = '' }) {
7591
8943
  scrollPositionRef.current = Math.max(0, Math.min(maxRows, currentPosition + appliedDelta));
7592
8944
  preservedScrollDeltaRef.current += appliedDelta;
7593
8945
  setScrollOffset(Math.max(0, Math.round(desired)));
7594
- }, [transcriptWindow.totalRows, transcriptWindow.maxScrollRows, transcriptRowIndex, viewportHeight, scrollOffset, stopSmoothScroll]);
8946
+ }, [transcriptWindow.totalRows, transcriptWindow.maxScrollRows, transcriptRowIndex, transcriptContentHeight, transcriptBottomSlackRows, scrollOffset, stopSmoothScroll]);
8947
+ useLayoutEffect(() => {
8948
+ if (transcriptBottomSlackRows <= 0) return;
8949
+ if (transcriptAnchorRef.current || transcriptAnchorDirtyRef.current || followingRef.current) return;
8950
+ const currentTarget = Math.max(0, Number(scrollTargetRef.current) || 0);
8951
+ const currentPosition = Math.max(0, Number(scrollPositionRef.current) || 0);
8952
+ const currentOffset = Math.max(0, Number(scrollOffset) || 0);
8953
+ if (Math.max(currentTarget, currentPosition, currentOffset) === 0) return;
8954
+ if (Math.max(currentTarget, currentPosition, currentOffset) > transcriptBottomSlackRows) return;
8955
+ stopSmoothScroll();
8956
+ scrollTargetRef.current = 0;
8957
+ scrollPositionRef.current = 0;
8958
+ setScrollOffset(0);
8959
+ }, [transcriptBottomSlackRows, scrollOffset, stopSmoothScroll]);
7595
8960
  useLayoutEffect(() => {
7596
8961
  const top = Math.max(0, Number(transcriptViewportRef.current?.top) || 0);
7597
8962
  const next = {
7598
8963
  top,
7599
- height: Math.max(1, Number(viewportHeight) || 1),
8964
+ height: Math.max(1, Number(transcriptContentHeight) || 1),
7600
8965
  totalRows: Math.max(0, Number(transcriptWindow.totalRows) || 0),
7601
8966
  scrollOffset: Math.max(0, Number(transcriptWindow.effectiveScrollOffset) || 0),
7602
8967
  };
@@ -7615,8 +8980,14 @@ export function App({ store, initialStatusLine = '' }) {
7615
8980
  if (deltaY === 0) return;
7616
8981
  const clippedRect = withSelectionClip(shiftSelectionRectY(dragRef.current.rect, deltaY));
7617
8982
  dragRef.current = { ...dragRef.current, rect: clippedRect };
7618
- paintSelectionRect(clippedRect, { rememberText: true });
7619
- }, [viewportHeight, transcriptWindow.totalRows, transcriptWindow.effectiveScrollOffset, withSelectionClip, paintSelectionRect]);
8983
+ paintSelectionRect(clippedRect, { rememberText: true, immediate: true });
8984
+ }, [transcriptContentHeight, transcriptWindow.totalRows, transcriptWindow.effectiveScrollOffset, withSelectionClip, paintSelectionRect]);
8985
+ useEffect(() => {
8986
+ if (!dragRef.current.rect) return;
8987
+ const clippedRect = withSelectionClip(dragRef.current.rect);
8988
+ dragRef.current = { ...dragRef.current, rect: clippedRect };
8989
+ paintSelectionRect(clippedRect, { rememberText: true, immediate: true });
8990
+ }, [state.themeEpoch, withSelectionClip, paintSelectionRect]);
7620
8991
  useEffect(() => {
7621
8992
  const maxRows = Math.max(0, Number(transcriptWindow.maxScrollRows) || 0);
7622
8993
  if (scrollTargetRef.current <= maxRows && scrollPositionRef.current <= maxRows && scrollOffset <= maxRows) return;
@@ -7681,6 +9052,7 @@ export function App({ store, initialStatusLine = '' }) {
7681
9052
  onTab={cycleWorkflowFromPrompt}
7682
9053
  onPasteText={handlePromptPaste}
7683
9054
  onHistoryNavigate={handlePromptHistoryNavigate}
9055
+ onVoiceToggle={handleVoiceToggle}
7684
9056
  commandPaletteActive={slashPaletteOpen}
7685
9057
  onCommandPaletteNavigate={(direction) => {
7686
9058
  setSlashIndex((index) => {
@@ -7711,16 +9083,16 @@ export function App({ store, initialStatusLine = '' }) {
7711
9083
  // stack up from just over the input. A top flexGrow spacer sinks the whole
7712
9084
  // stack to the bottom; the transcript itself is a fixed-height clipping
7713
9085
  // viewport (see viewportHeight above).
7714
- <Box flexDirection="column" width={frameColumns} height={resizeState.rows} backgroundColor={theme.background}>
9086
+ <Box flexDirection="column" width={frameColumns} height={resizeState.rows} backgroundColor={surfaceBackground()}>
7715
9087
  {/* Empty-transcript header stays outside the bottom-anchored viewport and
7716
9088
  has its own reserved rows, so it cannot steal space from the input. */}
7717
9089
  {showWelcomeBanner ? (
7718
- <Box flexDirection="column" height={7} flexShrink={0} marginTop={3} marginBottom={1} backgroundColor={theme.background}>
9090
+ <Box flexDirection="column" height={7} flexShrink={0} marginTop={3} marginBottom={1} backgroundColor={surfaceBackground()}>
7719
9091
  <Text color={theme.text} bold>{centerLine('███╗ ███╗██╗██╗ ██╗██████╗ ██████╗ ██████╗ ', frameColumns)}</Text>
7720
9092
  <Text color={theme.text} bold>{centerLine('████╗ ████║██║╚██╗██╔╝██╔══██╗██╔═══██╗██╔════╝ ', frameColumns)}</Text>
7721
- <Text color={theme.claude} bold>{centerLine('██╔████╔██║██║ ╚███╔╝ ██║ ██║██║ ██║██║ ███╗', frameColumns)}</Text>
7722
- <Text color={theme.claude} bold>{centerLine('██║╚██╔╝██║██║ ██╔██╗ ██║ ██║██║ ██║██║ ██║', frameColumns)}</Text>
7723
- <Text color={theme.claude} bold>{centerLine('██║ ╚═╝ ██║██║██╔╝ ██╗██████╔╝╚██████╔╝╚██████╔╝', frameColumns)}</Text>
9093
+ <Text color={theme.logo ?? theme.claude} bold>{centerLine('██╔████╔██║██║ ╚███╔╝ ██║ ██║██║ ██║██║ ███╗', frameColumns)}</Text>
9094
+ <Text color={theme.logo ?? theme.claude} bold>{centerLine('██║╚██╔╝██║██║ ██╔██╗ ██║ ██║██║ ██║██║ ██║', frameColumns)}</Text>
9095
+ <Text color={theme.logo ?? theme.claude} bold>{centerLine('██║ ╚═╝ ██║██║██╔╝ ██╗██████╔╝╚██████╔╝╚██████╔╝', frameColumns)}</Text>
7724
9096
  <Box height={1} flexShrink={0} />
7725
9097
  <Text color={theme.inactive}>{centerLine(`mixdog coding agent · ${state.cwd}`, frameColumns, 4)}</Text>
7726
9098
  </Box>
@@ -7742,6 +9114,14 @@ export function App({ store, initialStatusLine = '' }) {
7742
9114
  overflow="hidden"
7743
9115
  justifyContent="flex-end"
7744
9116
  >
9117
+ <Box
9118
+ flexDirection="column"
9119
+ width="100%"
9120
+ height={transcriptContentHeight}
9121
+ flexShrink={0}
9122
+ overflow="hidden"
9123
+ justifyContent="flex-end"
9124
+ >
7745
9125
  {/* Wheel scroll: with the viewport bottom-anchored (flex-end), a NEGATIVE
7746
9126
  marginBottom pushes the transcript column DOWN past the bottom edge,
7747
9127
  bringing older content above the window into view (overflow hidden
@@ -7760,20 +9140,18 @@ export function App({ store, initialStatusLine = '' }) {
7760
9140
  * OVERSCAN: TRANSCRIPT_WINDOW_OVERSCAN_ROWS extra rows above the viewport so
7761
9141
  * fast wheel scrolls don't show a blank gap before re-render.
7762
9142
  */}
7763
- {transcriptVisibleItems.map((item, i, arr) => {
7764
- const showRightMessage = attachInputHintToTurnDone
7765
- && item.id === lastTranscriptItem?.id
7766
- && (item.kind === 'turndone' || item.kind === 'statusdone');
9143
+ {renderedTranscriptItems.map((item, i, arr) => {
7767
9144
  const measureRef = transcriptMeasureRef(item);
9145
+ const attachOverlayHint = overlayHintOnLastItem && i === overlayHintAttachItemIndex;
7768
9146
  const itemNode = (
7769
9147
  <Item
7770
9148
  item={item}
7771
9149
  prevKind={i > 0 ? arr[i - 1].kind : state.items[transcriptWindow.startIndex - 1]?.kind ?? null}
7772
9150
  columns={frameColumns}
7773
9151
  toolOutputExpanded={toolOutputExpanded}
7774
- rightMessage={showRightMessage ? inputHint : ''}
7775
- rightTone={inputHintTone}
7776
- rightMessageWidth={transientStatusWidth || 24}
9152
+ rightMessage={attachOverlayHint ? inputHint : ''}
9153
+ rightTone={attachOverlayHint ? inputHintTone : 'info'}
9154
+ rightMessageWidth={attachOverlayHint ? (guardHintWidth || transientStatusWidth || 24) : 24}
7777
9155
  themeEpoch={state.themeEpoch || 0}
7778
9156
  />
7779
9157
  );
@@ -7794,6 +9172,22 @@ export function App({ store, initialStatusLine = '' }) {
7794
9172
  <Box height={transcriptWindow.bottomSpacerRows} flexShrink={0} />
7795
9173
  ) : null}
7796
9174
  </Box>
9175
+ {welcomePromptHintVisible ? (
9176
+ <Box height={1} flexShrink={0} width="100%" overflow="hidden">
9177
+ <Text color={theme.inactive} wrap="truncate">{centerLine(welcomePromptHintText, frameColumns, 2)}</Text>
9178
+ </Box>
9179
+ ) : null}
9180
+ </Box>
9181
+ {transcriptGuardRows > 0 ? (
9182
+ <Box height={transcriptGuardRows} flexShrink={0} backgroundColor={surfaceBackground()} flexDirection="row" width="100%" overflow="hidden">
9183
+ <Box flexGrow={1} flexShrink={1} overflow="hidden" />
9184
+ {overlayHintFallbackRow ? (
9185
+ <Box flexShrink={0} width={guardHintWidth || 1} marginLeft={1} marginRight={1} justifyContent="flex-end" overflow="hidden">
9186
+ <Text color={promptStatusColor(inputHintTone)} wrap="truncate">{inputHint}</Text>
9187
+ </Box>
9188
+ ) : null}
9189
+ </Box>
9190
+ ) : null}
7797
9191
  </Box>
7798
9192
 
7799
9193
  {/* Live reasoning and transient status live just above the prompt: reasoning
@@ -7803,9 +9197,9 @@ export function App({ store, initialStatusLine = '' }) {
7803
9197
  panels use their actual rendered height and shrink before the prompt
7804
9198
  can move; overflow is clipped from the top while the panel remains
7805
9199
  bottom-aligned against the prompt. */}
7806
- <Box flexDirection="column" flexShrink={0} width="100%" backgroundColor={theme.background}>
9200
+ <Box flexDirection="column" flexShrink={0} width="100%" backgroundColor={surfaceBackground()}>
7807
9201
  {floatingPanelRows > 0 ? (
7808
- <Box flexDirection="column" flexShrink={0} height={floatingPanelRows} overflow="hidden" justifyContent="flex-end" backgroundColor={theme.background}>
9202
+ <Box flexDirection="column" flexShrink={0} height={floatingPanelRows} overflow="hidden" justifyContent="flex-end" backgroundColor={surfaceBackground()}>
7809
9203
  {toolApproval ? (
7810
9204
  <Picker
7811
9205
  items={[
@@ -7883,6 +9277,7 @@ export function App({ store, initialStatusLine = '' }) {
7883
9277
  visibleCount={pickerVisibleRows}
7884
9278
  fillHeight={expandedOptionPanel}
7885
9279
  themeEpoch={state.themeEpoch || 0}
9280
+ confirmBar={picker.confirmBar}
7886
9281
  />
7887
9282
  ) : contextPanel ? (
7888
9283
  <ContextPanel
@@ -7950,7 +9345,7 @@ export function App({ store, initialStatusLine = '' }) {
7950
9345
  <TextEntryPanel
7951
9346
  title={channelPrompt.label}
7952
9347
  hint={channelPrompt.hint || 'Save channel setting.'}
7953
- mask={channelPrompt.kind === 'discord-token' || channelPrompt.kind === 'webhook-token'}
9348
+ mask={channelPrompt.kind === 'discord-token' || channelPrompt.kind === 'telegram-token' || channelPrompt.kind === 'webhook-token'}
7954
9349
  columns={frameColumns}
7955
9350
  promptLabel="Value > "
7956
9351
  onSubmit={onSubmit}
@@ -7998,56 +9393,47 @@ export function App({ store, initialStatusLine = '' }) {
7998
9393
  {!inputBoxHidden ? (
7999
9394
  <>
8000
9395
  {promptMetaVisible ? (
8001
- <Box
8002
- marginTop={floatingPanelRows > 0 ? 0 : 1}
8003
- height={1}
8004
- width="100%"
8005
- flexDirection="row"
8006
- backgroundColor={theme.background}
8007
- >
8008
- <Box flexGrow={1} flexShrink={1} overflow="hidden">
8009
- {liveSpinner ? (
8010
- <Spinner
8011
- verb={liveSpinner.verb}
8012
- startedAt={liveSpinner.startedAt}
8013
- outputTokens={liveSpinner?.outputTokens ?? liveSpinner?.tokens ?? 0}
8014
- thinking={!!(state.thinking || liveSpinner?.thinking)}
8015
- thinkingActiveSince={liveSpinner?.thinkingSegmentStartedAt ?? 0}
8016
- mode={liveSpinner?.mode || 'responding'}
8017
- columns={promptSpinnerColumns}
8018
- marginTop={0}
8019
- />
8020
- ) : null}
8021
- </Box>
8022
- {inputHint ? (
8023
- <Box flexShrink={0} width={transientStatusWidth || 1} marginLeft={1} marginRight={1} justifyContent="flex-end" overflow="hidden">
8024
- <Text color={promptStatusColor(inputHintTone)} wrap="truncate">{inputHint}</Text>
9396
+ <>
9397
+ <Box
9398
+ marginTop={0}
9399
+ marginBottom={0}
9400
+ height={1}
9401
+ width="100%"
9402
+ flexDirection="row"
9403
+ backgroundColor={surfaceBackground()}
9404
+ >
9405
+ <Box flexGrow={1} flexShrink={1} overflow="hidden">
9406
+ {liveSpinner ? (
9407
+ <Spinner
9408
+ verb={liveSpinner.verb}
9409
+ startedAt={liveSpinner.startedAt}
9410
+ outputTokens={liveSpinner?.outputTokens ?? liveSpinner?.tokens ?? 0}
9411
+ thinking={!!(state.thinking || liveSpinner?.thinking)}
9412
+ thinkingActiveSince={liveSpinner?.thinkingSegmentStartedAt ?? 0}
9413
+ mode={liveSpinner?.mode || 'responding'}
9414
+ columns={promptSpinnerColumns}
9415
+ marginTop={0}
9416
+ />
9417
+ ) : null}
8025
9418
  </Box>
8026
- ) : null}
8027
- </Box>
8028
- ) : null}
8029
- {overlayHintVisible ? (
8030
- <Box
8031
- height={1}
8032
- width="100%"
8033
- flexDirection="row"
8034
- backgroundColor={theme.background}
8035
- >
8036
- <Box flexGrow={1} flexShrink={1} overflow="hidden" />
8037
- <Box flexShrink={0} width={transientStatusWidth || 1} marginLeft={1} marginRight={1} justifyContent="flex-end" overflow="hidden">
8038
- <Text color={promptStatusColor(inputHintTone)} wrap="truncate">{inputHint}</Text>
9419
+ {inputHint ? (
9420
+ <Box flexShrink={0} width={transientStatusWidth || 1} marginLeft={1} marginRight={1} justifyContent="flex-end" overflow="hidden">
9421
+ <Text color={promptStatusColor(inputHintTone)} wrap="truncate">{inputHint}</Text>
9422
+ </Box>
9423
+ ) : null}
8039
9424
  </Box>
8040
- </Box>
9425
+ <Box height={1} width="100%" backgroundColor={surfaceBackground()} />
9426
+ </>
8041
9427
  ) : null}
8042
9428
  {queuedVisible ? (
8043
9429
  <QueuedCommands queued={state.queued} columns={frameColumns} />
8044
9430
  ) : null}
8045
9431
  <Box
8046
- marginTop={queuedVisible || overlayHintVisible ? 0 : (floatingPanelRows > 0 ? 0 : 1)}
9432
+ marginTop={0}
8047
9433
  width="100%"
8048
9434
  borderStyle="round"
8049
9435
  borderColor={theme.promptBorder}
8050
- backgroundColor={theme.background}
9436
+ backgroundColor={surfaceBackground()}
8051
9437
  paddingX={1}
8052
9438
  >
8053
9439
  {promptInputControl}
@@ -8075,6 +9461,7 @@ export function App({ store, initialStatusLine = '' }) {
8075
9461
  activeTools={activeTools}
8076
9462
  initialLine={initialStatusLine}
8077
9463
  workflow={state.workflow}
9464
+ remoteEnabled={state.remoteEnabled === true}
8078
9465
  themeEpoch={state.themeEpoch || 0}
8079
9466
  />
8080
9467
  </Box>