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