mixdog 0.9.1 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -1
- 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/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/rules-builder.cjs +32 -54
- package/src/mixdog-session-runtime.mjs +710 -318
- 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 -2
- 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 +332 -57
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +59 -32
- 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 +78 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +202 -98
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +183 -20
- 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 +15 -9
- 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/builtin/arg-guard.mjs +194 -24
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
- 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 -1224
- package/src/runtime/channels/lib/config.mjs +54 -2
- package/src/runtime/channels/lib/format.mjs +4 -2
- package/src/runtime/channels/lib/output-forwarder.mjs +80 -67
- package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
- package/src/runtime/channels/lib/scheduler.mjs +1 -1
- package/src/runtime/channels/lib/telegram-format.mjs +283 -0
- package/src/runtime/channels/lib/tool-format.mjs +1 -1
- package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
- package/src/runtime/channels/lib/webhook.mjs +59 -31
- package/src/runtime/channels/tool-defs.mjs +1 -1
- package/src/runtime/memory/index.mjs +184 -19
- 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/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.mjs +101 -4
- package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
- package/src/runtime/memory/lib/session-ingest.mjs +107 -0
- package/src/runtime/memory/lib/trace-store.mjs +69 -22
- package/src/runtime/memory/tool-defs.mjs +6 -3
- 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/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/usage-dashboard.mjs +1 -1
- package/src/tui/App.jsx +2096 -732
- 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 +83 -7
- 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 +170 -98
- package/src/tui/components/TurnDone.jsx +4 -4
- package/src/tui/components/UsagePanel.jsx +1 -1
- package/src/tui/components/tool-output-format.mjs +159 -21
- package/src/tui/components/tool-output-format.test.mjs +87 -0
- package/src/tui/display-width.mjs +69 -0
- package/src/tui/display-width.test.mjs +35 -0
- package/src/tui/dist/index.mjs +6965 -2391
- 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 +129 -76
- package/src/tui/markdown/format-token.test.mjs +61 -19
- 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 -657
- 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-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 +100 -12
- 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/output-styles/extreme-simple.md +0 -20
- package/src/rules/lead/04-workflow.md +0 -51
- 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) {
|
|
@@ -849,7 +933,7 @@ function toolExpandedRawTextForRows(item, rawRt) {
|
|
|
849
933
|
if (item?.aggregate) return rawRt;
|
|
850
934
|
const hasDisplayResult = toolHasDisplayResultForRows(item);
|
|
851
935
|
if (hasDisplayResult) return toolDisplayedResultTextForRows(item);
|
|
852
|
-
return rawRt;
|
|
936
|
+
return stripLeadingStatusMarkerFromTextForRows(rawRt || '');
|
|
853
937
|
}
|
|
854
938
|
|
|
855
939
|
function toolHeaderFailureOnlyForRows(item, normalizedName, hasDisplayResult) {
|
|
@@ -919,11 +1003,12 @@ function estimateTranscriptItemRows(item, columns, toolOutputExpanded) {
|
|
|
919
1003
|
case 'user':
|
|
920
1004
|
return 1 + estimateWrappedRows(item.text, columns, 4);
|
|
921
1005
|
case 'assistant':
|
|
922
|
-
// marginTop={1} (AssistantMessage <Box>) +
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
|
|
926
|
-
|
|
1006
|
+
// marginTop={1} (AssistantMessage <Box>) + rendered body height.
|
|
1007
|
+
// Streaming uses measureStreamingMarkdownRenderedRows (shared layout with
|
|
1008
|
+
// StreamingMarkdown); settled assistant uses measureMarkdownRenderedRows.
|
|
1009
|
+
return 1 + (item.streaming
|
|
1010
|
+
? measureStreamingMarkdownRenderedRows(item.text, columns, item.id)
|
|
1011
|
+
: measureMarkdownRenderedRows(item.text, columns));
|
|
927
1012
|
case 'tool': {
|
|
928
1013
|
const TOOL_MARGIN_TOP = 1;
|
|
929
1014
|
if (shouldSuppressFullyFailedToolItem(item)) return 0;
|
|
@@ -1270,7 +1355,7 @@ function measuredTranscriptRows(item, columns, toolOutputExpanded) {
|
|
|
1270
1355
|
const STREAMING_ROW_QUANTUM = 1;
|
|
1271
1356
|
|
|
1272
1357
|
function assistantTextForStreamingRowEstimate(text) {
|
|
1273
|
-
return
|
|
1358
|
+
return streamingLayoutText(text);
|
|
1274
1359
|
}
|
|
1275
1360
|
|
|
1276
1361
|
function streamingEstimateRows(item, columns, toolOutputExpanded) {
|
|
@@ -1465,9 +1550,13 @@ function transcriptRenderWindow(items, { scrollOffset = 0, viewportHeight = 24,
|
|
|
1465
1550
|
};
|
|
1466
1551
|
}
|
|
1467
1552
|
|
|
1468
|
-
export function App({ store, initialStatusLine = '' }) {
|
|
1553
|
+
export function App({ store, initialStatusLine = '', forceOnboarding = false }) {
|
|
1469
1554
|
const state = useEngine(store);
|
|
1470
1555
|
const [toolOutputExpanded, setToolOutputExpanded] = useState(false);
|
|
1556
|
+
// True for the entire first-run onboarding wizard (every step + nested depth)
|
|
1557
|
+
// so the welcome banner stays reserved and the layout doesn't jump when the
|
|
1558
|
+
// step pickers mount. Cleared on finish/cancel.
|
|
1559
|
+
const [onboardingActive, setOnboardingActive] = useState(false);
|
|
1471
1560
|
const { exit } = useApp();
|
|
1472
1561
|
// internal_eventEmitter is ink's parsed-input bus. ink 7 consumes stdin via
|
|
1473
1562
|
// the 'readable' event + stdin.read() (see ink's App.js), draining the buffer
|
|
@@ -1484,6 +1573,13 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
1484
1573
|
const [tuiReady, setTuiReady] = useState(false);
|
|
1485
1574
|
const exitRequestedRef = useRef(false);
|
|
1486
1575
|
const [resizeState, setResizeState] = useState(() => ({ ...terminalSize(stdout), epoch: 0 }));
|
|
1576
|
+
// Windows Terminal/conhost scrolls the alt-screen (auto-wrap/DECAWM) when the
|
|
1577
|
+
// bottom-right cell is written. WT_SESSION is also set when the UI runs under
|
|
1578
|
+
// a Unix-ish shell hosted by Windows Terminal, where process.platform is not
|
|
1579
|
+
// necessarily win32 but the terminal behavior is still Windows-like.
|
|
1580
|
+
const windowsLikeTerminal = process.platform === 'win32' || Boolean(process.env.WT_SESSION);
|
|
1581
|
+
const rightSafetyColumns = windowsLikeTerminal ? 1 : 0;
|
|
1582
|
+
const frameColumns = Math.max(1, resizeState.columns - rightSafetyColumns);
|
|
1487
1583
|
// scrollOffset = how many transcript ROWS we've scrolled UP from the bottom
|
|
1488
1584
|
// (0 = pinned to the latest, showing the newest content). Mouse wheel adjusts
|
|
1489
1585
|
// it; accepted prompts only arm bottom-follow; the snap happens when the
|
|
@@ -1492,6 +1588,7 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
1492
1588
|
const scrollPositionRef = useRef(0);
|
|
1493
1589
|
const scrollTargetRef = useRef(0);
|
|
1494
1590
|
const maxScrollRowsRef = useRef(0);
|
|
1591
|
+
const transcriptBottomSlackRowsRef = useRef(0);
|
|
1495
1592
|
const scrollAnimationRef = useRef(null);
|
|
1496
1593
|
const transcriptTotalRowsRef = useRef(0);
|
|
1497
1594
|
const preservedScrollDeltaRef = useRef(0);
|
|
@@ -1667,6 +1764,8 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
1667
1764
|
const toolApproval = state.toolApproval || null;
|
|
1668
1765
|
const [promptDraft, setPromptDraft] = useState('');
|
|
1669
1766
|
const [promptDraftOverride, setPromptDraftOverride] = useState(null);
|
|
1767
|
+
const promptLayoutValueRef = useRef('');
|
|
1768
|
+
const [, setPromptLayoutRows] = useState(1);
|
|
1670
1769
|
const [, setPastedImages] = useState({});
|
|
1671
1770
|
const pastedImagesRef = useRef({});
|
|
1672
1771
|
const nextPastedImageIdRef = useRef(1);
|
|
@@ -1683,20 +1782,40 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
1683
1782
|
const promptHistoryDraftChangeRef = useRef(false);
|
|
1684
1783
|
const [promptHint, setPromptHint] = useState('');
|
|
1685
1784
|
const [promptHintTone, setPromptHintTone] = useState('info');
|
|
1785
|
+
const [welcomePromptHintDismissed, setWelcomePromptHintDismissed] = useState(false);
|
|
1786
|
+
const [conditionalWelcomePromptHint, setConditionalWelcomePromptHint] = useState('');
|
|
1787
|
+
const welcomePromptHintRef = useRef(null);
|
|
1788
|
+
if (welcomePromptHintRef.current === null) {
|
|
1789
|
+
welcomePromptHintRef.current = randomWelcomePromptHint();
|
|
1790
|
+
}
|
|
1791
|
+
const dismissWelcomePromptHint = useCallback(() => {
|
|
1792
|
+
setWelcomePromptHintDismissed((dismissed) => dismissed || true);
|
|
1793
|
+
}, []);
|
|
1794
|
+
const toastErrorSignature = useMemo(() => (
|
|
1795
|
+
(state.toasts || [])
|
|
1796
|
+
.filter((toast) => toast?.tone === 'error')
|
|
1797
|
+
.map((toast) => `${toast.id || ''}:${toast.text || ''}`)
|
|
1798
|
+
.join('|')
|
|
1799
|
+
), [state.toasts]);
|
|
1686
1800
|
const [slashIndex, setSlashIndex] = useState(0);
|
|
1687
1801
|
const [slashDismissedFor, setSlashDismissedFor] = useState('');
|
|
1688
1802
|
const [disabledSkills, setDisabledSkills] = useState(() => new Set());
|
|
1689
1803
|
const slashPaletteRef = useRef({ open: false, count: 0 });
|
|
1690
1804
|
const scrollFocusRef = useRef({});
|
|
1691
1805
|
const onboardingStartedRef = useRef(false);
|
|
1692
|
-
const onboardingRef = useRef({ defaultRoute: null,
|
|
1806
|
+
const onboardingRef = useRef({ defaultRoute: null, searchRoute: null, agentRoutes: {}, agents: [], providerModels: [] });
|
|
1693
1807
|
const providerModelsCacheRef = useRef({ models: null, at: 0 });
|
|
1694
1808
|
const searchModelsCacheRef = useRef({ models: null, at: 0 });
|
|
1695
1809
|
const modelPickerRequestRef = useRef(0);
|
|
1810
|
+
// Generation guard for the Step 1 background prefetch: bumped on every
|
|
1811
|
+
// provider-scope cache clear (e.g. after auth) so a stale in-flight
|
|
1812
|
+
// listProviderModels() cannot repopulate the ref after invalidation.
|
|
1813
|
+
const onboardingPrefetchSeqRef = useRef(0);
|
|
1696
1814
|
const clearModelCaches = useCallback((scope = 'all') => {
|
|
1697
1815
|
if (scope === 'all' || scope === 'provider') {
|
|
1698
1816
|
providerModelsCacheRef.current = { models: null, at: 0 };
|
|
1699
1817
|
onboardingRef.current.providerModels = [];
|
|
1818
|
+
onboardingPrefetchSeqRef.current += 1;
|
|
1700
1819
|
}
|
|
1701
1820
|
if (scope === 'all' || scope === 'search') {
|
|
1702
1821
|
searchModelsCacheRef.current = { models: null, at: 0 };
|
|
@@ -1710,7 +1829,11 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
1710
1829
|
// region: which surface the in-progress (or last) selection belongs to —
|
|
1711
1830
|
// 'transcript' | 'status' (both ink-grid) | 'prompt' (PromptInput's own engine)
|
|
1712
1831
|
// | null. Press decides it; motion/release stay in that region.
|
|
1713
|
-
|
|
1832
|
+
// anchorSpan: for word/line multi-click selections, the initial word/line
|
|
1833
|
+
// bounds ({ lo:{x,y}, hi:{x,y}, kind:'word'|'line' }) so a subsequent drag
|
|
1834
|
+
// extends the selection whole-word/whole-line from that span (see selection.ts
|
|
1835
|
+
// extendSelection). Null ⇔ ordinary char-drag selection.
|
|
1836
|
+
const dragRef = useRef({ anchor: null, anchorScroll: 0, last: null, active: false, rect: null, region: null, anchorSpan: null });
|
|
1714
1837
|
const selectionPaintRef = useRef({ t: 0, rect: null, pending: null, timer: null });
|
|
1715
1838
|
const transcriptViewportRef = useRef({ top: 0, bottom: 0 });
|
|
1716
1839
|
// [mixdog] Latest terminal row count + the statusline band (bottom rows),
|
|
@@ -1719,12 +1842,85 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
1719
1842
|
// region. STATUSLINE_ROWS mirrors the layout reserve below.
|
|
1720
1843
|
const frameRowsRef = useRef(24);
|
|
1721
1844
|
const STATUSLINE_BAND_ROWS = 3;
|
|
1845
|
+
const promptContentColumns = Math.max(1, frameColumns - 4);
|
|
1846
|
+
const syncPromptLayoutRows = useCallback((value) => {
|
|
1847
|
+
const text = String(value ?? '');
|
|
1848
|
+
promptLayoutValueRef.current = text;
|
|
1849
|
+
const nextRows = promptContentRows(text, promptContentColumns);
|
|
1850
|
+
setPromptLayoutRows((prev) => (prev === nextRows ? prev : nextRows));
|
|
1851
|
+
}, [promptContentColumns]);
|
|
1852
|
+
useEffect(() => {
|
|
1853
|
+
syncPromptLayoutRows(promptLayoutValueRef.current);
|
|
1854
|
+
}, [syncPromptLayoutRows]);
|
|
1855
|
+
useEffect(() => {
|
|
1856
|
+
let alive = true;
|
|
1857
|
+
const refreshConditionalWelcomeHint = async () => {
|
|
1858
|
+
let next = '';
|
|
1859
|
+
try {
|
|
1860
|
+
const setup = await store.getProviderSetup?.();
|
|
1861
|
+
if (setup && !providerSetupHasUsableProvider(setup)) {
|
|
1862
|
+
next = CONDITIONAL_WELCOME_PROMPT_HINTS.noProvider;
|
|
1863
|
+
}
|
|
1864
|
+
} catch {
|
|
1865
|
+
// If provider setup probing fails, let the generic/error tip path decide.
|
|
1866
|
+
}
|
|
1867
|
+
if (!next) {
|
|
1868
|
+
const activeProvider = String(state.provider || '').trim();
|
|
1869
|
+
const activeModel = String(state.model || '').trim();
|
|
1870
|
+
if (!activeProvider || !activeModel) {
|
|
1871
|
+
next = CONDITIONAL_WELCOME_PROMPT_HINTS.noModel;
|
|
1872
|
+
} else {
|
|
1873
|
+
try {
|
|
1874
|
+
const models = await Promise.resolve(store.listProviderModels?.({ quick: true }) || []);
|
|
1875
|
+
if (Array.isArray(models) && models.length === 0) {
|
|
1876
|
+
next = CONDITIONAL_WELCOME_PROMPT_HINTS.noModel;
|
|
1877
|
+
}
|
|
1878
|
+
} catch {
|
|
1879
|
+
// Model probing is advisory only; avoid replacing the random hint on failure.
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
const activeWorkflow = activeWorkflowSummaryForStore(store, state.workflow || {});
|
|
1884
|
+
if (!next && String(activeWorkflow?.id || state.workflow?.id || '').toLowerCase() === 'solo') {
|
|
1885
|
+
next = CONDITIONAL_WELCOME_PROMPT_HINTS.soloWorkflow;
|
|
1886
|
+
}
|
|
1887
|
+
if (!next) {
|
|
1888
|
+
const searchRoute = store.getSearchRoute?.() || null;
|
|
1889
|
+
const searchProvider = String(searchRoute?.provider || '').trim();
|
|
1890
|
+
const searchModel = String(searchRoute?.model || '').trim();
|
|
1891
|
+
const defaultSearchRoute = searchProvider.toLowerCase() === 'default' && searchModel.toLowerCase() === 'default';
|
|
1892
|
+
if (defaultSearchRoute) {
|
|
1893
|
+
try {
|
|
1894
|
+
const models = await Promise.resolve(store.listProviderModels?.({ quick: true }) || []);
|
|
1895
|
+
const current = Array.isArray(models)
|
|
1896
|
+
? models.find((model) => model?.provider === state.provider && model?.id === state.model)
|
|
1897
|
+
: null;
|
|
1898
|
+
if (current && current.supportsWebSearch !== true) {
|
|
1899
|
+
next = CONDITIONAL_WELCOME_PROMPT_HINTS.searchDefaultUnsupported;
|
|
1900
|
+
}
|
|
1901
|
+
} catch {
|
|
1902
|
+
// Search default probing is advisory only.
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
if (!next && toastErrorSignature) {
|
|
1907
|
+
next = CONDITIONAL_WELCOME_PROMPT_HINTS.error;
|
|
1908
|
+
}
|
|
1909
|
+
if (alive) setConditionalWelcomePromptHint((prev) => (prev === next ? prev : next));
|
|
1910
|
+
};
|
|
1911
|
+
void refreshConditionalWelcomeHint();
|
|
1912
|
+
return () => { alive = false; };
|
|
1913
|
+
}, [store, state.provider, state.model, state.workflow?.id, toastErrorSignature]);
|
|
1722
1914
|
const selectionLayoutRef = useRef(null);
|
|
1723
1915
|
const selectionTextRef = useRef('');
|
|
1724
1916
|
const selectionTextTimerRef = useRef(null);
|
|
1725
1917
|
// lastClickRef tracks the previous left-press cell + time so the mouse handler
|
|
1726
|
-
// can detect a double-click (same cell within
|
|
1727
|
-
|
|
1918
|
+
// can detect a double-click (same cell within 500ms) for word selection.
|
|
1919
|
+
// count = consecutive qualifying presses on the same cell (1=single,
|
|
1920
|
+
// 2=double/word, 3=triple/line). A 4th qualifying press restarts the
|
|
1921
|
+
// sequence at 1 (claude-code cycles; simplest reset). Any non-qualifying
|
|
1922
|
+
// press resets to a fresh single.
|
|
1923
|
+
const lastClickRef = useRef({ x: -1, y: -1, t: 0, count: 0 });
|
|
1728
1924
|
|
|
1729
1925
|
const showSelectionCopyHint = useCallback((text, tone = 'plain') => {
|
|
1730
1926
|
if (promptHintTimerRef.current) clearTimeout(promptHintTimerRef.current);
|
|
@@ -1821,6 +2017,90 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
1821
2017
|
}, 2200);
|
|
1822
2018
|
}, []);
|
|
1823
2019
|
|
|
2020
|
+
// Voice recorder status hint — reuses the SAME promptHint state as
|
|
2021
|
+
// showPromptHint/clearPromptHint, but bypasses their 2200ms auto-clear
|
|
2022
|
+
// timer: '● REC' / '… transcribing' must stay visible for the ENTIRE
|
|
2023
|
+
// recording/transcribing duration (which can run well past 2.2s), not
|
|
2024
|
+
// vanish on a fixed timer. Also cancels any pending showPromptHint timer
|
|
2025
|
+
// so a stale auto-clear can never stomp the live recording status.
|
|
2026
|
+
const setVoiceStatusHint = useCallback((text, tone = 'info') => {
|
|
2027
|
+
if (promptHintTimerRef.current) {
|
|
2028
|
+
clearTimeout(promptHintTimerRef.current);
|
|
2029
|
+
promptHintTimerRef.current = null;
|
|
2030
|
+
}
|
|
2031
|
+
promptHintActiveRef.current = !!text;
|
|
2032
|
+
setPromptHint(String(text || ''));
|
|
2033
|
+
setPromptHintTone(tone);
|
|
2034
|
+
}, []);
|
|
2035
|
+
|
|
2036
|
+
// Ctrl+Space handler wired to PromptInput's onVoiceToggle. Dispatches on the
|
|
2037
|
+
// recorder's CURRENT state (idle/recording/transcribing) — see
|
|
2038
|
+
// src/tui/lib/voice-recorder.mjs for the state machine itself.
|
|
2039
|
+
const handleVoiceToggle = useCallback(async () => {
|
|
2040
|
+
if (!isVoiceEnabled()) {
|
|
2041
|
+
store.pushNotice('Voice is off — run /voice to turn it on', 'warn');
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
const recState = getRecorderState();
|
|
2045
|
+
if (recState === 'transcribing') {
|
|
2046
|
+
store.pushNotice('Voice: already transcribing…', 'info');
|
|
2047
|
+
return;
|
|
2048
|
+
}
|
|
2049
|
+
if (recState === 'recording') {
|
|
2050
|
+
setVoiceStatusHint('… transcribing', 'info');
|
|
2051
|
+
let result;
|
|
2052
|
+
try {
|
|
2053
|
+
result = await stopRecording();
|
|
2054
|
+
} catch (e) {
|
|
2055
|
+
result = { ok: false, reason: e?.message || String(e) };
|
|
2056
|
+
}
|
|
2057
|
+
setVoiceStatusHint('', 'info');
|
|
2058
|
+
if (!result) return; // race: recorder was no longer RECORDING
|
|
2059
|
+
if (!result.ok) {
|
|
2060
|
+
store.pushNotice(`Voice: ${result.reason || 'transcription failed'}`, 'error');
|
|
2061
|
+
return;
|
|
2062
|
+
}
|
|
2063
|
+
const text = String(result.text || '').trim();
|
|
2064
|
+
if (!text) {
|
|
2065
|
+
store.pushNotice('Voice: no speech detected', 'warn');
|
|
2066
|
+
return;
|
|
2067
|
+
}
|
|
2068
|
+
// End-of-draft insertion via promptDraftOverride (approved design —
|
|
2069
|
+
// no cursor-position insert; see gamerscroll skill's out-of-scope note
|
|
2070
|
+
// on PromptInput's imperative surface).
|
|
2071
|
+
// Med-4: promptValueRef.current is read HERE — after `await
|
|
2072
|
+
// stopRecording()` has already resolved — not captured earlier before
|
|
2073
|
+
// the await. PromptInput keeps valueRef.current live on every
|
|
2074
|
+
// keystroke (commitDraft), so this always reflects whatever the user
|
|
2075
|
+
// typed during the recording+transcribe window; nothing typed in that
|
|
2076
|
+
// gap is overwritten.
|
|
2077
|
+
const current = promptValueRef.current || '';
|
|
2078
|
+
const next = current ? `${current}${/\s$/.test(current) ? '' : ' '}${text}` : text;
|
|
2079
|
+
syncPromptLayoutRows(next);
|
|
2080
|
+
setPromptDraftOverride({ id: Date.now(), value: next });
|
|
2081
|
+
return;
|
|
2082
|
+
}
|
|
2083
|
+
// idle -> recording
|
|
2084
|
+
let result;
|
|
2085
|
+
try {
|
|
2086
|
+
result = await startRecording();
|
|
2087
|
+
} catch (e) {
|
|
2088
|
+
result = { ok: false, reason: e?.message || String(e) };
|
|
2089
|
+
}
|
|
2090
|
+
if (!result?.ok) {
|
|
2091
|
+
store.pushNotice(`Voice: ${result?.reason || 'failed to start recording'}`, 'error');
|
|
2092
|
+
return;
|
|
2093
|
+
}
|
|
2094
|
+
setVoiceStatusHint('● REC', 'error');
|
|
2095
|
+
}, [store, setVoiceStatusHint, syncPromptLayoutRows]);
|
|
2096
|
+
|
|
2097
|
+
// Best-effort recorder teardown on unmount (component-level cleanup;
|
|
2098
|
+
// index.jsx's runTui() also disposes via store.dispose on exit/signal, but
|
|
2099
|
+
// that path doesn't know about the TUI-local recorder singleton).
|
|
2100
|
+
useEffect(() => () => {
|
|
2101
|
+
disposeRecorder();
|
|
2102
|
+
}, []);
|
|
2103
|
+
|
|
1824
2104
|
const installPastedImages = useCallback((images, { merge = true } = {}) => {
|
|
1825
2105
|
if (!images || typeof images !== 'object' || Object.keys(images).length === 0) return;
|
|
1826
2106
|
const next = merge ? { ...pastedImagesRef.current, ...images } : { ...images };
|
|
@@ -1944,6 +2224,8 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
1944
2224
|
// long transcript jump to the bottom, then jump again when the new row is
|
|
1945
2225
|
// appended. Keep the current viewport stable and let the row-delta effect
|
|
1946
2226
|
// perform the single bottom-follow when the transcript actually grows.
|
|
2227
|
+
transcriptAnchorRef.current = null;
|
|
2228
|
+
transcriptAnchorDirtyRef.current = false;
|
|
1947
2229
|
followingRef.current = true;
|
|
1948
2230
|
stopSmoothScroll();
|
|
1949
2231
|
}, [stopSmoothScroll]);
|
|
@@ -1980,6 +2262,8 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
1980
2262
|
...rect,
|
|
1981
2263
|
clipY1: clip.y1,
|
|
1982
2264
|
clipY2: Math.max(clip.y1, clip.y2),
|
|
2265
|
+
selectionForeground: theme.selectionHighlightText || theme.selectionText,
|
|
2266
|
+
selectionBackground: theme.selectionHighlightBackground || theme.selectionBackground,
|
|
1983
2267
|
};
|
|
1984
2268
|
if (options.captureText === false) clipped.captureText = false;
|
|
1985
2269
|
return clipped;
|
|
@@ -2055,6 +2339,124 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
2055
2339
|
};
|
|
2056
2340
|
}, []);
|
|
2057
2341
|
|
|
2342
|
+
// Port of selection.ts extendSelection onto the linear-rect model, hoisted to
|
|
2343
|
+
// component scope so BOTH the mouse handler (motion/release) AND the
|
|
2344
|
+
// auto-scroll path (scrollTranscriptRows) can rebuild a span-aware rect. Grows
|
|
2345
|
+
// a word/line multi-click selection from its anchor span to the word/line under
|
|
2346
|
+
// the cursor: target ends before the span → extend backward (span.hi→targetLo);
|
|
2347
|
+
// target starts after → extend forward (span.lo→targetHi); overlapping → the
|
|
2348
|
+
// span. The moving end snaps to the word (getWordRectAt) or line (getLineRectAt)
|
|
2349
|
+
// at the cursor; a miss (blank/gutter) falls back to the raw cell. spanScroll
|
|
2350
|
+
// re-anchors the span to the current transcript scroll (status never scrolls) so
|
|
2351
|
+
// the original word/line tracks the content while dragging/auto-scrolling.
|
|
2352
|
+
const buildSpanRect = useCallback((span, x, y, region, spanScroll = 0) => {
|
|
2353
|
+
const conv = (pt) => (region === 'status' ? pt : selectionPointAtCurrentScroll(pt, spanScroll));
|
|
2354
|
+
const spanLo = conv(span.lo);
|
|
2355
|
+
const spanHi = conv(span.hi);
|
|
2356
|
+
let mLo;
|
|
2357
|
+
let mHi;
|
|
2358
|
+
if (span.kind === 'word') {
|
|
2359
|
+
const wr = store.getWordRectAt?.(x, y);
|
|
2360
|
+
if (wr) { mLo = { x: wr.x1, y: wr.y1 }; mHi = { x: wr.x2, y: wr.y2 }; }
|
|
2361
|
+
else { mLo = { x, y }; mHi = { x, y }; }
|
|
2362
|
+
} else {
|
|
2363
|
+
const lr = store.getLineRectAt?.(y);
|
|
2364
|
+
if (lr) { mLo = { x: lr.x1, y: lr.y1 }; mHi = { x: lr.x2, y: lr.y2 }; }
|
|
2365
|
+
else { mLo = { x: 0, y }; mHi = { x, y }; }
|
|
2366
|
+
}
|
|
2367
|
+
const rect = (a, b) => ({ mode: 'linear', x1: a.x, y1: a.y, x2: b.x, y2: b.y });
|
|
2368
|
+
if (comparePoints(mHi, spanLo) < 0) return rect(spanHi, mLo);
|
|
2369
|
+
if (comparePoints(mLo, spanHi) > 0) return rect(spanLo, mHi);
|
|
2370
|
+
return rect(spanLo, spanHi);
|
|
2371
|
+
}, [store, selectionPointAtCurrentScroll]);
|
|
2372
|
+
|
|
2373
|
+
const transcriptViewportRows = useCallback(() => {
|
|
2374
|
+
const top = Math.max(0, Number(transcriptViewportRef.current?.top) || 0);
|
|
2375
|
+
const bottom = Math.max(top, Number(transcriptViewportRef.current?.bottom) || top);
|
|
2376
|
+
return { top, bottom };
|
|
2377
|
+
}, []);
|
|
2378
|
+
|
|
2379
|
+
const statusBandRows = useCallback(() => {
|
|
2380
|
+
const rows = Math.max(1, Number(frameRowsRef.current) || 24);
|
|
2381
|
+
const top = Math.max(0, rows - STATUSLINE_BAND_ROWS);
|
|
2382
|
+
return { top, bottom: Math.max(top, rows - 1) };
|
|
2383
|
+
}, []);
|
|
2384
|
+
|
|
2385
|
+
const selectionMaxColAtRow = useCallback((row) => {
|
|
2386
|
+
const lr = store.getLineRectAt?.(row);
|
|
2387
|
+
if (lr != null && Number.isFinite(lr.x2)) return Math.max(0, lr.x2);
|
|
2388
|
+
return Math.max(0, frameColumns - 1);
|
|
2389
|
+
}, [store, frameColumns]);
|
|
2390
|
+
|
|
2391
|
+
const moveSelectionFocus = useCallback((move) => {
|
|
2392
|
+
const drag = dragRef.current;
|
|
2393
|
+
if (drag.active) return false;
|
|
2394
|
+
const region = drag.region;
|
|
2395
|
+
if (region !== 'transcript' && region !== 'status') return false;
|
|
2396
|
+
const rect = drag.rect;
|
|
2397
|
+
if (!rect) return false;
|
|
2398
|
+
if (rect.x1 === rect.x2 && rect.y1 === rect.y2) return false;
|
|
2399
|
+
|
|
2400
|
+
const anchor = { x: rect.x1, y: rect.y1 };
|
|
2401
|
+
let col = rect.x2;
|
|
2402
|
+
let row = rect.y2;
|
|
2403
|
+
const beforeCol = col;
|
|
2404
|
+
const beforeRow = row;
|
|
2405
|
+
|
|
2406
|
+
const { top, bottom } = region === 'status' ? statusBandRows() : transcriptViewportRows();
|
|
2407
|
+
|
|
2408
|
+
switch (move) {
|
|
2409
|
+
case 'left':
|
|
2410
|
+
if (col > 0) col -= 1;
|
|
2411
|
+
else if (row > top) {
|
|
2412
|
+
row -= 1;
|
|
2413
|
+
col = selectionMaxColAtRow(row);
|
|
2414
|
+
}
|
|
2415
|
+
break;
|
|
2416
|
+
case 'right': {
|
|
2417
|
+
const maxCol = selectionMaxColAtRow(row);
|
|
2418
|
+
if (col < maxCol) col += 1;
|
|
2419
|
+
else if (row < bottom) {
|
|
2420
|
+
row += 1;
|
|
2421
|
+
col = 0;
|
|
2422
|
+
}
|
|
2423
|
+
break;
|
|
2424
|
+
}
|
|
2425
|
+
case 'up':
|
|
2426
|
+
if (row > top) row -= 1;
|
|
2427
|
+
break;
|
|
2428
|
+
case 'down':
|
|
2429
|
+
if (row < bottom) row += 1;
|
|
2430
|
+
break;
|
|
2431
|
+
case 'lineStart':
|
|
2432
|
+
col = 0;
|
|
2433
|
+
break;
|
|
2434
|
+
case 'lineEnd':
|
|
2435
|
+
col = selectionMaxColAtRow(row);
|
|
2436
|
+
break;
|
|
2437
|
+
default:
|
|
2438
|
+
return false;
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
row = Math.max(top, Math.min(bottom, row));
|
|
2442
|
+
col = Math.max(0, Math.min(selectionMaxColAtRow(row), col));
|
|
2443
|
+
|
|
2444
|
+
if (col === beforeCol && row === beforeRow) return false;
|
|
2445
|
+
|
|
2446
|
+
if (drag.anchorSpan) drag.anchorSpan = null;
|
|
2447
|
+
|
|
2448
|
+
const focus = { x: col, y: row };
|
|
2449
|
+
applySelectionRect({
|
|
2450
|
+
mode: 'linear',
|
|
2451
|
+
x1: anchor.x,
|
|
2452
|
+
y1: anchor.y,
|
|
2453
|
+
x2: focus.x,
|
|
2454
|
+
y2: focus.y,
|
|
2455
|
+
});
|
|
2456
|
+
drag.last = { x: focus.x, y: focus.y };
|
|
2457
|
+
return true;
|
|
2458
|
+
}, [applySelectionRect, statusBandRows, transcriptViewportRows, selectionMaxColAtRow]);
|
|
2459
|
+
|
|
2058
2460
|
useEffect(() => () => {
|
|
2059
2461
|
const paintState = selectionPaintRef.current;
|
|
2060
2462
|
if (paintState.timer) clearTimeout(paintState.timer);
|
|
@@ -2078,7 +2480,7 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
2078
2480
|
// streaming growth could lurch the view. At/over the bottom, drop the anchor
|
|
2079
2481
|
// so the bottom-follow path owns the viewport again.
|
|
2080
2482
|
if (appliedDelta !== 0) {
|
|
2081
|
-
if (target <= 0) {
|
|
2483
|
+
if (target <= Math.max(0, Number(transcriptBottomSlackRowsRef.current) || 0)) {
|
|
2082
2484
|
transcriptAnchorRef.current = null;
|
|
2083
2485
|
transcriptAnchorDirtyRef.current = false;
|
|
2084
2486
|
} else {
|
|
@@ -2110,9 +2512,16 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
2110
2512
|
if (appliedDelta !== 0 && dragRef.current.rect) {
|
|
2111
2513
|
let rect;
|
|
2112
2514
|
if (dragRef.current.active) {
|
|
2113
|
-
const { anchor, anchorScroll, last } = dragRef.current;
|
|
2114
|
-
|
|
2115
|
-
|
|
2515
|
+
const { anchor, anchorScroll, last, anchorSpan, region } = dragRef.current;
|
|
2516
|
+
if (anchorSpan && last) {
|
|
2517
|
+
// Word/line multi-click drag that reached the edge: keep extending by
|
|
2518
|
+
// whole words/lines from the span to the word/line at the current cell,
|
|
2519
|
+
// NOT collapsing to a char {anchor->last} rect. Mirrors the motion path.
|
|
2520
|
+
rect = buildSpanRect(anchorSpan, last.x, last.y, region, anchorScroll);
|
|
2521
|
+
} else {
|
|
2522
|
+
const currentAnchor = selectionPointAtCurrentScroll(anchor, anchorScroll);
|
|
2523
|
+
rect = currentAnchor && last ? { mode: 'linear', x1: currentAnchor.x, y1: currentAnchor.y, x2: last.x, y2: last.y } : null;
|
|
2524
|
+
}
|
|
2116
2525
|
} else {
|
|
2117
2526
|
rect = shiftSelectionRectY(dragRef.current.rect, appliedDelta);
|
|
2118
2527
|
}
|
|
@@ -2127,7 +2536,7 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
2127
2536
|
stopSmoothScroll();
|
|
2128
2537
|
scrollPositionRef.current = target;
|
|
2129
2538
|
setScrollOffset(Math.round(target));
|
|
2130
|
-
}, [startSmoothScroll, stopSmoothScroll, paintSelectionRect, selectionPointAtCurrentScroll, withSelectionClip, cancelTranscriptFollow]);
|
|
2539
|
+
}, [startSmoothScroll, stopSmoothScroll, paintSelectionRect, selectionPointAtCurrentScroll, withSelectionClip, cancelTranscriptFollow, buildSpanRect]);
|
|
2131
2540
|
|
|
2132
2541
|
const passthroughCtrlWheelZoom = useCallback(() => {
|
|
2133
2542
|
if (!stdout?.write) return;
|
|
@@ -2184,6 +2593,8 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
2184
2593
|
y2: b.y,
|
|
2185
2594
|
};
|
|
2186
2595
|
};
|
|
2596
|
+
// Word/line multi-click drag-extension uses the hoisted buildSpanRect (same
|
|
2597
|
+
// logic reachable from the auto-scroll path in scrollTranscriptRows).
|
|
2187
2598
|
const transcriptViewport = () => {
|
|
2188
2599
|
const top = Math.max(0, Number(transcriptViewportRef.current?.top) || 0);
|
|
2189
2600
|
const bottom = Math.max(top, Number(transcriptViewportRef.current?.bottom) || top);
|
|
@@ -2244,6 +2655,7 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
2244
2655
|
// arrives whole. Guard so non-mouse keystrokes fall through untouched.
|
|
2245
2656
|
const s = typeof data === 'string' ? data : String(data ?? '');
|
|
2246
2657
|
if (s.indexOf('\x1b[<') === -1) return;
|
|
2658
|
+
dismissWelcomePromptHint();
|
|
2247
2659
|
let up = 0;
|
|
2248
2660
|
let down = 0;
|
|
2249
2661
|
let m;
|
|
@@ -2277,7 +2689,7 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
2277
2689
|
applySelectionRect(null);
|
|
2278
2690
|
const offset = promptOffsetAt(x, y);
|
|
2279
2691
|
stopSmoothScroll();
|
|
2280
|
-
dragRef.current = { anchor: { x, y }, anchorScroll: 0, last: { x, y }, active: true, rect: null, region: 'prompt' };
|
|
2692
|
+
dragRef.current = { anchor: { x, y }, anchorScroll: 0, last: { x, y }, active: true, rect: null, region: 'prompt', anchorSpan: null };
|
|
2281
2693
|
if (offset != null) promptMouseSelectionRef.current?.anchorAt?.(offset);
|
|
2282
2694
|
continue;
|
|
2283
2695
|
}
|
|
@@ -2287,45 +2699,65 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
2287
2699
|
lastClickRef.current = { x: -1, y: -1, t: 0 };
|
|
2288
2700
|
dragRef.current.active = false;
|
|
2289
2701
|
dragRef.current.region = null;
|
|
2702
|
+
dragRef.current.anchorSpan = null;
|
|
2290
2703
|
clearAllSelections();
|
|
2291
2704
|
continue;
|
|
2292
2705
|
}
|
|
2293
2706
|
const region = inTranscript ? 'transcript' : 'status';
|
|
2294
2707
|
// A press always clears the prompt-box selection (single active region).
|
|
2295
2708
|
promptMouseSelectionRef.current?.clear?.();
|
|
2296
|
-
//
|
|
2297
|
-
//
|
|
2298
|
-
//
|
|
2709
|
+
// Multi-click sequence: 2nd consecutive press = word (double-click),
|
|
2710
|
+
// 3rd = whole line (triple-click). Each press must land near the prior
|
|
2711
|
+
// one within 500ms — up to 2 columns and 1 row of drift (terminals
|
|
2712
|
+
// often report a shifted cell on repeat clicks); tighter matching made
|
|
2713
|
+
// word selection unreliable. A 4th qualifying press restarts the
|
|
2714
|
+
// sequence at 1 (claude-code cycles; simplest: reset). Works for
|
|
2715
|
+
// transcript AND status rows since getWordRectAt/getLineRectAt are
|
|
2716
|
+
// grid-based. Copy still happens on Ctrl+C, never here.
|
|
2299
2717
|
const now = Date.now();
|
|
2300
2718
|
const lc = lastClickRef.current;
|
|
2301
|
-
|
|
2302
|
-
// press within 500ms: up to 2 columns and 1 row of drift (terminals
|
|
2303
|
-
// often report a shifted cell on the second click). Tighter matching
|
|
2304
|
-
// made double-click word selection unreliable.
|
|
2305
|
-
const isDouble = (now - lc.t) < 500
|
|
2719
|
+
const qualifies = (now - lc.t) < 500
|
|
2306
2720
|
&& Math.abs(lc.y - y) <= 1
|
|
2307
2721
|
&& Math.abs(lc.x - x) <= 2;
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2722
|
+
let clickCount = qualifies ? (lc.count || 1) + 1 : 1;
|
|
2723
|
+
if (clickCount > 3) clickCount = 1;
|
|
2724
|
+
if (clickCount === 2 || clickCount === 3) {
|
|
2725
|
+
// Word (2) or line (3) select. Snap to the word/line under the cell
|
|
2726
|
+
// and record the span on dragRef so a following drag extends by whole
|
|
2727
|
+
// words/lines from this span (see buildSpanRect). Leave the drag
|
|
2728
|
+
// ARMED (active:true): a release without motion keeps this highlight
|
|
2729
|
+
// (buildSpanRect returns the span for an in-span target), while
|
|
2730
|
+
// any motion extends it. Mirrors selectWordAt/selectLineAt setting
|
|
2731
|
+
// isDragging=true + anchorSpan; the mouse-up finalizes.
|
|
2732
|
+
const kind = clickCount === 2 ? 'word' : 'line';
|
|
2733
|
+
const wr = kind === 'word' ? store.getWordRectAt?.(x, y) : store.getLineRectAt?.(y);
|
|
2313
2734
|
if (wr) {
|
|
2314
|
-
const
|
|
2735
|
+
const lo = { x: wr.x1, y: wr.y1 };
|
|
2736
|
+
const hi = { x: wr.x2, y: wr.y2 };
|
|
2737
|
+
const rect = linearSelection(lo, hi);
|
|
2315
2738
|
stopSmoothScroll();
|
|
2316
|
-
dragRef.current = {
|
|
2739
|
+
dragRef.current = {
|
|
2740
|
+
anchor: { x, y },
|
|
2741
|
+
anchorScroll: region === 'transcript' ? scrollTargetRef.current : 0,
|
|
2742
|
+
last: { x, y },
|
|
2743
|
+
active: true,
|
|
2744
|
+
rect: null,
|
|
2745
|
+
region,
|
|
2746
|
+
anchorSpan: { lo, hi, kind },
|
|
2747
|
+
};
|
|
2317
2748
|
applySelectionRect(rect);
|
|
2318
|
-
|
|
2749
|
+
lastClickRef.current = { x, y, t: now, count: clickCount };
|
|
2319
2750
|
continue;
|
|
2320
2751
|
}
|
|
2321
2752
|
}
|
|
2322
|
-
lastClickRef.current = { x, y, t: now };
|
|
2753
|
+
lastClickRef.current = { x, y, t: now, count: 1 };
|
|
2323
2754
|
// Left-button press: begin a new selection anchored here.
|
|
2324
2755
|
// Anchor the drag but do NOT paint a zero-width selection yet; a plain
|
|
2325
2756
|
// single click should not flash a one-cell highlight. The selection is
|
|
2326
2757
|
// only rendered once a drag actually extends past the anchor.
|
|
2327
2758
|
// Status-band selections do NOT scroll, so anchorScroll is irrelevant
|
|
2328
2759
|
// there; keep the transcript scroll anchor only for the transcript.
|
|
2760
|
+
// Plain single press clears any word/line anchorSpan (char-drag mode).
|
|
2329
2761
|
stopSmoothScroll();
|
|
2330
2762
|
dragRef.current = {
|
|
2331
2763
|
anchor: { x, y },
|
|
@@ -2334,6 +2766,7 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
2334
2766
|
active: true,
|
|
2335
2767
|
rect: null,
|
|
2336
2768
|
region,
|
|
2769
|
+
anchorSpan: null,
|
|
2337
2770
|
};
|
|
2338
2771
|
} else if (baseButton === 0 && isMotion && dragRef.current.active) {
|
|
2339
2772
|
const region = dragRef.current.region;
|
|
@@ -2350,11 +2783,19 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
2350
2783
|
// current cell, clamped to the owning region's band.
|
|
2351
2784
|
const selectionY = region === 'status' ? clampToStatusBand(y) : clampToTranscriptViewport(y);
|
|
2352
2785
|
dragRef.current.last = { x, y: selectionY };
|
|
2353
|
-
const
|
|
2354
|
-
|
|
2355
|
-
:
|
|
2356
|
-
|
|
2357
|
-
|
|
2786
|
+
const span = dragRef.current.anchorSpan;
|
|
2787
|
+
if (span) {
|
|
2788
|
+
// Word/line multi-click drag: extend by whole words/lines from the
|
|
2789
|
+
// anchor span to the word/line under the cursor (see buildSpanRect).
|
|
2790
|
+
const rect = buildSpanRect(span, x, selectionY, region, dragRef.current.anchorScroll);
|
|
2791
|
+
applySelectionRectThrottled(rect);
|
|
2792
|
+
} else {
|
|
2793
|
+
const anchor = region === 'status'
|
|
2794
|
+
? dragRef.current.anchor
|
|
2795
|
+
: selectionPointAtCurrentScroll(dragRef.current.anchor, dragRef.current.anchorScroll);
|
|
2796
|
+
const rect = linearSelection(anchor, { x, y: selectionY });
|
|
2797
|
+
applySelectionRectThrottled(rect);
|
|
2798
|
+
}
|
|
2358
2799
|
// Auto-scroll-while-dragging is transcript-only (the status band does
|
|
2359
2800
|
// not scroll).
|
|
2360
2801
|
if (region === 'transcript') {
|
|
@@ -2378,18 +2819,28 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
2378
2819
|
// (the SGR release event carries col/row) and keep the selection
|
|
2379
2820
|
// visible. Copy is NOT automatic — the user presses Ctrl+C to copy.
|
|
2380
2821
|
// The highlight stays until ESC or a plain click.
|
|
2381
|
-
const
|
|
2382
|
-
? dragRef.current.anchor
|
|
2383
|
-
: selectionPointAtCurrentScroll(dragRef.current.anchor, dragRef.current.anchorScroll);
|
|
2384
|
-
dragRef.current.active = false;
|
|
2822
|
+
const span = dragRef.current.anchorSpan;
|
|
2385
2823
|
const releaseY = region === 'status' ? clampToStatusBand(y) : clampToTranscriptViewport(y);
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
//
|
|
2824
|
+
dragRef.current.active = false;
|
|
2825
|
+
if (span) {
|
|
2826
|
+
// Word/line multi-click release: finalize from the span to the
|
|
2827
|
+
// word/line at the release cell. A release-without-motion resolves
|
|
2828
|
+
// to the span itself (in-span target), so the original word/line
|
|
2829
|
+
// highlight stays — never cleared as "empty" like a bare click.
|
|
2830
|
+
const rect = buildSpanRect(span, x, releaseY, region, dragRef.current.anchorScroll);
|
|
2392
2831
|
applySelectionRect(rect);
|
|
2832
|
+
} else {
|
|
2833
|
+
const anchor = region === 'status'
|
|
2834
|
+
? dragRef.current.anchor
|
|
2835
|
+
: selectionPointAtCurrentScroll(dragRef.current.anchor, dragRef.current.anchorScroll);
|
|
2836
|
+
const rect = linearSelection(anchor, { x, y: releaseY });
|
|
2837
|
+
const empty = rect.x1 === rect.x2 && rect.y1 === rect.y2;
|
|
2838
|
+
if (empty) {
|
|
2839
|
+
applySelectionRect(null); // a plain click clears any prior highlight
|
|
2840
|
+
} else {
|
|
2841
|
+
// Push the final rect so ink re-renders the visible selection.
|
|
2842
|
+
applySelectionRect(rect);
|
|
2843
|
+
}
|
|
2393
2844
|
}
|
|
2394
2845
|
// Drag is over: the measured-height harvest was skipped for every
|
|
2395
2846
|
// motion commit (see the harvest effect's dragRef guard), so force one
|
|
@@ -2415,7 +2866,28 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
2415
2866
|
};
|
|
2416
2867
|
inkInput.on('input', onData);
|
|
2417
2868
|
return () => { inkInput.off('input', onData); };
|
|
2418
|
-
}, [inkInput, isRawModeSupported, store, passthroughCtrlWheelZoom, resizeState.rows, scrollTranscriptRows, applySelectionRect, applySelectionRectThrottled, selectionPointAtCurrentScroll]);
|
|
2869
|
+
}, [inkInput, isRawModeSupported, store, passthroughCtrlWheelZoom, resizeState.rows, scrollTranscriptRows, applySelectionRect, applySelectionRectThrottled, selectionPointAtCurrentScroll, buildSpanRect, dismissWelcomePromptHint]);
|
|
2870
|
+
|
|
2871
|
+
// Enable extended keyboard reporting (kitty + xterm modifyOtherKeys) the same
|
|
2872
|
+
// way Claude Code does: SYNCHRONOUSLY, ONCE, with NO query/round-trip. ink
|
|
2873
|
+
// turns raw mode on during the first useInput mount (synchronously, inside
|
|
2874
|
+
// render); this mount effect runs in the same commit phase, right after — i.e.
|
|
2875
|
+
// before the user can realistically press a key. We write BOTH enables
|
|
2876
|
+
// unconditionally (the terminal honors whichever it implements; Windows
|
|
2877
|
+
// Terminal 1.24 has no kitty but DOES honor modifyOtherKeys), gated only by the
|
|
2878
|
+
// supportsExtendedKeys() allowlist. Because the enable lands before the first
|
|
2879
|
+
// keypress is read, the FIRST Ctrl+Enter already arrives as a distinguishable
|
|
2880
|
+
// \x1b[27;5;13~ (or kitty \x1b[13;5u) instead of a bare \r — fixing the old
|
|
2881
|
+
// "first Ctrl+Enter submits, second works" race. Teardown lives in index.jsx's
|
|
2882
|
+
// restoreTerminal(). The empty dep array makes this run exactly once on mount.
|
|
2883
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2884
|
+
useEffect(() => {
|
|
2885
|
+
if (!isRawModeSupported || !stdout?.write) return;
|
|
2886
|
+
if (!supportsExtendedKeys()) return;
|
|
2887
|
+
try {
|
|
2888
|
+
stdout.write(ENABLE_KITTY_KEYBOARD + ENABLE_MODIFY_OTHER_KEYS);
|
|
2889
|
+
} catch { /* terminal may be closing */ }
|
|
2890
|
+
}, []);
|
|
2419
2891
|
|
|
2420
2892
|
// Item-count changes are the only time we can arm follow before row totals are
|
|
2421
2893
|
// recomputed. Pure streaming height growth is handled in the row-delta effect.
|
|
@@ -2428,7 +2900,7 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
2428
2900
|
return;
|
|
2429
2901
|
}
|
|
2430
2902
|
if (count === previousCount || dragRef.current.active) return;
|
|
2431
|
-
if (scrollTargetRef.current <=
|
|
2903
|
+
if (scrollTargetRef.current <= transcriptBottomSlackRows || followingRef.current) followingRef.current = true;
|
|
2432
2904
|
}, [state.items.length, resetTranscriptScroll]);
|
|
2433
2905
|
|
|
2434
2906
|
// `exiting` removes the inline caret (PromptInput draws none when disabled) and
|
|
@@ -2468,6 +2940,7 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
2468
2940
|
}
|
|
2469
2941
|
if (restoreDraft) {
|
|
2470
2942
|
if (restored.pastedImages) installPastedImages(restored.pastedImages, { merge: true });
|
|
2943
|
+
syncPromptLayoutRows(restored.text);
|
|
2471
2944
|
setPromptDraftOverride({ id: Date.now(), value: restored.text });
|
|
2472
2945
|
}
|
|
2473
2946
|
if (showHint) {
|
|
@@ -2476,7 +2949,7 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
2476
2949
|
clearPromptHint();
|
|
2477
2950
|
}
|
|
2478
2951
|
return true;
|
|
2479
|
-
}, [store, promptDraft, showPromptHint, clearPromptHint, installPastedImages]);
|
|
2952
|
+
}, [store, promptDraft, showPromptHint, clearPromptHint, installPastedImages, syncPromptLayoutRows]);
|
|
2480
2953
|
|
|
2481
2954
|
const recentPromptHistory = useMemo(() => {
|
|
2482
2955
|
const items = Array.isArray(state.items) ? state.items : [];
|
|
@@ -2562,6 +3035,18 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
2562
3035
|
// the active turn on the same Esc press.
|
|
2563
3036
|
// - empty prompt + active turn interrupts the active turn.
|
|
2564
3037
|
const handlePromptEscape = useCallback((text = '', meta = {}) => {
|
|
3038
|
+
// Recording takes priority over every other Esc branch (usage/context
|
|
3039
|
+
// panels, slash-clear, queue-restore, turn-interrupt): a live mic capture
|
|
3040
|
+
// must never silently keep running because Esc was consumed by something
|
|
3041
|
+
// else first. Only consumes Esc while actually RECORDING — transcribing
|
|
3042
|
+
// (already stopped, awaiting the HTTP round-trip) and idle fall through
|
|
3043
|
+
// to the existing branches unchanged.
|
|
3044
|
+
if (getRecorderState() === 'recording') {
|
|
3045
|
+
cancelRecording();
|
|
3046
|
+
setVoiceStatusHint('', 'info');
|
|
3047
|
+
store.pushNotice('Voice: recording cancelled', 'plain');
|
|
3048
|
+
return true;
|
|
3049
|
+
}
|
|
2565
3050
|
if (usagePanel) { closeUsagePanel(); return true; }
|
|
2566
3051
|
if (contextPanel) { setContextPanel(null); return true; }
|
|
2567
3052
|
|
|
@@ -2576,19 +3061,20 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
2576
3061
|
// Idle + empty + nothing to restore: nothing (double-press from empty
|
|
2577
3062
|
// opens message selector, but we don't have that feature yet).
|
|
2578
3063
|
return false;
|
|
2579
|
-
}, [contextPanel, usagePanel, closeUsagePanel, restoreQueuedToPrompt, clearPromptHint, clearPastedImagesSnapshot]);
|
|
3064
|
+
}, [contextPanel, usagePanel, closeUsagePanel, restoreQueuedToPrompt, clearPromptHint, clearPastedImagesSnapshot, setVoiceStatusHint, store]);
|
|
2580
3065
|
|
|
2581
3066
|
const handlePromptInterrupt = useCallback((currentText = '') => {
|
|
2582
3067
|
const result = store.abort?.();
|
|
2583
3068
|
if (result?.aborted === false) return undefined;
|
|
2584
3069
|
if (result?.pastedImages) installPastedImages(result.pastedImages, { merge: true });
|
|
3070
|
+
if (result?.discardPastedImages) clearPastedImagesSnapshot(result.discardPastedImages);
|
|
2585
3071
|
const restoreText = String(result?.restoreText || '').trim();
|
|
2586
3072
|
if (!restoreText) return undefined;
|
|
2587
3073
|
const existingText = String(currentText || '').trim();
|
|
2588
3074
|
const nextText = [restoreText, existingText].filter(Boolean).join('\n');
|
|
2589
3075
|
clearPromptHint();
|
|
2590
3076
|
return nextText;
|
|
2591
|
-
}, [store, clearPromptHint, installPastedImages]);
|
|
3077
|
+
}, [store, clearPromptHint, installPastedImages, clearPastedImagesSnapshot]);
|
|
2592
3078
|
|
|
2593
3079
|
// Ctrl+O toggles the global tool-output expansion, matching common terminal-chat
|
|
2594
3080
|
// expectation that this is a view mode rather than a per-card hidden state.
|
|
@@ -2597,6 +3083,7 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
2597
3083
|
}, []);
|
|
2598
3084
|
|
|
2599
3085
|
useInput((input, key) => {
|
|
3086
|
+
if (!welcomePromptHintDismissed) dismissWelcomePromptHint();
|
|
2600
3087
|
if (toolApproval) {
|
|
2601
3088
|
const value = String(input || '').trim().toLowerCase();
|
|
2602
3089
|
if (key.escape || value === 'd' || value === 'n') {
|
|
@@ -2638,6 +3125,20 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
2638
3125
|
toggleExpand();
|
|
2639
3126
|
return;
|
|
2640
3127
|
}
|
|
3128
|
+
if (
|
|
3129
|
+
!picker
|
|
3130
|
+
&& key.shift
|
|
3131
|
+
&& (key.leftArrow || key.rightArrow || key.upArrow || key.downArrow || key.home || key.end)
|
|
3132
|
+
) {
|
|
3133
|
+
let move = null;
|
|
3134
|
+
if (key.leftArrow) move = 'left';
|
|
3135
|
+
else if (key.rightArrow) move = 'right';
|
|
3136
|
+
else if (key.upArrow) move = 'up';
|
|
3137
|
+
else if (key.downArrow) move = 'down';
|
|
3138
|
+
else if (key.home) move = 'lineStart';
|
|
3139
|
+
else if (key.end) move = 'lineEnd';
|
|
3140
|
+
if (move && moveSelectionFocus(move)) return;
|
|
3141
|
+
}
|
|
2641
3142
|
if (key.escape && usagePanel && !picker) {
|
|
2642
3143
|
closeUsagePanel();
|
|
2643
3144
|
return;
|
|
@@ -2665,6 +3166,7 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
2665
3166
|
if (key.escape && !picker) {
|
|
2666
3167
|
dragRef.current.active = false;
|
|
2667
3168
|
dragRef.current.region = null;
|
|
3169
|
+
dragRef.current.anchorSpan = null;
|
|
2668
3170
|
// Clear whichever region's selection is active. PromptInput's own ESC also
|
|
2669
3171
|
// clears its selection when focused/enabled; this covers the disabled case
|
|
2670
3172
|
// and a status/transcript ink-grid selection in one press.
|
|
@@ -3474,6 +3976,10 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
3474
3976
|
|
|
3475
3977
|
const openOutputStylePicker = (options = {}) => {
|
|
3476
3978
|
const returnTo = typeof options.returnTo === 'function' ? options.returnTo : null;
|
|
3979
|
+
// Onboarding mode: Enter (row select) and ConfirmBar Next must both persist
|
|
3980
|
+
// the chosen style, then advance. `onboarding.onAdvance/onBack` drive the
|
|
3981
|
+
// wizard; the confirm bar is built here so both paths share `saveStyle`.
|
|
3982
|
+
const onboarding = options.onboarding || null;
|
|
3477
3983
|
let status = null;
|
|
3478
3984
|
try {
|
|
3479
3985
|
status = store.listOutputStyles?.() || null;
|
|
@@ -3487,6 +3993,7 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
3487
3993
|
return;
|
|
3488
3994
|
}
|
|
3489
3995
|
const currentId = status?.current?.id || 'default';
|
|
3996
|
+
let highlightedStyleId = currentId;
|
|
3490
3997
|
const items = styles.map((style) => ({
|
|
3491
3998
|
value: style.id,
|
|
3492
3999
|
label: style.label || style.id,
|
|
@@ -3501,30 +4008,55 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
3501
4008
|
setSettingsPrompt(null);
|
|
3502
4009
|
setContextPanel(null);
|
|
3503
4010
|
closeUsagePanel();
|
|
4011
|
+
const saveStyle = (styleId, { advance = false } = {}) => {
|
|
4012
|
+
if (!styleId) return;
|
|
4013
|
+
setPicker(null);
|
|
4014
|
+
void store.setOutputStyle?.(styleId)
|
|
4015
|
+
.then((result) => {
|
|
4016
|
+
if (!result) {
|
|
4017
|
+
store.pushNotice('Output style switch is already running.', 'warn');
|
|
4018
|
+
} else {
|
|
4019
|
+
store.pushNotice(outputStyleNotice(result), 'info');
|
|
4020
|
+
}
|
|
4021
|
+
if (advance && onboarding) onboarding.onAdvance?.();
|
|
4022
|
+
else if (returnTo) returnTo();
|
|
4023
|
+
})
|
|
4024
|
+
.catch((e) => store.pushNotice(`Couldn’t switch output style: ${e?.message || e}`, 'error'));
|
|
4025
|
+
};
|
|
3504
4026
|
setPicker({
|
|
3505
4027
|
title: 'Output Style',
|
|
3506
4028
|
description: 'Select response style.',
|
|
3507
|
-
|
|
4029
|
+
// Onboarding uses a ConfirmBar (←/→ = Back/Next); let the Picker supply
|
|
4030
|
+
// its ConfirmBar help instead of a stale ←/→ hint.
|
|
4031
|
+
help: onboarding ? undefined : (returnTo ? '↑/↓ Select · Enter Choose · Esc Settings' : '↑/↓ Select · Enter Choose · Esc Back'),
|
|
3508
4032
|
labelWidth: 18,
|
|
3509
4033
|
items,
|
|
4034
|
+
confirmBar: onboarding ? {
|
|
4035
|
+
buttons: [
|
|
4036
|
+
{ value: 'back', label: '◀ Back' },
|
|
4037
|
+
{ value: 'next', label: 'Next ▶' },
|
|
4038
|
+
],
|
|
4039
|
+
onConfirm: (button) => {
|
|
4040
|
+
if (button.value === 'back') {
|
|
4041
|
+
setPicker(null);
|
|
4042
|
+
onboarding.onBack?.();
|
|
4043
|
+
return;
|
|
4044
|
+
}
|
|
4045
|
+
saveStyle(highlightedStyleId, { advance: true });
|
|
4046
|
+
},
|
|
4047
|
+
} : (options.confirmBar || null),
|
|
4048
|
+
onHighlight: onboarding ? (_value, item) => {
|
|
4049
|
+
if (item?._style?.id) highlightedStyleId = item._style.id;
|
|
4050
|
+
} : undefined,
|
|
3510
4051
|
onSelect: (_value, item) => {
|
|
3511
4052
|
const style = item?._style;
|
|
3512
4053
|
if (!style) return;
|
|
3513
|
-
|
|
3514
|
-
void store.setOutputStyle?.(style.id)
|
|
3515
|
-
.then((result) => {
|
|
3516
|
-
if (!result) {
|
|
3517
|
-
store.pushNotice('Output style switch is already running.', 'warn');
|
|
3518
|
-
return;
|
|
3519
|
-
}
|
|
3520
|
-
store.pushNotice(outputStyleNotice(result), 'info');
|
|
3521
|
-
if (returnTo) returnTo();
|
|
3522
|
-
})
|
|
3523
|
-
.catch((e) => store.pushNotice(`Couldn’t switch output style: ${e?.message || e}`, 'error'));
|
|
4054
|
+
saveStyle(style.id, { advance: Boolean(onboarding) });
|
|
3524
4055
|
},
|
|
3525
4056
|
onCancel: () => {
|
|
3526
4057
|
setPicker(null);
|
|
3527
|
-
if (
|
|
4058
|
+
if (onboarding) onboarding.onCancel?.();
|
|
4059
|
+
else if (returnTo) returnTo();
|
|
3528
4060
|
},
|
|
3529
4061
|
});
|
|
3530
4062
|
};
|
|
@@ -3533,6 +4065,7 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
3533
4065
|
|
|
3534
4066
|
const openThemePicker = (options = {}) => {
|
|
3535
4067
|
const returnTo = typeof options.returnTo === 'function' ? options.returnTo : null;
|
|
4068
|
+
const onboarding = options.onboarding || null;
|
|
3536
4069
|
let themes = [];
|
|
3537
4070
|
try {
|
|
3538
4071
|
themes = store.listThemes?.() || [];
|
|
@@ -3545,6 +4078,7 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
3545
4078
|
return;
|
|
3546
4079
|
}
|
|
3547
4080
|
const currentId = store.getTheme?.() || themes.find((t) => t.current)?.id || themes[0]?.id;
|
|
4081
|
+
let highlightedThemeId = currentId;
|
|
3548
4082
|
const items = themes.map((entry) => ({
|
|
3549
4083
|
value: entry.id,
|
|
3550
4084
|
label: entry.label || entry.id,
|
|
@@ -3566,31 +4100,57 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
3566
4100
|
return null;
|
|
3567
4101
|
}
|
|
3568
4102
|
};
|
|
4103
|
+
// Onboarding: Enter (row) and ConfirmBar Next both persist the highlighted
|
|
4104
|
+
// theme, then advance; Back restores the original palette then steps back.
|
|
4105
|
+
const saveTheme = (id, { advance = false } = {}) => {
|
|
4106
|
+
setPicker(null);
|
|
4107
|
+
const applied = applyTheme(id, { persist: true });
|
|
4108
|
+
store.pushNotice(themeNotice(applied || { id }), 'info');
|
|
4109
|
+
if (advance && onboarding) onboarding.onAdvance?.();
|
|
4110
|
+
else if (returnTo) returnTo();
|
|
4111
|
+
};
|
|
3569
4112
|
setPicker({
|
|
3570
4113
|
title: 'Theme',
|
|
3571
4114
|
description: 'Choose the color theme that looks best with your terminal.',
|
|
3572
|
-
help: returnTo ? '↑/↓ Preview · Enter Choose · Esc Settings' : '↑/↓ Preview · Enter Choose · Esc Back',
|
|
4115
|
+
help: onboarding ? undefined : (returnTo ? '↑/↓ Preview · Enter Choose · Esc Settings' : '↑/↓ Preview · Enter Choose · Esc Back'),
|
|
3573
4116
|
labelWidth: 22,
|
|
3574
4117
|
initialIndex: Math.max(0, items.findIndex((item) => item.value === currentId)),
|
|
3575
4118
|
items,
|
|
4119
|
+
confirmBar: onboarding ? {
|
|
4120
|
+
buttons: [
|
|
4121
|
+
{ value: 'back', label: '◀ Back' },
|
|
4122
|
+
{ value: 'next', label: 'Next ▶' },
|
|
4123
|
+
],
|
|
4124
|
+
onConfirm: (button) => {
|
|
4125
|
+
if (button.value === 'back') {
|
|
4126
|
+
setPicker(null);
|
|
4127
|
+
// Restore the palette active before the picker opened, then step back.
|
|
4128
|
+
if (currentId) applyTheme(currentId, { persist: false });
|
|
4129
|
+
onboarding.onBack?.();
|
|
4130
|
+
return;
|
|
4131
|
+
}
|
|
4132
|
+
saveTheme(highlightedThemeId, { advance: true });
|
|
4133
|
+
},
|
|
4134
|
+
} : (options.confirmBar || null),
|
|
3576
4135
|
// Live preview while moving: apply (no persist) so the surface re-tones
|
|
3577
4136
|
// as the selection moves. Enter persists; Esc restores the original.
|
|
3578
4137
|
onHighlight: (_value, item) => {
|
|
3579
|
-
if (item?._theme?.id)
|
|
4138
|
+
if (item?._theme?.id) {
|
|
4139
|
+
highlightedThemeId = item._theme.id;
|
|
4140
|
+
applyTheme(item._theme.id, { persist: false });
|
|
4141
|
+
}
|
|
3580
4142
|
},
|
|
3581
4143
|
onSelect: (_value, item) => {
|
|
3582
4144
|
const entry = item?._theme;
|
|
3583
4145
|
if (!entry) return;
|
|
3584
|
-
|
|
3585
|
-
const applied = applyTheme(entry.id, { persist: true });
|
|
3586
|
-
store.pushNotice(themeNotice(applied || entry), 'info');
|
|
3587
|
-
if (returnTo) returnTo();
|
|
4146
|
+
saveTheme(entry.id, { advance: Boolean(onboarding) });
|
|
3588
4147
|
},
|
|
3589
4148
|
onCancel: () => {
|
|
3590
4149
|
setPicker(null);
|
|
3591
4150
|
// Restore the theme that was active before the picker opened.
|
|
3592
4151
|
if (currentId) applyTheme(currentId, { persist: false });
|
|
3593
|
-
if (
|
|
4152
|
+
if (onboarding) onboarding.onCancel?.();
|
|
4153
|
+
else if (returnTo) returnTo();
|
|
3594
4154
|
},
|
|
3595
4155
|
});
|
|
3596
4156
|
};
|
|
@@ -3838,7 +4398,7 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
3838
4398
|
const pct = (value, total = windowTokens) => {
|
|
3839
4399
|
const n = Number(value || 0);
|
|
3840
4400
|
const d = Number(total || 0);
|
|
3841
|
-
if (!d) return '
|
|
4401
|
+
if (!d) return 'N/A';
|
|
3842
4402
|
return `${((n / d) * 100).toFixed(n > 0 && n < d / 100 ? 1 : 0)}%`;
|
|
3843
4403
|
};
|
|
3844
4404
|
const fmt = (value) => {
|
|
@@ -3850,11 +4410,17 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
3850
4410
|
return `${Math.round(n)}`;
|
|
3851
4411
|
};
|
|
3852
4412
|
const cachedRead = Number(usage.lastCachedReadTokens || 0);
|
|
3853
|
-
const
|
|
3854
|
-
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);
|
|
3855
4420
|
const cacheHitRate = cacheDenom > 0
|
|
3856
4421
|
? `${((cachedRead / cacheDenom) * 100).toFixed(0)}%`
|
|
3857
|
-
: '
|
|
4422
|
+
: 'N/A';
|
|
4423
|
+
const cacheWriteLabel = cacheWrite > 0 ? ` · ${fmt(cacheWrite)} write` : '';
|
|
3858
4424
|
const contextSource = context.usedSource === 'last_api_request' ? 'last API request' : 'estimated';
|
|
3859
4425
|
const lastApiLabel = context.lastApiRequestStale ? 'last API request (pre-compact)' : 'last API request';
|
|
3860
4426
|
const compactElapsed = (value) => {
|
|
@@ -3922,13 +4488,13 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
3922
4488
|
{
|
|
3923
4489
|
value: 'last-api',
|
|
3924
4490
|
label: 'Last API usage',
|
|
3925
|
-
description: `${fmt(usage.lastContextTokens)} context · ${fmt(
|
|
4491
|
+
description: `${fmt(usage.lastContextTokens)} context · ${fmt(freshInput)} uncached input · ${fmt(usage.lastOutputTokens)} output · ${lastApiLabel}`,
|
|
3926
4492
|
_action: 'last-api',
|
|
3927
4493
|
},
|
|
3928
4494
|
{
|
|
3929
4495
|
value: 'cache',
|
|
3930
4496
|
label: 'Prompt cache',
|
|
3931
|
-
description: `${cacheHitRate} hit · ${fmt(usage.lastCachedReadTokens)} read · ${fmt(
|
|
4497
|
+
description: `${cacheHitRate} hit · ${fmt(usage.lastCachedReadTokens)} read${cacheWriteLabel} · ${fmt(freshInput)} new (last request)`,
|
|
3932
4498
|
_action: 'cache',
|
|
3933
4499
|
},
|
|
3934
4500
|
{
|
|
@@ -3993,12 +4559,14 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
3993
4559
|
},
|
|
3994
4560
|
lastApi: {
|
|
3995
4561
|
contextTokens: usage.lastContextTokens,
|
|
3996
|
-
inputTokens:
|
|
4562
|
+
inputTokens: freshInput,
|
|
4563
|
+
rawInputTokens: usage.lastInputTokens,
|
|
3997
4564
|
outputTokens: usage.lastOutputTokens,
|
|
3998
4565
|
},
|
|
3999
4566
|
cache: {
|
|
4000
4567
|
hitRate: cacheHitRate,
|
|
4001
4568
|
readTokens: usage.lastCachedReadTokens,
|
|
4569
|
+
writeTokens: cacheWrite,
|
|
4002
4570
|
},
|
|
4003
4571
|
extensions: {
|
|
4004
4572
|
skills: skills.count,
|
|
@@ -4048,6 +4616,17 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
4048
4616
|
const plugins = store.pluginsStatus?.() || { count: 0 };
|
|
4049
4617
|
const skills = store.skillsStatus?.() || { count: 0 };
|
|
4050
4618
|
const channelWorker = store.getChannelWorkerStatus?.();
|
|
4619
|
+
let channelBackend = 'discord';
|
|
4620
|
+
try {
|
|
4621
|
+
channelBackend = store.getChannelSetup?.()?.backend || 'discord';
|
|
4622
|
+
} catch {
|
|
4623
|
+
channelBackend = 'discord';
|
|
4624
|
+
}
|
|
4625
|
+
const channelBackendLabel = channelBackend === 'telegram' ? 'Telegram' : 'Discord';
|
|
4626
|
+
const remoteEnabled = store.isRemoteEnabled?.() === true;
|
|
4627
|
+
const remoteRuntimeDescription = channelWorker?.running
|
|
4628
|
+
? `runtime running · pid ${channelWorker.pid}`
|
|
4629
|
+
: 'runtime stopped';
|
|
4051
4630
|
const compactType = compaction.compactType || compaction.type || 'semantic';
|
|
4052
4631
|
const compactTypeLabel = compactType === 'recall-fasttrack' ? 'Fast-track' : 'Default';
|
|
4053
4632
|
const outputStyleLabel = outputStyle?.current?.label || outputStyle?.current?.id || outputStyle?.configured || 'Default';
|
|
@@ -4174,6 +4753,31 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
4174
4753
|
}
|
|
4175
4754
|
openSettingsPicker();
|
|
4176
4755
|
};
|
|
4756
|
+
const applyRemoteRuntime = () => {
|
|
4757
|
+
const enabled = store.toggleRemote?.() === true;
|
|
4758
|
+
store.pushNotice(enabled ? 'Remote mode ON' : 'Remote mode OFF', 'info');
|
|
4759
|
+
openSettingsPicker();
|
|
4760
|
+
};
|
|
4761
|
+
const cycleChannelBackend = (direction = 1) => {
|
|
4762
|
+
const backends = ['discord', 'telegram'];
|
|
4763
|
+
const currentIndex = Math.max(0, backends.indexOf(channelBackend));
|
|
4764
|
+
const chosen = backends[(currentIndex + direction + backends.length) % backends.length];
|
|
4765
|
+
if (chosen === channelBackend) {
|
|
4766
|
+
openSettingsPicker();
|
|
4767
|
+
return;
|
|
4768
|
+
}
|
|
4769
|
+
try {
|
|
4770
|
+
store.setBackend(chosen);
|
|
4771
|
+
const label = chosen === 'telegram' ? 'Telegram' : 'Discord';
|
|
4772
|
+
const restartHint = (store.isRemoteEnabled?.() === true || channelWorker?.running)
|
|
4773
|
+
? `Channel set to ${label}. Restart remote to apply.`
|
|
4774
|
+
: `Channel set to ${label}.`;
|
|
4775
|
+
store.pushNotice(restartHint, 'info');
|
|
4776
|
+
} catch (e) {
|
|
4777
|
+
store.pushNotice(`channel backend failed: ${e?.message || e}`, 'error');
|
|
4778
|
+
}
|
|
4779
|
+
openSettingsPicker();
|
|
4780
|
+
};
|
|
4177
4781
|
const items = [
|
|
4178
4782
|
{
|
|
4179
4783
|
value: 'profile',
|
|
@@ -4234,10 +4838,24 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
4234
4838
|
_action: 'channels',
|
|
4235
4839
|
},
|
|
4236
4840
|
{
|
|
4237
|
-
value: '
|
|
4238
|
-
label: '
|
|
4239
|
-
|
|
4240
|
-
|
|
4841
|
+
value: 'remote-runtime',
|
|
4842
|
+
label: 'Remote Runtime',
|
|
4843
|
+
meta: boolLabel(remoteEnabled),
|
|
4844
|
+
description: remoteRuntimeDescription,
|
|
4845
|
+
_action: 'remote-runtime',
|
|
4846
|
+
},
|
|
4847
|
+
{
|
|
4848
|
+
value: 'channel-backend',
|
|
4849
|
+
label: 'Channel',
|
|
4850
|
+
meta: channelBackendLabel,
|
|
4851
|
+
description: 'Left/Right or Enter changes channel type (Discord or Telegram).',
|
|
4852
|
+
_action: 'channel-backend',
|
|
4853
|
+
},
|
|
4854
|
+
{
|
|
4855
|
+
value: 'channel-setting',
|
|
4856
|
+
label: 'Setting',
|
|
4857
|
+
description: 'Configure credentials and main channel/chat for the active type.',
|
|
4858
|
+
_action: 'channel-setting',
|
|
4241
4859
|
},
|
|
4242
4860
|
{
|
|
4243
4861
|
value: 'output-style',
|
|
@@ -4310,6 +4928,21 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
4310
4928
|
description: `${skills.count || 0} available`,
|
|
4311
4929
|
_action: 'skills',
|
|
4312
4930
|
},
|
|
4931
|
+
{
|
|
4932
|
+
value: 'update',
|
|
4933
|
+
label: 'Update',
|
|
4934
|
+
meta: (() => {
|
|
4935
|
+
try {
|
|
4936
|
+
const upd = store.getUpdateSettings?.() || {};
|
|
4937
|
+
const current = upd.currentVersion || 'unknown';
|
|
4938
|
+
if (upd.updateAvailable && upd.latestVersion) return `${current} → ${upd.latestVersion}`;
|
|
4939
|
+
if (!upd.currentVersion) return 'unknown';
|
|
4940
|
+
return `${current} (latest)`;
|
|
4941
|
+
} catch { return 'unknown'; }
|
|
4942
|
+
})(),
|
|
4943
|
+
description: 'Check version and update mixdog.',
|
|
4944
|
+
_action: 'update',
|
|
4945
|
+
},
|
|
4313
4946
|
];
|
|
4314
4947
|
setProviderPrompt(null);
|
|
4315
4948
|
setChannelPrompt(null);
|
|
@@ -4333,6 +4966,8 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
4333
4966
|
}
|
|
4334
4967
|
else if (item?._action === 'memory') applyMemory(!(memory.enabled !== false));
|
|
4335
4968
|
else if (item?._action === 'channels') applyChannels(!(channels.enabled !== false));
|
|
4969
|
+
else if (item?._action === 'remote-runtime') applyRemoteRuntime();
|
|
4970
|
+
else if (item?._action === 'channel-backend') cycleChannelBackend(-1);
|
|
4336
4971
|
else if (item?._action === 'output-style') cycleOutputStyle(-1);
|
|
4337
4972
|
else if (item?._action === 'theme') cycleTheme(-1);
|
|
4338
4973
|
else if (item?._action === 'workflow') cycleWorkflow(-1);
|
|
@@ -4346,6 +4981,8 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
4346
4981
|
}
|
|
4347
4982
|
else if (item?._action === 'memory') applyMemory(!(memory.enabled !== false));
|
|
4348
4983
|
else if (item?._action === 'channels') applyChannels(!(channels.enabled !== false));
|
|
4984
|
+
else if (item?._action === 'remote-runtime') applyRemoteRuntime();
|
|
4985
|
+
else if (item?._action === 'channel-backend') cycleChannelBackend(1);
|
|
4349
4986
|
else if (item?._action === 'output-style') cycleOutputStyle(1);
|
|
4350
4987
|
else if (item?._action === 'theme') cycleTheme(1);
|
|
4351
4988
|
else if (item?._action === 'workflow') cycleWorkflow(1);
|
|
@@ -4362,7 +4999,9 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
4362
4999
|
else if (item._action === 'memory') applyMemory(!(memory.enabled !== false));
|
|
4363
5000
|
else if (item._action === 'memory-dashboard') openMemoryPicker();
|
|
4364
5001
|
else if (item._action === 'channels') applyChannels(!(channels.enabled !== false));
|
|
4365
|
-
else if (item._action === '
|
|
5002
|
+
else if (item._action === 'remote-runtime') applyRemoteRuntime();
|
|
5003
|
+
else if (item._action === 'channel-backend') cycleChannelBackend(1);
|
|
5004
|
+
else if (item._action === 'channel-setting') openChannelSettingTypePicker({ returnTo: openSettingsPicker });
|
|
4366
5005
|
else if (item._action === 'output-style') openOutputStylePicker({ returnTo: openSettingsPicker });
|
|
4367
5006
|
else if (item._action === 'theme') openThemePicker({ returnTo: openSettingsPicker });
|
|
4368
5007
|
else if (item._action === 'workflow') openWorkflowPicker({ returnTo: openSettingsPicker });
|
|
@@ -4387,6 +5026,7 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
4387
5026
|
else if (item._action === 'plugins') openPluginsPicker();
|
|
4388
5027
|
else if (item._action === 'hooks') openHooksPicker();
|
|
4389
5028
|
else if (item._action === 'skills') openSkillsPicker();
|
|
5029
|
+
else if (item._action === 'update') openUpdatePicker({ returnTo: openSettingsPicker });
|
|
4390
5030
|
},
|
|
4391
5031
|
onCancel: () => {
|
|
4392
5032
|
setPicker(null);
|
|
@@ -4402,37 +5042,44 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
4402
5042
|
setChannelPrompt(null);
|
|
4403
5043
|
setHookPrompt(null);
|
|
4404
5044
|
setSettingsPrompt(null);
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
5045
|
+
// Onboarding (and any caller) can pass a preloaded provider setup so we skip
|
|
5046
|
+
// the "Checking Providers" placeholder frame that otherwise flashes before
|
|
5047
|
+
// the real list — that swap is what looked like a jump on Step 1 entry.
|
|
5048
|
+
let setup = options.preloadedSetup && typeof options.preloadedSetup === 'object'
|
|
5049
|
+
? options.preloadedSetup
|
|
5050
|
+
: null;
|
|
5051
|
+
if (!setup) {
|
|
5052
|
+
setPicker({
|
|
5053
|
+
title: options.title || 'Providers',
|
|
5054
|
+
description: options.description || 'Choose a provider to configure.',
|
|
5055
|
+
labelWidth: 18,
|
|
5056
|
+
metaWidth: 10,
|
|
5057
|
+
pickerKey: 'providers-loading',
|
|
5058
|
+
initialIndex: 0,
|
|
5059
|
+
items: [{
|
|
5060
|
+
value: 'checking',
|
|
5061
|
+
label: 'Checking Providers',
|
|
5062
|
+
meta: '',
|
|
5063
|
+
description: 'please wait',
|
|
5064
|
+
_type: 'loading',
|
|
5065
|
+
}],
|
|
5066
|
+
onSelect: () => {},
|
|
5067
|
+
onCancel: () => {
|
|
5068
|
+
setPicker(null);
|
|
5069
|
+
if (onCancel) onCancel();
|
|
5070
|
+
},
|
|
5071
|
+
});
|
|
5072
|
+
try {
|
|
5073
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
5074
|
+
setup = await store.getProviderSetup();
|
|
5075
|
+
} catch (e) {
|
|
5076
|
+
store.pushNotice(`providers failed: ${e?.message || e}`, 'error');
|
|
5077
|
+
return;
|
|
5078
|
+
}
|
|
4432
5079
|
}
|
|
4433
5080
|
|
|
4434
5081
|
const items = [];
|
|
4435
|
-
if (returnTo || onContinue) {
|
|
5082
|
+
if ((returnTo || onContinue) && !options.confirmBar) {
|
|
4436
5083
|
items.push({
|
|
4437
5084
|
value: 'continue-setup',
|
|
4438
5085
|
label: options.continueLabel || 'Continue setup',
|
|
@@ -4828,14 +5475,15 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
4828
5475
|
title: options.title || 'Providers',
|
|
4829
5476
|
description: options.description || 'Choose a provider. Enter opens provider actions.',
|
|
4830
5477
|
footer: providerFooter,
|
|
4831
|
-
footerGapRows:
|
|
4832
|
-
help: '↑/↓ Select · Enter Open · Esc Back',
|
|
5478
|
+
footerGapRows: 1,
|
|
5479
|
+
help: options.confirmBar ? undefined : '↑/↓ Select · Enter Open · Esc Back',
|
|
4833
5480
|
indexMode: 'always',
|
|
4834
5481
|
labelWidth: 18,
|
|
4835
5482
|
metaWidth: 12,
|
|
4836
5483
|
pickerKey: `providers-main:${options.highlightProviderValue || 'root'}`,
|
|
4837
5484
|
initialIndex: providerMainInitialIndex(),
|
|
4838
5485
|
items,
|
|
5486
|
+
confirmBar: options.confirmBar || null,
|
|
4839
5487
|
onHighlight: (_value, item) => {
|
|
4840
5488
|
if (item?._providerId) rememberProviderSelection(item);
|
|
4841
5489
|
},
|
|
@@ -4865,70 +5513,274 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
4865
5513
|
});
|
|
4866
5514
|
};
|
|
4867
5515
|
|
|
4868
|
-
|
|
4869
|
-
|
|
4870
|
-
|
|
4871
|
-
|
|
4872
|
-
|
|
4873
|
-
|
|
4874
|
-
|
|
4875
|
-
|
|
4876
|
-
|
|
5516
|
+
// First-run onboarding is a 5-step wizard. Each step's ROOT screen carries a
|
|
5517
|
+
// ConfirmBar (Back/Next, Finish on the last step); the Picker owns the bar
|
|
5518
|
+
// focus and only fires onConfirm from the bar. Nested depths (API-key entry,
|
|
5519
|
+
// model route picker, channel setting/webhook) render without a ConfirmBar so
|
|
5520
|
+
// their own key semantics are untouched and step-switching is disabled there.
|
|
5521
|
+
// Esc/cancel during onboarding = confirm skip. Mark onboarding complete
|
|
5522
|
+
// (routes/agents/provider untouched) so it does not reopen next launch;
|
|
5523
|
+
// `mixdog --onboarding` (forceOnboarding) still reopens regardless.
|
|
5524
|
+
const onboardingWarnReopen = () => {
|
|
5525
|
+
setOnboardingActive(false);
|
|
5526
|
+
try {
|
|
5527
|
+
store.skipOnboarding?.();
|
|
5528
|
+
store.pushNotice('Setup skipped. Run `mixdog --onboarding` to set up later.', 'info');
|
|
5529
|
+
} catch (e) {
|
|
5530
|
+
store.pushNotice(`Couldn’t save skip: ${e?.message || e}`, 'error');
|
|
5531
|
+
}
|
|
4877
5532
|
};
|
|
4878
5533
|
|
|
4879
|
-
|
|
4880
|
-
|
|
4881
|
-
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
|
|
5534
|
+
// Warm the Step 2 data (provider models + agent roster) in the background as
|
|
5535
|
+
// soon as Step 1 opens, so advancing to Step 2 renders instantly instead of
|
|
5536
|
+
// flashing an empty panel while the async load runs.
|
|
5537
|
+
const prefetchOnboardingStep2 = () => {
|
|
5538
|
+
if (!Array.isArray(onboardingRef.current.providerModels) || onboardingRef.current.providerModels.length === 0) {
|
|
5539
|
+
const seq = onboardingPrefetchSeqRef.current;
|
|
5540
|
+
void Promise.resolve(store.listProviderModels?.())
|
|
5541
|
+
.then((models) => {
|
|
5542
|
+
// Drop a stale result: if the provider cache was invalidated (auth
|
|
5543
|
+
// change) while this load was in flight, its generation moved on.
|
|
5544
|
+
if (seq !== onboardingPrefetchSeqRef.current) return;
|
|
5545
|
+
if (Array.isArray(models) && models.length) {
|
|
5546
|
+
onboardingRef.current.providerModels = models;
|
|
5547
|
+
providerModelsCacheRef.current = { models, at: Date.now() };
|
|
5548
|
+
}
|
|
5549
|
+
})
|
|
5550
|
+
.catch(() => { /* Step 2 falls back to its own load on entry. */ });
|
|
4886
5551
|
}
|
|
4887
|
-
|
|
4888
|
-
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
|
|
5552
|
+
if (!Array.isArray(onboardingRef.current.agents) || onboardingRef.current.agents.length === 0) {
|
|
5553
|
+
try {
|
|
5554
|
+
const roster = (store.listAgents?.() || []).map((a) => ({ id: a.id, label: a.label || a.id, description: a.description || '' }));
|
|
5555
|
+
if (roster.length) onboardingRef.current.agents = roster;
|
|
5556
|
+
} catch { /* Step 2 retries on entry. */ }
|
|
5557
|
+
}
|
|
5558
|
+
};
|
|
5559
|
+
|
|
5560
|
+
const openOnboardingAuthStep = async () => {
|
|
5561
|
+
prefetchOnboardingStep2();
|
|
5562
|
+
// Load the provider setup BEFORE opening the picker so Step 1 renders the
|
|
5563
|
+
// real list in one frame instead of flashing the "Checking Providers"
|
|
5564
|
+
// placeholder (that swap is what looked like a jump on entry). On failure,
|
|
5565
|
+
// fall back to the picker's own in-panel loading path.
|
|
5566
|
+
let preloadedSetup = null;
|
|
5567
|
+
try {
|
|
5568
|
+
preloadedSetup = await store.getProviderSetup?.();
|
|
5569
|
+
} catch { /* openProviderSetupPicker will show its loading frame + error. */ }
|
|
5570
|
+
void openProviderSetupPicker({
|
|
5571
|
+
title: 'First Run · Step 1/5 · Provider Auth',
|
|
5572
|
+
returnTo: () => openOnboardingAuthStep(),
|
|
5573
|
+
preloadedSetup,
|
|
5574
|
+
confirmBar: {
|
|
5575
|
+
buttons: [{ value: 'next', label: 'Next ▶' }],
|
|
5576
|
+
onConfirm: () => { setPicker(null); void openOnboardingWorkflowStep(); },
|
|
4894
5577
|
},
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
4898
|
-
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
|
|
4902
|
-
|
|
4903
|
-
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
|
|
5578
|
+
onCancel: onboardingWarnReopen,
|
|
5579
|
+
});
|
|
5580
|
+
};
|
|
5581
|
+
|
|
5582
|
+
const openOnboardingThemeStep = () => {
|
|
5583
|
+
openThemePicker({
|
|
5584
|
+
onboarding: {
|
|
5585
|
+
onAdvance: () => openOnboardingOutputStyleStep(),
|
|
5586
|
+
onBack: () => void openOnboardingWorkflowStep(),
|
|
5587
|
+
onCancel: onboardingWarnReopen,
|
|
5588
|
+
},
|
|
5589
|
+
});
|
|
5590
|
+
};
|
|
5591
|
+
|
|
5592
|
+
const openOnboardingOutputStyleStep = () => {
|
|
5593
|
+
openOutputStylePicker({
|
|
5594
|
+
onboarding: {
|
|
5595
|
+
onAdvance: () => void openOnboardingRemoteStep(),
|
|
5596
|
+
onBack: () => openOnboardingThemeStep(),
|
|
5597
|
+
onCancel: onboardingWarnReopen,
|
|
5598
|
+
},
|
|
5599
|
+
});
|
|
5600
|
+
};
|
|
5601
|
+
|
|
5602
|
+
const openOnboardingRemoteStep = () => {
|
|
5603
|
+
void openChannelSetupPicker('all', {
|
|
5604
|
+
onboarding: true,
|
|
5605
|
+
confirmBar: {
|
|
5606
|
+
buttons: [
|
|
5607
|
+
{ value: 'back', label: '◀ Back' },
|
|
5608
|
+
{ value: 'finish', label: 'Finish ✓' },
|
|
5609
|
+
],
|
|
5610
|
+
onConfirm: (button) => {
|
|
5611
|
+
if (button.value === 'back') {
|
|
5612
|
+
setPicker(null);
|
|
5613
|
+
openOnboardingOutputStyleStep();
|
|
5614
|
+
return;
|
|
5615
|
+
}
|
|
5616
|
+
finishOnboarding();
|
|
5617
|
+
},
|
|
5618
|
+
},
|
|
5619
|
+
});
|
|
5620
|
+
};
|
|
5621
|
+
|
|
5622
|
+
const finishOnboarding = () => {
|
|
5623
|
+
const defaultRoute = onboardingRef.current.defaultRoute;
|
|
5624
|
+
const searchRoute = onboardingRef.current.searchRoute || null;
|
|
5625
|
+
const overrides = onboardingRef.current.agentRoutes || {};
|
|
5626
|
+
const hasOverrides = Object.keys(overrides).length > 0;
|
|
5627
|
+
setPicker(null);
|
|
5628
|
+
setOnboardingActive(false);
|
|
5629
|
+
const done = () => store.pushNotice('First-run setup complete.', 'info');
|
|
5630
|
+
const failed = (e) => store.pushNotice(`Couldn’t save setup: ${e?.message || e}`, 'error');
|
|
5631
|
+
// Branch 1 — Main Model set: full persist. Agents without an explicit
|
|
5632
|
+
// override are sent; untouched agents are left out so the backend never
|
|
5633
|
+
// overwrites them (they follow the Main Model dynamically at runtime).
|
|
5634
|
+
if (defaultRoute) {
|
|
5635
|
+
void store.completeOnboarding?.({
|
|
5636
|
+
defaultRoute,
|
|
5637
|
+
defaultProvider: defaultRoute.provider,
|
|
5638
|
+
...(hasOverrides ? { agentRoutes: { ...overrides } } : {}),
|
|
5639
|
+
...(searchRoute ? { searchRoute } : {}),
|
|
5640
|
+
}).then(done).catch(failed);
|
|
5641
|
+
return;
|
|
5642
|
+
}
|
|
5643
|
+
// Branch 2 — Main unset but some Search/agent picks exist: partial persist.
|
|
5644
|
+
// Only the explicit overrides are sent (no defaultRoute/defaultProvider); the
|
|
5645
|
+
// backend skips agents lacking a route and marks onboarding complete.
|
|
5646
|
+
if (hasOverrides || searchRoute) {
|
|
5647
|
+
void store.completeOnboarding?.({
|
|
5648
|
+
...(hasOverrides ? { agentRoutes: { ...overrides } } : {}),
|
|
5649
|
+
...(searchRoute ? { searchRoute } : {}),
|
|
5650
|
+
}).then(done).catch(failed);
|
|
5651
|
+
return;
|
|
5652
|
+
}
|
|
5653
|
+
// Branch 3 — nothing configured: mark done only, leave config untouched.
|
|
5654
|
+
try {
|
|
5655
|
+
store.skipOnboarding?.();
|
|
5656
|
+
} catch (e) {
|
|
5657
|
+
failed(e);
|
|
5658
|
+
return;
|
|
5659
|
+
}
|
|
5660
|
+
done();
|
|
5661
|
+
};
|
|
5662
|
+
|
|
5663
|
+
// Onboarding Step 2 per-target model picker. `target` is either the pseudo
|
|
5664
|
+
// slot 'lead' (Main Model) or a real agent id from listAgents(). No
|
|
5665
|
+
// recommendation logic: the current effective route is pre-highlighted, and
|
|
5666
|
+
// the plain model list is shown. Selecting Main Model updates defaultRoute;
|
|
5667
|
+
// agents that have no explicit override keep inheriting it.
|
|
5668
|
+
const openOnboardingRoleModelPicker = async (target) => {
|
|
5669
|
+
const isLead = target === 'lead';
|
|
5670
|
+
const isSearch = target === 'search';
|
|
5671
|
+
// Search uses the search-capable model list; lead/agent use provider models.
|
|
5672
|
+
let models;
|
|
5673
|
+
if (isSearch) {
|
|
5674
|
+
let searchModels = [];
|
|
5675
|
+
try {
|
|
5676
|
+
searchModels = await Promise.resolve(store.listSearchModels?.() || []);
|
|
5677
|
+
} catch (e) {
|
|
5678
|
+
store.pushNotice(`could not list search models: ${e?.message || e}`, 'warn');
|
|
5679
|
+
}
|
|
5680
|
+
models = normalizeModelOptions(searchModels || []);
|
|
5681
|
+
if (models.length === 0) {
|
|
5682
|
+
store.pushNotice('no native search models available; connect OpenAI, Grok, Gemini, or Anthropic', 'warn');
|
|
5683
|
+
void openOnboardingWorkflowStep();
|
|
5684
|
+
return;
|
|
5685
|
+
}
|
|
5686
|
+
} else {
|
|
5687
|
+
models = normalizeModelOptions(onboardingRef.current.providerModels || []);
|
|
5688
|
+
if (models.length === 0) {
|
|
5689
|
+
store.pushNotice('no provider models available; open /providers to sign in', 'warn');
|
|
5690
|
+
openOnboardingAuthStep();
|
|
5691
|
+
return;
|
|
5692
|
+
}
|
|
5693
|
+
}
|
|
5694
|
+
const overrides = onboardingRef.current.agentRoutes || {};
|
|
5695
|
+
// Current effective route for pre-marking: Main/Search show their own stored
|
|
5696
|
+
// route (or none); agents show their explicit override only (unset = none,
|
|
5697
|
+
// so we don't falsely mark the Main Model row on an untouched agent).
|
|
5698
|
+
const currentRoute = isLead
|
|
5699
|
+
? (onboardingRef.current.defaultRoute || null)
|
|
5700
|
+
: isSearch
|
|
5701
|
+
? (onboardingRef.current.searchRoute || null)
|
|
5702
|
+
: (overrides[target] || null);
|
|
5703
|
+
const routeMatchesModel = (route, m) => route?.provider === m.provider && route?.model === m.id;
|
|
5704
|
+
// Non-lead targets get a leading "Default" row that makes the target follow
|
|
5705
|
+
// the Main Model at runtime. For agents this clears the override (null);
|
|
5706
|
+
// for search this stores the SEARCH_DEFAULT marker route. "Default" is the
|
|
5707
|
+
// pre-marked row when the target is unset (agent) or on the marker (search).
|
|
5708
|
+
const isDefaultSelected = isSearch
|
|
5709
|
+
? (!currentRoute || isSearchDefaultRoute(currentRoute))
|
|
5710
|
+
: !currentRoute;
|
|
5711
|
+
const isUnset = isDefaultSelected;
|
|
5712
|
+
const modelItems = models.map((m) => ({
|
|
5713
|
+
value: `${m.provider}:${m.id}`,
|
|
5714
|
+
label: m.display || m.id,
|
|
5715
|
+
marker: routeMatchesModel(currentRoute, m) ? '✓' : '',
|
|
5716
|
+
markerColor: theme.success,
|
|
5717
|
+
description: modelDescription(m),
|
|
5718
|
+
_model: m,
|
|
5719
|
+
}));
|
|
5720
|
+
const items = isLead
|
|
5721
|
+
? modelItems
|
|
5722
|
+
: [
|
|
5723
|
+
{
|
|
5724
|
+
value: '__default__',
|
|
5725
|
+
label: 'Default',
|
|
5726
|
+
marker: isUnset ? '✓' : '',
|
|
5727
|
+
markerColor: theme.success,
|
|
5728
|
+
description: 'follows Main Model',
|
|
5729
|
+
_default: true,
|
|
5730
|
+
},
|
|
5731
|
+
...modelItems,
|
|
5732
|
+
];
|
|
5733
|
+
const matchIdx = models.findIndex((m) => routeMatchesModel(currentRoute, m));
|
|
5734
|
+
const initialIndex = isLead
|
|
5735
|
+
? Math.max(0, matchIdx)
|
|
5736
|
+
: (isUnset || matchIdx < 0 ? 0 : matchIdx + 1);
|
|
5737
|
+
const label = isLead
|
|
5738
|
+
? 'Main'
|
|
5739
|
+
: isSearch
|
|
5740
|
+
? 'Search'
|
|
5741
|
+
: (onboardingRef.current.agents || []).find((a) => a.id === target)?.label || target;
|
|
4909
5742
|
setPicker({
|
|
4910
|
-
title: `First Run · ${
|
|
4911
|
-
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,
|
|
4912
5750
|
items,
|
|
4913
5751
|
onSelect: (_value, item) => {
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
5752
|
+
// "Default" → clear the override so this target follows the Main Model.
|
|
5753
|
+
if (item?._default) {
|
|
5754
|
+
if (isSearch) {
|
|
5755
|
+
// Store the SEARCH_DEFAULT marker so finish persists it and the
|
|
5756
|
+
// runtime follows the Main Model (not a null that drops the field).
|
|
5757
|
+
onboardingRef.current.searchRoute = { ...SEARCH_DEFAULT_ROUTE };
|
|
5758
|
+
} else {
|
|
5759
|
+
const nextOverrides = { ...(onboardingRef.current.agentRoutes || {}) };
|
|
5760
|
+
delete nextOverrides[target];
|
|
5761
|
+
onboardingRef.current.agentRoutes = nextOverrides;
|
|
5762
|
+
}
|
|
5763
|
+
setPicker(null);
|
|
5764
|
+
void openOnboardingWorkflowStep();
|
|
5765
|
+
return;
|
|
5766
|
+
}
|
|
5767
|
+
const next = item?._model ? routeFromModel(item._model) : null;
|
|
4919
5768
|
if (!next) {
|
|
4920
5769
|
store.pushNotice('select a provider model first', 'warn');
|
|
4921
5770
|
setPicker(null);
|
|
4922
|
-
|
|
5771
|
+
void openOnboardingWorkflowStep();
|
|
4923
5772
|
return;
|
|
4924
5773
|
}
|
|
4925
|
-
if (
|
|
5774
|
+
if (isLead) {
|
|
4926
5775
|
onboardingRef.current.defaultRoute = next;
|
|
5776
|
+
} else if (isSearch) {
|
|
5777
|
+
onboardingRef.current.searchRoute = next;
|
|
5778
|
+
} else {
|
|
5779
|
+
onboardingRef.current.agentRoutes = {
|
|
5780
|
+
...(onboardingRef.current.agentRoutes || {}),
|
|
5781
|
+
[target]: next,
|
|
5782
|
+
};
|
|
4927
5783
|
}
|
|
4928
|
-
onboardingRef.current.workflowRoutes = {
|
|
4929
|
-
...(onboardingRef.current.workflowRoutes || {}),
|
|
4930
|
-
[slot]: next,
|
|
4931
|
-
};
|
|
4932
5784
|
setPicker(null);
|
|
4933
5785
|
void openOnboardingWorkflowStep();
|
|
4934
5786
|
},
|
|
@@ -4952,90 +5804,86 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
4952
5804
|
const models = onboardingRef.current.providerModels || [];
|
|
4953
5805
|
if (models.length === 0) {
|
|
4954
5806
|
onboardingRef.current.defaultRoute = null;
|
|
4955
|
-
onboardingRef.current.
|
|
5807
|
+
onboardingRef.current.agentRoutes = {};
|
|
4956
5808
|
store.pushNotice('no provider models available; open /providers to sign in', 'warn');
|
|
4957
5809
|
openOnboardingAuthStep();
|
|
4958
5810
|
return;
|
|
4959
5811
|
}
|
|
4960
|
-
|
|
4961
|
-
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
5812
|
+
// Main Model stays unset until the user picks one; no auto-recommendation.
|
|
5813
|
+
// Load the real agent roster once (explore/maintainer/worker/heavy-worker/
|
|
5814
|
+
// reviewer/debugger). Each agent defaults to the Main Model unless the user
|
|
5815
|
+
// set an explicit override in agentRoutes.
|
|
5816
|
+
if (!Array.isArray(onboardingRef.current.agents) || onboardingRef.current.agents.length === 0) {
|
|
5817
|
+
try {
|
|
5818
|
+
onboardingRef.current.agents = (store.listAgents?.() || []).map((a) => ({ id: a.id, label: a.label || a.id, description: a.description || '' }));
|
|
5819
|
+
} catch (e) {
|
|
5820
|
+
onboardingRef.current.agents = [];
|
|
5821
|
+
store.pushNotice(`could not list agents: ${e?.message || e}`, 'warn');
|
|
5822
|
+
}
|
|
4965
5823
|
}
|
|
4966
|
-
onboardingRef.current.
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
const routes = onboardingRef.current.workflowRoutes || {};
|
|
4971
|
-
const slots = [
|
|
4972
|
-
['agent', 'Agent', 'agent dispatch route'],
|
|
4973
|
-
['explorer', 'Explorer', 'code graph, file reading, repo exploration'],
|
|
4974
|
-
['memory', 'Memory', 'memory cycles and curation'],
|
|
4975
|
-
];
|
|
5824
|
+
const defaultRoute = onboardingRef.current.defaultRoute;
|
|
5825
|
+
const searchRoute = onboardingRef.current.searchRoute || null;
|
|
5826
|
+
const overrides = onboardingRef.current.agentRoutes || {};
|
|
5827
|
+
const agents = onboardingRef.current.agents || [];
|
|
4976
5828
|
setProviderPrompt(null);
|
|
4977
5829
|
setChannelPrompt(null);
|
|
4978
5830
|
setHookPrompt(null);
|
|
4979
5831
|
setSettingsPrompt(null);
|
|
4980
5832
|
setPicker({
|
|
4981
|
-
title: 'First Run · Step 2/
|
|
4982
|
-
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,
|
|
4983
5838
|
items: [
|
|
4984
5839
|
{
|
|
4985
|
-
value: '
|
|
4986
|
-
label: '
|
|
4987
|
-
|
|
4988
|
-
|
|
5840
|
+
value: 'main-model',
|
|
5841
|
+
label: 'Main',
|
|
5842
|
+
metaParts: agentModelParts(defaultRoute),
|
|
5843
|
+
description: 'main chat, planning, and agent default',
|
|
5844
|
+
_action: 'slot',
|
|
5845
|
+
_target: 'lead',
|
|
4989
5846
|
},
|
|
4990
5847
|
{
|
|
4991
|
-
value: '
|
|
4992
|
-
label: '
|
|
4993
|
-
|
|
5848
|
+
value: 'search-model',
|
|
5849
|
+
label: 'Search',
|
|
5850
|
+
// Marker route = follow Main Model → show a hint, not 'default/default'.
|
|
5851
|
+
metaParts: isSearchDefaultRoute(searchRoute)
|
|
5852
|
+
? [{ text: '(follows main)', width: 17 }, { text: '', width: 6 }, { text: '', width: 4 }]
|
|
5853
|
+
: agentModelParts(searchRoute),
|
|
5854
|
+
description: 'native search model',
|
|
4994
5855
|
_action: 'slot',
|
|
4995
|
-
|
|
5856
|
+
_target: 'search',
|
|
4996
5857
|
},
|
|
4997
|
-
...
|
|
4998
|
-
value:
|
|
4999
|
-
label,
|
|
5000
|
-
|
|
5858
|
+
...agents.map((agent) => ({
|
|
5859
|
+
value: `agent:${agent.id}`,
|
|
5860
|
+
label: agent.label,
|
|
5861
|
+
metaParts: agentModelParts(overrides[agent.id] || null),
|
|
5862
|
+
description: agent.description || '',
|
|
5001
5863
|
_action: 'slot',
|
|
5002
|
-
|
|
5864
|
+
_target: agent.id,
|
|
5003
5865
|
})),
|
|
5004
|
-
{
|
|
5005
|
-
value: 'back',
|
|
5006
|
-
label: 'Back to provider auth',
|
|
5007
|
-
description: 'change API keys, OAuth, or local endpoints',
|
|
5008
|
-
_action: 'back',
|
|
5009
|
-
},
|
|
5010
5866
|
],
|
|
5867
|
+
confirmBar: {
|
|
5868
|
+
buttons: [
|
|
5869
|
+
{ value: 'back', label: '◀ Back' },
|
|
5870
|
+
{ value: 'next', label: 'Next ▶' },
|
|
5871
|
+
],
|
|
5872
|
+
onConfirm: (button) => {
|
|
5873
|
+
setPicker(null);
|
|
5874
|
+
if (button.value === 'back') openOnboardingAuthStep();
|
|
5875
|
+
else openOnboardingThemeStep();
|
|
5876
|
+
},
|
|
5877
|
+
},
|
|
5011
5878
|
onSelect: (_value, item) => {
|
|
5012
5879
|
setPicker(null);
|
|
5013
|
-
if (item._action === 'finish') {
|
|
5014
|
-
const defaultRoute = onboardingRef.current.defaultRoute;
|
|
5015
|
-
if (!defaultRoute) {
|
|
5016
|
-
store.pushNotice('select a provider model before finishing setup', 'warn');
|
|
5017
|
-
openOnboardingAuthStep();
|
|
5018
|
-
return;
|
|
5019
|
-
}
|
|
5020
|
-
void store.completeOnboarding?.({
|
|
5021
|
-
defaultRoute,
|
|
5022
|
-
workflowRoutes: onboardingRef.current.workflowRoutes || {},
|
|
5023
|
-
})
|
|
5024
|
-
.then(() => store.pushNotice('First-run setup complete.', 'info'))
|
|
5025
|
-
.catch((e) => store.pushNotice(`Couldn’t save setup: ${e?.message || e}`, 'error'));
|
|
5026
|
-
return;
|
|
5027
|
-
}
|
|
5028
|
-
if (item._action === 'back') {
|
|
5029
|
-
openOnboardingAuthStep();
|
|
5030
|
-
return;
|
|
5031
|
-
}
|
|
5032
5880
|
if (item._action === 'slot') {
|
|
5033
|
-
openOnboardingRoleModelPicker(item.
|
|
5881
|
+
openOnboardingRoleModelPicker(item._target);
|
|
5034
5882
|
}
|
|
5035
5883
|
},
|
|
5036
5884
|
onCancel: () => {
|
|
5037
5885
|
setPicker(null);
|
|
5038
|
-
|
|
5886
|
+
onboardingWarnReopen();
|
|
5039
5887
|
},
|
|
5040
5888
|
});
|
|
5041
5889
|
};
|
|
@@ -5045,8 +5893,9 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
5045
5893
|
let canceled = false;
|
|
5046
5894
|
try {
|
|
5047
5895
|
const status = store.getOnboardingStatus?.();
|
|
5048
|
-
if (status?.completed === true) return undefined;
|
|
5896
|
+
if (status?.completed === true && !forceOnboarding) return undefined;
|
|
5049
5897
|
onboardingStartedRef.current = true;
|
|
5898
|
+
setOnboardingActive(true);
|
|
5050
5899
|
setTimeout(() => {
|
|
5051
5900
|
if (!canceled) openOnboardingAuthStep();
|
|
5052
5901
|
}, 0);
|
|
@@ -5056,9 +5905,176 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
5056
5905
|
return () => {
|
|
5057
5906
|
canceled = true;
|
|
5058
5907
|
};
|
|
5059
|
-
}, [store]);
|
|
5908
|
+
}, [store, forceOnboarding]);
|
|
5909
|
+
|
|
5910
|
+
const openChannelTypeActionsPicker = (backend, options = {}) => {
|
|
5911
|
+
const parentReturn = typeof options.returnTo === 'function'
|
|
5912
|
+
? options.returnTo
|
|
5913
|
+
: () => openChannelSettingTypePicker();
|
|
5914
|
+
setProviderPrompt(null);
|
|
5915
|
+
setHookPrompt(null);
|
|
5916
|
+
setSettingsPrompt(null);
|
|
5917
|
+
setContextPanel(null);
|
|
5918
|
+
let setup;
|
|
5919
|
+
try {
|
|
5920
|
+
setup = store.getChannelSetup();
|
|
5921
|
+
} catch (e) {
|
|
5922
|
+
store.pushNotice(`channels failed: ${e?.message || e}`, 'error');
|
|
5923
|
+
return;
|
|
5924
|
+
}
|
|
5925
|
+
const isTelegram = backend === 'telegram';
|
|
5926
|
+
const activeBackend = setup.backend || 'discord';
|
|
5927
|
+
const tokenDescription = isTelegram
|
|
5928
|
+
? `${setup.telegram?.status ?? 'Off'}${setup.telegram?.problem ? ' · Invalid' : ''}`
|
|
5929
|
+
: `${setup.discord.status}${setup.discord.problem ? ' · Invalid' : ''}`;
|
|
5930
|
+
const mainEntry = (setup.channels || []).find((ch) => ch.main)
|
|
5931
|
+
|| (setup.channels || []).find((ch) => ch.name === 'main');
|
|
5932
|
+
const mainTarget = isTelegram
|
|
5933
|
+
? (mainEntry?.telegramChatId || (activeBackend === 'telegram' ? mainEntry?.channelId : ''))
|
|
5934
|
+
: (mainEntry?.discordChannelId || (activeBackend === 'discord' ? mainEntry?.channelId : ''));
|
|
5935
|
+
const mainDescription = isTelegram
|
|
5936
|
+
? (mainTarget ? `Chat ID ${mainTarget}` : 'Not set · Enter Telegram chat ID')
|
|
5937
|
+
: (mainTarget ? `Channel ID ${mainTarget}` : 'Not set · Enter Discord channel ID');
|
|
5938
|
+
|
|
5939
|
+
const openChannelPrompt = (prompt) => {
|
|
5940
|
+
setPicker(null);
|
|
5941
|
+
setContextPanel(null);
|
|
5942
|
+
setChannelPrompt({
|
|
5943
|
+
...prompt,
|
|
5944
|
+
afterSave: () => openChannelTypeActionsPicker(backend, options),
|
|
5945
|
+
});
|
|
5946
|
+
};
|
|
5947
|
+
|
|
5948
|
+
setPicker({
|
|
5949
|
+
title: isTelegram ? 'Telegram' : 'Discord',
|
|
5950
|
+
description: activeBackend === backend
|
|
5951
|
+
? 'Active channel type · token and main target'
|
|
5952
|
+
: 'Token and main target settings',
|
|
5953
|
+
help: '↑/↓ Select · Enter Edit · Esc Back',
|
|
5954
|
+
indexMode: 'always',
|
|
5955
|
+
labelWidth: 18,
|
|
5956
|
+
items: [
|
|
5957
|
+
{
|
|
5958
|
+
value: 'token',
|
|
5959
|
+
label: 'Bot token',
|
|
5960
|
+
description: tokenDescription,
|
|
5961
|
+
_action: isTelegram ? 'telegram-token' : 'discord-token',
|
|
5962
|
+
},
|
|
5963
|
+
{
|
|
5964
|
+
value: 'main',
|
|
5965
|
+
label: isTelegram ? 'Main chat' : 'Main channel',
|
|
5966
|
+
description: mainDescription,
|
|
5967
|
+
_action: 'main-target',
|
|
5968
|
+
},
|
|
5969
|
+
],
|
|
5970
|
+
onSelect: (_value, item) => {
|
|
5971
|
+
try {
|
|
5972
|
+
if (item._action === 'discord-token') {
|
|
5973
|
+
openChannelPrompt({
|
|
5974
|
+
kind: 'discord-token',
|
|
5975
|
+
label: 'Discord bot token',
|
|
5976
|
+
hint: 'Paste the Discord bot token. It is stored in the OS keychain.',
|
|
5977
|
+
});
|
|
5978
|
+
return;
|
|
5979
|
+
}
|
|
5980
|
+
if (item._action === 'telegram-token') {
|
|
5981
|
+
openChannelPrompt({
|
|
5982
|
+
kind: 'telegram-token',
|
|
5983
|
+
label: 'Telegram bot token',
|
|
5984
|
+
hint: 'Paste the Telegram bot token from @BotFather. Stored in the OS keychain.',
|
|
5985
|
+
});
|
|
5986
|
+
return;
|
|
5987
|
+
}
|
|
5988
|
+
if (item._action === 'main-target') {
|
|
5989
|
+
openChannelPrompt({
|
|
5990
|
+
kind: 'channel-add',
|
|
5991
|
+
backend,
|
|
5992
|
+
label: isTelegram ? 'Main chat' : 'Main channel',
|
|
5993
|
+
hint: isTelegram
|
|
5994
|
+
? 'Format: main | Telegram chat ID | mode(interactive/broadcast) | main'
|
|
5995
|
+
: 'Format: main | Discord channel ID | mode(interactive/broadcast) | main',
|
|
5996
|
+
});
|
|
5997
|
+
}
|
|
5998
|
+
} catch (e) {
|
|
5999
|
+
store.pushNotice(`channels update failed: ${e?.message || e}`, 'error');
|
|
6000
|
+
}
|
|
6001
|
+
},
|
|
6002
|
+
onCancel: () => {
|
|
6003
|
+
parentReturn();
|
|
6004
|
+
},
|
|
6005
|
+
});
|
|
6006
|
+
};
|
|
6007
|
+
|
|
6008
|
+
const openChannelSettingTypePicker = (options = {}) => {
|
|
6009
|
+
const returnTo = typeof options.returnTo === 'function' ? options.returnTo : () => {};
|
|
6010
|
+
setProviderPrompt(null);
|
|
6011
|
+
setChannelPrompt(null);
|
|
6012
|
+
setHookPrompt(null);
|
|
6013
|
+
setSettingsPrompt(null);
|
|
6014
|
+
setContextPanel(null);
|
|
6015
|
+
let setup;
|
|
6016
|
+
try {
|
|
6017
|
+
setup = store.getChannelSetup();
|
|
6018
|
+
} catch (e) {
|
|
6019
|
+
store.pushNotice(`channels failed: ${e?.message || e}`, 'error');
|
|
6020
|
+
return;
|
|
6021
|
+
}
|
|
6022
|
+
const activeBackend = setup.backend || 'discord';
|
|
6023
|
+
const mainEntry = (setup.channels || []).find((ch) => ch.main)
|
|
6024
|
+
|| (setup.channels || []).find((ch) => ch.name === 'main');
|
|
6025
|
+
const typeDescription = (backend) => {
|
|
6026
|
+
const selected = activeBackend === backend;
|
|
6027
|
+
const hasToken = backend === 'telegram'
|
|
6028
|
+
? setup.telegram?.authenticated === true
|
|
6029
|
+
: setup.discord?.authenticated === true;
|
|
6030
|
+
const hasTarget = backend === 'telegram'
|
|
6031
|
+
? Boolean(mainEntry?.telegramChatId || (activeBackend === 'telegram' && mainEntry?.channelId))
|
|
6032
|
+
: Boolean(mainEntry?.discordChannelId || (activeBackend === 'discord' && mainEntry?.channelId));
|
|
6033
|
+
const needs = [
|
|
6034
|
+
...(hasToken ? [] : ['token']),
|
|
6035
|
+
...(hasTarget ? [] : [backend === 'telegram' ? 'chat ID' : 'channel ID']),
|
|
6036
|
+
];
|
|
6037
|
+
return [
|
|
6038
|
+
...(selected ? ['Selected'] : []),
|
|
6039
|
+
needs.length ? `Needs ${needs.join(' + ')}` : 'Ready',
|
|
6040
|
+
].join(' · ');
|
|
6041
|
+
};
|
|
6042
|
+
setPicker({
|
|
6043
|
+
title: 'Channel Type Settings',
|
|
6044
|
+
description: 'Choose a type. Selected is the active backend; Ready means token and main target are set.',
|
|
6045
|
+
help: '↑/↓ Select · Enter Open · Esc Back',
|
|
6046
|
+
indexMode: 'always',
|
|
6047
|
+
labelWidth: 18,
|
|
6048
|
+
items: [
|
|
6049
|
+
{
|
|
6050
|
+
value: 'discord',
|
|
6051
|
+
label: 'Discord',
|
|
6052
|
+
description: typeDescription('discord'),
|
|
6053
|
+
_backend: 'discord',
|
|
6054
|
+
},
|
|
6055
|
+
{
|
|
6056
|
+
value: 'telegram',
|
|
6057
|
+
label: 'Telegram',
|
|
6058
|
+
description: typeDescription('telegram'),
|
|
6059
|
+
_backend: 'telegram',
|
|
6060
|
+
},
|
|
6061
|
+
],
|
|
6062
|
+
onSelect: (value, item) => {
|
|
6063
|
+
const backend = item?._backend || (value === 'telegram' ? 'telegram' : value === 'discord' ? 'discord' : null);
|
|
6064
|
+
if (!backend) return;
|
|
6065
|
+
setPicker(null);
|
|
6066
|
+
openChannelTypeActionsPicker(backend, {
|
|
6067
|
+
returnTo: () => openChannelSettingTypePicker(options),
|
|
6068
|
+
});
|
|
6069
|
+
},
|
|
6070
|
+
onCancel: () => {
|
|
6071
|
+
setPicker(null);
|
|
6072
|
+
returnTo();
|
|
6073
|
+
},
|
|
6074
|
+
});
|
|
6075
|
+
};
|
|
5060
6076
|
|
|
5061
|
-
const openChannelSetupPicker = async (focus = 'all') => {
|
|
6077
|
+
const openChannelSetupPicker = async (focus = 'all', options = {}) => {
|
|
5062
6078
|
setProviderPrompt(null);
|
|
5063
6079
|
setChannelPrompt(null);
|
|
5064
6080
|
setHookPrompt(null);
|
|
@@ -5078,23 +6094,19 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
5078
6094
|
setChannelPrompt(prompt);
|
|
5079
6095
|
};
|
|
5080
6096
|
|
|
6097
|
+
const channelRemoteEnabled = store.isRemoteEnabled?.() === true;
|
|
6098
|
+
|
|
5081
6099
|
if (focus === 'schedules') {
|
|
5082
6100
|
const schedules = setup.schedules || [];
|
|
5083
6101
|
const items = [
|
|
5084
|
-
{
|
|
5085
|
-
value: 'schedule-add',
|
|
5086
|
-
label: 'Add schedule',
|
|
5087
|
-
description: 'name | cron | instructions | optional channel | optional model',
|
|
5088
|
-
_action: 'schedule-add',
|
|
5089
|
-
},
|
|
5090
6102
|
...(schedules.length ? schedules.map((schedule) => {
|
|
5091
6103
|
const enabled = schedule.enabled !== false;
|
|
5092
6104
|
return {
|
|
5093
6105
|
value: `schedule:${schedule.name}`,
|
|
5094
6106
|
label: schedule.name,
|
|
5095
6107
|
marker: enabled ? '●' : '○',
|
|
5096
|
-
markerColor: enabled ? theme.success : theme.inactive,
|
|
5097
|
-
description: `${schedule.time || '(no cron)'} · ${schedule.route}${schedule.model ? ` · ${schedule.model}` : ''}`,
|
|
6108
|
+
markerColor: channelRemoteEnabled ? (enabled ? theme.success : theme.inactive) : theme.inactive,
|
|
6109
|
+
description: `${schedule.time || '(no cron)'} · ${schedule.route}${schedule.model ? ` · ${schedule.model}` : ''}${channelRemoteEnabled ? '' : ' · channel off'}`,
|
|
5098
6110
|
_action: 'schedule-toggle',
|
|
5099
6111
|
_name: schedule.name,
|
|
5100
6112
|
_enabled: enabled,
|
|
@@ -5105,40 +6117,87 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
5105
6117
|
description: 'no schedules configured',
|
|
5106
6118
|
_action: 'noop',
|
|
5107
6119
|
}]),
|
|
6120
|
+
];
|
|
6121
|
+
const toggleSchedule = (item) => {
|
|
6122
|
+
if (item._action !== 'schedule-toggle') return;
|
|
6123
|
+
if (!channelRemoteEnabled) {
|
|
6124
|
+
store.pushNotice('enable channel first', 'warn');
|
|
6125
|
+
return;
|
|
6126
|
+
}
|
|
6127
|
+
try {
|
|
6128
|
+
store.setScheduleEnabled?.(item._name, !item._enabled);
|
|
6129
|
+
void openChannelSetupPicker('schedules', { highlightValue: `schedule:${item._name}` });
|
|
6130
|
+
} catch (e) {
|
|
6131
|
+
store.pushNotice(`schedule toggle failed: ${e?.message || e}`, 'error');
|
|
6132
|
+
}
|
|
6133
|
+
};
|
|
6134
|
+
setPicker({
|
|
6135
|
+
title: 'Schedules',
|
|
6136
|
+
description: channelRemoteEnabled ? 'Enable or disable cron schedules.' : 'Enable channel to toggle schedules.',
|
|
6137
|
+
initialIndex: Math.max(0, items.findIndex((entry) => entry.value === options.highlightValue)),
|
|
6138
|
+
items,
|
|
6139
|
+
onSelect: (_value, item) => toggleSchedule(item),
|
|
6140
|
+
onLeft: (item) => toggleSchedule(item),
|
|
6141
|
+
onRight: (item) => toggleSchedule(item),
|
|
6142
|
+
onCancel: () => {
|
|
6143
|
+
setPicker(null);
|
|
6144
|
+
},
|
|
6145
|
+
});
|
|
6146
|
+
return;
|
|
6147
|
+
}
|
|
6148
|
+
|
|
6149
|
+
if (focus === 'webhook-endpoint') {
|
|
6150
|
+
const returnTo = typeof options.returnTo === 'function'
|
|
6151
|
+
? options.returnTo
|
|
6152
|
+
: () => setPicker(null);
|
|
6153
|
+
const domain = setup.webhook?.ngrokDomain || setup.webhook?.domain || '';
|
|
6154
|
+
const items = [
|
|
6155
|
+
{
|
|
6156
|
+
value: 'endpoint-domain',
|
|
6157
|
+
label: 'ngrok domain',
|
|
6158
|
+
description: domain ? domain : 'Not set · Enter ngrok domain',
|
|
6159
|
+
_action: 'endpoint-domain',
|
|
6160
|
+
},
|
|
5108
6161
|
{
|
|
5109
|
-
value: '
|
|
5110
|
-
label: '
|
|
5111
|
-
description: '
|
|
5112
|
-
_action: '
|
|
6162
|
+
value: 'endpoint-authtoken',
|
|
6163
|
+
label: 'authtoken',
|
|
6164
|
+
description: setup.webhook?.authenticated === true ? 'Set' : 'Not set · Enter authtoken',
|
|
6165
|
+
_action: 'endpoint-authtoken',
|
|
5113
6166
|
},
|
|
5114
6167
|
];
|
|
5115
6168
|
setPicker({
|
|
5116
|
-
title: '
|
|
5117
|
-
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,
|
|
5118
6174
|
items,
|
|
5119
6175
|
onSelect: (_value, item) => {
|
|
5120
6176
|
try {
|
|
5121
|
-
if (item._action === '
|
|
6177
|
+
if (item._action === 'endpoint-domain') {
|
|
5122
6178
|
openChannelPrompt({
|
|
5123
|
-
kind: '
|
|
5124
|
-
label: '
|
|
5125
|
-
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),
|
|
5126
6183
|
});
|
|
5127
6184
|
return;
|
|
5128
6185
|
}
|
|
5129
|
-
if (item._action === '
|
|
5130
|
-
|
|
5131
|
-
|
|
6186
|
+
if (item._action === 'endpoint-authtoken') {
|
|
6187
|
+
openChannelPrompt({
|
|
6188
|
+
kind: 'webhook-token',
|
|
6189
|
+
label: 'Webhook/ngrok authtoken',
|
|
6190
|
+
hint: 'Paste the webhook/ngrok authtoken. It is stored in the OS keychain.',
|
|
6191
|
+
afterSave: () => void openChannelSetupPicker('webhook-endpoint', options),
|
|
6192
|
+
});
|
|
5132
6193
|
}
|
|
5133
|
-
if (item._action !== 'schedule-toggle') return;
|
|
5134
|
-
store.setScheduleEnabled?.(item._name, !item._enabled);
|
|
5135
|
-
void openChannelSetupPicker('schedules');
|
|
5136
6194
|
} catch (e) {
|
|
5137
|
-
store.pushNotice(`
|
|
6195
|
+
store.pushNotice(`webhook endpoint failed: ${e?.message || e}`, 'error');
|
|
5138
6196
|
}
|
|
5139
6197
|
},
|
|
5140
6198
|
onCancel: () => {
|
|
5141
6199
|
setPicker(null);
|
|
6200
|
+
returnTo();
|
|
5142
6201
|
},
|
|
5143
6202
|
});
|
|
5144
6203
|
return;
|
|
@@ -5146,31 +6205,15 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
5146
6205
|
|
|
5147
6206
|
if (focus === 'webhooks') {
|
|
5148
6207
|
const hooks = setup.webhooks || [];
|
|
5149
|
-
const serverEnabled = setup.webhook.enabled !== false;
|
|
5150
6208
|
const items = [
|
|
5151
|
-
{
|
|
5152
|
-
value: 'webhook-add',
|
|
5153
|
-
label: 'Add webhook',
|
|
5154
|
-
description: 'name | instructions | optional channel | optional model | parser',
|
|
5155
|
-
_action: 'webhook-add',
|
|
5156
|
-
},
|
|
5157
|
-
{
|
|
5158
|
-
value: 'webhook-server',
|
|
5159
|
-
label: 'Webhook server',
|
|
5160
|
-
marker: serverEnabled ? '●' : '○',
|
|
5161
|
-
markerColor: serverEnabled ? theme.success : theme.inactive,
|
|
5162
|
-
description: `port ${setup.webhook.port || 3333} · auth ${setup.webhook.status}`,
|
|
5163
|
-
_action: 'server-toggle',
|
|
5164
|
-
_enabled: serverEnabled,
|
|
5165
|
-
},
|
|
5166
6209
|
...(hooks.length ? hooks.map((hook) => {
|
|
5167
6210
|
const enabled = hook.enabled !== false;
|
|
5168
6211
|
return {
|
|
5169
6212
|
value: `webhook:${hook.name}`,
|
|
5170
6213
|
label: hook.name,
|
|
5171
6214
|
marker: enabled ? '●' : '○',
|
|
5172
|
-
markerColor: enabled ? theme.success : theme.inactive,
|
|
5173
|
-
description: `${hook.parser || 'github'} · ${hook.route} · secret:${hook.secretSet ? 'set' : 'missing'}`,
|
|
6215
|
+
markerColor: channelRemoteEnabled ? (enabled ? theme.success : theme.inactive) : theme.inactive,
|
|
6216
|
+
description: `${hook.parser || 'github'} · ${hook.route} · secret:${hook.secretSet ? 'set' : 'missing'}${channelRemoteEnabled ? '' : ' · channel off'}`,
|
|
5174
6217
|
_action: 'webhook-toggle',
|
|
5175
6218
|
_name: hook.name,
|
|
5176
6219
|
_enabled: enabled,
|
|
@@ -5181,44 +6224,28 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
5181
6224
|
description: 'no webhook endpoints configured',
|
|
5182
6225
|
_action: 'noop',
|
|
5183
6226
|
}]),
|
|
5184
|
-
{
|
|
5185
|
-
value: 'back',
|
|
5186
|
-
label: 'Back',
|
|
5187
|
-
description: 'return to channel setup',
|
|
5188
|
-
_action: 'back',
|
|
5189
|
-
},
|
|
5190
6227
|
];
|
|
6228
|
+
const toggleWebhook = (item) => {
|
|
6229
|
+
if (item._action !== 'webhook-toggle') return;
|
|
6230
|
+
if (!channelRemoteEnabled) {
|
|
6231
|
+
store.pushNotice('enable channel first', 'warn');
|
|
6232
|
+
return;
|
|
6233
|
+
}
|
|
6234
|
+
try {
|
|
6235
|
+
store.setWebhookEnabled?.(item._name, !item._enabled);
|
|
6236
|
+
void openChannelSetupPicker('webhooks', { highlightValue: `webhook:${item._name}` });
|
|
6237
|
+
} catch (e) {
|
|
6238
|
+
store.pushNotice(`webhook toggle failed: ${e?.message || e}`, 'error');
|
|
6239
|
+
}
|
|
6240
|
+
};
|
|
5191
6241
|
setPicker({
|
|
5192
6242
|
title: 'Webhooks',
|
|
5193
|
-
description: '
|
|
6243
|
+
description: channelRemoteEnabled ? 'Enable or disable inbound webhook endpoints.' : 'Enable channel to toggle webhooks.',
|
|
6244
|
+
initialIndex: Math.max(0, items.findIndex((entry) => entry.value === options.highlightValue)),
|
|
5194
6245
|
items,
|
|
5195
|
-
onSelect: (_value, item) =>
|
|
5196
|
-
|
|
5197
|
-
|
|
5198
|
-
openChannelPrompt({
|
|
5199
|
-
kind: 'webhook-add',
|
|
5200
|
-
label: 'Add webhook',
|
|
5201
|
-
hint: 'Format: name | instructions | channel(optional) | model(required with channel) | parser(github/generic/stripe/sentry)',
|
|
5202
|
-
});
|
|
5203
|
-
return;
|
|
5204
|
-
}
|
|
5205
|
-
if (item._action === 'back') {
|
|
5206
|
-
void openChannelSetupPicker('all');
|
|
5207
|
-
return;
|
|
5208
|
-
}
|
|
5209
|
-
if (item._action === 'server-toggle') {
|
|
5210
|
-
store.setWebhookConfig?.({ enabled: !item._enabled });
|
|
5211
|
-
void openChannelSetupPicker('webhooks');
|
|
5212
|
-
return;
|
|
5213
|
-
}
|
|
5214
|
-
if (item._action === 'webhook-toggle') {
|
|
5215
|
-
store.setWebhookEnabled?.(item._name, !item._enabled);
|
|
5216
|
-
void openChannelSetupPicker('webhooks');
|
|
5217
|
-
}
|
|
5218
|
-
} catch (e) {
|
|
5219
|
-
store.pushNotice(`webhook toggle failed: ${e?.message || e}`, 'error');
|
|
5220
|
-
}
|
|
5221
|
-
},
|
|
6246
|
+
onSelect: (_value, item) => toggleWebhook(item),
|
|
6247
|
+
onLeft: (item) => toggleWebhook(item),
|
|
6248
|
+
onRight: (item) => toggleWebhook(item),
|
|
5222
6249
|
onCancel: () => {
|
|
5223
6250
|
setPicker(null);
|
|
5224
6251
|
},
|
|
@@ -5227,144 +6254,128 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
5227
6254
|
}
|
|
5228
6255
|
|
|
5229
6256
|
const worker = store.getChannelWorkerStatus?.();
|
|
5230
|
-
const
|
|
6257
|
+
const activeBackend = setup.backend === 'telegram' ? 'telegram' : 'discord';
|
|
6258
|
+
const backendLabel = activeBackend === 'telegram' ? 'Telegram' : 'Discord';
|
|
6259
|
+
const remoteEnabled = store.isRemoteEnabled?.() === true;
|
|
6260
|
+
const boolLabel = (enabled) => enabled ? 'On' : 'Off';
|
|
6261
|
+
// Onboarding Step 5 reuses this root picker with a ConfirmBar. Reopens after
|
|
6262
|
+
// a toggle must carry the onboarding context (confirmBar + flag) forward so
|
|
6263
|
+
// Back/Finish stay wired and the general /channels path keeps its own opts.
|
|
6264
|
+
const reopenRoot = (extra = {}) => {
|
|
6265
|
+
const preserved = options.onboarding
|
|
6266
|
+
? { onboarding: true, confirmBar: options.confirmBar || null }
|
|
6267
|
+
: {};
|
|
6268
|
+
void openChannelSetupPicker('all', { ...preserved, ...extra });
|
|
6269
|
+
};
|
|
6270
|
+
const applyRemoteRuntime = (highlightValue = 'remote-runtime') => {
|
|
6271
|
+
const enabled = store.toggleRemote?.() === true;
|
|
6272
|
+
store.pushNotice(enabled ? 'Remote mode ON' : 'Remote mode OFF', 'info');
|
|
6273
|
+
reopenRoot({ highlightValue });
|
|
6274
|
+
};
|
|
6275
|
+
const cycleChannelBackend = (direction = 1, highlightValue = 'channel-backend') => {
|
|
6276
|
+
const backends = ['discord', 'telegram'];
|
|
6277
|
+
const currentIndex = Math.max(0, backends.indexOf(activeBackend));
|
|
6278
|
+
const chosen = backends[(currentIndex + direction + backends.length) % backends.length];
|
|
6279
|
+
if (chosen === activeBackend) {
|
|
6280
|
+
reopenRoot({ highlightValue, backendOverride: activeBackend });
|
|
6281
|
+
return;
|
|
6282
|
+
}
|
|
6283
|
+
try {
|
|
6284
|
+
store.setBackend(chosen);
|
|
6285
|
+
const label = chosen === 'telegram' ? 'Telegram' : 'Discord';
|
|
6286
|
+
const restartHint = (store.isRemoteEnabled?.() === true || worker?.running)
|
|
6287
|
+
? `Channel set to ${label}. Restart remote to apply.`
|
|
6288
|
+
: `Channel set to ${label}.`;
|
|
6289
|
+
store.pushNotice(restartHint, 'info');
|
|
6290
|
+
} catch (e) {
|
|
6291
|
+
store.pushNotice(`channel backend failed: ${e?.message || e}`, 'error');
|
|
6292
|
+
}
|
|
6293
|
+
reopenRoot({ highlightValue, backendOverride: chosen });
|
|
6294
|
+
};
|
|
5231
6295
|
const items = [
|
|
5232
6296
|
{
|
|
5233
|
-
value: '
|
|
5234
|
-
label: '
|
|
5235
|
-
|
|
5236
|
-
|
|
5237
|
-
|
|
5238
|
-
{
|
|
5239
|
-
value: 'discord-token',
|
|
5240
|
-
label: 'Discord token',
|
|
5241
|
-
description: `Bot token · ${setup.discord.status}${setup.discord.problem ? ' · invalid' : ''}`,
|
|
5242
|
-
_action: 'discord-token',
|
|
5243
|
-
},
|
|
5244
|
-
{
|
|
5245
|
-
value: 'channel-add',
|
|
5246
|
-
label: 'Add channel',
|
|
5247
|
-
description: 'name | channelId | mode(interactive/broadcast)',
|
|
5248
|
-
_action: 'channel-add',
|
|
6297
|
+
value: 'remote-runtime',
|
|
6298
|
+
label: 'Remote Runtime',
|
|
6299
|
+
meta: boolLabel(remoteEnabled),
|
|
6300
|
+
description: worker?.running ? `Running · pid ${worker.pid}` : 'Stopped',
|
|
6301
|
+
_action: 'remote-runtime',
|
|
5249
6302
|
},
|
|
5250
6303
|
{
|
|
5251
|
-
value: '
|
|
5252
|
-
label: '
|
|
5253
|
-
|
|
5254
|
-
|
|
6304
|
+
value: 'channel-backend',
|
|
6305
|
+
label: 'Channel',
|
|
6306
|
+
meta: backendLabel,
|
|
6307
|
+
description: 'Select Discord or Telegram',
|
|
6308
|
+
_action: 'channel-backend',
|
|
5255
6309
|
},
|
|
5256
6310
|
{
|
|
5257
|
-
value: '
|
|
5258
|
-
label: '
|
|
5259
|
-
description: '
|
|
5260
|
-
_action: '
|
|
6311
|
+
value: 'channel-setting',
|
|
6312
|
+
label: 'Setting',
|
|
6313
|
+
description: 'Token and main target',
|
|
6314
|
+
_action: 'channel-setting',
|
|
5261
6315
|
},
|
|
5262
6316
|
{
|
|
5263
|
-
value: 'webhook-
|
|
5264
|
-
label: 'Webhook
|
|
5265
|
-
|
|
5266
|
-
|
|
5267
|
-
|
|
5268
|
-
|
|
5269
|
-
|
|
5270
|
-
|
|
5271
|
-
|
|
5272
|
-
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
5280
|
-
description: `${(setup.webhooks || []).length} configured`,
|
|
5281
|
-
_action: 'webhooks',
|
|
5282
|
-
},
|
|
5283
|
-
{
|
|
5284
|
-
value: 'webhook-add',
|
|
5285
|
-
label: 'Add webhook',
|
|
5286
|
-
description: 'name | instructions | optional channel | optional model | parser',
|
|
5287
|
-
_action: 'webhook-add',
|
|
6317
|
+
value: 'webhook-endpoint',
|
|
6318
|
+
label: 'Webhook endpoint',
|
|
6319
|
+
meta: (() => {
|
|
6320
|
+
const hasDomain = Boolean(setup.webhook?.ngrokDomain || setup.webhook?.domain);
|
|
6321
|
+
const hasAuth = setup.webhook?.authenticated === true;
|
|
6322
|
+
return (hasDomain && hasAuth) ? 'On' : 'Off';
|
|
6323
|
+
})(),
|
|
6324
|
+
description: (() => {
|
|
6325
|
+
const hasDomain = Boolean(setup.webhook?.ngrokDomain || setup.webhook?.domain);
|
|
6326
|
+
const hasAuth = setup.webhook?.authenticated === true;
|
|
6327
|
+
const needs = [
|
|
6328
|
+
...(hasDomain ? [] : ['domain']),
|
|
6329
|
+
...(hasAuth ? [] : ['authtoken']),
|
|
6330
|
+
];
|
|
6331
|
+
return needs.length ? `Needs ${needs.join(' + ')}` : 'ngrok domain and authtoken set';
|
|
6332
|
+
})(),
|
|
6333
|
+
_action: 'webhook-endpoint',
|
|
5288
6334
|
},
|
|
5289
|
-
...((setup.channels || []).map((ch) => ({
|
|
5290
|
-
value: `channel:${ch.name}`,
|
|
5291
|
-
label: `# ${ch.name}`,
|
|
5292
|
-
description: `${ch.channelId || '(unset)'} · ${ch.mode}${ch.main ? ' · main' : ''} · edit`,
|
|
5293
|
-
_action: 'channel-edit',
|
|
5294
|
-
_channel: ch,
|
|
5295
|
-
}))),
|
|
5296
6335
|
];
|
|
5297
6336
|
|
|
5298
6337
|
setPicker({
|
|
5299
6338
|
title: 'Channels',
|
|
5300
|
-
description: '
|
|
6339
|
+
description: 'Remote access and channel setup.',
|
|
6340
|
+
// Onboarding overlays a ConfirmBar (←/→ drive Back/Finish, not toggles),
|
|
6341
|
+
// so drop the ←/→ Change hint there and use the Picker's ConfirmBar help.
|
|
6342
|
+
help: options.confirmBar ? undefined : '↑/↓ Select · ←/→ Change · Enter Choose/Toggle · Esc Back',
|
|
6343
|
+
indexMode: 'always',
|
|
6344
|
+
labelWidth: 18,
|
|
6345
|
+
metaWidth: 12,
|
|
6346
|
+
pickerKey: `channels:${activeBackend}:${remoteEnabled ? 'on' : 'off'}:${options.highlightValue || 'root'}`,
|
|
6347
|
+
initialIndex: Math.max(0, items.findIndex((entry) => entry.value === options.highlightValue)),
|
|
5301
6348
|
items,
|
|
6349
|
+
confirmBar: options.confirmBar || null,
|
|
6350
|
+
onLeft: options.confirmBar ? undefined : (item) => {
|
|
6351
|
+
if (item?._action === 'remote-runtime') applyRemoteRuntime(item.value);
|
|
6352
|
+
else if (item?._action === 'channel-backend') cycleChannelBackend(-1, item.value);
|
|
6353
|
+
},
|
|
6354
|
+
onRight: options.confirmBar ? undefined : (item) => {
|
|
6355
|
+
if (item?._action === 'remote-runtime') applyRemoteRuntime(item.value);
|
|
6356
|
+
else if (item?._action === 'channel-backend') cycleChannelBackend(1, item.value);
|
|
6357
|
+
},
|
|
5302
6358
|
onSelect: (_value, item) => {
|
|
5303
6359
|
try {
|
|
5304
|
-
if (item._action === '
|
|
5305
|
-
|
|
5306
|
-
return;
|
|
5307
|
-
}
|
|
5308
|
-
if (item._action === 'discord-token') {
|
|
5309
|
-
openChannelPrompt({
|
|
5310
|
-
kind: 'discord-token',
|
|
5311
|
-
label: 'Discord bot token',
|
|
5312
|
-
hint: 'Paste the Discord bot token. It is stored in the OS keychain.',
|
|
5313
|
-
});
|
|
5314
|
-
return;
|
|
5315
|
-
}
|
|
5316
|
-
if (item._action === 'webhook-token') {
|
|
5317
|
-
openChannelPrompt({
|
|
5318
|
-
kind: 'webhook-token',
|
|
5319
|
-
label: 'Webhook/ngrok authtoken',
|
|
5320
|
-
hint: 'Paste the webhook/ngrok authtoken. It is stored in the OS keychain.',
|
|
5321
|
-
});
|
|
5322
|
-
return;
|
|
5323
|
-
}
|
|
5324
|
-
if (item._action === 'channel-add') {
|
|
5325
|
-
openChannelPrompt({
|
|
5326
|
-
kind: 'channel-add',
|
|
5327
|
-
label: 'Add channel',
|
|
5328
|
-
hint: 'Format: name | Discord channel ID | mode(interactive/broadcast) | main(optional)',
|
|
5329
|
-
});
|
|
6360
|
+
if (item._action === 'remote-runtime') {
|
|
6361
|
+
applyRemoteRuntime(item.value);
|
|
5330
6362
|
return;
|
|
5331
6363
|
}
|
|
5332
|
-
if (item._action === 'channel-
|
|
5333
|
-
|
|
5334
|
-
openChannelPrompt({
|
|
5335
|
-
kind: 'channel-add',
|
|
5336
|
-
label: `Edit channel · ${ch.name}`,
|
|
5337
|
-
hint: `Format: ${ch.name} | ${ch.channelId || '<channel-id>'} | ${ch.mode || 'interactive'} | ${ch.main ? 'main' : 'main(optional)'}`,
|
|
5338
|
-
});
|
|
6364
|
+
if (item._action === 'channel-backend') {
|
|
6365
|
+
cycleChannelBackend(1, item.value);
|
|
5339
6366
|
return;
|
|
5340
6367
|
}
|
|
5341
|
-
if (item._action === '
|
|
5342
|
-
|
|
5343
|
-
|
|
5344
|
-
label: 'Add schedule',
|
|
5345
|
-
hint: 'Format: name | cron (5 or 6 fields) | instructions | channel(optional) | model(required with channel)',
|
|
6368
|
+
if (item._action === 'channel-setting') {
|
|
6369
|
+
openChannelSettingTypePicker({
|
|
6370
|
+
returnTo: () => reopenRoot({ highlightValue: 'channel-setting' }),
|
|
5346
6371
|
});
|
|
5347
6372
|
return;
|
|
5348
6373
|
}
|
|
5349
|
-
if (item._action === 'webhook-
|
|
5350
|
-
|
|
5351
|
-
|
|
5352
|
-
|
|
5353
|
-
hint: 'Format: name | instructions | channel(optional) | model(required with channel) | parser(github/generic/stripe/sentry)',
|
|
6374
|
+
if (item._action === 'webhook-endpoint') {
|
|
6375
|
+
void openChannelSetupPicker('webhook-endpoint', {
|
|
6376
|
+
...(options.onboarding ? { onboarding: true, confirmBar: options.confirmBar || null } : {}),
|
|
6377
|
+
returnTo: () => reopenRoot({ highlightValue: 'webhook-endpoint' }),
|
|
5354
6378
|
});
|
|
5355
|
-
return;
|
|
5356
|
-
}
|
|
5357
|
-
if (item._action === 'webhook-toggle') {
|
|
5358
|
-
store.setWebhookConfig?.({ enabled: !item._enabled });
|
|
5359
|
-
void openChannelSetupPicker('all');
|
|
5360
|
-
return;
|
|
5361
|
-
}
|
|
5362
|
-
if (item._action === 'schedules') {
|
|
5363
|
-
void openChannelSetupPicker('schedules');
|
|
5364
|
-
return;
|
|
5365
|
-
}
|
|
5366
|
-
if (item._action === 'webhooks') {
|
|
5367
|
-
void openChannelSetupPicker('webhooks');
|
|
5368
6379
|
}
|
|
5369
6380
|
} catch (e) {
|
|
5370
6381
|
store.pushNotice(`channels update failed: ${e?.message || e}`, 'error');
|
|
@@ -5372,6 +6383,8 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
5372
6383
|
},
|
|
5373
6384
|
onCancel: () => {
|
|
5374
6385
|
setPicker(null);
|
|
6386
|
+
// Onboarding Step 5 Esc mirrors the other steps' reopen-next-launch warning.
|
|
6387
|
+
if (options.onboarding) onboardingWarnReopen();
|
|
5375
6388
|
},
|
|
5376
6389
|
});
|
|
5377
6390
|
};
|
|
@@ -5387,46 +6400,7 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
5387
6400
|
return { ...status, servers: status.servers || [] };
|
|
5388
6401
|
};
|
|
5389
6402
|
|
|
5390
|
-
const
|
|
5391
|
-
if (server?.error) store.pushNotice(`${server.name}: ${server.error}`, 'warn');
|
|
5392
|
-
const enabled = server?.enabled !== false;
|
|
5393
|
-
const items = [
|
|
5394
|
-
{
|
|
5395
|
-
value: enabled ? 'disable' : 'enable',
|
|
5396
|
-
label: enabled ? 'Disable server' : 'Enable server',
|
|
5397
|
-
description: server?.configured ? `${server?.status || 'unknown'} · ${server?.transport || 'unknown'}` : 'server is not configured',
|
|
5398
|
-
_action: server?.configured ? (enabled ? 'disable' : 'enable') : 'noop',
|
|
5399
|
-
},
|
|
5400
|
-
{
|
|
5401
|
-
value: 'reconnect',
|
|
5402
|
-
label: 'Reconnect server',
|
|
5403
|
-
description: 'refresh configured MCP servers',
|
|
5404
|
-
_action: 'reconnect',
|
|
5405
|
-
},
|
|
5406
|
-
];
|
|
5407
|
-
setPicker({
|
|
5408
|
-
title: `MCP · ${server?.name || 'server'}`,
|
|
5409
|
-
description: 'Enable, disable, or reconnect this MCP server.',
|
|
5410
|
-
items,
|
|
5411
|
-
onSelect: (_toolValue, toolItem) => {
|
|
5412
|
-
setPicker(null);
|
|
5413
|
-
if (toolItem._action === 'enable' || toolItem._action === 'disable') {
|
|
5414
|
-
void store.setMcpServerEnabled?.(server.name, toolItem._action === 'enable')
|
|
5415
|
-
.then(() => openMcpServersPicker())
|
|
5416
|
-
.catch((e) => store.pushNotice(`mcp toggle failed: ${e?.message || e}`, 'error'));
|
|
5417
|
-
return;
|
|
5418
|
-
}
|
|
5419
|
-
if (toolItem._action === 'reconnect') {
|
|
5420
|
-
void store.reconnectMcp?.()
|
|
5421
|
-
.then(() => openMcpServersPicker())
|
|
5422
|
-
.catch((e) => store.pushNotice(`mcp reconnect failed: ${e?.message || e}`, 'error'));
|
|
5423
|
-
}
|
|
5424
|
-
},
|
|
5425
|
-
onCancel: () => openMcpServersPicker(),
|
|
5426
|
-
});
|
|
5427
|
-
};
|
|
5428
|
-
|
|
5429
|
-
const openMcpServersPicker = () => {
|
|
6403
|
+
const openMcpServersPicker = (options = {}) => {
|
|
5430
6404
|
const status = mcpStatus();
|
|
5431
6405
|
if (!status) return;
|
|
5432
6406
|
const servers = status.servers || [];
|
|
@@ -5440,27 +6414,36 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
5440
6414
|
});
|
|
5441
6415
|
}
|
|
5442
6416
|
for (const server of servers) {
|
|
6417
|
+
const enabled = server.enabled !== false;
|
|
5443
6418
|
items.push({
|
|
5444
6419
|
value: `server:${server.name}`,
|
|
5445
|
-
label: server.
|
|
6420
|
+
label: server.name,
|
|
6421
|
+
marker: enabled ? '●' : '○',
|
|
6422
|
+
markerColor: enabled ? theme.success : theme.inactive,
|
|
5446
6423
|
description: `${server.status || 'unknown'} · ${server.transport || 'unknown'} · ${server.toolCount || 0} tools${server.error ? ` · ${server.error}` : ''}`,
|
|
5447
6424
|
_action: 'server',
|
|
5448
6425
|
_server: server,
|
|
6426
|
+
_enabled: enabled,
|
|
5449
6427
|
});
|
|
5450
6428
|
}
|
|
5451
6429
|
setProviderPrompt(null);
|
|
5452
6430
|
setChannelPrompt(null);
|
|
5453
6431
|
setHookPrompt(null);
|
|
5454
6432
|
setSettingsPrompt(null);
|
|
6433
|
+
const toggleServer = (item) => {
|
|
6434
|
+
if (item._action !== 'server' || !item._server?.name) return;
|
|
6435
|
+
void store.setMcpServerEnabled?.(item._server.name, !item._enabled)
|
|
6436
|
+
.then(() => openMcpServersPicker({ highlightValue: `server:${item._server.name}` }))
|
|
6437
|
+
.catch((e) => store.pushNotice(`mcp toggle failed: ${e?.message || e}`, 'error'));
|
|
6438
|
+
};
|
|
5455
6439
|
setPicker({
|
|
5456
6440
|
title: 'MCP servers',
|
|
5457
|
-
description: '
|
|
6441
|
+
description: 'Enable or disable configured MCP servers.',
|
|
6442
|
+
initialIndex: Math.max(0, items.findIndex((entry) => entry.value === options?.highlightValue)),
|
|
5458
6443
|
items,
|
|
5459
|
-
onSelect: (_value, item) =>
|
|
5460
|
-
|
|
5461
|
-
|
|
5462
|
-
openMcpServerPicker(item._server);
|
|
5463
|
-
},
|
|
6444
|
+
onSelect: (_value, item) => toggleServer(item),
|
|
6445
|
+
onLeft: (item) => toggleServer(item),
|
|
6446
|
+
onRight: (item) => toggleServer(item),
|
|
5464
6447
|
onCancel: () => {
|
|
5465
6448
|
setPicker(null);
|
|
5466
6449
|
},
|
|
@@ -5524,10 +6507,11 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
5524
6507
|
});
|
|
5525
6508
|
};
|
|
5526
6509
|
|
|
5527
|
-
const openSkillsPicker = () => {
|
|
6510
|
+
const openSkillsPicker = (options = {}) => {
|
|
5528
6511
|
const status = skillsStatus();
|
|
5529
6512
|
if (!status) return;
|
|
5530
6513
|
const skills = status.skills || [];
|
|
6514
|
+
const disabledSet = options.disabledOverride instanceof Set ? options.disabledOverride : disabledSkills;
|
|
5531
6515
|
const items = [];
|
|
5532
6516
|
if (skills.length === 0) {
|
|
5533
6517
|
items.push({
|
|
@@ -5538,28 +6522,39 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
5538
6522
|
});
|
|
5539
6523
|
}
|
|
5540
6524
|
for (const skill of skills) {
|
|
5541
|
-
const
|
|
6525
|
+
const enabled = !disabledSet.has(skill.name);
|
|
5542
6526
|
items.push({
|
|
5543
6527
|
value: skill.name,
|
|
5544
|
-
label:
|
|
5545
|
-
|
|
6528
|
+
label: skill.name,
|
|
6529
|
+
marker: enabled ? '●' : '○',
|
|
6530
|
+
markerColor: enabled ? theme.success : theme.inactive,
|
|
6531
|
+
description: `${skill.source || 'skill'} · ${skill.description || skill.filePath || ''}`,
|
|
5546
6532
|
_action: 'skill',
|
|
5547
6533
|
_skill: skill,
|
|
6534
|
+
_enabled: enabled,
|
|
5548
6535
|
});
|
|
5549
6536
|
}
|
|
5550
6537
|
setProviderPrompt(null);
|
|
5551
6538
|
setChannelPrompt(null);
|
|
5552
6539
|
setHookPrompt(null);
|
|
5553
6540
|
setSettingsPrompt(null);
|
|
6541
|
+
const toggleSkill = (item) => {
|
|
6542
|
+
if (item._action !== 'skill' || !item._skill?.name) return;
|
|
6543
|
+
const name = item._skill.name;
|
|
6544
|
+
const next = new Set(disabledSet);
|
|
6545
|
+
if (item._enabled) next.add(name); else next.delete(name);
|
|
6546
|
+
setDisabledSkills(next);
|
|
6547
|
+
store.pushNotice(`skill ${item._enabled ? 'disabled' : 'enabled'}: ${name}`, 'info');
|
|
6548
|
+
openSkillsPicker({ highlightValue: name, disabledOverride: next });
|
|
6549
|
+
};
|
|
5554
6550
|
setPicker({
|
|
5555
6551
|
title: 'Skills',
|
|
5556
|
-
description: '
|
|
6552
|
+
description: 'Enable or disable project skills.',
|
|
6553
|
+
initialIndex: Math.max(0, items.findIndex((entry) => entry.value === options.highlightValue)),
|
|
5557
6554
|
items,
|
|
5558
|
-
onSelect: (_value, item) =>
|
|
5559
|
-
|
|
5560
|
-
|
|
5561
|
-
openSkillDetailPicker(item._skill);
|
|
5562
|
-
},
|
|
6555
|
+
onSelect: (_value, item) => toggleSkill(item),
|
|
6556
|
+
onLeft: (item) => toggleSkill(item),
|
|
6557
|
+
onRight: (item) => toggleSkill(item),
|
|
5563
6558
|
onCancel: () => {
|
|
5564
6559
|
setPicker(null);
|
|
5565
6560
|
},
|
|
@@ -5841,21 +6836,22 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
5841
6836
|
setChannelPrompt(null);
|
|
5842
6837
|
setHookPrompt(null);
|
|
5843
6838
|
setSettingsPrompt(null);
|
|
6839
|
+
const toggleRule = (item) => {
|
|
6840
|
+
if (item._action !== 'rule') return;
|
|
6841
|
+
try {
|
|
6842
|
+
store.setHookRuleEnabled?.(item._rule.index, !item._rule.enabled);
|
|
6843
|
+
void openHooksPicker();
|
|
6844
|
+
} catch (e) {
|
|
6845
|
+
store.pushNotice(`hook toggle failed: ${e?.message || e}`, 'error');
|
|
6846
|
+
}
|
|
6847
|
+
};
|
|
5844
6848
|
setPicker({
|
|
5845
6849
|
title: 'Hooks',
|
|
5846
6850
|
description: 'Before-tool hook rules; Enter toggles a rule.',
|
|
5847
6851
|
items,
|
|
5848
|
-
onSelect: (_value, item) =>
|
|
5849
|
-
|
|
5850
|
-
|
|
5851
|
-
try {
|
|
5852
|
-
store.setHookRuleEnabled?.(item._rule.index, !item._rule.enabled);
|
|
5853
|
-
void openHooksPicker();
|
|
5854
|
-
} catch (e) {
|
|
5855
|
-
store.pushNotice(`hook toggle failed: ${e?.message || e}`, 'error');
|
|
5856
|
-
}
|
|
5857
|
-
}
|
|
5858
|
-
},
|
|
6852
|
+
onSelect: (_value, item) => toggleRule(item),
|
|
6853
|
+
onLeft: (item) => toggleRule(item),
|
|
6854
|
+
onRight: (item) => toggleRule(item),
|
|
5859
6855
|
onCancel: () => {
|
|
5860
6856
|
setPicker(null);
|
|
5861
6857
|
},
|
|
@@ -5925,6 +6921,126 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
5925
6921
|
.catch((e) => store.pushNotice(`memory failed: ${e?.message || e}`, 'error'));
|
|
5926
6922
|
};
|
|
5927
6923
|
|
|
6924
|
+
const openUpdatePicker = (options = {}) => {
|
|
6925
|
+
const returnTo = typeof options.returnTo === 'function' ? options.returnTo : null;
|
|
6926
|
+
const readSettings = () => {
|
|
6927
|
+
try { return store.getUpdateSettings?.() || {}; } catch { return {}; }
|
|
6928
|
+
};
|
|
6929
|
+
const readStatus = () => {
|
|
6930
|
+
try { return store.getUpdateStatus?.() || { phase: 'idle' }; } catch { return { phase: 'idle' }; }
|
|
6931
|
+
};
|
|
6932
|
+
const render = ({ checking = false } = {}) => {
|
|
6933
|
+
const upd = readSettings();
|
|
6934
|
+
const status = readStatus();
|
|
6935
|
+
const current = upd.currentVersion || 'unknown';
|
|
6936
|
+
const latestMeta = checking || status.phase === 'checking'
|
|
6937
|
+
? 'checking…'
|
|
6938
|
+
: (upd.latestVersion || 'unknown');
|
|
6939
|
+
const items = [
|
|
6940
|
+
{
|
|
6941
|
+
value: 'current',
|
|
6942
|
+
label: 'Current version',
|
|
6943
|
+
meta: current,
|
|
6944
|
+
description: 'Installed mixdog version.',
|
|
6945
|
+
_action: 'current',
|
|
6946
|
+
},
|
|
6947
|
+
{
|
|
6948
|
+
value: 'latest',
|
|
6949
|
+
label: 'Latest version',
|
|
6950
|
+
meta: latestMeta,
|
|
6951
|
+
description: 'Enter to re-check now.',
|
|
6952
|
+
_action: 'latest',
|
|
6953
|
+
},
|
|
6954
|
+
{
|
|
6955
|
+
value: 'auto-update',
|
|
6956
|
+
label: 'Auto-update',
|
|
6957
|
+
meta: upd.autoUpdate ? 'On' : 'Off',
|
|
6958
|
+
description: '←/→ or Enter to toggle automatic updates.',
|
|
6959
|
+
_action: 'auto-update',
|
|
6960
|
+
},
|
|
6961
|
+
{
|
|
6962
|
+
value: 'update-now',
|
|
6963
|
+
label: 'Update now',
|
|
6964
|
+
description: upd.updateAvailable
|
|
6965
|
+
? `Install ${upd.latestVersion || 'latest'}.`
|
|
6966
|
+
: 'Check and install the latest version.',
|
|
6967
|
+
_action: 'update-now',
|
|
6968
|
+
},
|
|
6969
|
+
];
|
|
6970
|
+
setProviderPrompt(null);
|
|
6971
|
+
setChannelPrompt(null);
|
|
6972
|
+
setHookPrompt(null);
|
|
6973
|
+
setSettingsPrompt(null);
|
|
6974
|
+
setPicker({
|
|
6975
|
+
title: 'Update',
|
|
6976
|
+
description: 'Check version and update mixdog.',
|
|
6977
|
+
help: '↑/↓ Select · ←/→ Change · Enter Open/Toggle · Esc Close',
|
|
6978
|
+
indexMode: 'always',
|
|
6979
|
+
labelWidth: 16,
|
|
6980
|
+
metaWidth: 16,
|
|
6981
|
+
fillAvailable: true,
|
|
6982
|
+
items,
|
|
6983
|
+
onLeft: (item) => {
|
|
6984
|
+
if (item?._action === 'auto-update') toggleAutoUpdate(!upd.autoUpdate);
|
|
6985
|
+
},
|
|
6986
|
+
onRight: (item) => {
|
|
6987
|
+
if (item?._action === 'auto-update') toggleAutoUpdate(!upd.autoUpdate);
|
|
6988
|
+
},
|
|
6989
|
+
onSelect: (_value, item) => {
|
|
6990
|
+
if (item?._action === 'latest') {
|
|
6991
|
+
recheck();
|
|
6992
|
+
} else if (item?._action === 'auto-update') {
|
|
6993
|
+
toggleAutoUpdate(!upd.autoUpdate);
|
|
6994
|
+
} else if (item?._action === 'update-now') {
|
|
6995
|
+
runUpdate();
|
|
6996
|
+
}
|
|
6997
|
+
},
|
|
6998
|
+
onCancel: () => {
|
|
6999
|
+
setPicker(null);
|
|
7000
|
+
if (returnTo) returnTo();
|
|
7001
|
+
},
|
|
7002
|
+
});
|
|
7003
|
+
};
|
|
7004
|
+
const toggleAutoUpdate = (enabled) => {
|
|
7005
|
+
try {
|
|
7006
|
+
void Promise.resolve(store.setAutoUpdate?.(enabled)).finally(() => render());
|
|
7007
|
+
store.pushNotice(`Auto-update ${enabled ? 'on' : 'off'}`, 'info');
|
|
7008
|
+
} catch (e) {
|
|
7009
|
+
store.pushNotice(`auto-update failed: ${e?.message || e}`, 'error');
|
|
7010
|
+
}
|
|
7011
|
+
render();
|
|
7012
|
+
};
|
|
7013
|
+
const recheck = () => {
|
|
7014
|
+
render({ checking: true });
|
|
7015
|
+
void Promise.resolve(store.checkForUpdate?.({ force: true }))
|
|
7016
|
+
.then(() => render())
|
|
7017
|
+
.catch((e) => {
|
|
7018
|
+
store.pushNotice(`update check failed: ${e?.message || e}`, 'error');
|
|
7019
|
+
render();
|
|
7020
|
+
});
|
|
7021
|
+
};
|
|
7022
|
+
const runUpdate = () => {
|
|
7023
|
+
store.pushNotice('Updating…', 'info');
|
|
7024
|
+
void Promise.resolve(store.runUpdateNow?.())
|
|
7025
|
+
.then((result) => {
|
|
7026
|
+
if (result?.ok) {
|
|
7027
|
+
store.pushNotice(`v${result.version} installed — restart to apply`, 'info');
|
|
7028
|
+
} else {
|
|
7029
|
+
store.pushNotice(`Update failed: ${result?.error || 'unknown error'}`, 'error');
|
|
7030
|
+
}
|
|
7031
|
+
render();
|
|
7032
|
+
})
|
|
7033
|
+
.catch((e) => {
|
|
7034
|
+
store.pushNotice(`Update failed: ${e?.message || e}`, 'error');
|
|
7035
|
+
render();
|
|
7036
|
+
});
|
|
7037
|
+
};
|
|
7038
|
+
render({ checking: true });
|
|
7039
|
+
void Promise.resolve(store.checkForUpdate?.({}))
|
|
7040
|
+
.then(() => render())
|
|
7041
|
+
.catch(() => render());
|
|
7042
|
+
};
|
|
7043
|
+
|
|
5928
7044
|
const openMemoryPicker = () => {
|
|
5929
7045
|
setProviderPrompt(null);
|
|
5930
7046
|
setChannelPrompt(null);
|
|
@@ -6325,6 +7441,19 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
6325
7441
|
.then(ok => store.pushNotice(ok ? modelSwitchNotice() : 'Model switch is already running.', ok ? 'info' : 'warn'))
|
|
6326
7442
|
.catch((e) => store.pushNotice(`Couldn’t switch model: ${e?.message || e}`, 'error'));
|
|
6327
7443
|
return true;
|
|
7444
|
+
case 'remote': {
|
|
7445
|
+
const enabled = store.toggleRemote?.() === true;
|
|
7446
|
+
store.pushNotice(enabled ? 'Remote mode ON' : 'Remote mode OFF', 'info');
|
|
7447
|
+
return true;
|
|
7448
|
+
}
|
|
7449
|
+
case 'voice': {
|
|
7450
|
+
// Step1 only: toggleVoice() owns config persistence (voice.enabled) +
|
|
7451
|
+
// the missing-component install sequence + its own notices (OFF/ON/
|
|
7452
|
+
// progress/failure). We don't push a redundant notice here; a null
|
|
7453
|
+
// return means "already running" or "failed", both already noticed.
|
|
7454
|
+
void toggleVoice({ pushNotice: store.pushNotice });
|
|
7455
|
+
return true;
|
|
7456
|
+
}
|
|
6328
7457
|
case 'search':
|
|
6329
7458
|
if (state.busy) {
|
|
6330
7459
|
store.pushNotice('wait for the current turn to finish before /search', 'warn');
|
|
@@ -6623,6 +7752,9 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
6623
7752
|
case 'profile':
|
|
6624
7753
|
openProfilePicker();
|
|
6625
7754
|
return true;
|
|
7755
|
+
case 'update':
|
|
7756
|
+
openUpdatePicker();
|
|
7757
|
+
return true;
|
|
6626
7758
|
case 'quit':
|
|
6627
7759
|
requestExit();
|
|
6628
7760
|
return true;
|
|
@@ -6750,26 +7882,42 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
6750
7882
|
return false;
|
|
6751
7883
|
}
|
|
6752
7884
|
try {
|
|
7885
|
+
const resumeAfterChannelPrompt = (prompt) => {
|
|
7886
|
+
const afterSave = prompt?.afterSave;
|
|
7887
|
+
setChannelPrompt(null);
|
|
7888
|
+
if (typeof afterSave === 'function') afterSave();
|
|
7889
|
+
else void openChannelSetupPicker('all');
|
|
7890
|
+
};
|
|
6753
7891
|
if (channelPrompt.kind === 'discord-token') {
|
|
6754
7892
|
if (!commandText) return false;
|
|
6755
7893
|
store.saveDiscordToken(commandText);
|
|
6756
|
-
|
|
6757
|
-
|
|
7894
|
+
resumeAfterChannelPrompt(channelPrompt);
|
|
7895
|
+
return true;
|
|
7896
|
+
}
|
|
7897
|
+
if (channelPrompt.kind === 'telegram-token') {
|
|
7898
|
+
if (!commandText) return false;
|
|
7899
|
+
store.saveTelegramToken(commandText);
|
|
7900
|
+
resumeAfterChannelPrompt(channelPrompt);
|
|
6758
7901
|
return true;
|
|
6759
7902
|
}
|
|
6760
7903
|
if (channelPrompt.kind === 'webhook-token') {
|
|
6761
7904
|
if (!commandText) return false;
|
|
6762
7905
|
store.saveWebhookAuthtoken(commandText);
|
|
6763
|
-
|
|
6764
|
-
|
|
7906
|
+
resumeAfterChannelPrompt(channelPrompt);
|
|
7907
|
+
return true;
|
|
7908
|
+
}
|
|
7909
|
+
if (channelPrompt.kind === 'webhook-domain') {
|
|
7910
|
+
if (!commandText) return false;
|
|
7911
|
+
store.setWebhookConfig?.({ ngrokDomain: commandText });
|
|
7912
|
+
resumeAfterChannelPrompt(channelPrompt);
|
|
6765
7913
|
return true;
|
|
6766
7914
|
}
|
|
6767
7915
|
const parts = commandText.split('|').map((part) => part.trim());
|
|
6768
7916
|
if (channelPrompt.kind === 'channel-add') {
|
|
6769
|
-
const [name, channelId, mode] = parts;
|
|
6770
|
-
|
|
6771
|
-
|
|
6772
|
-
|
|
7917
|
+
const [name, channelId, mode, mainFlag] = parts;
|
|
7918
|
+
const main = String(mainFlag || '').toLowerCase() === 'main' || String(name || '').toLowerCase() === 'main';
|
|
7919
|
+
store.saveChannel({ name, channelId, mode, main, backend: channelPrompt.backend });
|
|
7920
|
+
resumeAfterChannelPrompt(channelPrompt);
|
|
6773
7921
|
return true;
|
|
6774
7922
|
}
|
|
6775
7923
|
if (channelPrompt.kind === 'schedule-add') {
|
|
@@ -7015,6 +8163,8 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
7015
8163
|
}, [slashCommands.length, activeSlashQuery]);
|
|
7016
8164
|
|
|
7017
8165
|
const onPromptDraftChange = useCallback((value) => {
|
|
8166
|
+
if (String(value ?? '').length > 0) dismissWelcomePromptHint();
|
|
8167
|
+
syncPromptLayoutRows(value);
|
|
7018
8168
|
const suppressPromptHint = promptHistoryDraftChangeRef.current;
|
|
7019
8169
|
promptHistoryDraftChangeRef.current = false;
|
|
7020
8170
|
const historyNav = promptHistoryNavRef.current;
|
|
@@ -7053,7 +8203,7 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
7053
8203
|
if (slashDismissedFor) {
|
|
7054
8204
|
setSlashDismissedFor((dismissed) => (dismissed && dismissed !== value ? '' : dismissed));
|
|
7055
8205
|
}
|
|
7056
|
-
}, [clearPromptHint, resetPromptHistoryNav, showPromptHint, slashDismissedFor]);
|
|
8206
|
+
}, [clearPromptHint, dismissWelcomePromptHint, resetPromptHistoryNav, showPromptHint, slashDismissedFor, syncPromptLayoutRows]);
|
|
7057
8207
|
|
|
7058
8208
|
const cancelProviderPrompt = useCallback(() => {
|
|
7059
8209
|
try { providerPrompt?.login?.cancel?.(); } catch {}
|
|
@@ -7066,8 +8216,12 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
7066
8216
|
}, [providerPrompt, showPromptHint]);
|
|
7067
8217
|
|
|
7068
8218
|
const cancelChannelPrompt = useCallback(() => {
|
|
8219
|
+
const onCancel = channelPrompt?.onCancel;
|
|
8220
|
+
const afterSave = channelPrompt?.afterSave;
|
|
7069
8221
|
setChannelPrompt(null);
|
|
7070
|
-
|
|
8222
|
+
if (typeof onCancel === 'function') onCancel();
|
|
8223
|
+
else if (typeof afterSave === 'function') afterSave();
|
|
8224
|
+
}, [channelPrompt, showPromptHint]);
|
|
7071
8225
|
|
|
7072
8226
|
const cancelHookPrompt = useCallback(() => {
|
|
7073
8227
|
setHookPrompt(null);
|
|
@@ -7245,7 +8399,8 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
7245
8399
|
// − welcome header (empty transcript only)
|
|
7246
8400
|
// − live status (thinking / spinner / TurnDone)
|
|
7247
8401
|
// − queued prompts (marginTop 1 + N rows, only when queued)
|
|
7248
|
-
// −
|
|
8402
|
+
// − prompt meta (spinner / transient message / queued)
|
|
8403
|
+
// − input box (2 border + wrapped content)
|
|
7249
8404
|
// − statusline (reserved L1 + L2 + outer gap; total 3 rows)
|
|
7250
8405
|
//
|
|
7251
8406
|
// Every sibling outside the viewport must be accounted for here; otherwise
|
|
@@ -7263,24 +8418,39 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
7263
8418
|
// Slash search floats above the normal prompt. Actual option panels own the
|
|
7264
8419
|
// prompt/status area, so they hide those rows and expand into that space.
|
|
7265
8420
|
const inputBoxHidden = expandedOptionPanel;
|
|
7266
|
-
const showWelcomeBanner = (state.items.length === 0 && !hasFloatingPanel) || projectSelectionActive;
|
|
8421
|
+
const showWelcomeBanner = (state.items.length === 0 && !hasFloatingPanel) || projectSelectionActive || onboardingActive;
|
|
7267
8422
|
const WELCOME_ROWS = showWelcomeBanner ? 11 : 0;
|
|
7268
8423
|
const liveSpinner = state.spinner?.active ? state.spinner : (state.commandStatus?.active ? state.commandStatus : null);
|
|
7269
8424
|
const latestToast = state.toasts?.length ? state.toasts[state.toasts.length - 1] : null;
|
|
7270
8425
|
const toastHint = latestToast ? latestToast.text : '';
|
|
7271
8426
|
const inputHint = promptHint || toastHint;
|
|
7272
8427
|
const inputHintTone = promptHint ? promptHintTone : (latestToast?.tone || 'info');
|
|
7273
|
-
|
|
7274
|
-
|
|
7275
|
-
|
|
8428
|
+
const latestTranscriptItem = state.items[state.items.length - 1] || null;
|
|
8429
|
+
const latestDoneAtTail = latestTranscriptItem?.kind === 'turndone' || latestTranscriptItem?.kind === 'statusdone';
|
|
8430
|
+
// Bottom meta band ownership is LIVE-SPINNER ONLY. A finished turn's done row
|
|
8431
|
+
// (turndone/statusdone) is a normal transcript item and flows into scrollback
|
|
8432
|
+
// like anything else, so the area directly above the prompt is CLEAR when the
|
|
8433
|
+
// user is idle. Earlier this row was pinned in the meta band until the next
|
|
8434
|
+
// transcript item was appended (to dodge an autowrap overprint/bleed), which
|
|
8435
|
+
// left the completed status row stuck above the prompt while the user typed or
|
|
8436
|
+
// sat idle. That bleed is now fixed at the source by the tool-output width
|
|
8437
|
+
// clamp, so the pin is no longer needed. Kept as a named null const so the
|
|
8438
|
+
// downstream meta-band/hint logic collapses cleanly to the spinner-only path.
|
|
8439
|
+
const latestDoneItem = null;
|
|
7276
8440
|
const SCROLL_HINT_ROWS = 0;
|
|
7277
8441
|
const LIVE_STATUS_ROWS = 0;
|
|
7278
|
-
// The standalone prompt box is
|
|
7279
|
-
//
|
|
7280
|
-
//
|
|
7281
|
-
//
|
|
7282
|
-
//
|
|
7283
|
-
|
|
8442
|
+
// The standalone prompt box is 2 border rows + the wrapped PromptInput body.
|
|
8443
|
+
// The one-row scroll baseline gap ABOVE the prompt is owned by
|
|
8444
|
+
// transcriptGuardRows, not the prompt box itself. That keeps the scroll
|
|
8445
|
+
// reference at "textbox + 1" while the prompt/statusline bottom stays fixed.
|
|
8446
|
+
//
|
|
8447
|
+
// This must track the prompt draft's REAL wrapped height. Reserving a constant
|
|
8448
|
+
// one-line prompt lets long/multiline input grow the bottom cluster after the
|
|
8449
|
+
// transcript viewport has already claimed those rows, which makes transcript
|
|
8450
|
+
// body text overprint the textbox or slash command window.
|
|
8451
|
+
const currentPromptLayoutRows = promptContentRows(promptLayoutValueRef.current, promptContentColumns);
|
|
8452
|
+
const promptInputRows = inputBoxHidden ? 0 : currentPromptLayoutRows;
|
|
8453
|
+
const promptBoxRows = inputBoxHidden ? 0 : 2 + promptInputRows;
|
|
7284
8454
|
const STATUSLINE_ROWS = 3;
|
|
7285
8455
|
// Shared panel chrome math. Every floating panel follows the same vertical
|
|
7286
8456
|
// rhythm INSIDE its round border: title row, blank, description/hint row,
|
|
@@ -7297,11 +8467,26 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
7297
8467
|
const TEXT_ENTRY_ROWS = PANEL_CHROME_ROWS + 1;
|
|
7298
8468
|
const OPTION_PANEL_EXTRA_ROWS = expandedOptionPanel ? 3 : 0;
|
|
7299
8469
|
const queuedVisible = !hasFloatingPanel && !inputBoxHidden && state.queued?.length > 0;
|
|
7300
|
-
//
|
|
7301
|
-
//
|
|
7302
|
-
//
|
|
7303
|
-
//
|
|
8470
|
+
// While the slash palette is open it owns the area above the prompt, so the
|
|
8471
|
+
// live spinner/meta row is suppressed entirely — no reservation and no render.
|
|
8472
|
+
// Normalize the spinner → TurnDone handoff by making them occupy the SAME
|
|
8473
|
+
// two-row slot. Engine appends turndone/statusdone before clearing spinner, so
|
|
8474
|
+
// a transient frame can otherwise contain BOTH: transcript grows by two rows
|
|
8475
|
+
// while the bottom spinner still reserves two rows, making the viewport visibly
|
|
8476
|
+
// jump. As soon as the done row is the transcript tail, drop the spinner slot;
|
|
8477
|
+
// the new done row replaces that height in the same frame, with no ms timer.
|
|
8478
|
+
const promptMetaVisible = !inputBoxHidden && !slashPaletteOpen && !!liveSpinner && !latestDoneAtTail;
|
|
8479
|
+
const promptMetaRows = promptMetaVisible ? 2 : 0;
|
|
8480
|
+
// Toast/error text without a live spinner uses the existing transcript guard
|
|
8481
|
+
// row directly above the prompt. Do NOT reserve another row here: that made a
|
|
8482
|
+
// transient hint add a visible newline/prompt jump whenever no spinner was
|
|
8483
|
+
// active.
|
|
8484
|
+
const overlayHintRequested = !inputBoxHidden && !hasFloatingPanel && !liveSpinner && !!inputHint && !queuedVisible;
|
|
8485
|
+
const overlayHintRows = 0;
|
|
8486
|
+
// QueuedCommands renders one row per queued command, pinned above the prompt
|
|
8487
|
+
// box (no extra top-margin row).
|
|
7304
8488
|
const queuedRows = queuedVisible ? state.queued.length : 0;
|
|
8489
|
+
const INPUT_BOX_ROWS = promptBoxRows + promptMetaRows + overlayHintRows;
|
|
7305
8490
|
const baseReserve = WELCOME_ROWS + SCROLL_HINT_ROWS + LIVE_STATUS_ROWS + INPUT_BOX_ROWS + STATUSLINE_ROWS + queuedRows;
|
|
7306
8491
|
const maxFloatingPanelRows = Math.max(0, resizeState.rows - baseReserve - 1);
|
|
7307
8492
|
const desiredFloatingPanelRows = toolApproval
|
|
@@ -7315,19 +8500,51 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
7315
8500
|
: slashPaletteOpen
|
|
7316
8501
|
? PANEL_MAX_VISIBLE + PANEL_CHROME_ROWS
|
|
7317
8502
|
: hasTextEntryPrompt
|
|
7318
|
-
? TEXT_ENTRY_ROWS
|
|
8503
|
+
? TEXT_ENTRY_ROWS
|
|
7319
8504
|
: 0;
|
|
7320
8505
|
const floatingPanelRows = desiredFloatingPanelRows > 0
|
|
7321
8506
|
? Math.min(desiredFloatingPanelRows, maxFloatingPanelRows)
|
|
7322
8507
|
: 0;
|
|
8508
|
+
// Give the list every content row the panel exposes. The panel already grew
|
|
8509
|
+
// by OPTION_PANEL_EXTRA_ROWS; previously that growth was subtracted back out
|
|
8510
|
+
// here, so the rows leaked into an empty flexGrow gap instead of the list.
|
|
8511
|
+
// Reserving only PICKER_CHROME_ROWS lets the list occupy the full interior
|
|
8512
|
+
// (the footer's own reservation is handled inside Picker).
|
|
7323
8513
|
const pickerVisibleRows = picker
|
|
7324
|
-
? Math.max(1, floatingPanelRows - PICKER_CHROME_ROWS
|
|
8514
|
+
? Math.max(1, floatingPanelRows - PICKER_CHROME_ROWS)
|
|
7325
8515
|
: PANEL_MAX_VISIBLE;
|
|
7326
8516
|
const bottomReserve = baseReserve + floatingPanelRows;
|
|
7327
8517
|
const viewportHeight = Math.max(1, resizeState.rows - bottomReserve);
|
|
8518
|
+
// Keep one physical row between the transcript clip and the bottom cluster
|
|
8519
|
+
// even when pinned to the live tail. Windows Terminal/conhost can still
|
|
8520
|
+
// surface one clipped/off-by-one transcript row below the statusline during
|
|
8521
|
+
// rapid tool-card updates; a permanent guard row makes that row blank instead
|
|
8522
|
+
// of a tool header/detail.
|
|
8523
|
+
const baseGuardRows = viewportHeight > 1 ? 1 : 0;
|
|
8524
|
+
// ── Scroll-time overprint guard ───────────────────────────────────────────
|
|
8525
|
+
// Wheel/manual scroll pushes the transcript column DOWN via a negative
|
|
8526
|
+
// marginBottom (see the viewport render). Under conhost/Windows Terminal the
|
|
8527
|
+
// incremental redraw can leave the row that slid past the clip edge painted
|
|
8528
|
+
// OVER the bottom cluster (input box / statusline) for a frame — the reported
|
|
8529
|
+
// "scrolled text shows on the statusline row" bug. One guard row is enough
|
|
8530
|
+
// while pinned to the live tail, but during an active scroll the slid row can
|
|
8531
|
+
// still bleed one line further, so widen the guard to TWO rows whenever the
|
|
8532
|
+
// viewport is genuinely scrolled up. The extra blank row absorbs the stray
|
|
8533
|
+
// paint instead of the statusline. Requires a viewport tall enough to spare
|
|
8534
|
+
// the row, and never shrinks below the base guard.
|
|
8535
|
+
const transcriptGuardRows = baseGuardRows;
|
|
8536
|
+
const transcriptContentHeight = Math.max(1, viewportHeight - transcriptGuardRows);
|
|
8537
|
+
// Bottom-follow / pin semantics must NOT widen with the scroll-time guard, or
|
|
8538
|
+
// the "pinned to tail" threshold would drift and streaming could freeze a row
|
|
8539
|
+
// above bottom. Keep the slack anchored to the BASE (single) guard: if a stale
|
|
8540
|
+
// pre-guard offset of 1 survives while no reading anchor is active, still treat
|
|
8541
|
+
// the viewport as pinned to the live tail so streaming/tool output continues
|
|
8542
|
+
// to auto-follow instead of freezing one row above bottom.
|
|
8543
|
+
const transcriptBottomSlackRows = Math.max(0, baseGuardRows);
|
|
8544
|
+
transcriptBottomSlackRowsRef.current = transcriptBottomSlackRows;
|
|
7328
8545
|
transcriptViewportRef.current = {
|
|
7329
8546
|
top: WELCOME_ROWS,
|
|
7330
|
-
bottom: Math.max(WELCOME_ROWS, WELCOME_ROWS +
|
|
8547
|
+
bottom: Math.max(WELCOME_ROWS, WELCOME_ROWS + transcriptContentHeight - 1),
|
|
7331
8548
|
};
|
|
7332
8549
|
// [mixdog] Keep the live terminal row count current for the mouse handler's
|
|
7333
8550
|
// region routing + status-band selection clip (see onData).
|
|
@@ -7336,24 +8553,6 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
7336
8553
|
// bottom area), drop its stale measured rect so the mouse handler does not
|
|
7337
8554
|
// route presses to a prompt box that is not on screen.
|
|
7338
8555
|
if (inputBoxHidden) promptBoxRectRef.current = null;
|
|
7339
|
-
// Windows Terminal/conhost scrolls the alt-screen (auto-wrap/DECAWM) when the
|
|
7340
|
-
// bottom-right cell is written, so reserve one cell on win32. Other platforms
|
|
7341
|
-
// render at full width.
|
|
7342
|
-
const rightSafetyColumns = process.platform === 'win32' ? 1 : 0;
|
|
7343
|
-
const frameColumns = Math.max(1, resizeState.columns - rightSafetyColumns);
|
|
7344
|
-
const promptMetaVisible = !inputBoxHidden && !!liveSpinner && !slashPaletteOpen;
|
|
7345
|
-
// Same-row done hint: when the newest transcript row is a TurnDone/StatusDone
|
|
7346
|
-
// and there is a transient hint to show (and no live spinner owns the status
|
|
7347
|
-
// band), attach the hint to that done row's right side instead of reserving a
|
|
7348
|
-
// separate overlay row. overlayHintVisible excludes this case so the hint is
|
|
7349
|
-
// not double-painted.
|
|
7350
|
-
const lastTranscriptItem = (state.items || []).at(-1) ?? null;
|
|
7351
|
-
const lastItemIsDoneRow = lastTranscriptItem?.kind === 'turndone'
|
|
7352
|
-
|| lastTranscriptItem?.kind === 'statusdone';
|
|
7353
|
-
const attachInputHintToTurnDone = !inputBoxHidden
|
|
7354
|
-
&& !liveSpinner
|
|
7355
|
-
&& !!inputHint
|
|
7356
|
-
&& lastItemIsDoneRow;
|
|
7357
8556
|
// Toast/error text has two mutually exclusive placements:
|
|
7358
8557
|
// - while a live status row exists (thinking/compacting/responding), attach it
|
|
7359
8558
|
// to that row so the bottom cluster reserves exactly one status band;
|
|
@@ -7361,13 +8560,32 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
7361
8560
|
// the blank spacer instead of reserving an extra row. This keeps late errors
|
|
7362
8561
|
// from pushing the prompt/statusline upward, and when thinking starts the
|
|
7363
8562
|
// hint moves into the live row on the same render instead of double-painting.
|
|
7364
|
-
|
|
7365
|
-
|
|
8563
|
+
// Transient hint placement while no spinner owns the band is resolved after
|
|
8564
|
+
// transcript windowing (see overlayHintOnLastItem / overlayHintFallbackRow).
|
|
8565
|
+
const spinnerHintWidth = inputHint
|
|
8566
|
+
? Math.max(1, Math.min(Math.max(1, frameColumns - 4), Math.max(12, Math.floor(frameColumns * 0.42))))
|
|
8567
|
+
: 0;
|
|
8568
|
+
// When no live spinner owns a status band, the transient hint/error is drawn
|
|
8569
|
+
// into the existing transcript guard row directly above the prompt. Mirror the
|
|
8570
|
+
// spinner-row placement: a fixed-width right slot, not a full-width left box.
|
|
8571
|
+
const guardHintWidth = inputHint
|
|
7366
8572
|
? Math.max(1, Math.min(Math.max(1, frameColumns - 4), Math.max(12, Math.floor(frameColumns * 0.42))))
|
|
7367
8573
|
: 0;
|
|
8574
|
+
const transientStatusWidth = liveSpinner ? spinnerHintWidth : guardHintWidth;
|
|
7368
8575
|
const promptSpinnerColumns = liveSpinner && inputHint
|
|
7369
|
-
? Math.max(1, frameColumns -
|
|
8576
|
+
? Math.max(1, frameColumns - spinnerHintWidth - 1)
|
|
7370
8577
|
: frameColumns;
|
|
8578
|
+
const welcomePromptHintText = conditionalWelcomePromptHint || welcomePromptHintRef.current || '';
|
|
8579
|
+
const welcomePromptHintVisible = Boolean(
|
|
8580
|
+
welcomePromptHintText
|
|
8581
|
+
&& !welcomePromptHintDismissed
|
|
8582
|
+
&& state.items.length === 0
|
|
8583
|
+
&& !hasFloatingPanel
|
|
8584
|
+
&& !inputBoxHidden
|
|
8585
|
+
&& !queuedVisible
|
|
8586
|
+
&& !liveSpinner
|
|
8587
|
+
&& !inputHint
|
|
8588
|
+
);
|
|
7371
8589
|
// Key the heavy O(n) row-index + windowing memos on a STRUCTURE signature
|
|
7372
8590
|
// instead of the `state.items` array identity. The engine swaps `state.items`
|
|
7373
8591
|
// for a new array on every streaming flush (~8ms) while only the final
|
|
@@ -7401,40 +8619,111 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
7401
8619
|
// SAME render, so the completed line fills its space with no one-frame gap.
|
|
7402
8620
|
// Falls back to the live scrollOffset state when there is no active anchor
|
|
7403
8621
|
// (bottom-follow / pinned) or it cannot be aligned (anchor item gone).
|
|
7404
|
-
const
|
|
8622
|
+
const hasReadingAnchor = !!transcriptAnchorRef.current && !transcriptAnchorDirtyRef.current;
|
|
8623
|
+
const scrolledUpRows = Math.max(0, Number(scrollTargetRef.current) || 0);
|
|
8624
|
+
// "Genuinely scrolled up" = the viewport is above the bottom slack band. The
|
|
8625
|
+
// bottom-follow / pinned path owns everything at-or-below the slack; anything
|
|
8626
|
+
// above it is the user reading older transcript.
|
|
8627
|
+
const scrolledUp = scrolledUpRows > transcriptBottomSlackRows;
|
|
8628
|
+
// A genuine reading anchor wins even if followingRef is stale-true while the
|
|
8629
|
+
// user is scrolled up. The follow-arm SHOULD have been cleared when the anchor
|
|
8630
|
+
// was captured, but if it lingers true we must not fall through to the stale-
|
|
8631
|
+
// offset render path (that is one half of the newline-jump bug). Keep the
|
|
8632
|
+
// plain !following gate for the anchor-less follow case.
|
|
8633
|
+
const anchorLockActive = hasReadingAnchor && !followingRef.current && scrolledUp;
|
|
8634
|
+
const targetNearBottom = followingRef.current || !scrolledUp;
|
|
8635
|
+
const nearBottomWithoutAnchor = !transcriptAnchorRef.current
|
|
7405
8636
|
&& !transcriptAnchorDirtyRef.current
|
|
7406
|
-
&&
|
|
7407
|
-
let renderScrollOffset = scrollOffset;
|
|
8637
|
+
&& targetNearBottom;
|
|
8638
|
+
let renderScrollOffset = targetNearBottom ? 0 : scrollOffset;
|
|
8639
|
+
const lockViewRows = Math.max(1, Number(transcriptContentHeight) || 1);
|
|
8640
|
+
const lockTotalRows = Math.max(0, Number(transcriptRowIndex?.totalRows) || 0);
|
|
8641
|
+
const lockMaxRows = Math.max(0, lockTotalRows - lockViewRows);
|
|
8642
|
+
const curPrefixForLock = transcriptRowIndex?.prefixRows || null;
|
|
7408
8643
|
if (anchorLockActive) {
|
|
7409
|
-
const lockViewRows = Math.max(1, Number(viewportHeight) || 1);
|
|
7410
|
-
const lockTotalRows = Math.max(0, Number(transcriptRowIndex?.totalRows) || 0);
|
|
7411
|
-
const lockMaxRows = Math.max(0, lockTotalRows - lockViewRows);
|
|
7412
8644
|
const locked = resolveAnchorScrollOffset({
|
|
7413
8645
|
anchor: transcriptAnchorRef.current,
|
|
7414
8646
|
items: state.items,
|
|
7415
|
-
curPrefix:
|
|
8647
|
+
curPrefix: curPrefixForLock,
|
|
7416
8648
|
totalRows: lockTotalRows,
|
|
7417
8649
|
viewRows: lockViewRows,
|
|
7418
8650
|
maxRows: lockMaxRows,
|
|
7419
8651
|
});
|
|
7420
8652
|
if (locked != null) renderScrollOffset = locked;
|
|
8653
|
+
} else if (!followingRef.current && !nearBottomWithoutAnchor && scrolledUp) {
|
|
8654
|
+
// ── Same-frame anchor CAPTURE for the missing/dirty-anchor case ─────────
|
|
8655
|
+
// The viewport is genuinely scrolled up but there is NO usable same-frame
|
|
8656
|
+
// anchor: it is missing, or dirtied by a manual scroll whose synchronous
|
|
8657
|
+
// capture failed, or a stale-true follow-arm already dropped it. Without an
|
|
8658
|
+
// anchor this frame would render with the STALE bottom-relative
|
|
8659
|
+
// scrollOffset, so any row growth THIS frame (a streaming newline
|
|
8660
|
+
// completing a line, a transcript row expanding) shifts the visible top by
|
|
8661
|
+
// the delta BEFORE the post-commit rowDelta effect can capture/correct — and
|
|
8662
|
+
// that effect would then capture from the ALREADY-shifted totalRows. That is
|
|
8663
|
+
// the confirmed one-frame jump/jitter.
|
|
8664
|
+
//
|
|
8665
|
+
// Fix it at render time: capture an anchor from the PREVIOUS published
|
|
8666
|
+
// geometry (transcriptGeomRef still holds the prior frame here — THIS
|
|
8667
|
+
// frame's geom is published a few lines below), identifying the item id +
|
|
8668
|
+
// row offset that sat at the previous visible-TOP edge, then resolve the
|
|
8669
|
+
// offset against the CURRENT prefix table with the same pure helper so that
|
|
8670
|
+
// exact top row stays put THIS frame. Persist the captured anchor so the
|
|
8671
|
+
// post-commit effect keeps the identical anchor stable instead of
|
|
8672
|
+
// re-deriving one from the already-shifted totalRows.
|
|
8673
|
+
const geom = transcriptGeomRef.current || {};
|
|
8674
|
+
const prevPrefix = geom.prefixRows;
|
|
8675
|
+
if (Array.isArray(prevPrefix) && prevPrefix.length > 1) {
|
|
8676
|
+
const prevTotal = Math.max(0, Number(geom.totalRows) || 0);
|
|
8677
|
+
const prevView = Math.max(1, Number(geom.viewRows) || 1);
|
|
8678
|
+
const prevOffset = Math.max(0, Number(geom.renderOffset) || 0);
|
|
8679
|
+
const prevItems = geom.items || [];
|
|
8680
|
+
// Bottom-relative window math (same as transcriptRenderWindow): the top
|
|
8681
|
+
// edge sits `offset + viewRows` rows up from the previous total.
|
|
8682
|
+
const prevTopRow = Math.max(0, Math.min(prevTotal, prevTotal - prevOffset - prevView));
|
|
8683
|
+
let idx = upperBound(prevPrefix, prevTopRow) - 1;
|
|
8684
|
+
if (idx < 0) idx = 0;
|
|
8685
|
+
if (idx > prevPrefix.length - 2) idx = prevPrefix.length - 2;
|
|
8686
|
+
const anchorItem = prevItems[idx];
|
|
8687
|
+
if (anchorItem && anchorItem.id != null) {
|
|
8688
|
+
const captured = { id: anchorItem.id, offset: Math.max(0, prevTopRow - (prevPrefix[idx] || 0)) };
|
|
8689
|
+
const locked = resolveAnchorScrollOffset({
|
|
8690
|
+
anchor: captured,
|
|
8691
|
+
items: state.items,
|
|
8692
|
+
curPrefix: curPrefixForLock,
|
|
8693
|
+
totalRows: lockTotalRows,
|
|
8694
|
+
viewRows: lockViewRows,
|
|
8695
|
+
maxRows: lockMaxRows,
|
|
8696
|
+
});
|
|
8697
|
+
if (locked != null) {
|
|
8698
|
+
renderScrollOffset = locked;
|
|
8699
|
+
transcriptAnchorRef.current = captured;
|
|
8700
|
+
transcriptAnchorDirtyRef.current = false;
|
|
8701
|
+
}
|
|
8702
|
+
}
|
|
8703
|
+
}
|
|
7421
8704
|
}
|
|
7422
8705
|
const transcriptWindow = useMemo(() => transcriptRenderWindow(state.items, {
|
|
7423
8706
|
scrollOffset: renderScrollOffset,
|
|
7424
|
-
viewportHeight,
|
|
8707
|
+
viewportHeight: transcriptContentHeight,
|
|
7425
8708
|
columns: frameColumns,
|
|
7426
8709
|
toolOutputExpanded,
|
|
7427
8710
|
rowIndex: transcriptRowIndex,
|
|
7428
8711
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: sig+scroll/viewport capture the relevant changes
|
|
7429
|
-
}), [transcriptStructureSig, renderScrollOffset,
|
|
8712
|
+
}), [transcriptStructureSig, renderScrollOffset, transcriptContentHeight, transcriptRowIndex]);
|
|
7430
8713
|
maxScrollRowsRef.current = transcriptWindow.maxScrollRows;
|
|
7431
8714
|
// Publish this frame's geometry so a manual scroll can capture the reading
|
|
7432
8715
|
// anchor synchronously (see captureTranscriptAnchorAt).
|
|
7433
8716
|
transcriptGeomRef.current = {
|
|
7434
8717
|
prefixRows: transcriptRowIndex?.prefixRows || null,
|
|
7435
8718
|
totalRows: Math.max(0, Number(transcriptWindow.totalRows) || 0),
|
|
7436
|
-
viewRows: Math.max(1, Number(
|
|
8719
|
+
viewRows: Math.max(1, Number(transcriptContentHeight) || 1),
|
|
7437
8720
|
items: state.items || null,
|
|
8721
|
+
// The offset THIS frame actually rendered with. The same-frame anchor
|
|
8722
|
+
// CAPTURE for a missing/dirty anchor (above) reads this from the PREVIOUS
|
|
8723
|
+
// frame to reconstruct the exact top-edge row that was on screen, so the
|
|
8724
|
+
// capture matches what the user saw rather than the stale scrollOffset
|
|
8725
|
+
// state. Bottom-relative, matching transcriptRenderWindow's window math.
|
|
8726
|
+
renderOffset: Math.max(0, Number(renderScrollOffset) || 0),
|
|
7438
8727
|
};
|
|
7439
8728
|
// The window memo is keyed on a structure signature that intentionally
|
|
7440
8729
|
// ignores per-character growth of the streaming assistant text, so its
|
|
@@ -7446,8 +8735,27 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
7446
8735
|
transcriptWindow.startIndex,
|
|
7447
8736
|
transcriptWindow.endIndex,
|
|
7448
8737
|
);
|
|
7449
|
-
//
|
|
7450
|
-
//
|
|
8738
|
+
// The bottom meta band is spinner-only, so nothing is pulled out of the
|
|
8739
|
+
// transcript for it. A finished turn's done row (turndone/statusdone) renders
|
|
8740
|
+
// inline in scrollback like any other item — no filtering, no double-paint.
|
|
8741
|
+
const renderedTranscriptItems = transcriptVisibleItems;
|
|
8742
|
+
let overlayHintAttachItemIndex = -1;
|
|
8743
|
+
for (let i = renderedTranscriptItems.length - 1; i >= 0; i--) {
|
|
8744
|
+
const item = renderedTranscriptItems[i];
|
|
8745
|
+
if (item?.kind === 'tool' && shouldSuppressFullyFailedToolItem(item)) continue;
|
|
8746
|
+
overlayHintAttachItemIndex = i;
|
|
8747
|
+
break;
|
|
8748
|
+
}
|
|
8749
|
+
const transcriptTailPinned = Math.max(0, Number(transcriptWindow.effectiveScrollOffset) || 0) <= transcriptBottomSlackRows;
|
|
8750
|
+
const overlayHintOnLastItem = overlayHintRequested
|
|
8751
|
+
&& floatingPanelRows <= 0
|
|
8752
|
+
&& transcriptWindow.bottomSpacerRows === 0
|
|
8753
|
+
&& transcriptTailPinned
|
|
8754
|
+
&& overlayHintAttachItemIndex >= 0;
|
|
8755
|
+
const overlayHintFallbackRow = overlayHintRequested
|
|
8756
|
+
&& floatingPanelRows <= 0
|
|
8757
|
+
&& transcriptGuardRows > 0
|
|
8758
|
+
&& !overlayHintOnLastItem;
|
|
7451
8759
|
// ── App-level measured height harvest (ScrollBox/useVirtualScroll-inspired) ─
|
|
7452
8760
|
// Runs after EVERY commit (no deps): Yoga has just laid out the mounted rows,
|
|
7453
8761
|
// so each tracked item Box's getComputedHeight() is its REAL terminal height.
|
|
@@ -7537,8 +8845,18 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
7537
8845
|
const currentPosition = Math.max(0, Number(scrollPositionRef.current) || 0);
|
|
7538
8846
|
const currentOffset = Math.max(0, Number(scrollOffset) || 0);
|
|
7539
8847
|
const maxRows = Math.max(0, Number(transcriptWindow.maxScrollRows) || 0);
|
|
7540
|
-
const
|
|
7541
|
-
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;
|
|
7542
8860
|
const shouldFollowBottom = rowDelta > 0 && (followOnGrowth || pinnedToBottom);
|
|
7543
8861
|
if (shouldFollowBottom) {
|
|
7544
8862
|
// Bottom follow: while pinned to the newest output,
|
|
@@ -7547,7 +8865,18 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
7547
8865
|
// during streaming makes the transcript jump down/up and can clip the
|
|
7548
8866
|
// currently generated assistant text. Keep all scroll refs at zero so
|
|
7549
8867
|
// character generation stays visually stable.
|
|
7550
|
-
|
|
8868
|
+
stopSmoothScroll();
|
|
8869
|
+
scrollTargetRef.current = 0;
|
|
8870
|
+
scrollPositionRef.current = 0;
|
|
8871
|
+
transcriptAnchorRef.current = null;
|
|
8872
|
+
transcriptAnchorDirtyRef.current = false;
|
|
8873
|
+
if (currentOffset !== 0) setScrollOffset(0);
|
|
8874
|
+
return;
|
|
8875
|
+
}
|
|
8876
|
+
|
|
8877
|
+
// Viewport-only changes, such as swapping TextEntryPanel for a picker, must
|
|
8878
|
+
// not turn a bottom-pinned transcript into a reading-anchor lock.
|
|
8879
|
+
if (rowDelta <= 0 && nearBottom) {
|
|
7551
8880
|
stopSmoothScroll();
|
|
7552
8881
|
scrollTargetRef.current = 0;
|
|
7553
8882
|
scrollPositionRef.current = 0;
|
|
@@ -7569,12 +8898,12 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
7569
8898
|
// only moves the bottom — the top is rock-stable. Changes ABOVE the anchor
|
|
7570
8899
|
// move the item's prefix start and are absorbed the same way. No deltas, no
|
|
7571
8900
|
// fallback, no drift.
|
|
7572
|
-
const viewRows = Math.max(1, Number(
|
|
8901
|
+
const viewRows = Math.max(1, Number(transcriptContentHeight) || 1);
|
|
7573
8902
|
const items = state.items || [];
|
|
7574
8903
|
let anchor = transcriptAnchorRef.current;
|
|
7575
8904
|
// (Re)capture the anchor from the current viewport-top edge when missing or
|
|
7576
8905
|
// invalidated by a manual scroll. anchorRow = absolute row at the top edge.
|
|
7577
|
-
if (!anchor || transcriptAnchorDirtyRef.current) {
|
|
8906
|
+
if (!followingRef.current && (!anchor || transcriptAnchorDirtyRef.current)) {
|
|
7578
8907
|
if (curPrefix && curPrefix.length > 1) {
|
|
7579
8908
|
const anchorRow = Math.max(0, Math.min(totalRows, totalRows - currentTarget - viewRows));
|
|
7580
8909
|
let idx = upperBound(curPrefix, anchorRow) - 1;
|
|
@@ -7614,12 +8943,25 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
7614
8943
|
scrollPositionRef.current = Math.max(0, Math.min(maxRows, currentPosition + appliedDelta));
|
|
7615
8944
|
preservedScrollDeltaRef.current += appliedDelta;
|
|
7616
8945
|
setScrollOffset(Math.max(0, Math.round(desired)));
|
|
7617
|
-
}, [transcriptWindow.totalRows, transcriptWindow.maxScrollRows, transcriptRowIndex,
|
|
8946
|
+
}, [transcriptWindow.totalRows, transcriptWindow.maxScrollRows, transcriptRowIndex, transcriptContentHeight, transcriptBottomSlackRows, scrollOffset, stopSmoothScroll]);
|
|
8947
|
+
useLayoutEffect(() => {
|
|
8948
|
+
if (transcriptBottomSlackRows <= 0) return;
|
|
8949
|
+
if (transcriptAnchorRef.current || transcriptAnchorDirtyRef.current || followingRef.current) return;
|
|
8950
|
+
const currentTarget = Math.max(0, Number(scrollTargetRef.current) || 0);
|
|
8951
|
+
const currentPosition = Math.max(0, Number(scrollPositionRef.current) || 0);
|
|
8952
|
+
const currentOffset = Math.max(0, Number(scrollOffset) || 0);
|
|
8953
|
+
if (Math.max(currentTarget, currentPosition, currentOffset) === 0) return;
|
|
8954
|
+
if (Math.max(currentTarget, currentPosition, currentOffset) > transcriptBottomSlackRows) return;
|
|
8955
|
+
stopSmoothScroll();
|
|
8956
|
+
scrollTargetRef.current = 0;
|
|
8957
|
+
scrollPositionRef.current = 0;
|
|
8958
|
+
setScrollOffset(0);
|
|
8959
|
+
}, [transcriptBottomSlackRows, scrollOffset, stopSmoothScroll]);
|
|
7618
8960
|
useLayoutEffect(() => {
|
|
7619
8961
|
const top = Math.max(0, Number(transcriptViewportRef.current?.top) || 0);
|
|
7620
8962
|
const next = {
|
|
7621
8963
|
top,
|
|
7622
|
-
height: Math.max(1, Number(
|
|
8964
|
+
height: Math.max(1, Number(transcriptContentHeight) || 1),
|
|
7623
8965
|
totalRows: Math.max(0, Number(transcriptWindow.totalRows) || 0),
|
|
7624
8966
|
scrollOffset: Math.max(0, Number(transcriptWindow.effectiveScrollOffset) || 0),
|
|
7625
8967
|
};
|
|
@@ -7639,7 +8981,13 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
7639
8981
|
const clippedRect = withSelectionClip(shiftSelectionRectY(dragRef.current.rect, deltaY));
|
|
7640
8982
|
dragRef.current = { ...dragRef.current, rect: clippedRect };
|
|
7641
8983
|
paintSelectionRect(clippedRect, { rememberText: true, immediate: true });
|
|
7642
|
-
}, [
|
|
8984
|
+
}, [transcriptContentHeight, transcriptWindow.totalRows, transcriptWindow.effectiveScrollOffset, withSelectionClip, paintSelectionRect]);
|
|
8985
|
+
useEffect(() => {
|
|
8986
|
+
if (!dragRef.current.rect) return;
|
|
8987
|
+
const clippedRect = withSelectionClip(dragRef.current.rect);
|
|
8988
|
+
dragRef.current = { ...dragRef.current, rect: clippedRect };
|
|
8989
|
+
paintSelectionRect(clippedRect, { rememberText: true, immediate: true });
|
|
8990
|
+
}, [state.themeEpoch, withSelectionClip, paintSelectionRect]);
|
|
7643
8991
|
useEffect(() => {
|
|
7644
8992
|
const maxRows = Math.max(0, Number(transcriptWindow.maxScrollRows) || 0);
|
|
7645
8993
|
if (scrollTargetRef.current <= maxRows && scrollPositionRef.current <= maxRows && scrollOffset <= maxRows) return;
|
|
@@ -7704,6 +9052,7 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
7704
9052
|
onTab={cycleWorkflowFromPrompt}
|
|
7705
9053
|
onPasteText={handlePromptPaste}
|
|
7706
9054
|
onHistoryNavigate={handlePromptHistoryNavigate}
|
|
9055
|
+
onVoiceToggle={handleVoiceToggle}
|
|
7707
9056
|
commandPaletteActive={slashPaletteOpen}
|
|
7708
9057
|
onCommandPaletteNavigate={(direction) => {
|
|
7709
9058
|
setSlashIndex((index) => {
|
|
@@ -7734,16 +9083,16 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
7734
9083
|
// stack up from just over the input. A top flexGrow spacer sinks the whole
|
|
7735
9084
|
// stack to the bottom; the transcript itself is a fixed-height clipping
|
|
7736
9085
|
// viewport (see viewportHeight above).
|
|
7737
|
-
<Box flexDirection="column" width={frameColumns} height={resizeState.rows} backgroundColor={
|
|
9086
|
+
<Box flexDirection="column" width={frameColumns} height={resizeState.rows} backgroundColor={surfaceBackground()}>
|
|
7738
9087
|
{/* Empty-transcript header stays outside the bottom-anchored viewport and
|
|
7739
9088
|
has its own reserved rows, so it cannot steal space from the input. */}
|
|
7740
9089
|
{showWelcomeBanner ? (
|
|
7741
|
-
<Box flexDirection="column" height={7} flexShrink={0} marginTop={3} marginBottom={1} backgroundColor={
|
|
9090
|
+
<Box flexDirection="column" height={7} flexShrink={0} marginTop={3} marginBottom={1} backgroundColor={surfaceBackground()}>
|
|
7742
9091
|
<Text color={theme.text} bold>{centerLine('███╗ ███╗██╗██╗ ██╗██████╗ ██████╗ ██████╗ ', frameColumns)}</Text>
|
|
7743
9092
|
<Text color={theme.text} bold>{centerLine('████╗ ████║██║╚██╗██╔╝██╔══██╗██╔═══██╗██╔════╝ ', frameColumns)}</Text>
|
|
7744
|
-
<Text color={theme.claude} bold>{centerLine('██╔████╔██║██║ ╚███╔╝ ██║ ██║██║ ██║██║ ███╗', frameColumns)}</Text>
|
|
7745
|
-
<Text color={theme.claude} bold>{centerLine('██║╚██╔╝██║██║ ██╔██╗ ██║ ██║██║ ██║██║ ██║', frameColumns)}</Text>
|
|
7746
|
-
<Text color={theme.claude} bold>{centerLine('██║ ╚═╝ ██║██║██╔╝ ██╗██████╔╝╚██████╔╝╚██████╔╝', frameColumns)}</Text>
|
|
9093
|
+
<Text color={theme.logo ?? theme.claude} bold>{centerLine('██╔████╔██║██║ ╚███╔╝ ██║ ██║██║ ██║██║ ███╗', frameColumns)}</Text>
|
|
9094
|
+
<Text color={theme.logo ?? theme.claude} bold>{centerLine('██║╚██╔╝██║██║ ██╔██╗ ██║ ██║██║ ██║██║ ██║', frameColumns)}</Text>
|
|
9095
|
+
<Text color={theme.logo ?? theme.claude} bold>{centerLine('██║ ╚═╝ ██║██║██╔╝ ██╗██████╔╝╚██████╔╝╚██████╔╝', frameColumns)}</Text>
|
|
7747
9096
|
<Box height={1} flexShrink={0} />
|
|
7748
9097
|
<Text color={theme.inactive}>{centerLine(`mixdog coding agent · ${state.cwd}`, frameColumns, 4)}</Text>
|
|
7749
9098
|
</Box>
|
|
@@ -7765,6 +9114,14 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
7765
9114
|
overflow="hidden"
|
|
7766
9115
|
justifyContent="flex-end"
|
|
7767
9116
|
>
|
|
9117
|
+
<Box
|
|
9118
|
+
flexDirection="column"
|
|
9119
|
+
width="100%"
|
|
9120
|
+
height={transcriptContentHeight}
|
|
9121
|
+
flexShrink={0}
|
|
9122
|
+
overflow="hidden"
|
|
9123
|
+
justifyContent="flex-end"
|
|
9124
|
+
>
|
|
7768
9125
|
{/* Wheel scroll: with the viewport bottom-anchored (flex-end), a NEGATIVE
|
|
7769
9126
|
marginBottom pushes the transcript column DOWN past the bottom edge,
|
|
7770
9127
|
bringing older content above the window into view (overflow hidden
|
|
@@ -7783,20 +9140,18 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
7783
9140
|
* OVERSCAN: TRANSCRIPT_WINDOW_OVERSCAN_ROWS extra rows above the viewport so
|
|
7784
9141
|
* fast wheel scrolls don't show a blank gap before re-render.
|
|
7785
9142
|
*/}
|
|
7786
|
-
{
|
|
7787
|
-
const showRightMessage = attachInputHintToTurnDone
|
|
7788
|
-
&& item.id === lastTranscriptItem?.id
|
|
7789
|
-
&& (item.kind === 'turndone' || item.kind === 'statusdone');
|
|
9143
|
+
{renderedTranscriptItems.map((item, i, arr) => {
|
|
7790
9144
|
const measureRef = transcriptMeasureRef(item);
|
|
9145
|
+
const attachOverlayHint = overlayHintOnLastItem && i === overlayHintAttachItemIndex;
|
|
7791
9146
|
const itemNode = (
|
|
7792
9147
|
<Item
|
|
7793
9148
|
item={item}
|
|
7794
9149
|
prevKind={i > 0 ? arr[i - 1].kind : state.items[transcriptWindow.startIndex - 1]?.kind ?? null}
|
|
7795
9150
|
columns={frameColumns}
|
|
7796
9151
|
toolOutputExpanded={toolOutputExpanded}
|
|
7797
|
-
rightMessage={
|
|
7798
|
-
rightTone={inputHintTone}
|
|
7799
|
-
rightMessageWidth={transientStatusWidth || 24}
|
|
9152
|
+
rightMessage={attachOverlayHint ? inputHint : ''}
|
|
9153
|
+
rightTone={attachOverlayHint ? inputHintTone : 'info'}
|
|
9154
|
+
rightMessageWidth={attachOverlayHint ? (guardHintWidth || transientStatusWidth || 24) : 24}
|
|
7800
9155
|
themeEpoch={state.themeEpoch || 0}
|
|
7801
9156
|
/>
|
|
7802
9157
|
);
|
|
@@ -7817,6 +9172,22 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
7817
9172
|
<Box height={transcriptWindow.bottomSpacerRows} flexShrink={0} />
|
|
7818
9173
|
) : null}
|
|
7819
9174
|
</Box>
|
|
9175
|
+
{welcomePromptHintVisible ? (
|
|
9176
|
+
<Box height={1} flexShrink={0} width="100%" overflow="hidden">
|
|
9177
|
+
<Text color={theme.inactive} wrap="truncate">{centerLine(welcomePromptHintText, frameColumns, 2)}</Text>
|
|
9178
|
+
</Box>
|
|
9179
|
+
) : null}
|
|
9180
|
+
</Box>
|
|
9181
|
+
{transcriptGuardRows > 0 ? (
|
|
9182
|
+
<Box height={transcriptGuardRows} flexShrink={0} backgroundColor={surfaceBackground()} flexDirection="row" width="100%" overflow="hidden">
|
|
9183
|
+
<Box flexGrow={1} flexShrink={1} overflow="hidden" />
|
|
9184
|
+
{overlayHintFallbackRow ? (
|
|
9185
|
+
<Box flexShrink={0} width={guardHintWidth || 1} marginLeft={1} marginRight={1} justifyContent="flex-end" overflow="hidden">
|
|
9186
|
+
<Text color={promptStatusColor(inputHintTone)} wrap="truncate">{inputHint}</Text>
|
|
9187
|
+
</Box>
|
|
9188
|
+
) : null}
|
|
9189
|
+
</Box>
|
|
9190
|
+
) : null}
|
|
7820
9191
|
</Box>
|
|
7821
9192
|
|
|
7822
9193
|
{/* Live reasoning and transient status live just above the prompt: reasoning
|
|
@@ -7826,9 +9197,9 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
7826
9197
|
panels use their actual rendered height and shrink before the prompt
|
|
7827
9198
|
can move; overflow is clipped from the top while the panel remains
|
|
7828
9199
|
bottom-aligned against the prompt. */}
|
|
7829
|
-
<Box flexDirection="column" flexShrink={0} width="100%" backgroundColor={
|
|
9200
|
+
<Box flexDirection="column" flexShrink={0} width="100%" backgroundColor={surfaceBackground()}>
|
|
7830
9201
|
{floatingPanelRows > 0 ? (
|
|
7831
|
-
<Box flexDirection="column" flexShrink={0} height={floatingPanelRows} overflow="hidden" justifyContent="flex-end" backgroundColor={
|
|
9202
|
+
<Box flexDirection="column" flexShrink={0} height={floatingPanelRows} overflow="hidden" justifyContent="flex-end" backgroundColor={surfaceBackground()}>
|
|
7832
9203
|
{toolApproval ? (
|
|
7833
9204
|
<Picker
|
|
7834
9205
|
items={[
|
|
@@ -7906,6 +9277,7 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
7906
9277
|
visibleCount={pickerVisibleRows}
|
|
7907
9278
|
fillHeight={expandedOptionPanel}
|
|
7908
9279
|
themeEpoch={state.themeEpoch || 0}
|
|
9280
|
+
confirmBar={picker.confirmBar}
|
|
7909
9281
|
/>
|
|
7910
9282
|
) : contextPanel ? (
|
|
7911
9283
|
<ContextPanel
|
|
@@ -7973,7 +9345,7 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
7973
9345
|
<TextEntryPanel
|
|
7974
9346
|
title={channelPrompt.label}
|
|
7975
9347
|
hint={channelPrompt.hint || 'Save channel setting.'}
|
|
7976
|
-
mask={channelPrompt.kind === 'discord-token' || channelPrompt.kind === 'webhook-token'}
|
|
9348
|
+
mask={channelPrompt.kind === 'discord-token' || channelPrompt.kind === 'telegram-token' || channelPrompt.kind === 'webhook-token'}
|
|
7977
9349
|
columns={frameColumns}
|
|
7978
9350
|
promptLabel="Value > "
|
|
7979
9351
|
onSubmit={onSubmit}
|
|
@@ -8021,56 +9393,47 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
8021
9393
|
{!inputBoxHidden ? (
|
|
8022
9394
|
<>
|
|
8023
9395
|
{promptMetaVisible ? (
|
|
8024
|
-
|
|
8025
|
-
|
|
8026
|
-
|
|
8027
|
-
|
|
8028
|
-
|
|
8029
|
-
|
|
8030
|
-
|
|
8031
|
-
|
|
8032
|
-
|
|
8033
|
-
|
|
8034
|
-
|
|
8035
|
-
|
|
8036
|
-
|
|
8037
|
-
|
|
8038
|
-
|
|
8039
|
-
|
|
8040
|
-
|
|
8041
|
-
|
|
8042
|
-
|
|
8043
|
-
|
|
8044
|
-
|
|
8045
|
-
|
|
8046
|
-
<Box flexShrink={0} width={transientStatusWidth || 1} marginLeft={1} marginRight={1} justifyContent="flex-end" overflow="hidden">
|
|
8047
|
-
<Text color={promptStatusColor(inputHintTone)} wrap="truncate">{inputHint}</Text>
|
|
9396
|
+
<>
|
|
9397
|
+
<Box
|
|
9398
|
+
marginTop={0}
|
|
9399
|
+
marginBottom={0}
|
|
9400
|
+
height={1}
|
|
9401
|
+
width="100%"
|
|
9402
|
+
flexDirection="row"
|
|
9403
|
+
backgroundColor={surfaceBackground()}
|
|
9404
|
+
>
|
|
9405
|
+
<Box flexGrow={1} flexShrink={1} overflow="hidden">
|
|
9406
|
+
{liveSpinner ? (
|
|
9407
|
+
<Spinner
|
|
9408
|
+
verb={liveSpinner.verb}
|
|
9409
|
+
startedAt={liveSpinner.startedAt}
|
|
9410
|
+
outputTokens={liveSpinner?.outputTokens ?? liveSpinner?.tokens ?? 0}
|
|
9411
|
+
thinking={!!(state.thinking || liveSpinner?.thinking)}
|
|
9412
|
+
thinkingActiveSince={liveSpinner?.thinkingSegmentStartedAt ?? 0}
|
|
9413
|
+
mode={liveSpinner?.mode || 'responding'}
|
|
9414
|
+
columns={promptSpinnerColumns}
|
|
9415
|
+
marginTop={0}
|
|
9416
|
+
/>
|
|
9417
|
+
) : null}
|
|
8048
9418
|
</Box>
|
|
8049
|
-
|
|
8050
|
-
|
|
8051
|
-
|
|
8052
|
-
|
|
8053
|
-
|
|
8054
|
-
height={1}
|
|
8055
|
-
width="100%"
|
|
8056
|
-
flexDirection="row"
|
|
8057
|
-
backgroundColor={theme.background}
|
|
8058
|
-
>
|
|
8059
|
-
<Box flexGrow={1} flexShrink={1} overflow="hidden" />
|
|
8060
|
-
<Box flexShrink={0} width={transientStatusWidth || 1} marginLeft={1} marginRight={1} justifyContent="flex-end" overflow="hidden">
|
|
8061
|
-
<Text color={promptStatusColor(inputHintTone)} wrap="truncate">{inputHint}</Text>
|
|
9419
|
+
{inputHint ? (
|
|
9420
|
+
<Box flexShrink={0} width={transientStatusWidth || 1} marginLeft={1} marginRight={1} justifyContent="flex-end" overflow="hidden">
|
|
9421
|
+
<Text color={promptStatusColor(inputHintTone)} wrap="truncate">{inputHint}</Text>
|
|
9422
|
+
</Box>
|
|
9423
|
+
) : null}
|
|
8062
9424
|
</Box>
|
|
8063
|
-
|
|
9425
|
+
<Box height={1} width="100%" backgroundColor={surfaceBackground()} />
|
|
9426
|
+
</>
|
|
8064
9427
|
) : null}
|
|
8065
9428
|
{queuedVisible ? (
|
|
8066
9429
|
<QueuedCommands queued={state.queued} columns={frameColumns} />
|
|
8067
9430
|
) : null}
|
|
8068
9431
|
<Box
|
|
8069
|
-
marginTop={
|
|
9432
|
+
marginTop={0}
|
|
8070
9433
|
width="100%"
|
|
8071
9434
|
borderStyle="round"
|
|
8072
9435
|
borderColor={theme.promptBorder}
|
|
8073
|
-
backgroundColor={
|
|
9436
|
+
backgroundColor={surfaceBackground()}
|
|
8074
9437
|
paddingX={1}
|
|
8075
9438
|
>
|
|
8076
9439
|
{promptInputControl}
|
|
@@ -8098,6 +9461,7 @@ export function App({ store, initialStatusLine = '' }) {
|
|
|
8098
9461
|
activeTools={activeTools}
|
|
8099
9462
|
initialLine={initialStatusLine}
|
|
8100
9463
|
workflow={state.workflow}
|
|
9464
|
+
remoteEnabled={state.remoteEnabled === true}
|
|
8101
9465
|
themeEpoch={state.themeEpoch || 0}
|
|
8102
9466
|
/>
|
|
8103
9467
|
</Box>
|