mixdog 0.9.1 → 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 (214) hide show
  1. package/package.json +8 -1
  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/task-bench.mjs +207 -0
  23. package/scripts/tool-failures.mjs +6 -6
  24. package/scripts/tool-smoke.mjs +306 -66
  25. package/scripts/toolcall-args-test.mjs +81 -0
  26. package/src/agents/debugger/AGENT.md +4 -4
  27. package/src/agents/heavy-worker/AGENT.md +4 -2
  28. package/src/agents/reviewer/AGENT.md +4 -4
  29. package/src/agents/worker/AGENT.md +4 -2
  30. package/src/app.mjs +10 -6
  31. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  32. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  33. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  34. package/src/headless-role.mjs +14 -14
  35. package/src/help.mjs +1 -0
  36. package/src/lib/rules-builder.cjs +32 -54
  37. package/src/mixdog-session-runtime.mjs +710 -318
  38. package/src/output-styles/default.md +12 -7
  39. package/src/output-styles/minimal.md +25 -0
  40. package/src/output-styles/oneline.md +21 -0
  41. package/src/output-styles/simple.md +10 -9
  42. package/src/repl.mjs +12 -2
  43. package/src/rules/agent/00-common.md +7 -5
  44. package/src/rules/agent/30-explorer.md +7 -8
  45. package/src/rules/lead/01-general.md +3 -1
  46. package/src/rules/lead/lead-tool.md +7 -0
  47. package/src/rules/shared/01-tool.md +17 -12
  48. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  49. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  50. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  51. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  52. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  53. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  54. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  55. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  56. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  57. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  58. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  59. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  60. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  61. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  62. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +332 -57
  63. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +59 -32
  64. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  65. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  66. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  67. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  68. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  69. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +78 -3
  70. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +202 -98
  71. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +183 -20
  72. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  73. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  74. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  75. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -9
  76. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  77. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  78. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  79. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  80. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  81. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  82. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  83. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  84. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
  85. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  86. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  87. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  88. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  89. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  90. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  91. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  92. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  93. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  94. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  95. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  96. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  97. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  98. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  99. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  100. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  101. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  102. package/src/runtime/channels/backends/discord.mjs +99 -9
  103. package/src/runtime/channels/backends/telegram.mjs +501 -0
  104. package/src/runtime/channels/index.mjs +441 -1224
  105. package/src/runtime/channels/lib/config.mjs +54 -2
  106. package/src/runtime/channels/lib/format.mjs +4 -2
  107. package/src/runtime/channels/lib/output-forwarder.mjs +80 -67
  108. package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
  109. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  110. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  111. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  112. package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
  113. package/src/runtime/channels/lib/webhook.mjs +59 -31
  114. package/src/runtime/channels/tool-defs.mjs +1 -1
  115. package/src/runtime/memory/index.mjs +184 -19
  116. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  117. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  118. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  119. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  120. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  121. package/src/runtime/memory/lib/memory.mjs +101 -4
  122. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  123. package/src/runtime/memory/lib/session-ingest.mjs +107 -0
  124. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  125. package/src/runtime/memory/tool-defs.mjs +6 -3
  126. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  127. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  128. package/src/runtime/shared/config.mjs +9 -0
  129. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  130. package/src/runtime/shared/schedules-store.mjs +21 -19
  131. package/src/runtime/shared/tool-surface.mjs +98 -13
  132. package/src/runtime/shared/transcript-writer.mjs +129 -0
  133. package/src/runtime/shared/update-checker.mjs +214 -0
  134. package/src/standalone/agent-tool.mjs +255 -109
  135. package/src/standalone/channel-admin.mjs +133 -40
  136. package/src/standalone/channel-worker.mjs +8 -291
  137. package/src/standalone/explore-tool.mjs +2 -2
  138. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  139. package/src/standalone/provider-admin.mjs +11 -0
  140. package/src/standalone/usage-dashboard.mjs +1 -1
  141. package/src/tui/App.jsx +2096 -732
  142. package/src/tui/components/ConfirmBar.jsx +47 -0
  143. package/src/tui/components/ContextPanel.jsx +5 -3
  144. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  145. package/src/tui/components/Markdown.jsx +22 -98
  146. package/src/tui/components/Message.jsx +14 -35
  147. package/src/tui/components/Picker.jsx +87 -12
  148. package/src/tui/components/PromptInput.jsx +83 -7
  149. package/src/tui/components/QueuedCommands.jsx +1 -1
  150. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  151. package/src/tui/components/Spinner.jsx +7 -7
  152. package/src/tui/components/StatusLine.jsx +40 -21
  153. package/src/tui/components/TextEntryPanel.jsx +51 -7
  154. package/src/tui/components/ToolExecution.jsx +170 -98
  155. package/src/tui/components/TurnDone.jsx +4 -4
  156. package/src/tui/components/UsagePanel.jsx +1 -1
  157. package/src/tui/components/tool-output-format.mjs +159 -21
  158. package/src/tui/components/tool-output-format.test.mjs +87 -0
  159. package/src/tui/display-width.mjs +69 -0
  160. package/src/tui/display-width.test.mjs +35 -0
  161. package/src/tui/dist/index.mjs +6965 -2391
  162. package/src/tui/engine.mjs +287 -126
  163. package/src/tui/index.jsx +117 -7
  164. package/src/tui/keyboard-protocol.mjs +42 -0
  165. package/src/tui/lib/voice-recorder.mjs +453 -0
  166. package/src/tui/markdown/format-token.mjs +129 -76
  167. package/src/tui/markdown/format-token.test.mjs +61 -19
  168. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  169. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  170. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  171. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  172. package/src/tui/markdown/table-layout.mjs +9 -9
  173. package/src/tui/paste-attachments.mjs +0 -11
  174. package/src/tui/prompt-history-store.mjs +129 -0
  175. package/src/tui/prompt-history-store.test.mjs +52 -0
  176. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  177. package/src/tui/theme.mjs +41 -657
  178. package/src/tui/themes/base.mjs +86 -0
  179. package/src/tui/themes/basic.mjs +85 -0
  180. package/src/tui/themes/catppuccin.mjs +72 -0
  181. package/src/tui/themes/dracula.mjs +70 -0
  182. package/src/tui/themes/everforest.mjs +71 -0
  183. package/src/tui/themes/gruvbox.mjs +71 -0
  184. package/src/tui/themes/index.mjs +71 -0
  185. package/src/tui/themes/indigo.mjs +78 -0
  186. package/src/tui/themes/kanagawa.mjs +80 -0
  187. package/src/tui/themes/light.mjs +81 -0
  188. package/src/tui/themes/nord.mjs +72 -0
  189. package/src/tui/themes/onedark.mjs +16 -0
  190. package/src/tui/themes/rosepine.mjs +70 -0
  191. package/src/tui/themes/teal.mjs +81 -0
  192. package/src/tui/themes/tokyonight.mjs +79 -0
  193. package/src/tui/themes/utils.mjs +106 -0
  194. package/src/tui/themes/warm.mjs +79 -0
  195. package/src/tui/transcript-tool-failures.mjs +13 -2
  196. package/src/ui/markdown.mjs +1 -1
  197. package/src/ui/model-display.mjs +2 -2
  198. package/src/ui/statusline.mjs +26 -27
  199. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  200. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  201. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  202. package/src/workflows/default/WORKFLOW.md +39 -12
  203. package/src/workflows/sequential/WORKFLOW.md +46 -0
  204. package/src/workflows/solo/WORKFLOW.md +7 -0
  205. package/vendor/ink/build/display-width.js +62 -0
  206. package/vendor/ink/build/ink.js +100 -12
  207. package/vendor/ink/build/measure-text.js +4 -1
  208. package/vendor/ink/build/output.js +115 -9
  209. package/vendor/ink/build/render-node-to-output.js +4 -1
  210. package/vendor/ink/build/render.js +4 -0
  211. package/src/output-styles/extreme-simple.md +0 -20
  212. package/src/rules/lead/04-workflow.md +0 -51
  213. package/src/workflows/default/workflow.json +0 -13
  214. 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) {
@@ -849,7 +933,7 @@ function toolExpandedRawTextForRows(item, rawRt) {
849
933
  if (item?.aggregate) return rawRt;
850
934
  const hasDisplayResult = toolHasDisplayResultForRows(item);
851
935
  if (hasDisplayResult) return toolDisplayedResultTextForRows(item);
852
- return rawRt;
936
+ return stripLeadingStatusMarkerFromTextForRows(rawRt || '');
853
937
  }
854
938
 
855
939
  function toolHeaderFailureOnlyForRows(item, normalizedName, hasDisplayResult) {
@@ -919,11 +1003,12 @@ function estimateTranscriptItemRows(item, columns, toolOutputExpanded) {
919
1003
  case 'user':
920
1004
  return 1 + estimateWrappedRows(item.text, columns, 4);
921
1005
  case 'assistant':
922
- // marginTop={1} (AssistantMessage <Box>) + the exact rendered body height.
923
- // measureMarkdownRenderedRows mirrors the <Markdown> segment/gap/table
924
- // layout at the real body width (columns-3), so the reserved height equals
925
- // what ink draws — no phantom blank band (over) and no top-clip (under).
926
- 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));
927
1012
  case 'tool': {
928
1013
  const TOOL_MARGIN_TOP = 1;
929
1014
  if (shouldSuppressFullyFailedToolItem(item)) return 0;
@@ -1270,7 +1355,7 @@ function measuredTranscriptRows(item, columns, toolOutputExpanded) {
1270
1355
  const STREAMING_ROW_QUANTUM = 1;
1271
1356
 
1272
1357
  function assistantTextForStreamingRowEstimate(text) {
1273
- return String(text ?? '').replace(/^\n+|\n+$/g, '');
1358
+ return streamingLayoutText(text);
1274
1359
  }
1275
1360
 
1276
1361
  function streamingEstimateRows(item, columns, toolOutputExpanded) {
@@ -1465,9 +1550,13 @@ function transcriptRenderWindow(items, { scrollOffset = 0, viewportHeight = 24,
1465
1550
  };
1466
1551
  }
1467
1552
 
1468
- export function App({ store, initialStatusLine = '' }) {
1553
+ export function App({ store, initialStatusLine = '', forceOnboarding = false }) {
1469
1554
  const state = useEngine(store);
1470
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);
1471
1560
  const { exit } = useApp();
1472
1561
  // internal_eventEmitter is ink's parsed-input bus. ink 7 consumes stdin via
1473
1562
  // the 'readable' event + stdin.read() (see ink's App.js), draining the buffer
@@ -1484,6 +1573,13 @@ export function App({ store, initialStatusLine = '' }) {
1484
1573
  const [tuiReady, setTuiReady] = useState(false);
1485
1574
  const exitRequestedRef = useRef(false);
1486
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);
1487
1583
  // scrollOffset = how many transcript ROWS we've scrolled UP from the bottom
1488
1584
  // (0 = pinned to the latest, showing the newest content). Mouse wheel adjusts
1489
1585
  // it; accepted prompts only arm bottom-follow; the snap happens when the
@@ -1492,6 +1588,7 @@ export function App({ store, initialStatusLine = '' }) {
1492
1588
  const scrollPositionRef = useRef(0);
1493
1589
  const scrollTargetRef = useRef(0);
1494
1590
  const maxScrollRowsRef = useRef(0);
1591
+ const transcriptBottomSlackRowsRef = useRef(0);
1495
1592
  const scrollAnimationRef = useRef(null);
1496
1593
  const transcriptTotalRowsRef = useRef(0);
1497
1594
  const preservedScrollDeltaRef = useRef(0);
@@ -1667,6 +1764,8 @@ export function App({ store, initialStatusLine = '' }) {
1667
1764
  const toolApproval = state.toolApproval || null;
1668
1765
  const [promptDraft, setPromptDraft] = useState('');
1669
1766
  const [promptDraftOverride, setPromptDraftOverride] = useState(null);
1767
+ const promptLayoutValueRef = useRef('');
1768
+ const [, setPromptLayoutRows] = useState(1);
1670
1769
  const [, setPastedImages] = useState({});
1671
1770
  const pastedImagesRef = useRef({});
1672
1771
  const nextPastedImageIdRef = useRef(1);
@@ -1683,20 +1782,40 @@ export function App({ store, initialStatusLine = '' }) {
1683
1782
  const promptHistoryDraftChangeRef = useRef(false);
1684
1783
  const [promptHint, setPromptHint] = useState('');
1685
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]);
1686
1800
  const [slashIndex, setSlashIndex] = useState(0);
1687
1801
  const [slashDismissedFor, setSlashDismissedFor] = useState('');
1688
1802
  const [disabledSkills, setDisabledSkills] = useState(() => new Set());
1689
1803
  const slashPaletteRef = useRef({ open: false, count: 0 });
1690
1804
  const scrollFocusRef = useRef({});
1691
1805
  const onboardingStartedRef = useRef(false);
1692
- const onboardingRef = useRef({ defaultRoute: null, workflowRoutes: {}, providerModels: [] });
1806
+ const onboardingRef = useRef({ defaultRoute: null, searchRoute: null, agentRoutes: {}, agents: [], providerModels: [] });
1693
1807
  const providerModelsCacheRef = useRef({ models: null, at: 0 });
1694
1808
  const searchModelsCacheRef = useRef({ models: null, at: 0 });
1695
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);
1696
1814
  const clearModelCaches = useCallback((scope = 'all') => {
1697
1815
  if (scope === 'all' || scope === 'provider') {
1698
1816
  providerModelsCacheRef.current = { models: null, at: 0 };
1699
1817
  onboardingRef.current.providerModels = [];
1818
+ onboardingPrefetchSeqRef.current += 1;
1700
1819
  }
1701
1820
  if (scope === 'all' || scope === 'search') {
1702
1821
  searchModelsCacheRef.current = { models: null, at: 0 };
@@ -1710,7 +1829,11 @@ export function App({ store, initialStatusLine = '' }) {
1710
1829
  // region: which surface the in-progress (or last) selection belongs to —
1711
1830
  // 'transcript' | 'status' (both ink-grid) | 'prompt' (PromptInput's own engine)
1712
1831
  // | null. Press decides it; motion/release stay in that region.
1713
- 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 });
1714
1837
  const selectionPaintRef = useRef({ t: 0, rect: null, pending: null, timer: null });
1715
1838
  const transcriptViewportRef = useRef({ top: 0, bottom: 0 });
1716
1839
  // [mixdog] Latest terminal row count + the statusline band (bottom rows),
@@ -1719,12 +1842,85 @@ export function App({ store, initialStatusLine = '' }) {
1719
1842
  // region. STATUSLINE_ROWS mirrors the layout reserve below.
1720
1843
  const frameRowsRef = useRef(24);
1721
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]);
1722
1914
  const selectionLayoutRef = useRef(null);
1723
1915
  const selectionTextRef = useRef('');
1724
1916
  const selectionTextTimerRef = useRef(null);
1725
1917
  // lastClickRef tracks the previous left-press cell + time so the mouse handler
1726
- // can detect a double-click (same cell within 400ms) for word selection.
1727
- 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 });
1728
1924
 
1729
1925
  const showSelectionCopyHint = useCallback((text, tone = 'plain') => {
1730
1926
  if (promptHintTimerRef.current) clearTimeout(promptHintTimerRef.current);
@@ -1821,6 +2017,90 @@ export function App({ store, initialStatusLine = '' }) {
1821
2017
  }, 2200);
1822
2018
  }, []);
1823
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
+
1824
2104
  const installPastedImages = useCallback((images, { merge = true } = {}) => {
1825
2105
  if (!images || typeof images !== 'object' || Object.keys(images).length === 0) return;
1826
2106
  const next = merge ? { ...pastedImagesRef.current, ...images } : { ...images };
@@ -1944,6 +2224,8 @@ export function App({ store, initialStatusLine = '' }) {
1944
2224
  // long transcript jump to the bottom, then jump again when the new row is
1945
2225
  // appended. Keep the current viewport stable and let the row-delta effect
1946
2226
  // perform the single bottom-follow when the transcript actually grows.
2227
+ transcriptAnchorRef.current = null;
2228
+ transcriptAnchorDirtyRef.current = false;
1947
2229
  followingRef.current = true;
1948
2230
  stopSmoothScroll();
1949
2231
  }, [stopSmoothScroll]);
@@ -1980,6 +2262,8 @@ export function App({ store, initialStatusLine = '' }) {
1980
2262
  ...rect,
1981
2263
  clipY1: clip.y1,
1982
2264
  clipY2: Math.max(clip.y1, clip.y2),
2265
+ selectionForeground: theme.selectionHighlightText || theme.selectionText,
2266
+ selectionBackground: theme.selectionHighlightBackground || theme.selectionBackground,
1983
2267
  };
1984
2268
  if (options.captureText === false) clipped.captureText = false;
1985
2269
  return clipped;
@@ -2055,6 +2339,124 @@ export function App({ store, initialStatusLine = '' }) {
2055
2339
  };
2056
2340
  }, []);
2057
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
+
2058
2460
  useEffect(() => () => {
2059
2461
  const paintState = selectionPaintRef.current;
2060
2462
  if (paintState.timer) clearTimeout(paintState.timer);
@@ -2078,7 +2480,7 @@ export function App({ store, initialStatusLine = '' }) {
2078
2480
  // streaming growth could lurch the view. At/over the bottom, drop the anchor
2079
2481
  // so the bottom-follow path owns the viewport again.
2080
2482
  if (appliedDelta !== 0) {
2081
- if (target <= 0) {
2483
+ if (target <= Math.max(0, Number(transcriptBottomSlackRowsRef.current) || 0)) {
2082
2484
  transcriptAnchorRef.current = null;
2083
2485
  transcriptAnchorDirtyRef.current = false;
2084
2486
  } else {
@@ -2110,9 +2512,16 @@ export function App({ store, initialStatusLine = '' }) {
2110
2512
  if (appliedDelta !== 0 && dragRef.current.rect) {
2111
2513
  let rect;
2112
2514
  if (dragRef.current.active) {
2113
- const { anchor, anchorScroll, last } = dragRef.current;
2114
- const currentAnchor = selectionPointAtCurrentScroll(anchor, anchorScroll);
2115
- 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
+ }
2116
2525
  } else {
2117
2526
  rect = shiftSelectionRectY(dragRef.current.rect, appliedDelta);
2118
2527
  }
@@ -2127,7 +2536,7 @@ export function App({ store, initialStatusLine = '' }) {
2127
2536
  stopSmoothScroll();
2128
2537
  scrollPositionRef.current = target;
2129
2538
  setScrollOffset(Math.round(target));
2130
- }, [startSmoothScroll, stopSmoothScroll, paintSelectionRect, selectionPointAtCurrentScroll, withSelectionClip, cancelTranscriptFollow]);
2539
+ }, [startSmoothScroll, stopSmoothScroll, paintSelectionRect, selectionPointAtCurrentScroll, withSelectionClip, cancelTranscriptFollow, buildSpanRect]);
2131
2540
 
2132
2541
  const passthroughCtrlWheelZoom = useCallback(() => {
2133
2542
  if (!stdout?.write) return;
@@ -2184,6 +2593,8 @@ export function App({ store, initialStatusLine = '' }) {
2184
2593
  y2: b.y,
2185
2594
  };
2186
2595
  };
2596
+ // Word/line multi-click drag-extension uses the hoisted buildSpanRect (same
2597
+ // logic reachable from the auto-scroll path in scrollTranscriptRows).
2187
2598
  const transcriptViewport = () => {
2188
2599
  const top = Math.max(0, Number(transcriptViewportRef.current?.top) || 0);
2189
2600
  const bottom = Math.max(top, Number(transcriptViewportRef.current?.bottom) || top);
@@ -2244,6 +2655,7 @@ export function App({ store, initialStatusLine = '' }) {
2244
2655
  // arrives whole. Guard so non-mouse keystrokes fall through untouched.
2245
2656
  const s = typeof data === 'string' ? data : String(data ?? '');
2246
2657
  if (s.indexOf('\x1b[<') === -1) return;
2658
+ dismissWelcomePromptHint();
2247
2659
  let up = 0;
2248
2660
  let down = 0;
2249
2661
  let m;
@@ -2277,7 +2689,7 @@ export function App({ store, initialStatusLine = '' }) {
2277
2689
  applySelectionRect(null);
2278
2690
  const offset = promptOffsetAt(x, y);
2279
2691
  stopSmoothScroll();
2280
- 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 };
2281
2693
  if (offset != null) promptMouseSelectionRef.current?.anchorAt?.(offset);
2282
2694
  continue;
2283
2695
  }
@@ -2287,45 +2699,65 @@ export function App({ store, initialStatusLine = '' }) {
2287
2699
  lastClickRef.current = { x: -1, y: -1, t: 0 };
2288
2700
  dragRef.current.active = false;
2289
2701
  dragRef.current.region = null;
2702
+ dragRef.current.anchorSpan = null;
2290
2703
  clearAllSelections();
2291
2704
  continue;
2292
2705
  }
2293
2706
  const region = inTranscript ? 'transcript' : 'status';
2294
2707
  // A press always clears the prompt-box selection (single active region).
2295
2708
  promptMouseSelectionRef.current?.clear?.();
2296
- // Double-click on a word: select just that word and copy it, reusing
2297
- // the existing selection/copy pipeline, then advance to the next event.
2298
- // 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.
2299
2717
  const now = Date.now();
2300
2718
  const lc = lastClickRef.current;
2301
- // Treat a second press as a double-click when it lands near the first
2302
- // press within 500ms: up to 2 columns and 1 row of drift (terminals
2303
- // often report a shifted cell on the second click). Tighter matching
2304
- // made double-click word selection unreliable.
2305
- const isDouble = (now - lc.t) < 500
2719
+ const qualifies = (now - lc.t) < 500
2306
2720
  && Math.abs(lc.y - y) <= 1
2307
2721
  && Math.abs(lc.x - x) <= 2;
2308
- if (isDouble) {
2309
- // Consume this click so a following press is not mistaken for another
2310
- // double-click (avoids a stray single-cell selection flicker).
2311
- lastClickRef.current = { x: -1, y: -1, t: 0 };
2312
- 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);
2313
2734
  if (wr) {
2314
- 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);
2315
2738
  stopSmoothScroll();
2316
- 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
+ };
2317
2748
  applySelectionRect(rect);
2318
- // Selection only copy happens on Ctrl+C (see useInput), not here.
2749
+ lastClickRef.current = { x, y, t: now, count: clickCount };
2319
2750
  continue;
2320
2751
  }
2321
2752
  }
2322
- lastClickRef.current = { x, y, t: now };
2753
+ lastClickRef.current = { x, y, t: now, count: 1 };
2323
2754
  // Left-button press: begin a new selection anchored here.
2324
2755
  // Anchor the drag but do NOT paint a zero-width selection yet; a plain
2325
2756
  // single click should not flash a one-cell highlight. The selection is
2326
2757
  // only rendered once a drag actually extends past the anchor.
2327
2758
  // Status-band selections do NOT scroll, so anchorScroll is irrelevant
2328
2759
  // there; keep the transcript scroll anchor only for the transcript.
2760
+ // Plain single press clears any word/line anchorSpan (char-drag mode).
2329
2761
  stopSmoothScroll();
2330
2762
  dragRef.current = {
2331
2763
  anchor: { x, y },
@@ -2334,6 +2766,7 @@ export function App({ store, initialStatusLine = '' }) {
2334
2766
  active: true,
2335
2767
  rect: null,
2336
2768
  region,
2769
+ anchorSpan: null,
2337
2770
  };
2338
2771
  } else if (baseButton === 0 && isMotion && dragRef.current.active) {
2339
2772
  const region = dragRef.current.region;
@@ -2350,11 +2783,19 @@ export function App({ store, initialStatusLine = '' }) {
2350
2783
  // current cell, clamped to the owning region's band.
2351
2784
  const selectionY = region === 'status' ? clampToStatusBand(y) : clampToTranscriptViewport(y);
2352
2785
  dragRef.current.last = { x, y: selectionY };
2353
- const anchor = region === 'status'
2354
- ? dragRef.current.anchor
2355
- : selectionPointAtCurrentScroll(dragRef.current.anchor, dragRef.current.anchorScroll);
2356
- const rect = linearSelection(anchor, { x, y: selectionY });
2357
- 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
+ }
2358
2799
  // Auto-scroll-while-dragging is transcript-only (the status band does
2359
2800
  // not scroll).
2360
2801
  if (region === 'transcript') {
@@ -2378,18 +2819,28 @@ export function App({ store, initialStatusLine = '' }) {
2378
2819
  // (the SGR release event carries col/row) and keep the selection
2379
2820
  // visible. Copy is NOT automatic — the user presses Ctrl+C to copy.
2380
2821
  // The highlight stays until ESC or a plain click.
2381
- const anchor = region === 'status'
2382
- ? dragRef.current.anchor
2383
- : selectionPointAtCurrentScroll(dragRef.current.anchor, dragRef.current.anchorScroll);
2384
- dragRef.current.active = false;
2822
+ const span = dragRef.current.anchorSpan;
2385
2823
  const releaseY = region === 'status' ? clampToStatusBand(y) : clampToTranscriptViewport(y);
2386
- const rect = linearSelection(anchor, { x, y: releaseY });
2387
- const empty = rect.x1 === rect.x2 && rect.y1 === rect.y2;
2388
- if (empty) {
2389
- applySelectionRect(null); // a plain click clears any prior highlight
2390
- } else {
2391
- // 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);
2392
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
+ }
2393
2844
  }
2394
2845
  // Drag is over: the measured-height harvest was skipped for every
2395
2846
  // motion commit (see the harvest effect's dragRef guard), so force one
@@ -2415,7 +2866,28 @@ export function App({ store, initialStatusLine = '' }) {
2415
2866
  };
2416
2867
  inkInput.on('input', onData);
2417
2868
  return () => { inkInput.off('input', onData); };
2418
- }, [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
+ }, []);
2419
2891
 
2420
2892
  // Item-count changes are the only time we can arm follow before row totals are
2421
2893
  // recomputed. Pure streaming height growth is handled in the row-delta effect.
@@ -2428,7 +2900,7 @@ export function App({ store, initialStatusLine = '' }) {
2428
2900
  return;
2429
2901
  }
2430
2902
  if (count === previousCount || dragRef.current.active) return;
2431
- if (scrollTargetRef.current <= 0 || followingRef.current) followingRef.current = true;
2903
+ if (scrollTargetRef.current <= transcriptBottomSlackRows || followingRef.current) followingRef.current = true;
2432
2904
  }, [state.items.length, resetTranscriptScroll]);
2433
2905
 
2434
2906
  // `exiting` removes the inline caret (PromptInput draws none when disabled) and
@@ -2468,6 +2940,7 @@ export function App({ store, initialStatusLine = '' }) {
2468
2940
  }
2469
2941
  if (restoreDraft) {
2470
2942
  if (restored.pastedImages) installPastedImages(restored.pastedImages, { merge: true });
2943
+ syncPromptLayoutRows(restored.text);
2471
2944
  setPromptDraftOverride({ id: Date.now(), value: restored.text });
2472
2945
  }
2473
2946
  if (showHint) {
@@ -2476,7 +2949,7 @@ export function App({ store, initialStatusLine = '' }) {
2476
2949
  clearPromptHint();
2477
2950
  }
2478
2951
  return true;
2479
- }, [store, promptDraft, showPromptHint, clearPromptHint, installPastedImages]);
2952
+ }, [store, promptDraft, showPromptHint, clearPromptHint, installPastedImages, syncPromptLayoutRows]);
2480
2953
 
2481
2954
  const recentPromptHistory = useMemo(() => {
2482
2955
  const items = Array.isArray(state.items) ? state.items : [];
@@ -2562,6 +3035,18 @@ export function App({ store, initialStatusLine = '' }) {
2562
3035
  // the active turn on the same Esc press.
2563
3036
  // - empty prompt + active turn interrupts the active turn.
2564
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
+ }
2565
3050
  if (usagePanel) { closeUsagePanel(); return true; }
2566
3051
  if (contextPanel) { setContextPanel(null); return true; }
2567
3052
 
@@ -2576,19 +3061,20 @@ export function App({ store, initialStatusLine = '' }) {
2576
3061
  // Idle + empty + nothing to restore: nothing (double-press from empty
2577
3062
  // opens message selector, but we don't have that feature yet).
2578
3063
  return false;
2579
- }, [contextPanel, usagePanel, closeUsagePanel, restoreQueuedToPrompt, clearPromptHint, clearPastedImagesSnapshot]);
3064
+ }, [contextPanel, usagePanel, closeUsagePanel, restoreQueuedToPrompt, clearPromptHint, clearPastedImagesSnapshot, setVoiceStatusHint, store]);
2580
3065
 
2581
3066
  const handlePromptInterrupt = useCallback((currentText = '') => {
2582
3067
  const result = store.abort?.();
2583
3068
  if (result?.aborted === false) return undefined;
2584
3069
  if (result?.pastedImages) installPastedImages(result.pastedImages, { merge: true });
3070
+ if (result?.discardPastedImages) clearPastedImagesSnapshot(result.discardPastedImages);
2585
3071
  const restoreText = String(result?.restoreText || '').trim();
2586
3072
  if (!restoreText) return undefined;
2587
3073
  const existingText = String(currentText || '').trim();
2588
3074
  const nextText = [restoreText, existingText].filter(Boolean).join('\n');
2589
3075
  clearPromptHint();
2590
3076
  return nextText;
2591
- }, [store, clearPromptHint, installPastedImages]);
3077
+ }, [store, clearPromptHint, installPastedImages, clearPastedImagesSnapshot]);
2592
3078
 
2593
3079
  // Ctrl+O toggles the global tool-output expansion, matching common terminal-chat
2594
3080
  // expectation that this is a view mode rather than a per-card hidden state.
@@ -2597,6 +3083,7 @@ export function App({ store, initialStatusLine = '' }) {
2597
3083
  }, []);
2598
3084
 
2599
3085
  useInput((input, key) => {
3086
+ if (!welcomePromptHintDismissed) dismissWelcomePromptHint();
2600
3087
  if (toolApproval) {
2601
3088
  const value = String(input || '').trim().toLowerCase();
2602
3089
  if (key.escape || value === 'd' || value === 'n') {
@@ -2638,6 +3125,20 @@ export function App({ store, initialStatusLine = '' }) {
2638
3125
  toggleExpand();
2639
3126
  return;
2640
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
+ }
2641
3142
  if (key.escape && usagePanel && !picker) {
2642
3143
  closeUsagePanel();
2643
3144
  return;
@@ -2665,6 +3166,7 @@ export function App({ store, initialStatusLine = '' }) {
2665
3166
  if (key.escape && !picker) {
2666
3167
  dragRef.current.active = false;
2667
3168
  dragRef.current.region = null;
3169
+ dragRef.current.anchorSpan = null;
2668
3170
  // Clear whichever region's selection is active. PromptInput's own ESC also
2669
3171
  // clears its selection when focused/enabled; this covers the disabled case
2670
3172
  // and a status/transcript ink-grid selection in one press.
@@ -3474,6 +3976,10 @@ export function App({ store, initialStatusLine = '' }) {
3474
3976
 
3475
3977
  const openOutputStylePicker = (options = {}) => {
3476
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;
3477
3983
  let status = null;
3478
3984
  try {
3479
3985
  status = store.listOutputStyles?.() || null;
@@ -3487,6 +3993,7 @@ export function App({ store, initialStatusLine = '' }) {
3487
3993
  return;
3488
3994
  }
3489
3995
  const currentId = status?.current?.id || 'default';
3996
+ let highlightedStyleId = currentId;
3490
3997
  const items = styles.map((style) => ({
3491
3998
  value: style.id,
3492
3999
  label: style.label || style.id,
@@ -3501,30 +4008,55 @@ export function App({ store, initialStatusLine = '' }) {
3501
4008
  setSettingsPrompt(null);
3502
4009
  setContextPanel(null);
3503
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
+ };
3504
4026
  setPicker({
3505
4027
  title: 'Output Style',
3506
4028
  description: 'Select response style.',
3507
- 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'),
3508
4032
  labelWidth: 18,
3509
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,
3510
4051
  onSelect: (_value, item) => {
3511
4052
  const style = item?._style;
3512
4053
  if (!style) return;
3513
- setPicker(null);
3514
- void store.setOutputStyle?.(style.id)
3515
- .then((result) => {
3516
- if (!result) {
3517
- store.pushNotice('Output style switch is already running.', 'warn');
3518
- return;
3519
- }
3520
- store.pushNotice(outputStyleNotice(result), 'info');
3521
- if (returnTo) returnTo();
3522
- })
3523
- .catch((e) => store.pushNotice(`Couldn’t switch output style: ${e?.message || e}`, 'error'));
4054
+ saveStyle(style.id, { advance: Boolean(onboarding) });
3524
4055
  },
3525
4056
  onCancel: () => {
3526
4057
  setPicker(null);
3527
- if (returnTo) returnTo();
4058
+ if (onboarding) onboarding.onCancel?.();
4059
+ else if (returnTo) returnTo();
3528
4060
  },
3529
4061
  });
3530
4062
  };
@@ -3533,6 +4065,7 @@ export function App({ store, initialStatusLine = '' }) {
3533
4065
 
3534
4066
  const openThemePicker = (options = {}) => {
3535
4067
  const returnTo = typeof options.returnTo === 'function' ? options.returnTo : null;
4068
+ const onboarding = options.onboarding || null;
3536
4069
  let themes = [];
3537
4070
  try {
3538
4071
  themes = store.listThemes?.() || [];
@@ -3545,6 +4078,7 @@ export function App({ store, initialStatusLine = '' }) {
3545
4078
  return;
3546
4079
  }
3547
4080
  const currentId = store.getTheme?.() || themes.find((t) => t.current)?.id || themes[0]?.id;
4081
+ let highlightedThemeId = currentId;
3548
4082
  const items = themes.map((entry) => ({
3549
4083
  value: entry.id,
3550
4084
  label: entry.label || entry.id,
@@ -3566,31 +4100,57 @@ export function App({ store, initialStatusLine = '' }) {
3566
4100
  return null;
3567
4101
  }
3568
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
+ };
3569
4112
  setPicker({
3570
4113
  title: 'Theme',
3571
4114
  description: 'Choose the color theme that looks best with your terminal.',
3572
- 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'),
3573
4116
  labelWidth: 22,
3574
4117
  initialIndex: Math.max(0, items.findIndex((item) => item.value === currentId)),
3575
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),
3576
4135
  // Live preview while moving: apply (no persist) so the surface re-tones
3577
4136
  // as the selection moves. Enter persists; Esc restores the original.
3578
4137
  onHighlight: (_value, item) => {
3579
- 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
+ }
3580
4142
  },
3581
4143
  onSelect: (_value, item) => {
3582
4144
  const entry = item?._theme;
3583
4145
  if (!entry) return;
3584
- setPicker(null);
3585
- const applied = applyTheme(entry.id, { persist: true });
3586
- store.pushNotice(themeNotice(applied || entry), 'info');
3587
- if (returnTo) returnTo();
4146
+ saveTheme(entry.id, { advance: Boolean(onboarding) });
3588
4147
  },
3589
4148
  onCancel: () => {
3590
4149
  setPicker(null);
3591
4150
  // Restore the theme that was active before the picker opened.
3592
4151
  if (currentId) applyTheme(currentId, { persist: false });
3593
- if (returnTo) returnTo();
4152
+ if (onboarding) onboarding.onCancel?.();
4153
+ else if (returnTo) returnTo();
3594
4154
  },
3595
4155
  });
3596
4156
  };
@@ -3838,7 +4398,7 @@ export function App({ store, initialStatusLine = '' }) {
3838
4398
  const pct = (value, total = windowTokens) => {
3839
4399
  const n = Number(value || 0);
3840
4400
  const d = Number(total || 0);
3841
- if (!d) return 'n/a';
4401
+ if (!d) return 'N/A';
3842
4402
  return `${((n / d) * 100).toFixed(n > 0 && n < d / 100 ? 1 : 0)}%`;
3843
4403
  };
3844
4404
  const fmt = (value) => {
@@ -3850,11 +4410,17 @@ export function App({ store, initialStatusLine = '' }) {
3850
4410
  return `${Math.round(n)}`;
3851
4411
  };
3852
4412
  const cachedRead = Number(usage.lastCachedReadTokens || 0);
3853
- const freshInput = Number(usage.lastInputTokens || 0);
3854
- 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);
3855
4420
  const cacheHitRate = cacheDenom > 0
3856
4421
  ? `${((cachedRead / cacheDenom) * 100).toFixed(0)}%`
3857
- : 'n/a';
4422
+ : 'N/A';
4423
+ const cacheWriteLabel = cacheWrite > 0 ? ` · ${fmt(cacheWrite)} write` : '';
3858
4424
  const contextSource = context.usedSource === 'last_api_request' ? 'last API request' : 'estimated';
3859
4425
  const lastApiLabel = context.lastApiRequestStale ? 'last API request (pre-compact)' : 'last API request';
3860
4426
  const compactElapsed = (value) => {
@@ -3922,13 +4488,13 @@ export function App({ store, initialStatusLine = '' }) {
3922
4488
  {
3923
4489
  value: 'last-api',
3924
4490
  label: 'Last API usage',
3925
- 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}`,
3926
4492
  _action: 'last-api',
3927
4493
  },
3928
4494
  {
3929
4495
  value: 'cache',
3930
4496
  label: 'Prompt cache',
3931
- 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)`,
3932
4498
  _action: 'cache',
3933
4499
  },
3934
4500
  {
@@ -3993,12 +4559,14 @@ export function App({ store, initialStatusLine = '' }) {
3993
4559
  },
3994
4560
  lastApi: {
3995
4561
  contextTokens: usage.lastContextTokens,
3996
- inputTokens: usage.lastInputTokens,
4562
+ inputTokens: freshInput,
4563
+ rawInputTokens: usage.lastInputTokens,
3997
4564
  outputTokens: usage.lastOutputTokens,
3998
4565
  },
3999
4566
  cache: {
4000
4567
  hitRate: cacheHitRate,
4001
4568
  readTokens: usage.lastCachedReadTokens,
4569
+ writeTokens: cacheWrite,
4002
4570
  },
4003
4571
  extensions: {
4004
4572
  skills: skills.count,
@@ -4048,6 +4616,17 @@ export function App({ store, initialStatusLine = '' }) {
4048
4616
  const plugins = store.pluginsStatus?.() || { count: 0 };
4049
4617
  const skills = store.skillsStatus?.() || { count: 0 };
4050
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';
4051
4630
  const compactType = compaction.compactType || compaction.type || 'semantic';
4052
4631
  const compactTypeLabel = compactType === 'recall-fasttrack' ? 'Fast-track' : 'Default';
4053
4632
  const outputStyleLabel = outputStyle?.current?.label || outputStyle?.current?.id || outputStyle?.configured || 'Default';
@@ -4174,6 +4753,31 @@ export function App({ store, initialStatusLine = '' }) {
4174
4753
  }
4175
4754
  openSettingsPicker();
4176
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
+ };
4177
4781
  const items = [
4178
4782
  {
4179
4783
  value: 'profile',
@@ -4234,10 +4838,24 @@ export function App({ store, initialStatusLine = '' }) {
4234
4838
  _action: 'channels',
4235
4839
  },
4236
4840
  {
4237
- value: 'channels-setup',
4238
- label: 'Channels setup',
4239
- description: channelWorker?.running ? `runtime running · pid ${channelWorker.pid}` : 'runtime stopped',
4240
- _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',
4241
4859
  },
4242
4860
  {
4243
4861
  value: 'output-style',
@@ -4310,6 +4928,21 @@ export function App({ store, initialStatusLine = '' }) {
4310
4928
  description: `${skills.count || 0} available`,
4311
4929
  _action: 'skills',
4312
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
+ },
4313
4946
  ];
4314
4947
  setProviderPrompt(null);
4315
4948
  setChannelPrompt(null);
@@ -4333,6 +4966,8 @@ export function App({ store, initialStatusLine = '' }) {
4333
4966
  }
4334
4967
  else if (item?._action === 'memory') applyMemory(!(memory.enabled !== false));
4335
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);
4336
4971
  else if (item?._action === 'output-style') cycleOutputStyle(-1);
4337
4972
  else if (item?._action === 'theme') cycleTheme(-1);
4338
4973
  else if (item?._action === 'workflow') cycleWorkflow(-1);
@@ -4346,6 +4981,8 @@ export function App({ store, initialStatusLine = '' }) {
4346
4981
  }
4347
4982
  else if (item?._action === 'memory') applyMemory(!(memory.enabled !== false));
4348
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);
4349
4986
  else if (item?._action === 'output-style') cycleOutputStyle(1);
4350
4987
  else if (item?._action === 'theme') cycleTheme(1);
4351
4988
  else if (item?._action === 'workflow') cycleWorkflow(1);
@@ -4362,7 +4999,9 @@ export function App({ store, initialStatusLine = '' }) {
4362
4999
  else if (item._action === 'memory') applyMemory(!(memory.enabled !== false));
4363
5000
  else if (item._action === 'memory-dashboard') openMemoryPicker();
4364
5001
  else if (item._action === 'channels') applyChannels(!(channels.enabled !== false));
4365
- 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 });
4366
5005
  else if (item._action === 'output-style') openOutputStylePicker({ returnTo: openSettingsPicker });
4367
5006
  else if (item._action === 'theme') openThemePicker({ returnTo: openSettingsPicker });
4368
5007
  else if (item._action === 'workflow') openWorkflowPicker({ returnTo: openSettingsPicker });
@@ -4387,6 +5026,7 @@ export function App({ store, initialStatusLine = '' }) {
4387
5026
  else if (item._action === 'plugins') openPluginsPicker();
4388
5027
  else if (item._action === 'hooks') openHooksPicker();
4389
5028
  else if (item._action === 'skills') openSkillsPicker();
5029
+ else if (item._action === 'update') openUpdatePicker({ returnTo: openSettingsPicker });
4390
5030
  },
4391
5031
  onCancel: () => {
4392
5032
  setPicker(null);
@@ -4402,37 +5042,44 @@ export function App({ store, initialStatusLine = '' }) {
4402
5042
  setChannelPrompt(null);
4403
5043
  setHookPrompt(null);
4404
5044
  setSettingsPrompt(null);
4405
- setPicker({
4406
- title: options.title || 'Providers',
4407
- description: options.description || 'Choose a provider to configure.',
4408
- labelWidth: 18,
4409
- metaWidth: 10,
4410
- pickerKey: 'providers-loading',
4411
- initialIndex: 0,
4412
- items: [{
4413
- value: 'checking',
4414
- label: 'Checking Providers',
4415
- meta: '',
4416
- description: 'please wait',
4417
- _type: 'loading',
4418
- }],
4419
- onSelect: () => {},
4420
- onCancel: () => {
4421
- setPicker(null);
4422
- if (onCancel) onCancel();
4423
- },
4424
- });
4425
- let setup;
4426
- try {
4427
- await new Promise((resolve) => setTimeout(resolve, 0));
4428
- setup = await store.getProviderSetup();
4429
- } catch (e) {
4430
- store.pushNotice(`providers failed: ${e?.message || e}`, 'error');
4431
- return;
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
+ }
4432
5079
  }
4433
5080
 
4434
5081
  const items = [];
4435
- if (returnTo || onContinue) {
5082
+ if ((returnTo || onContinue) && !options.confirmBar) {
4436
5083
  items.push({
4437
5084
  value: 'continue-setup',
4438
5085
  label: options.continueLabel || 'Continue setup',
@@ -4828,14 +5475,15 @@ export function App({ store, initialStatusLine = '' }) {
4828
5475
  title: options.title || 'Providers',
4829
5476
  description: options.description || 'Choose a provider. Enter opens provider actions.',
4830
5477
  footer: providerFooter,
4831
- footerGapRows: 0,
4832
- help: '↑/↓ Select · Enter Open · Esc Back',
5478
+ footerGapRows: 1,
5479
+ help: options.confirmBar ? undefined : '↑/↓ Select · Enter Open · Esc Back',
4833
5480
  indexMode: 'always',
4834
5481
  labelWidth: 18,
4835
5482
  metaWidth: 12,
4836
5483
  pickerKey: `providers-main:${options.highlightProviderValue || 'root'}`,
4837
5484
  initialIndex: providerMainInitialIndex(),
4838
5485
  items,
5486
+ confirmBar: options.confirmBar || null,
4839
5487
  onHighlight: (_value, item) => {
4840
5488
  if (item?._providerId) rememberProviderSelection(item);
4841
5489
  },
@@ -4865,70 +5513,274 @@ export function App({ store, initialStatusLine = '' }) {
4865
5513
  });
4866
5514
  };
4867
5515
 
4868
- const openOnboardingAuthStep = () => {
4869
- void openProviderSetupPicker({
4870
- title: 'First Run · Step 1/2 · Provider Auth',
4871
- continueLabel: 'Continue to model setup',
4872
- continueDescription: 'choose the default and workflow models',
4873
- returnTo: () => openOnboardingAuthStep(),
4874
- onContinue: () => void openOnboardingWorkflowStep(),
4875
- onCancel: () => store.pushNotice('first-run setup will open again next launch', 'warn'),
4876
- });
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
+ }
4877
5532
  };
4878
5533
 
4879
- const openOnboardingRoleModelPicker = (slot) => {
4880
- const models = normalizeModelOptions(onboardingRef.current.providerModels || []);
4881
- const fallbackRoute = onboardingRef.current.defaultRoute || null;
4882
- if (models.length === 0) {
4883
- store.pushNotice('no provider models available; open /providers to sign in', 'warn');
4884
- openOnboardingAuthStep();
4885
- return;
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. */ });
4886
5551
  }
4887
- const recommendedRoute = chooseRecommendedModel(models, slot, fallbackRoute);
4888
- const items = [
4889
- {
4890
- value: 'recommended',
4891
- label: 'Use recommended',
4892
- description: routeLabel(recommendedRoute),
4893
- _action: 'recommended',
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. */ }
5570
+ void openProviderSetupPicker({
5571
+ title: 'First Run · Step 1/5 · Provider Auth',
5572
+ returnTo: () => openOnboardingAuthStep(),
5573
+ preloadedSetup,
5574
+ confirmBar: {
5575
+ buttons: [{ value: 'next', label: 'Next ▶' }],
5576
+ onConfirm: () => { setPicker(null); void openOnboardingWorkflowStep(); },
4894
5577
  },
4895
- ...models.map((m) => ({
4896
- value: `${m.provider}:${m.id}`,
4897
- label: m.display || m.id,
4898
- description: modelDescription(m),
4899
- _action: 'select-model',
4900
- _model: m,
4901
- })),
4902
- ...(fallbackRoute ? [{
4903
- value: 'fallback',
4904
- label: 'Use lead model',
4905
- description: routeLabel(fallbackRoute),
4906
- _action: 'fallback',
4907
- }] : []),
4908
- ];
5578
+ onCancel: onboardingWarnReopen,
5579
+ });
5580
+ };
5581
+
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);
5641
+ return;
5642
+ }
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;
4909
5742
  setPicker({
4910
- title: `First Run · ${slot} model`,
4911
- 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,
4912
5750
  items,
4913
5751
  onSelect: (_value, item) => {
4914
- const next = item._action === 'select-model'
4915
- ? routeFromModel(item._model)
4916
- : item._action === 'recommended'
4917
- ? recommendedRoute
4918
- : 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;
4919
5768
  if (!next) {
4920
5769
  store.pushNotice('select a provider model first', 'warn');
4921
5770
  setPicker(null);
4922
- openOnboardingAuthStep();
5771
+ void openOnboardingWorkflowStep();
4923
5772
  return;
4924
5773
  }
4925
- if (slot === 'lead') {
5774
+ if (isLead) {
4926
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
+ };
4927
5783
  }
4928
- onboardingRef.current.workflowRoutes = {
4929
- ...(onboardingRef.current.workflowRoutes || {}),
4930
- [slot]: next,
4931
- };
4932
5784
  setPicker(null);
4933
5785
  void openOnboardingWorkflowStep();
4934
5786
  },
@@ -4952,90 +5804,86 @@ export function App({ store, initialStatusLine = '' }) {
4952
5804
  const models = onboardingRef.current.providerModels || [];
4953
5805
  if (models.length === 0) {
4954
5806
  onboardingRef.current.defaultRoute = null;
4955
- onboardingRef.current.workflowRoutes = {};
5807
+ onboardingRef.current.agentRoutes = {};
4956
5808
  store.pushNotice('no provider models available; open /providers to sign in', 'warn');
4957
5809
  openOnboardingAuthStep();
4958
5810
  return;
4959
5811
  }
4960
- if (!onboardingRef.current.defaultRoute) {
4961
- onboardingRef.current.defaultRoute = chooseRecommendedModel(models, 'lead', null);
4962
- }
4963
- if (!onboardingRef.current.workflowRoutes || Object.keys(onboardingRef.current.workflowRoutes).length === 0) {
4964
- 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
+ }
4965
5823
  }
4966
- onboardingRef.current.workflowRoutes = {
4967
- ...(onboardingRef.current.workflowRoutes || {}),
4968
- lead: onboardingRef.current.defaultRoute,
4969
- };
4970
- const routes = onboardingRef.current.workflowRoutes || {};
4971
- const slots = [
4972
- ['agent', 'Agent', 'agent dispatch route'],
4973
- ['explorer', 'Explorer', 'code graph, file reading, repo exploration'],
4974
- ['memory', 'Memory', 'memory cycles and curation'],
4975
- ];
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 || [];
4976
5828
  setProviderPrompt(null);
4977
5829
  setChannelPrompt(null);
4978
5830
  setHookPrompt(null);
4979
5831
  setSettingsPrompt(null);
4980
5832
  setPicker({
4981
- title: 'First Run · Step 2/2 · Workflow Routes',
4982
- 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,
4983
5838
  items: [
4984
5839
  {
4985
- value: 'finish',
4986
- label: 'Finish setup',
4987
- description: 'save model and workflow route mapping',
4988
- _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',
4989
5846
  },
4990
5847
  {
4991
- value: 'lead',
4992
- label: 'Default model',
4993
- 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',
4994
5855
  _action: 'slot',
4995
- _slot: 'lead',
5856
+ _target: 'search',
4996
5857
  },
4997
- ...slots.map(([slot, label, description]) => ({
4998
- value: slot,
4999
- label,
5000
- 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 || '',
5001
5863
  _action: 'slot',
5002
- _slot: slot,
5864
+ _target: agent.id,
5003
5865
  })),
5004
- {
5005
- value: 'back',
5006
- label: 'Back to provider auth',
5007
- description: 'change API keys, OAuth, or local endpoints',
5008
- _action: 'back',
5009
- },
5010
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
+ },
5011
5878
  onSelect: (_value, item) => {
5012
5879
  setPicker(null);
5013
- if (item._action === 'finish') {
5014
- const defaultRoute = onboardingRef.current.defaultRoute;
5015
- if (!defaultRoute) {
5016
- store.pushNotice('select a provider model before finishing setup', 'warn');
5017
- openOnboardingAuthStep();
5018
- return;
5019
- }
5020
- void store.completeOnboarding?.({
5021
- defaultRoute,
5022
- workflowRoutes: onboardingRef.current.workflowRoutes || {},
5023
- })
5024
- .then(() => store.pushNotice('First-run setup complete.', 'info'))
5025
- .catch((e) => store.pushNotice(`Couldn’t save setup: ${e?.message || e}`, 'error'));
5026
- return;
5027
- }
5028
- if (item._action === 'back') {
5029
- openOnboardingAuthStep();
5030
- return;
5031
- }
5032
5880
  if (item._action === 'slot') {
5033
- openOnboardingRoleModelPicker(item._slot);
5881
+ openOnboardingRoleModelPicker(item._target);
5034
5882
  }
5035
5883
  },
5036
5884
  onCancel: () => {
5037
5885
  setPicker(null);
5038
- store.pushNotice('first-run setup will open again next launch', 'warn');
5886
+ onboardingWarnReopen();
5039
5887
  },
5040
5888
  });
5041
5889
  };
@@ -5045,8 +5893,9 @@ export function App({ store, initialStatusLine = '' }) {
5045
5893
  let canceled = false;
5046
5894
  try {
5047
5895
  const status = store.getOnboardingStatus?.();
5048
- if (status?.completed === true) return undefined;
5896
+ if (status?.completed === true && !forceOnboarding) return undefined;
5049
5897
  onboardingStartedRef.current = true;
5898
+ setOnboardingActive(true);
5050
5899
  setTimeout(() => {
5051
5900
  if (!canceled) openOnboardingAuthStep();
5052
5901
  }, 0);
@@ -5056,9 +5905,176 @@ export function App({ store, initialStatusLine = '' }) {
5056
5905
  return () => {
5057
5906
  canceled = true;
5058
5907
  };
5059
- }, [store]);
5908
+ }, [store, forceOnboarding]);
5909
+
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
+ };
5060
6076
 
5061
- const openChannelSetupPicker = async (focus = 'all') => {
6077
+ const openChannelSetupPicker = async (focus = 'all', options = {}) => {
5062
6078
  setProviderPrompt(null);
5063
6079
  setChannelPrompt(null);
5064
6080
  setHookPrompt(null);
@@ -5078,23 +6094,19 @@ export function App({ store, initialStatusLine = '' }) {
5078
6094
  setChannelPrompt(prompt);
5079
6095
  };
5080
6096
 
6097
+ const channelRemoteEnabled = store.isRemoteEnabled?.() === true;
6098
+
5081
6099
  if (focus === 'schedules') {
5082
6100
  const schedules = setup.schedules || [];
5083
6101
  const items = [
5084
- {
5085
- value: 'schedule-add',
5086
- label: 'Add schedule',
5087
- description: 'name | cron | instructions | optional channel | optional model',
5088
- _action: 'schedule-add',
5089
- },
5090
6102
  ...(schedules.length ? schedules.map((schedule) => {
5091
6103
  const enabled = schedule.enabled !== false;
5092
6104
  return {
5093
6105
  value: `schedule:${schedule.name}`,
5094
6106
  label: schedule.name,
5095
6107
  marker: enabled ? '●' : '○',
5096
- markerColor: enabled ? theme.success : theme.inactive,
5097
- 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'}`,
5098
6110
  _action: 'schedule-toggle',
5099
6111
  _name: schedule.name,
5100
6112
  _enabled: enabled,
@@ -5105,40 +6117,87 @@ export function App({ store, initialStatusLine = '' }) {
5105
6117
  description: 'no schedules configured',
5106
6118
  _action: 'noop',
5107
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
+ },
5108
6161
  {
5109
- value: 'back',
5110
- label: 'Back',
5111
- description: 'return to channel setup',
5112
- _action: 'back',
6162
+ value: 'endpoint-authtoken',
6163
+ label: 'authtoken',
6164
+ description: setup.webhook?.authenticated === true ? 'Set' : 'Not set · Enter authtoken',
6165
+ _action: 'endpoint-authtoken',
5113
6166
  },
5114
6167
  ];
5115
6168
  setPicker({
5116
- title: 'Schedules',
5117
- 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,
5118
6174
  items,
5119
6175
  onSelect: (_value, item) => {
5120
6176
  try {
5121
- if (item._action === 'schedule-add') {
6177
+ if (item._action === 'endpoint-domain') {
5122
6178
  openChannelPrompt({
5123
- kind: 'schedule-add',
5124
- label: 'Add schedule',
5125
- 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),
5126
6183
  });
5127
6184
  return;
5128
6185
  }
5129
- if (item._action === 'back') {
5130
- void openChannelSetupPicker('all');
5131
- 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
+ });
5132
6193
  }
5133
- if (item._action !== 'schedule-toggle') return;
5134
- store.setScheduleEnabled?.(item._name, !item._enabled);
5135
- void openChannelSetupPicker('schedules');
5136
6194
  } catch (e) {
5137
- store.pushNotice(`schedule toggle failed: ${e?.message || e}`, 'error');
6195
+ store.pushNotice(`webhook endpoint failed: ${e?.message || e}`, 'error');
5138
6196
  }
5139
6197
  },
5140
6198
  onCancel: () => {
5141
6199
  setPicker(null);
6200
+ returnTo();
5142
6201
  },
5143
6202
  });
5144
6203
  return;
@@ -5146,31 +6205,15 @@ export function App({ store, initialStatusLine = '' }) {
5146
6205
 
5147
6206
  if (focus === 'webhooks') {
5148
6207
  const hooks = setup.webhooks || [];
5149
- const serverEnabled = setup.webhook.enabled !== false;
5150
6208
  const items = [
5151
- {
5152
- value: 'webhook-add',
5153
- label: 'Add webhook',
5154
- description: 'name | instructions | optional channel | optional model | parser',
5155
- _action: 'webhook-add',
5156
- },
5157
- {
5158
- value: 'webhook-server',
5159
- label: 'Webhook server',
5160
- marker: serverEnabled ? '●' : '○',
5161
- markerColor: serverEnabled ? theme.success : theme.inactive,
5162
- description: `port ${setup.webhook.port || 3333} · auth ${setup.webhook.status}`,
5163
- _action: 'server-toggle',
5164
- _enabled: serverEnabled,
5165
- },
5166
6209
  ...(hooks.length ? hooks.map((hook) => {
5167
6210
  const enabled = hook.enabled !== false;
5168
6211
  return {
5169
6212
  value: `webhook:${hook.name}`,
5170
6213
  label: hook.name,
5171
6214
  marker: enabled ? '●' : '○',
5172
- markerColor: enabled ? theme.success : theme.inactive,
5173
- 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'}`,
5174
6217
  _action: 'webhook-toggle',
5175
6218
  _name: hook.name,
5176
6219
  _enabled: enabled,
@@ -5181,44 +6224,28 @@ export function App({ store, initialStatusLine = '' }) {
5181
6224
  description: 'no webhook endpoints configured',
5182
6225
  _action: 'noop',
5183
6226
  }]),
5184
- {
5185
- value: 'back',
5186
- label: 'Back',
5187
- description: 'return to channel setup',
5188
- _action: 'back',
5189
- },
5190
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
+ };
5191
6241
  setPicker({
5192
6242
  title: 'Webhooks',
5193
- 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)),
5194
6245
  items,
5195
- onSelect: (_value, item) => {
5196
- try {
5197
- if (item._action === 'webhook-add') {
5198
- openChannelPrompt({
5199
- kind: 'webhook-add',
5200
- label: 'Add webhook',
5201
- hint: 'Format: name | instructions | channel(optional) | model(required with channel) | parser(github/generic/stripe/sentry)',
5202
- });
5203
- return;
5204
- }
5205
- if (item._action === 'back') {
5206
- void openChannelSetupPicker('all');
5207
- return;
5208
- }
5209
- if (item._action === 'server-toggle') {
5210
- store.setWebhookConfig?.({ enabled: !item._enabled });
5211
- void openChannelSetupPicker('webhooks');
5212
- return;
5213
- }
5214
- if (item._action === 'webhook-toggle') {
5215
- store.setWebhookEnabled?.(item._name, !item._enabled);
5216
- void openChannelSetupPicker('webhooks');
5217
- }
5218
- } catch (e) {
5219
- store.pushNotice(`webhook toggle failed: ${e?.message || e}`, 'error');
5220
- }
5221
- },
6246
+ onSelect: (_value, item) => toggleWebhook(item),
6247
+ onLeft: (item) => toggleWebhook(item),
6248
+ onRight: (item) => toggleWebhook(item),
5222
6249
  onCancel: () => {
5223
6250
  setPicker(null);
5224
6251
  },
@@ -5227,144 +6254,128 @@ export function App({ store, initialStatusLine = '' }) {
5227
6254
  }
5228
6255
 
5229
6256
  const worker = store.getChannelWorkerStatus?.();
5230
- 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
+ };
5231
6295
  const items = [
5232
6296
  {
5233
- value: 'worker-status',
5234
- label: 'Channel runtime',
5235
- description: worker?.running ? `running · pid ${worker.pid}` : 'stopped',
5236
- _action: 'worker-status',
5237
- },
5238
- {
5239
- value: 'discord-token',
5240
- label: 'Discord token',
5241
- description: `Bot token · ${setup.discord.status}${setup.discord.problem ? ' · invalid' : ''}`,
5242
- _action: 'discord-token',
5243
- },
5244
- {
5245
- value: 'channel-add',
5246
- label: 'Add channel',
5247
- description: 'name | channelId | mode(interactive/broadcast)',
5248
- _action: 'channel-add',
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',
5249
6302
  },
5250
6303
  {
5251
- value: 'schedules',
5252
- label: 'Schedules',
5253
- description: `${(setup.schedules || []).length} configured`,
5254
- _action: 'schedules',
6304
+ value: 'channel-backend',
6305
+ label: 'Channel',
6306
+ meta: backendLabel,
6307
+ description: 'Select Discord or Telegram',
6308
+ _action: 'channel-backend',
5255
6309
  },
5256
6310
  {
5257
- value: 'schedule-add',
5258
- label: 'Add schedule',
5259
- description: 'name | cron | instructions | optional channel | optional model',
5260
- _action: 'schedule-add',
6311
+ value: 'channel-setting',
6312
+ label: 'Setting',
6313
+ description: 'Token and main target',
6314
+ _action: 'channel-setting',
5261
6315
  },
5262
6316
  {
5263
- value: 'webhook-token',
5264
- label: 'Webhook auth',
5265
- description: `ngrok/webhook authtoken · ${setup.webhook.status}`,
5266
- _action: 'webhook-token',
5267
- },
5268
- {
5269
- value: 'webhook-toggle',
5270
- label: 'Webhook server',
5271
- marker: serverEnabled ? '●' : '○',
5272
- markerColor: serverEnabled ? theme.success : theme.inactive,
5273
- description: `${serverEnabled ? 'enabled' : 'disabled'} · port ${setup.webhook.port || 3333}`,
5274
- _action: 'webhook-toggle',
5275
- _enabled: serverEnabled,
5276
- },
5277
- {
5278
- value: 'webhooks',
5279
- label: 'Webhooks',
5280
- description: `${(setup.webhooks || []).length} configured`,
5281
- _action: 'webhooks',
5282
- },
5283
- {
5284
- value: 'webhook-add',
5285
- label: 'Add webhook',
5286
- description: 'name | instructions | optional channel | optional model | parser',
5287
- _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',
5288
6334
  },
5289
- ...((setup.channels || []).map((ch) => ({
5290
- value: `channel:${ch.name}`,
5291
- label: `# ${ch.name}`,
5292
- description: `${ch.channelId || '(unset)'} · ${ch.mode}${ch.main ? ' · main' : ''} · edit`,
5293
- _action: 'channel-edit',
5294
- _channel: ch,
5295
- }))),
5296
6335
  ];
5297
6336
 
5298
6337
  setPicker({
5299
6338
  title: 'Channels',
5300
- 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)),
5301
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
+ },
5302
6358
  onSelect: (_value, item) => {
5303
6359
  try {
5304
- if (item._action === 'worker-status') {
5305
- store.pushNotice(worker?.running ? `channel runtime running: pid ${worker.pid}` : 'channel runtime stopped', 'info');
5306
- return;
5307
- }
5308
- if (item._action === 'discord-token') {
5309
- openChannelPrompt({
5310
- kind: 'discord-token',
5311
- label: 'Discord bot token',
5312
- hint: 'Paste the Discord bot token. It is stored in the OS keychain.',
5313
- });
5314
- return;
5315
- }
5316
- if (item._action === 'webhook-token') {
5317
- openChannelPrompt({
5318
- kind: 'webhook-token',
5319
- label: 'Webhook/ngrok authtoken',
5320
- hint: 'Paste the webhook/ngrok authtoken. It is stored in the OS keychain.',
5321
- });
5322
- return;
5323
- }
5324
- if (item._action === 'channel-add') {
5325
- openChannelPrompt({
5326
- kind: 'channel-add',
5327
- label: 'Add channel',
5328
- hint: 'Format: name | Discord channel ID | mode(interactive/broadcast) | main(optional)',
5329
- });
6360
+ if (item._action === 'remote-runtime') {
6361
+ applyRemoteRuntime(item.value);
5330
6362
  return;
5331
6363
  }
5332
- if (item._action === 'channel-edit') {
5333
- const ch = item._channel || {};
5334
- openChannelPrompt({
5335
- kind: 'channel-add',
5336
- label: `Edit channel · ${ch.name}`,
5337
- hint: `Format: ${ch.name} | ${ch.channelId || '<channel-id>'} | ${ch.mode || 'interactive'} | ${ch.main ? 'main' : 'main(optional)'}`,
5338
- });
6364
+ if (item._action === 'channel-backend') {
6365
+ cycleChannelBackend(1, item.value);
5339
6366
  return;
5340
6367
  }
5341
- if (item._action === 'schedule-add') {
5342
- openChannelPrompt({
5343
- kind: 'schedule-add',
5344
- label: 'Add schedule',
5345
- hint: 'Format: name | cron (5 or 6 fields) | instructions | channel(optional) | model(required with channel)',
6368
+ if (item._action === 'channel-setting') {
6369
+ openChannelSettingTypePicker({
6370
+ returnTo: () => reopenRoot({ highlightValue: 'channel-setting' }),
5346
6371
  });
5347
6372
  return;
5348
6373
  }
5349
- if (item._action === 'webhook-add') {
5350
- openChannelPrompt({
5351
- kind: 'webhook-add',
5352
- label: 'Add webhook',
5353
- hint: 'Format: name | instructions | channel(optional) | model(required with channel) | parser(github/generic/stripe/sentry)',
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' }),
5354
6378
  });
5355
- return;
5356
- }
5357
- if (item._action === 'webhook-toggle') {
5358
- store.setWebhookConfig?.({ enabled: !item._enabled });
5359
- void openChannelSetupPicker('all');
5360
- return;
5361
- }
5362
- if (item._action === 'schedules') {
5363
- void openChannelSetupPicker('schedules');
5364
- return;
5365
- }
5366
- if (item._action === 'webhooks') {
5367
- void openChannelSetupPicker('webhooks');
5368
6379
  }
5369
6380
  } catch (e) {
5370
6381
  store.pushNotice(`channels update failed: ${e?.message || e}`, 'error');
@@ -5372,6 +6383,8 @@ export function App({ store, initialStatusLine = '' }) {
5372
6383
  },
5373
6384
  onCancel: () => {
5374
6385
  setPicker(null);
6386
+ // Onboarding Step 5 Esc mirrors the other steps' reopen-next-launch warning.
6387
+ if (options.onboarding) onboardingWarnReopen();
5375
6388
  },
5376
6389
  });
5377
6390
  };
@@ -5387,46 +6400,7 @@ export function App({ store, initialStatusLine = '' }) {
5387
6400
  return { ...status, servers: status.servers || [] };
5388
6401
  };
5389
6402
 
5390
- const openMcpServerPicker = (server) => {
5391
- if (server?.error) store.pushNotice(`${server.name}: ${server.error}`, 'warn');
5392
- const enabled = server?.enabled !== false;
5393
- const items = [
5394
- {
5395
- value: enabled ? 'disable' : 'enable',
5396
- label: enabled ? 'Disable server' : 'Enable server',
5397
- description: server?.configured ? `${server?.status || 'unknown'} · ${server?.transport || 'unknown'}` : 'server is not configured',
5398
- _action: server?.configured ? (enabled ? 'disable' : 'enable') : 'noop',
5399
- },
5400
- {
5401
- value: 'reconnect',
5402
- label: 'Reconnect server',
5403
- description: 'refresh configured MCP servers',
5404
- _action: 'reconnect',
5405
- },
5406
- ];
5407
- setPicker({
5408
- title: `MCP · ${server?.name || 'server'}`,
5409
- description: 'Enable, disable, or reconnect this MCP server.',
5410
- items,
5411
- onSelect: (_toolValue, toolItem) => {
5412
- setPicker(null);
5413
- if (toolItem._action === 'enable' || toolItem._action === 'disable') {
5414
- void store.setMcpServerEnabled?.(server.name, toolItem._action === 'enable')
5415
- .then(() => openMcpServersPicker())
5416
- .catch((e) => store.pushNotice(`mcp toggle failed: ${e?.message || e}`, 'error'));
5417
- return;
5418
- }
5419
- if (toolItem._action === 'reconnect') {
5420
- void store.reconnectMcp?.()
5421
- .then(() => openMcpServersPicker())
5422
- .catch((e) => store.pushNotice(`mcp reconnect failed: ${e?.message || e}`, 'error'));
5423
- }
5424
- },
5425
- onCancel: () => openMcpServersPicker(),
5426
- });
5427
- };
5428
-
5429
- const openMcpServersPicker = () => {
6403
+ const openMcpServersPicker = (options = {}) => {
5430
6404
  const status = mcpStatus();
5431
6405
  if (!status) return;
5432
6406
  const servers = status.servers || [];
@@ -5440,27 +6414,36 @@ export function App({ store, initialStatusLine = '' }) {
5440
6414
  });
5441
6415
  }
5442
6416
  for (const server of servers) {
6417
+ const enabled = server.enabled !== false;
5443
6418
  items.push({
5444
6419
  value: `server:${server.name}`,
5445
- label: server.enabled === false ? `${server.name} (off)` : server.name,
6420
+ label: server.name,
6421
+ marker: enabled ? '●' : '○',
6422
+ markerColor: enabled ? theme.success : theme.inactive,
5446
6423
  description: `${server.status || 'unknown'} · ${server.transport || 'unknown'} · ${server.toolCount || 0} tools${server.error ? ` · ${server.error}` : ''}`,
5447
6424
  _action: 'server',
5448
6425
  _server: server,
6426
+ _enabled: enabled,
5449
6427
  });
5450
6428
  }
5451
6429
  setProviderPrompt(null);
5452
6430
  setChannelPrompt(null);
5453
6431
  setHookPrompt(null);
5454
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
+ };
5455
6439
  setPicker({
5456
6440
  title: 'MCP servers',
5457
- 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)),
5458
6443
  items,
5459
- onSelect: (_value, item) => {
5460
- setPicker(null);
5461
- if (item._action !== 'server') return;
5462
- openMcpServerPicker(item._server);
5463
- },
6444
+ onSelect: (_value, item) => toggleServer(item),
6445
+ onLeft: (item) => toggleServer(item),
6446
+ onRight: (item) => toggleServer(item),
5464
6447
  onCancel: () => {
5465
6448
  setPicker(null);
5466
6449
  },
@@ -5524,10 +6507,11 @@ export function App({ store, initialStatusLine = '' }) {
5524
6507
  });
5525
6508
  };
5526
6509
 
5527
- const openSkillsPicker = () => {
6510
+ const openSkillsPicker = (options = {}) => {
5528
6511
  const status = skillsStatus();
5529
6512
  if (!status) return;
5530
6513
  const skills = status.skills || [];
6514
+ const disabledSet = options.disabledOverride instanceof Set ? options.disabledOverride : disabledSkills;
5531
6515
  const items = [];
5532
6516
  if (skills.length === 0) {
5533
6517
  items.push({
@@ -5538,28 +6522,39 @@ export function App({ store, initialStatusLine = '' }) {
5538
6522
  });
5539
6523
  }
5540
6524
  for (const skill of skills) {
5541
- const disabled = disabledSkills.has(skill.name);
6525
+ const enabled = !disabledSet.has(skill.name);
5542
6526
  items.push({
5543
6527
  value: skill.name,
5544
- label: disabled ? `${skill.name} (disabled)` : skill.name,
5545
- 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 || ''}`,
5546
6532
  _action: 'skill',
5547
6533
  _skill: skill,
6534
+ _enabled: enabled,
5548
6535
  });
5549
6536
  }
5550
6537
  setProviderPrompt(null);
5551
6538
  setChannelPrompt(null);
5552
6539
  setHookPrompt(null);
5553
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
+ };
5554
6550
  setPicker({
5555
6551
  title: 'Skills',
5556
- 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)),
5557
6554
  items,
5558
- onSelect: (_value, item) => {
5559
- setPicker(null);
5560
- if (item._action !== 'skill' || !item._skill?.name) return;
5561
- openSkillDetailPicker(item._skill);
5562
- },
6555
+ onSelect: (_value, item) => toggleSkill(item),
6556
+ onLeft: (item) => toggleSkill(item),
6557
+ onRight: (item) => toggleSkill(item),
5563
6558
  onCancel: () => {
5564
6559
  setPicker(null);
5565
6560
  },
@@ -5841,21 +6836,22 @@ export function App({ store, initialStatusLine = '' }) {
5841
6836
  setChannelPrompt(null);
5842
6837
  setHookPrompt(null);
5843
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
+ };
5844
6848
  setPicker({
5845
6849
  title: 'Hooks',
5846
6850
  description: 'Before-tool hook rules; Enter toggles a rule.',
5847
6851
  items,
5848
- onSelect: (_value, item) => {
5849
- setPicker(null);
5850
- if (item._action === 'rule') {
5851
- try {
5852
- store.setHookRuleEnabled?.(item._rule.index, !item._rule.enabled);
5853
- void openHooksPicker();
5854
- } catch (e) {
5855
- store.pushNotice(`hook toggle failed: ${e?.message || e}`, 'error');
5856
- }
5857
- }
5858
- },
6852
+ onSelect: (_value, item) => toggleRule(item),
6853
+ onLeft: (item) => toggleRule(item),
6854
+ onRight: (item) => toggleRule(item),
5859
6855
  onCancel: () => {
5860
6856
  setPicker(null);
5861
6857
  },
@@ -5925,6 +6921,126 @@ export function App({ store, initialStatusLine = '' }) {
5925
6921
  .catch((e) => store.pushNotice(`memory failed: ${e?.message || e}`, 'error'));
5926
6922
  };
5927
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
+
5928
7044
  const openMemoryPicker = () => {
5929
7045
  setProviderPrompt(null);
5930
7046
  setChannelPrompt(null);
@@ -6325,6 +7441,19 @@ export function App({ store, initialStatusLine = '' }) {
6325
7441
  .then(ok => store.pushNotice(ok ? modelSwitchNotice() : 'Model switch is already running.', ok ? 'info' : 'warn'))
6326
7442
  .catch((e) => store.pushNotice(`Couldn’t switch model: ${e?.message || e}`, 'error'));
6327
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
+ }
6328
7457
  case 'search':
6329
7458
  if (state.busy) {
6330
7459
  store.pushNotice('wait for the current turn to finish before /search', 'warn');
@@ -6623,6 +7752,9 @@ export function App({ store, initialStatusLine = '' }) {
6623
7752
  case 'profile':
6624
7753
  openProfilePicker();
6625
7754
  return true;
7755
+ case 'update':
7756
+ openUpdatePicker();
7757
+ return true;
6626
7758
  case 'quit':
6627
7759
  requestExit();
6628
7760
  return true;
@@ -6750,26 +7882,42 @@ export function App({ store, initialStatusLine = '' }) {
6750
7882
  return false;
6751
7883
  }
6752
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
+ };
6753
7891
  if (channelPrompt.kind === 'discord-token') {
6754
7892
  if (!commandText) return false;
6755
7893
  store.saveDiscordToken(commandText);
6756
- setChannelPrompt(null);
6757
- 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);
6758
7901
  return true;
6759
7902
  }
6760
7903
  if (channelPrompt.kind === 'webhook-token') {
6761
7904
  if (!commandText) return false;
6762
7905
  store.saveWebhookAuthtoken(commandText);
6763
- setChannelPrompt(null);
6764
- 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);
6765
7913
  return true;
6766
7914
  }
6767
7915
  const parts = commandText.split('|').map((part) => part.trim());
6768
7916
  if (channelPrompt.kind === 'channel-add') {
6769
- const [name, channelId, mode] = parts;
6770
- store.saveChannel({ name, channelId, mode });
6771
- setChannelPrompt(null);
6772
- 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);
6773
7921
  return true;
6774
7922
  }
6775
7923
  if (channelPrompt.kind === 'schedule-add') {
@@ -7015,6 +8163,8 @@ export function App({ store, initialStatusLine = '' }) {
7015
8163
  }, [slashCommands.length, activeSlashQuery]);
7016
8164
 
7017
8165
  const onPromptDraftChange = useCallback((value) => {
8166
+ if (String(value ?? '').length > 0) dismissWelcomePromptHint();
8167
+ syncPromptLayoutRows(value);
7018
8168
  const suppressPromptHint = promptHistoryDraftChangeRef.current;
7019
8169
  promptHistoryDraftChangeRef.current = false;
7020
8170
  const historyNav = promptHistoryNavRef.current;
@@ -7053,7 +8203,7 @@ export function App({ store, initialStatusLine = '' }) {
7053
8203
  if (slashDismissedFor) {
7054
8204
  setSlashDismissedFor((dismissed) => (dismissed && dismissed !== value ? '' : dismissed));
7055
8205
  }
7056
- }, [clearPromptHint, resetPromptHistoryNav, showPromptHint, slashDismissedFor]);
8206
+ }, [clearPromptHint, dismissWelcomePromptHint, resetPromptHistoryNav, showPromptHint, slashDismissedFor, syncPromptLayoutRows]);
7057
8207
 
7058
8208
  const cancelProviderPrompt = useCallback(() => {
7059
8209
  try { providerPrompt?.login?.cancel?.(); } catch {}
@@ -7066,8 +8216,12 @@ export function App({ store, initialStatusLine = '' }) {
7066
8216
  }, [providerPrompt, showPromptHint]);
7067
8217
 
7068
8218
  const cancelChannelPrompt = useCallback(() => {
8219
+ const onCancel = channelPrompt?.onCancel;
8220
+ const afterSave = channelPrompt?.afterSave;
7069
8221
  setChannelPrompt(null);
7070
- }, [showPromptHint]);
8222
+ if (typeof onCancel === 'function') onCancel();
8223
+ else if (typeof afterSave === 'function') afterSave();
8224
+ }, [channelPrompt, showPromptHint]);
7071
8225
 
7072
8226
  const cancelHookPrompt = useCallback(() => {
7073
8227
  setHookPrompt(null);
@@ -7245,7 +8399,8 @@ export function App({ store, initialStatusLine = '' }) {
7245
8399
  // − welcome header (empty transcript only)
7246
8400
  // − live status (thinking / spinner / TurnDone)
7247
8401
  // − queued prompts (marginTop 1 + N rows, only when queued)
7248
- // − input box (marginTop 1 + 2 border + 1 content)
8402
+ // − prompt meta (spinner / transient message / queued)
8403
+ // − input box (2 border + wrapped content)
7249
8404
  // − statusline (reserved L1 + L2 + outer gap; total 3 rows)
7250
8405
  //
7251
8406
  // Every sibling outside the viewport must be accounted for here; otherwise
@@ -7263,24 +8418,39 @@ export function App({ store, initialStatusLine = '' }) {
7263
8418
  // Slash search floats above the normal prompt. Actual option panels own the
7264
8419
  // prompt/status area, so they hide those rows and expand into that space.
7265
8420
  const inputBoxHidden = expandedOptionPanel;
7266
- const showWelcomeBanner = (state.items.length === 0 && !hasFloatingPanel) || projectSelectionActive;
8421
+ const showWelcomeBanner = (state.items.length === 0 && !hasFloatingPanel) || projectSelectionActive || onboardingActive;
7267
8422
  const WELCOME_ROWS = showWelcomeBanner ? 11 : 0;
7268
8423
  const liveSpinner = state.spinner?.active ? state.spinner : (state.commandStatus?.active ? state.commandStatus : null);
7269
8424
  const latestToast = state.toasts?.length ? state.toasts[state.toasts.length - 1] : null;
7270
8425
  const toastHint = latestToast ? latestToast.text : '';
7271
8426
  const inputHint = promptHint || toastHint;
7272
8427
  const inputHintTone = promptHint ? promptHintTone : (latestToast?.tone || 'info');
7273
- // While the slash palette is open it owns the area above the prompt, so the
7274
- // live spinner/meta row is suppressed entirely no reservation and no render.
7275
- 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;
7276
8440
  const SCROLL_HINT_ROWS = 0;
7277
8441
  const LIVE_STATUS_ROWS = 0;
7278
- // The standalone prompt box is 3 rows (round border + one input line). Normal
7279
- // mode keeps a one-row top gap, but slash mode pins the command palette flush
7280
- // to the prompt, so reserve only the actual prompt height there. Otherwise an
7281
- // extra reserved row remains below the statusline and the prompt appears one
7282
- // row too high.
7283
- 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;
7284
8454
  const STATUSLINE_ROWS = 3;
7285
8455
  // Shared panel chrome math. Every floating panel follows the same vertical
7286
8456
  // rhythm INSIDE its round border: title row, blank, description/hint row,
@@ -7297,11 +8467,26 @@ export function App({ store, initialStatusLine = '' }) {
7297
8467
  const TEXT_ENTRY_ROWS = PANEL_CHROME_ROWS + 1;
7298
8468
  const OPTION_PANEL_EXTRA_ROWS = expandedOptionPanel ? 3 : 0;
7299
8469
  const queuedVisible = !hasFloatingPanel && !inputBoxHidden && state.queued?.length > 0;
7300
- // QueuedCommands has its own top margin, and the prompt box drops its normal
7301
- // top margin while a queue is visible. Net extra height is therefore only the
7302
- // queued rows themselves; counting the queue margin as an extra row over-
7303
- // 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).
7304
8488
  const queuedRows = queuedVisible ? state.queued.length : 0;
8489
+ const INPUT_BOX_ROWS = promptBoxRows + promptMetaRows + overlayHintRows;
7305
8490
  const baseReserve = WELCOME_ROWS + SCROLL_HINT_ROWS + LIVE_STATUS_ROWS + INPUT_BOX_ROWS + STATUSLINE_ROWS + queuedRows;
7306
8491
  const maxFloatingPanelRows = Math.max(0, resizeState.rows - baseReserve - 1);
7307
8492
  const desiredFloatingPanelRows = toolApproval
@@ -7315,19 +8500,51 @@ export function App({ store, initialStatusLine = '' }) {
7315
8500
  : slashPaletteOpen
7316
8501
  ? PANEL_MAX_VISIBLE + PANEL_CHROME_ROWS
7317
8502
  : hasTextEntryPrompt
7318
- ? TEXT_ENTRY_ROWS + OPTION_PANEL_EXTRA_ROWS
8503
+ ? TEXT_ENTRY_ROWS
7319
8504
  : 0;
7320
8505
  const floatingPanelRows = desiredFloatingPanelRows > 0
7321
8506
  ? Math.min(desiredFloatingPanelRows, maxFloatingPanelRows)
7322
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).
7323
8513
  const pickerVisibleRows = picker
7324
- ? Math.max(1, floatingPanelRows - PICKER_CHROME_ROWS - (picker.fillAvailable ? 0 : OPTION_PANEL_EXTRA_ROWS))
8514
+ ? Math.max(1, floatingPanelRows - PICKER_CHROME_ROWS)
7325
8515
  : PANEL_MAX_VISIBLE;
7326
8516
  const bottomReserve = baseReserve + floatingPanelRows;
7327
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;
7328
8545
  transcriptViewportRef.current = {
7329
8546
  top: WELCOME_ROWS,
7330
- bottom: Math.max(WELCOME_ROWS, WELCOME_ROWS + viewportHeight - 1),
8547
+ bottom: Math.max(WELCOME_ROWS, WELCOME_ROWS + transcriptContentHeight - 1),
7331
8548
  };
7332
8549
  // [mixdog] Keep the live terminal row count current for the mouse handler's
7333
8550
  // region routing + status-band selection clip (see onData).
@@ -7336,24 +8553,6 @@ export function App({ store, initialStatusLine = '' }) {
7336
8553
  // bottom area), drop its stale measured rect so the mouse handler does not
7337
8554
  // route presses to a prompt box that is not on screen.
7338
8555
  if (inputBoxHidden) promptBoxRectRef.current = null;
7339
- // Windows Terminal/conhost scrolls the alt-screen (auto-wrap/DECAWM) when the
7340
- // bottom-right cell is written, so reserve one cell on win32. Other platforms
7341
- // render at full width.
7342
- const rightSafetyColumns = process.platform === 'win32' ? 1 : 0;
7343
- const frameColumns = Math.max(1, resizeState.columns - rightSafetyColumns);
7344
- const promptMetaVisible = !inputBoxHidden && !!liveSpinner && !slashPaletteOpen;
7345
- // Same-row done hint: when the newest transcript row is a TurnDone/StatusDone
7346
- // and there is a transient hint to show (and no live spinner owns the status
7347
- // band), attach the hint to that done row's right side instead of reserving a
7348
- // separate overlay row. overlayHintVisible excludes this case so the hint is
7349
- // not double-painted.
7350
- const lastTranscriptItem = (state.items || []).at(-1) ?? null;
7351
- const lastItemIsDoneRow = lastTranscriptItem?.kind === 'turndone'
7352
- || lastTranscriptItem?.kind === 'statusdone';
7353
- const attachInputHintToTurnDone = !inputBoxHidden
7354
- && !liveSpinner
7355
- && !!inputHint
7356
- && lastItemIsDoneRow;
7357
8556
  // Toast/error text has two mutually exclusive placements:
7358
8557
  // - while a live status row exists (thinking/compacting/responding), attach it
7359
8558
  // to that row so the bottom cluster reserves exactly one status band;
@@ -7361,13 +8560,32 @@ export function App({ store, initialStatusLine = '' }) {
7361
8560
  // the blank spacer instead of reserving an extra row. This keeps late errors
7362
8561
  // from pushing the prompt/statusline upward, and when thinking starts the
7363
8562
  // hint moves into the live row on the same render instead of double-painting.
7364
- const overlayHintVisible = !inputBoxHidden && !liveSpinner && !!inputHint && !queuedVisible && floatingPanelRows <= 0 && !attachInputHintToTurnDone;
7365
- 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
8566
+ ? Math.max(1, Math.min(Math.max(1, frameColumns - 4), Math.max(12, Math.floor(frameColumns * 0.42))))
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
7366
8572
  ? Math.max(1, Math.min(Math.max(1, frameColumns - 4), Math.max(12, Math.floor(frameColumns * 0.42))))
7367
8573
  : 0;
8574
+ const transientStatusWidth = liveSpinner ? spinnerHintWidth : guardHintWidth;
7368
8575
  const promptSpinnerColumns = liveSpinner && inputHint
7369
- ? Math.max(1, frameColumns - transientStatusWidth - 1)
8576
+ ? Math.max(1, frameColumns - spinnerHintWidth - 1)
7370
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
+ );
7371
8589
  // Key the heavy O(n) row-index + windowing memos on a STRUCTURE signature
7372
8590
  // instead of the `state.items` array identity. The engine swaps `state.items`
7373
8591
  // for a new array on every streaming flush (~8ms) while only the final
@@ -7401,40 +8619,111 @@ export function App({ store, initialStatusLine = '' }) {
7401
8619
  // SAME render, so the completed line fills its space with no one-frame gap.
7402
8620
  // Falls back to the live scrollOffset state when there is no active anchor
7403
8621
  // (bottom-follow / pinned) or it cannot be aligned (anchor item gone).
7404
- 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
7405
8636
  && !transcriptAnchorDirtyRef.current
7406
- && !followingRef.current;
7407
- 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;
7408
8643
  if (anchorLockActive) {
7409
- const lockViewRows = Math.max(1, Number(viewportHeight) || 1);
7410
- const lockTotalRows = Math.max(0, Number(transcriptRowIndex?.totalRows) || 0);
7411
- const lockMaxRows = Math.max(0, lockTotalRows - lockViewRows);
7412
8644
  const locked = resolveAnchorScrollOffset({
7413
8645
  anchor: transcriptAnchorRef.current,
7414
8646
  items: state.items,
7415
- curPrefix: transcriptRowIndex?.prefixRows || null,
8647
+ curPrefix: curPrefixForLock,
7416
8648
  totalRows: lockTotalRows,
7417
8649
  viewRows: lockViewRows,
7418
8650
  maxRows: lockMaxRows,
7419
8651
  });
7420
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
+ }
7421
8704
  }
7422
8705
  const transcriptWindow = useMemo(() => transcriptRenderWindow(state.items, {
7423
8706
  scrollOffset: renderScrollOffset,
7424
- viewportHeight,
8707
+ viewportHeight: transcriptContentHeight,
7425
8708
  columns: frameColumns,
7426
8709
  toolOutputExpanded,
7427
8710
  rowIndex: transcriptRowIndex,
7428
8711
  // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: sig+scroll/viewport capture the relevant changes
7429
- }), [transcriptStructureSig, renderScrollOffset, viewportHeight, transcriptRowIndex]);
8712
+ }), [transcriptStructureSig, renderScrollOffset, transcriptContentHeight, transcriptRowIndex]);
7430
8713
  maxScrollRowsRef.current = transcriptWindow.maxScrollRows;
7431
8714
  // Publish this frame's geometry so a manual scroll can capture the reading
7432
8715
  // anchor synchronously (see captureTranscriptAnchorAt).
7433
8716
  transcriptGeomRef.current = {
7434
8717
  prefixRows: transcriptRowIndex?.prefixRows || null,
7435
8718
  totalRows: Math.max(0, Number(transcriptWindow.totalRows) || 0),
7436
- viewRows: Math.max(1, Number(viewportHeight) || 1),
8719
+ viewRows: Math.max(1, Number(transcriptContentHeight) || 1),
7437
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),
7438
8727
  };
7439
8728
  // The window memo is keyed on a structure signature that intentionally
7440
8729
  // ignores per-character growth of the streaming assistant text, so its
@@ -7446,8 +8735,27 @@ export function App({ store, initialStatusLine = '' }) {
7446
8735
  transcriptWindow.startIndex,
7447
8736
  transcriptWindow.endIndex,
7448
8737
  );
7449
- // (attachInputHintToTurnDone / lastTranscriptItem computed earlier, before
7450
- // 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;
7451
8759
  // ── App-level measured height harvest (ScrollBox/useVirtualScroll-inspired) ─
7452
8760
  // Runs after EVERY commit (no deps): Yoga has just laid out the mounted rows,
7453
8761
  // so each tracked item Box's getComputedHeight() is its REAL terminal height.
@@ -7537,8 +8845,18 @@ export function App({ store, initialStatusLine = '' }) {
7537
8845
  const currentPosition = Math.max(0, Number(scrollPositionRef.current) || 0);
7538
8846
  const currentOffset = Math.max(0, Number(scrollOffset) || 0);
7539
8847
  const maxRows = Math.max(0, Number(transcriptWindow.maxScrollRows) || 0);
7540
- const pinnedToBottom = currentTarget <= 0 && currentPosition <= 0 && currentOffset <= 0;
7541
- 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;
7542
8860
  const shouldFollowBottom = rowDelta > 0 && (followOnGrowth || pinnedToBottom);
7543
8861
  if (shouldFollowBottom) {
7544
8862
  // Bottom follow: while pinned to the newest output,
@@ -7547,7 +8865,18 @@ export function App({ store, initialStatusLine = '' }) {
7547
8865
  // during streaming makes the transcript jump down/up and can clip the
7548
8866
  // currently generated assistant text. Keep all scroll refs at zero so
7549
8867
  // character generation stays visually stable.
7550
- 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) {
7551
8880
  stopSmoothScroll();
7552
8881
  scrollTargetRef.current = 0;
7553
8882
  scrollPositionRef.current = 0;
@@ -7569,12 +8898,12 @@ export function App({ store, initialStatusLine = '' }) {
7569
8898
  // only moves the bottom — the top is rock-stable. Changes ABOVE the anchor
7570
8899
  // move the item's prefix start and are absorbed the same way. No deltas, no
7571
8900
  // fallback, no drift.
7572
- const viewRows = Math.max(1, Number(viewportHeight) || 1);
8901
+ const viewRows = Math.max(1, Number(transcriptContentHeight) || 1);
7573
8902
  const items = state.items || [];
7574
8903
  let anchor = transcriptAnchorRef.current;
7575
8904
  // (Re)capture the anchor from the current viewport-top edge when missing or
7576
8905
  // invalidated by a manual scroll. anchorRow = absolute row at the top edge.
7577
- if (!anchor || transcriptAnchorDirtyRef.current) {
8906
+ if (!followingRef.current && (!anchor || transcriptAnchorDirtyRef.current)) {
7578
8907
  if (curPrefix && curPrefix.length > 1) {
7579
8908
  const anchorRow = Math.max(0, Math.min(totalRows, totalRows - currentTarget - viewRows));
7580
8909
  let idx = upperBound(curPrefix, anchorRow) - 1;
@@ -7614,12 +8943,25 @@ export function App({ store, initialStatusLine = '' }) {
7614
8943
  scrollPositionRef.current = Math.max(0, Math.min(maxRows, currentPosition + appliedDelta));
7615
8944
  preservedScrollDeltaRef.current += appliedDelta;
7616
8945
  setScrollOffset(Math.max(0, Math.round(desired)));
7617
- }, [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]);
7618
8960
  useLayoutEffect(() => {
7619
8961
  const top = Math.max(0, Number(transcriptViewportRef.current?.top) || 0);
7620
8962
  const next = {
7621
8963
  top,
7622
- height: Math.max(1, Number(viewportHeight) || 1),
8964
+ height: Math.max(1, Number(transcriptContentHeight) || 1),
7623
8965
  totalRows: Math.max(0, Number(transcriptWindow.totalRows) || 0),
7624
8966
  scrollOffset: Math.max(0, Number(transcriptWindow.effectiveScrollOffset) || 0),
7625
8967
  };
@@ -7639,7 +8981,13 @@ export function App({ store, initialStatusLine = '' }) {
7639
8981
  const clippedRect = withSelectionClip(shiftSelectionRectY(dragRef.current.rect, deltaY));
7640
8982
  dragRef.current = { ...dragRef.current, rect: clippedRect };
7641
8983
  paintSelectionRect(clippedRect, { rememberText: true, immediate: true });
7642
- }, [viewportHeight, transcriptWindow.totalRows, transcriptWindow.effectiveScrollOffset, withSelectionClip, paintSelectionRect]);
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]);
7643
8991
  useEffect(() => {
7644
8992
  const maxRows = Math.max(0, Number(transcriptWindow.maxScrollRows) || 0);
7645
8993
  if (scrollTargetRef.current <= maxRows && scrollPositionRef.current <= maxRows && scrollOffset <= maxRows) return;
@@ -7704,6 +9052,7 @@ export function App({ store, initialStatusLine = '' }) {
7704
9052
  onTab={cycleWorkflowFromPrompt}
7705
9053
  onPasteText={handlePromptPaste}
7706
9054
  onHistoryNavigate={handlePromptHistoryNavigate}
9055
+ onVoiceToggle={handleVoiceToggle}
7707
9056
  commandPaletteActive={slashPaletteOpen}
7708
9057
  onCommandPaletteNavigate={(direction) => {
7709
9058
  setSlashIndex((index) => {
@@ -7734,16 +9083,16 @@ export function App({ store, initialStatusLine = '' }) {
7734
9083
  // stack up from just over the input. A top flexGrow spacer sinks the whole
7735
9084
  // stack to the bottom; the transcript itself is a fixed-height clipping
7736
9085
  // viewport (see viewportHeight above).
7737
- <Box flexDirection="column" width={frameColumns} height={resizeState.rows} backgroundColor={theme.background}>
9086
+ <Box flexDirection="column" width={frameColumns} height={resizeState.rows} backgroundColor={surfaceBackground()}>
7738
9087
  {/* Empty-transcript header stays outside the bottom-anchored viewport and
7739
9088
  has its own reserved rows, so it cannot steal space from the input. */}
7740
9089
  {showWelcomeBanner ? (
7741
- <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()}>
7742
9091
  <Text color={theme.text} bold>{centerLine('███╗ ███╗██╗██╗ ██╗██████╗ ██████╗ ██████╗ ', frameColumns)}</Text>
7743
9092
  <Text color={theme.text} bold>{centerLine('████╗ ████║██║╚██╗██╔╝██╔══██╗██╔═══██╗██╔════╝ ', frameColumns)}</Text>
7744
- <Text color={theme.claude} bold>{centerLine('██╔████╔██║██║ ╚███╔╝ ██║ ██║██║ ██║██║ ███╗', frameColumns)}</Text>
7745
- <Text color={theme.claude} bold>{centerLine('██║╚██╔╝██║██║ ██╔██╗ ██║ ██║██║ ██║██║ ██║', frameColumns)}</Text>
7746
- <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>
7747
9096
  <Box height={1} flexShrink={0} />
7748
9097
  <Text color={theme.inactive}>{centerLine(`mixdog coding agent · ${state.cwd}`, frameColumns, 4)}</Text>
7749
9098
  </Box>
@@ -7765,6 +9114,14 @@ export function App({ store, initialStatusLine = '' }) {
7765
9114
  overflow="hidden"
7766
9115
  justifyContent="flex-end"
7767
9116
  >
9117
+ <Box
9118
+ flexDirection="column"
9119
+ width="100%"
9120
+ height={transcriptContentHeight}
9121
+ flexShrink={0}
9122
+ overflow="hidden"
9123
+ justifyContent="flex-end"
9124
+ >
7768
9125
  {/* Wheel scroll: with the viewport bottom-anchored (flex-end), a NEGATIVE
7769
9126
  marginBottom pushes the transcript column DOWN past the bottom edge,
7770
9127
  bringing older content above the window into view (overflow hidden
@@ -7783,20 +9140,18 @@ export function App({ store, initialStatusLine = '' }) {
7783
9140
  * OVERSCAN: TRANSCRIPT_WINDOW_OVERSCAN_ROWS extra rows above the viewport so
7784
9141
  * fast wheel scrolls don't show a blank gap before re-render.
7785
9142
  */}
7786
- {transcriptVisibleItems.map((item, i, arr) => {
7787
- const showRightMessage = attachInputHintToTurnDone
7788
- && item.id === lastTranscriptItem?.id
7789
- && (item.kind === 'turndone' || item.kind === 'statusdone');
9143
+ {renderedTranscriptItems.map((item, i, arr) => {
7790
9144
  const measureRef = transcriptMeasureRef(item);
9145
+ const attachOverlayHint = overlayHintOnLastItem && i === overlayHintAttachItemIndex;
7791
9146
  const itemNode = (
7792
9147
  <Item
7793
9148
  item={item}
7794
9149
  prevKind={i > 0 ? arr[i - 1].kind : state.items[transcriptWindow.startIndex - 1]?.kind ?? null}
7795
9150
  columns={frameColumns}
7796
9151
  toolOutputExpanded={toolOutputExpanded}
7797
- rightMessage={showRightMessage ? inputHint : ''}
7798
- rightTone={inputHintTone}
7799
- rightMessageWidth={transientStatusWidth || 24}
9152
+ rightMessage={attachOverlayHint ? inputHint : ''}
9153
+ rightTone={attachOverlayHint ? inputHintTone : 'info'}
9154
+ rightMessageWidth={attachOverlayHint ? (guardHintWidth || transientStatusWidth || 24) : 24}
7800
9155
  themeEpoch={state.themeEpoch || 0}
7801
9156
  />
7802
9157
  );
@@ -7817,6 +9172,22 @@ export function App({ store, initialStatusLine = '' }) {
7817
9172
  <Box height={transcriptWindow.bottomSpacerRows} flexShrink={0} />
7818
9173
  ) : null}
7819
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}
7820
9191
  </Box>
7821
9192
 
7822
9193
  {/* Live reasoning and transient status live just above the prompt: reasoning
@@ -7826,9 +9197,9 @@ export function App({ store, initialStatusLine = '' }) {
7826
9197
  panels use their actual rendered height and shrink before the prompt
7827
9198
  can move; overflow is clipped from the top while the panel remains
7828
9199
  bottom-aligned against the prompt. */}
7829
- <Box flexDirection="column" flexShrink={0} width="100%" backgroundColor={theme.background}>
9200
+ <Box flexDirection="column" flexShrink={0} width="100%" backgroundColor={surfaceBackground()}>
7830
9201
  {floatingPanelRows > 0 ? (
7831
- <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()}>
7832
9203
  {toolApproval ? (
7833
9204
  <Picker
7834
9205
  items={[
@@ -7906,6 +9277,7 @@ export function App({ store, initialStatusLine = '' }) {
7906
9277
  visibleCount={pickerVisibleRows}
7907
9278
  fillHeight={expandedOptionPanel}
7908
9279
  themeEpoch={state.themeEpoch || 0}
9280
+ confirmBar={picker.confirmBar}
7909
9281
  />
7910
9282
  ) : contextPanel ? (
7911
9283
  <ContextPanel
@@ -7973,7 +9345,7 @@ export function App({ store, initialStatusLine = '' }) {
7973
9345
  <TextEntryPanel
7974
9346
  title={channelPrompt.label}
7975
9347
  hint={channelPrompt.hint || 'Save channel setting.'}
7976
- mask={channelPrompt.kind === 'discord-token' || channelPrompt.kind === 'webhook-token'}
9348
+ mask={channelPrompt.kind === 'discord-token' || channelPrompt.kind === 'telegram-token' || channelPrompt.kind === 'webhook-token'}
7977
9349
  columns={frameColumns}
7978
9350
  promptLabel="Value > "
7979
9351
  onSubmit={onSubmit}
@@ -8021,56 +9393,47 @@ export function App({ store, initialStatusLine = '' }) {
8021
9393
  {!inputBoxHidden ? (
8022
9394
  <>
8023
9395
  {promptMetaVisible ? (
8024
- <Box
8025
- marginTop={floatingPanelRows > 0 ? 0 : 1}
8026
- height={1}
8027
- width="100%"
8028
- flexDirection="row"
8029
- backgroundColor={theme.background}
8030
- >
8031
- <Box flexGrow={1} flexShrink={1} overflow="hidden">
8032
- {liveSpinner ? (
8033
- <Spinner
8034
- verb={liveSpinner.verb}
8035
- startedAt={liveSpinner.startedAt}
8036
- outputTokens={liveSpinner?.outputTokens ?? liveSpinner?.tokens ?? 0}
8037
- thinking={!!(state.thinking || liveSpinner?.thinking)}
8038
- thinkingActiveSince={liveSpinner?.thinkingSegmentStartedAt ?? 0}
8039
- mode={liveSpinner?.mode || 'responding'}
8040
- columns={promptSpinnerColumns}
8041
- marginTop={0}
8042
- />
8043
- ) : null}
8044
- </Box>
8045
- {inputHint ? (
8046
- <Box flexShrink={0} width={transientStatusWidth || 1} marginLeft={1} marginRight={1} justifyContent="flex-end" overflow="hidden">
8047
- <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}
8048
9418
  </Box>
8049
- ) : null}
8050
- </Box>
8051
- ) : null}
8052
- {overlayHintVisible ? (
8053
- <Box
8054
- height={1}
8055
- width="100%"
8056
- flexDirection="row"
8057
- backgroundColor={theme.background}
8058
- >
8059
- <Box flexGrow={1} flexShrink={1} overflow="hidden" />
8060
- <Box flexShrink={0} width={transientStatusWidth || 1} marginLeft={1} marginRight={1} justifyContent="flex-end" overflow="hidden">
8061
- <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}
8062
9424
  </Box>
8063
- </Box>
9425
+ <Box height={1} width="100%" backgroundColor={surfaceBackground()} />
9426
+ </>
8064
9427
  ) : null}
8065
9428
  {queuedVisible ? (
8066
9429
  <QueuedCommands queued={state.queued} columns={frameColumns} />
8067
9430
  ) : null}
8068
9431
  <Box
8069
- marginTop={queuedVisible || overlayHintVisible ? 0 : (floatingPanelRows > 0 ? 0 : 1)}
9432
+ marginTop={0}
8070
9433
  width="100%"
8071
9434
  borderStyle="round"
8072
9435
  borderColor={theme.promptBorder}
8073
- backgroundColor={theme.background}
9436
+ backgroundColor={surfaceBackground()}
8074
9437
  paddingX={1}
8075
9438
  >
8076
9439
  {promptInputControl}
@@ -8098,6 +9461,7 @@ export function App({ store, initialStatusLine = '' }) {
8098
9461
  activeTools={activeTools}
8099
9462
  initialLine={initialStatusLine}
8100
9463
  workflow={state.workflow}
9464
+ remoteEnabled={state.remoteEnabled === true}
8101
9465
  themeEpoch={state.themeEpoch || 0}
8102
9466
  />
8103
9467
  </Box>