mixdog 0.9.2 → 0.9.3
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 +2 -1
- package/scripts/anthropic-maxtokens-test.mjs +119 -0
- package/scripts/build-tui.mjs +13 -1
- package/scripts/explore-bench.mjs +124 -0
- package/scripts/hook-bus-test.mjs +191 -0
- package/scripts/path-suffix-test.mjs +57 -0
- package/scripts/recall-bench.mjs +207 -0
- package/scripts/tool-smoke.mjs +7 -4
- package/src/agents/debugger/AGENT.md +2 -2
- package/src/agents/heavy-worker/AGENT.md +20 -11
- package/src/agents/reviewer/AGENT.md +2 -2
- package/src/agents/worker/AGENT.md +17 -11
- package/src/mixdog-session-runtime.mjs +424 -1812
- package/src/repl.mjs +5 -5
- package/src/rules/agent/30-explorer.md +8 -11
- package/src/rules/lead/lead-tool.md +9 -5
- package/src/rules/shared/01-tool.md +11 -5
- package/src/runtime/agent/orchestrator/context/collect.mjs +51 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +6 -2
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +22 -68
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +46 -7
- package/src/runtime/agent/orchestrator/providers/api-usage.mjs +1 -13
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +19 -12
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +7 -5
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +33 -23
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +31 -14
- package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +38 -12
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +7 -8
- package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
- package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
- package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
- package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
- package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
- package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
- package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
- package/src/runtime/agent/orchestrator/session/loop.mjs +169 -918
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
- package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +65 -1032
- package/src/runtime/agent/orchestrator/stall-policy.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
- package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +5 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
- package/src/runtime/agent/orchestrator/tools/code-graph.mjs +36 -4277
- package/src/runtime/agent/orchestrator/tools/patch.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
- package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
- package/src/runtime/channels/index.mjs +14 -233
- package/src/runtime/channels/lib/boot-profile.mjs +23 -0
- package/src/runtime/channels/lib/crash-log.mjs +106 -0
- package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
- package/src/runtime/channels/lib/output-forwarder.mjs +8 -0
- package/src/runtime/channels/lib/telegram-format.mjs +19 -22
- package/src/runtime/channels/lib/whisper-language.mjs +42 -0
- package/src/runtime/memory/index.mjs +314 -359
- package/src/runtime/memory/lib/core-memory-store.mjs +351 -1
- package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
- package/src/runtime/memory/lib/http-wire.mjs +57 -0
- package/src/runtime/memory/lib/memory-cycle2.mjs +56 -3
- package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
- package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
- package/src/runtime/memory/lib/memory.mjs +20 -0
- package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
- package/src/runtime/memory/lib/recall-format.mjs +183 -0
- package/src/runtime/memory/tool-defs.mjs +4 -4
- package/src/runtime/shared/abort-controller.mjs +1 -1
- package/src/runtime/shared/background-tasks.mjs +2 -3
- package/src/runtime/shared/buffered-appender.mjs +149 -0
- package/src/runtime/shared/task-notification-envelope.mjs +98 -0
- package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
- package/src/runtime/shared/tool-execution-contract.mjs +2 -2
- package/src/runtime/shared/transcript-writer.mjs +29 -2
- package/src/session-runtime/config-helpers.mjs +209 -0
- package/src/session-runtime/effort.mjs +128 -0
- package/src/session-runtime/fs-utils.mjs +10 -0
- package/src/session-runtime/model-capabilities.mjs +130 -0
- package/src/session-runtime/output-styles.mjs +124 -0
- package/src/session-runtime/plugin-mcp.mjs +114 -0
- package/src/session-runtime/session-text.mjs +100 -0
- package/src/session-runtime/statusline-route.mjs +35 -0
- package/src/session-runtime/tool-catalog.mjs +720 -0
- package/src/session-runtime/workflow.mjs +358 -0
- package/src/standalone/agent-tool.mjs +49 -10
- package/src/standalone/channel-worker.mjs +3 -2
- package/src/standalone/explore-tool.mjs +10 -3
- package/src/standalone/hook-bus.mjs +165 -8
- package/src/standalone/opencode-go-login.mjs +121 -0
- package/src/standalone/provider-admin.mjs +14 -3
- package/src/tui/App.jsx +884 -143
- package/src/tui/components/PromptInput.jsx +272 -15
- package/src/tui/components/ToolExecution.jsx +20 -10
- package/src/tui/components/tool-output-format.mjs +2 -2
- package/src/tui/dist/index.mjs +1771 -1947
- package/src/tui/engine/agent-envelope.mjs +296 -0
- package/src/tui/engine/boot-profile.mjs +21 -0
- package/src/tui/engine/labels.mjs +67 -0
- package/src/tui/engine/notice-text.mjs +112 -0
- package/src/tui/engine/queue-helpers.mjs +161 -0
- package/src/tui/engine/session-stats.mjs +46 -0
- package/src/tui/engine/tool-call-fields.mjs +23 -0
- package/src/tui/engine/tool-result-text.mjs +126 -0
- package/src/tui/engine.mjs +311 -851
- package/src/tui/input-editing.mjs +58 -8
- package/src/tui/input-editing.selection.test.mjs +75 -0
- package/src/tui/keyboard-protocol.mjs +2 -2
- package/src/tui/lib/voice-recorder.mjs +35 -19
- package/src/tui/markdown/format-token.mjs +7 -8
- package/src/tui/markdown/format-token.test.mjs +3 -3
- package/src/tui/paste-attachments.mjs +38 -0
- package/src/tui/paste-fix.test.mjs +119 -0
- package/src/tui/themes/base.mjs +2 -2
- package/src/tui/themes/kanagawa.mjs +4 -4
- package/src/tui/themes/teal.mjs +4 -5
- package/src/tui/themes/utils.mjs +1 -1
- package/src/ui/statusline.mjs +49 -0
- package/src/workflows/default/WORKFLOW.md +16 -9
- package/src/workflows/sequential/WORKFLOW.md +16 -11
- package/src/workflows/solo/WORKFLOW.md +5 -1
package/src/tui/App.jsx
CHANGED
|
@@ -54,6 +54,12 @@ import {
|
|
|
54
54
|
readImageAttachmentFromPath,
|
|
55
55
|
splitPastedImagePathCandidates,
|
|
56
56
|
} from './paste-attachments.mjs';
|
|
57
|
+
import {
|
|
58
|
+
expandPastedTextTokens,
|
|
59
|
+
formatPastedTextRef,
|
|
60
|
+
pastedTextReferenceIds,
|
|
61
|
+
shouldFoldPastedText,
|
|
62
|
+
} from './paste-attachments.mjs';
|
|
57
63
|
import { formatDuration } from './time-format.mjs';
|
|
58
64
|
import {
|
|
59
65
|
listProjects,
|
|
@@ -89,6 +95,12 @@ const MOUSE_TRACKING_ON = '\x1b[?1000h\x1b[?1002h\x1b[?1006h';
|
|
|
89
95
|
const MOUSE_TRACKING_OFF = '\x1b[?1006l\x1b[?1002l\x1b[?1000l';
|
|
90
96
|
const MOUSE_MODIFIER_MASK = 4 | 8 | 16;
|
|
91
97
|
const MOUSE_CTRL_MASK = 16;
|
|
98
|
+
// Bit 2 (4) of the SGR button byte = shift held during the click. Wheel/ctrl
|
|
99
|
+
// masking above intentionally strips it for scroll routing; button-press
|
|
100
|
+
// handling below reads it separately (before baseButton = button & 3 drops
|
|
101
|
+
// every modifier bit) so a shift-held left-click can extend an existing
|
|
102
|
+
// selection instead of starting a fresh one.
|
|
103
|
+
const MOUSE_SHIFT_MASK = 4;
|
|
92
104
|
// SEARCH_DEFAULT marker — mirrors backend SEARCH_DEFAULT_PROVIDER/MODEL
|
|
93
105
|
// (mixdog-session-runtime.mjs 1167-1168). A search route of {provider:'default',
|
|
94
106
|
// model:'default'} means "follow the Main Model" at runtime (nativeSearchRoutes).
|
|
@@ -115,6 +127,7 @@ const SLASH_COMMANDS = [
|
|
|
115
127
|
{ name: 'fast', usage: '/fast', params: '[on|off]', description: 'Toggle Fast mode for the current model' },
|
|
116
128
|
{ name: 'mcp', usage: '/mcp', description: 'Manage MCP servers and tools' },
|
|
117
129
|
{ name: 'skills', usage: '/skills', description: 'Choose a skill for the next request' },
|
|
130
|
+
{ name: 'memory', usage: '/memory', description: 'Memory status, core memories, cycles' },
|
|
118
131
|
{ name: 'plugins', usage: '/plugins', description: 'Manage local plugin integrations' },
|
|
119
132
|
{ name: 'hooks', usage: '/hooks', description: 'Manage before-tool hook rules and events' },
|
|
120
133
|
{ name: 'providers', usage: '/providers', description: 'Manage auth, API keys, OAuth, and local endpoints' },
|
|
@@ -205,7 +218,9 @@ function wrappedTextRows(value, width) {
|
|
|
205
218
|
const w = Math.max(1, Math.floor(Number(width) || 1));
|
|
206
219
|
let row = 0;
|
|
207
220
|
let col = 0;
|
|
208
|
-
|
|
221
|
+
const segments = [...graphemeSegmenter.segment(String(value ?? ''))];
|
|
222
|
+
for (let i = 0; i < segments.length; i += 1) {
|
|
223
|
+
const { segment } = segments[i];
|
|
209
224
|
if (segment === '\n') {
|
|
210
225
|
row += 1;
|
|
211
226
|
col = 0;
|
|
@@ -213,14 +228,17 @@ function wrappedTextRows(value, width) {
|
|
|
213
228
|
}
|
|
214
229
|
const segmentWidth = stringWidth(segment);
|
|
215
230
|
if (segmentWidth === 0) continue;
|
|
231
|
+
// Same wrap-ansi hard/wordWrap:false wrapping as caretPosition — keep the
|
|
232
|
+
// reserved row count in lock-step with the caret math so the prompt box
|
|
233
|
+
// height matches ink's actual wrap for wide-char lines.
|
|
216
234
|
if (col > 0 && col + segmentWidth > w) {
|
|
217
235
|
row += 1;
|
|
218
236
|
col = 0;
|
|
219
237
|
}
|
|
220
238
|
col += segmentWidth;
|
|
221
|
-
if (col
|
|
222
|
-
row +=
|
|
223
|
-
col
|
|
239
|
+
if (col === w && i < segments.length - 1) {
|
|
240
|
+
row += 1;
|
|
241
|
+
col = 0;
|
|
224
242
|
}
|
|
225
243
|
}
|
|
226
244
|
return row + 1;
|
|
@@ -404,17 +422,21 @@ function parseMemoryStatusRows(text) {
|
|
|
404
422
|
}
|
|
405
423
|
|
|
406
424
|
function parseMemoryCoreRows(text) {
|
|
425
|
+
let currentProjectId = null;
|
|
407
426
|
return String(text || '')
|
|
408
427
|
.split('\n')
|
|
409
428
|
.filter((line) => line.trim())
|
|
410
429
|
.map((line, index) => {
|
|
411
430
|
const raw = line.trim();
|
|
412
431
|
if (raw.endsWith(':') && !raw.includes('id=')) {
|
|
432
|
+
const label = raw.slice(0, -1);
|
|
433
|
+
currentProjectId = label === 'COMMON' ? null : label;
|
|
413
434
|
return {
|
|
414
435
|
value: `core-group-${index}`,
|
|
415
|
-
label
|
|
436
|
+
label,
|
|
416
437
|
description: 'core memory pool',
|
|
417
438
|
_line: raw,
|
|
439
|
+
_group: true,
|
|
418
440
|
};
|
|
419
441
|
}
|
|
420
442
|
const match = raw.match(/^id=(\d+)\s+\[([^\]]*)\]\s+(.+?)(?:\s+—\s+(.+))?$/);
|
|
@@ -423,8 +445,21 @@ function parseMemoryCoreRows(text) {
|
|
|
423
445
|
return {
|
|
424
446
|
value: `core-${id}`,
|
|
425
447
|
label: `#${id} [${category}] ${element}`,
|
|
448
|
+
meta: currentProjectId || 'common',
|
|
426
449
|
description: summary,
|
|
427
450
|
_line: raw,
|
|
451
|
+
_action: 'core-entry',
|
|
452
|
+
_id: Number(id),
|
|
453
|
+
_category: category,
|
|
454
|
+
_element: element,
|
|
455
|
+
_summary: summary,
|
|
456
|
+
_projectId: currentProjectId,
|
|
457
|
+
// Raw pre-edit values, distinct from the (possibly later-mutated)
|
|
458
|
+
// _element/_summary above -- beginEditCoreMemory reads these to
|
|
459
|
+
// decide whether the row's element duplicated its summary (single-
|
|
460
|
+
// sentence entry) before deciding whether to touch element on edit.
|
|
461
|
+
_origElement: element,
|
|
462
|
+
_origSummary: summary,
|
|
428
463
|
};
|
|
429
464
|
}
|
|
430
465
|
return {
|
|
@@ -436,6 +471,63 @@ function parseMemoryCoreRows(text) {
|
|
|
436
471
|
});
|
|
437
472
|
}
|
|
438
473
|
|
|
474
|
+
function parseMemoryCandidateRows(text) {
|
|
475
|
+
const trimmed = String(text || '').trim();
|
|
476
|
+
// op:'candidates' returns this exact sentinel (index.mjs ~3158) when the
|
|
477
|
+
// resolved scope has none — treat as an empty list, not an inert text row.
|
|
478
|
+
if (!trimmed || /^core candidates:\s*none$/i.test(trimmed)) return [];
|
|
479
|
+
// Backend row shape (index.mjs ~3164), one candidate per line, no group
|
|
480
|
+
// headers:
|
|
481
|
+
// id=<n> project=<COMMON|slug> [<category>] score=<x.xx|-> <element> — <summary> (<reason>)
|
|
482
|
+
const rowPattern = /^id=(\d+)\s+project=(\S+)\s+\[([^\]]*)\]\s+score=(\S+)\s+(.+?)\s+—\s+(.+?)\s+\(([^)]*)\)$/;
|
|
483
|
+
return trimmed
|
|
484
|
+
.split('\n')
|
|
485
|
+
.filter((line) => line.trim())
|
|
486
|
+
.map((line, index) => {
|
|
487
|
+
const raw = line.trim();
|
|
488
|
+
const match = raw.match(rowPattern);
|
|
489
|
+
if (match) {
|
|
490
|
+
const [, id, project, category, score, element, summary, reason] = match;
|
|
491
|
+
return {
|
|
492
|
+
value: `candidate-${id}`,
|
|
493
|
+
label: `#${id} [${category}] ${element}`,
|
|
494
|
+
meta: project === 'COMMON' ? 'common' : project,
|
|
495
|
+
description: `${summary}${score !== '-' ? ` (score ${score})` : ''} — ${reason}`,
|
|
496
|
+
_line: raw,
|
|
497
|
+
_action: 'candidate-entry',
|
|
498
|
+
_id: Number(id),
|
|
499
|
+
_projectId: project === 'COMMON' ? null : project,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
return {
|
|
503
|
+
value: `candidate-${index}`,
|
|
504
|
+
label: raw,
|
|
505
|
+
description: '',
|
|
506
|
+
_line: raw,
|
|
507
|
+
};
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Backend "core" op errors are flattened to plain text by store.memoryControl
|
|
512
|
+
// (isError is dropped -- see engine.mjs memoryControl / toolResponseText in
|
|
513
|
+
// mixdog-session-runtime.mjs). Success text always uses a past-tense verb
|
|
514
|
+
// ("core added/edited/deleted/promoted...", "core candidate dismissed...");
|
|
515
|
+
// every declared failure in index.mjs's core-op handling uses the op word
|
|
516
|
+
// followed by either ":" (validation message) or " failed" (caught
|
|
517
|
+
// exception) -- e.g. "core add: project_id required...", "core edit failed:
|
|
518
|
+
// no entry with id=5". That shape is used here to recover the error signal
|
|
519
|
+
// since store.memoryControl resolves instead of rejecting on isError.
|
|
520
|
+
function memoryCoreResultErrorText(text) {
|
|
521
|
+
const value = String(text || '').trim();
|
|
522
|
+
if (/^core (add|edit|delete|promote|dismiss)(:| failed)/i.test(value)) return value;
|
|
523
|
+
// Broader catch-all for other flattened "core" failures that don't name an
|
|
524
|
+
// op word right after "core" (e.g. "core: memory data dir is not
|
|
525
|
+
// initialized", "core requires op: ..."), plus any bare error/failed lead-in.
|
|
526
|
+
if (/^core:.*(not initialized|failed|error)/i.test(value)) return value;
|
|
527
|
+
if (/^(error|failed)\b/i.test(value)) return value;
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
|
|
439
531
|
function fitLine(value, columns, reserve = 4) {
|
|
440
532
|
const text = String(value || '');
|
|
441
533
|
const width = Math.max(1, Number(columns || 80) - reserve);
|
|
@@ -713,6 +805,11 @@ const TRANSCRIPT_WINDOW_OVERSCAN_ROWS = positiveIntEnv('MIXDOG_TUI_TRANSCRIPT_OV
|
|
|
713
805
|
// viewport + overscan on a large terminal. Env-tunable for A/B / revert.
|
|
714
806
|
const TRANSCRIPT_WINDOW_MAX_ITEMS = positiveIntEnv('MIXDOG_TUI_TRANSCRIPT_WINDOW_ITEMS', 80);
|
|
715
807
|
const SELECTION_PAINT_INTERVAL_MS = positiveIntEnv('MIXDOG_TUI_SELECTION_PAINT_MS', 24);
|
|
808
|
+
// Frame-coalesce edge-drag auto-scroll + wheel scroll: both paths accumulate
|
|
809
|
+
// deltas into one pending total and flush via a single scrollTranscriptRows
|
|
810
|
+
// call per this interval, instead of firing the (expensive: anchor recompute +
|
|
811
|
+
// selection repaint) scrollTranscriptRows on every mousemove/wheel tick.
|
|
812
|
+
const SCROLL_COALESCE_MS = positiveIntEnv('MIXDOG_TUI_SCROLL_COALESCE_MS', 16);
|
|
716
813
|
const PROMPT_HISTORY_LIMIT = 50;
|
|
717
814
|
|
|
718
815
|
// Parse a boolean env var that DEFAULTS ON. Any of 0/false/off/no (case-
|
|
@@ -1312,8 +1409,7 @@ function computeTranscriptItemVariantKey(item) {
|
|
|
1312
1409
|
// flushes — so the values stay 100% identical to the uncached implementation.
|
|
1313
1410
|
const transcriptRowsCache = new WeakMap();
|
|
1314
1411
|
|
|
1315
|
-
// ── App-level MEASURED row heights (
|
|
1316
|
-
// heightCache) ────────────────────────────────────────────────────────────────
|
|
1412
|
+
// ── App-level MEASURED row heights (real, per-item height cache) ───────────
|
|
1317
1413
|
// Keyed on the transcript item OBJECT (stable until engine.mjs swaps it on a
|
|
1318
1414
|
// patch — a patched item legitimately changed height, so a cache miss → re-
|
|
1319
1415
|
// measure is correct). Stores the REAL Yoga getComputedHeight() of the row the
|
|
@@ -1607,7 +1703,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
1607
1703
|
// the anchor "dirty" for one frame, and if streaming grows the transcript on
|
|
1608
1704
|
// that same frame the lock is not engaged yet and the view lurches.
|
|
1609
1705
|
const transcriptGeomRef = useRef({ prefixRows: null, totalRows: 0, viewRows: 1 });
|
|
1610
|
-
// App-level measured row heights (
|
|
1706
|
+
// App-level measured row heights (real per-item height cache). The map of
|
|
1611
1707
|
// mounted item id → ink DOM element is read every commit to harvest each
|
|
1612
1708
|
// row's REAL Yoga height into transcriptMeasuredRowsCache. measuredRowsVersion
|
|
1613
1709
|
// is bumped whenever a height actually changes so the row-index/window memos
|
|
@@ -1752,6 +1848,12 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
1752
1848
|
const [contextPanel, setContextPanel] = useState(null);
|
|
1753
1849
|
const [usagePanel, setUsagePanel] = useState(null);
|
|
1754
1850
|
const usageRequestRef = useRef(0);
|
|
1851
|
+
// Cache of the last computed heavy settings-picker status objects (MCP,
|
|
1852
|
+
// hooks, plugins, skills, channel backend). ←/→ cycle/toggle handlers in
|
|
1853
|
+
// openSettingsPicker() pass { light: true } to reuse this cache instead of
|
|
1854
|
+
// re-querying these heavy getters on every keystroke; only a full open
|
|
1855
|
+
// (initial /config or Esc-return) recomputes them.
|
|
1856
|
+
const settingsHeavyCacheRef = useRef(null);
|
|
1755
1857
|
const closeUsagePanel = useCallback(() => {
|
|
1756
1858
|
usageRequestRef.current += 1;
|
|
1757
1859
|
setUsagePanel(null);
|
|
@@ -1769,6 +1871,11 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
1769
1871
|
const [, setPastedImages] = useState({});
|
|
1770
1872
|
const pastedImagesRef = useRef({});
|
|
1771
1873
|
const nextPastedImageIdRef = useRef(1);
|
|
1874
|
+
// Large pasted texts folded into [Pasted text #N +M lines] tokens; mirrors
|
|
1875
|
+
// pastedImagesRef. Original text is expanded back on submit.
|
|
1876
|
+
const [, setPastedTexts] = useState({});
|
|
1877
|
+
const pastedTextsRef = useRef({});
|
|
1878
|
+
const nextPastedTextIdRef = useRef(1);
|
|
1772
1879
|
const promptValueRef = useRef('');
|
|
1773
1880
|
const promptSelectionRef = useRef(null);
|
|
1774
1881
|
// [mixdog] Prompt-box mouse selection wiring. boxRect is the editable text
|
|
@@ -1835,6 +1942,10 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
1835
1942
|
// extendSelection). Null ⇔ ordinary char-drag selection.
|
|
1836
1943
|
const dragRef = useRef({ anchor: null, anchorScroll: 0, last: null, active: false, rect: null, region: null, anchorSpan: null });
|
|
1837
1944
|
const selectionPaintRef = useRef({ t: 0, rect: null, pending: null, timer: null });
|
|
1945
|
+
// Coalescer for edge-drag auto-scroll + wheel scroll deltas (see
|
|
1946
|
+
// SCROLL_COALESCE_MS above). Both call sites accumulate into pendingRows and
|
|
1947
|
+
// rely on flushScrollCoalesce to make the single scrollTranscriptRows call.
|
|
1948
|
+
const scrollCoalesceRef = useRef({ pendingRows: 0, timer: null });
|
|
1838
1949
|
const transcriptViewportRef = useRef({ top: 0, bottom: 0 });
|
|
1839
1950
|
// [mixdog] Latest terminal row count + the statusline band (bottom rows),
|
|
1840
1951
|
// refreshed each render. The mouse handler uses these to (a) clip a status-bar
|
|
@@ -1842,7 +1953,19 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
1842
1953
|
// region. STATUSLINE_ROWS mirrors the layout reserve below.
|
|
1843
1954
|
const frameRowsRef = useRef(24);
|
|
1844
1955
|
const STATUSLINE_BAND_ROWS = 3;
|
|
1845
|
-
|
|
1956
|
+
// Voice enabled is cached in state instead of calling isVoiceEnabled() on
|
|
1957
|
+
// every render — readSection() is a sync config-file read + JSON.parse per
|
|
1958
|
+
// call, which would otherwise run on every keystroke re-render. Updated by
|
|
1959
|
+
// the /voice command handler when toggleVoice resolves.
|
|
1960
|
+
const [voiceEnabled, setVoiceEnabled] = useState(() => isVoiceEnabled());
|
|
1961
|
+
// When the prompt-box voice indicator is visible, PromptInput reserves up
|
|
1962
|
+
// to 3 columns (2-cell 'recording' glyph pair + 1 gap) out of its content
|
|
1963
|
+
// width. The row-reservation math here must shrink by the SAME worst-case
|
|
1964
|
+
// amount, or wrapped input near the right edge under-reserves rows and the
|
|
1965
|
+
// transcript overprints the prompt box. Over-reserving by a column in the
|
|
1966
|
+
// 1-cell indicator states only costs an occasional early wrap — safe side.
|
|
1967
|
+
const voiceIndicatorVisible = Boolean(state.progressHint?.text) || voiceEnabled || getRecorderState() !== 'idle';
|
|
1968
|
+
const promptContentColumns = Math.max(1, frameColumns - 4 - (voiceIndicatorVisible ? 3 : 0));
|
|
1846
1969
|
const syncPromptLayoutRows = useCallback((value) => {
|
|
1847
1970
|
const text = String(value ?? '');
|
|
1848
1971
|
promptLayoutValueRef.current = text;
|
|
@@ -1918,7 +2041,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
1918
2041
|
// can detect a double-click (same cell within 500ms) for word selection.
|
|
1919
2042
|
// count = consecutive qualifying presses on the same cell (1=single,
|
|
1920
2043
|
// 2=double/word, 3=triple/line). A 4th qualifying press restarts the
|
|
1921
|
-
// sequence at 1 (
|
|
2044
|
+
// sequence at 1 (simplest reset: no ratcheting/back-off). Any non-qualifying
|
|
1922
2045
|
// press resets to a fresh single.
|
|
1923
2046
|
const lastClickRef = useRef({ x: -1, y: -1, t: 0, count: 0 });
|
|
1924
2047
|
|
|
@@ -2033,6 +2156,13 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2033
2156
|
setPromptHintTone(tone);
|
|
2034
2157
|
}, []);
|
|
2035
2158
|
|
|
2159
|
+
// Dedicated recorder-phase state for the prompt-box voice indicator.
|
|
2160
|
+
// Deliberately NOT derived from the promptHint text ('● REC' equality):
|
|
2161
|
+
// any draft keystroke clears promptHint via clearPromptHint(), which would
|
|
2162
|
+
// silently demote a live recording's indicator back to 'idle' while the
|
|
2163
|
+
// mic is still hot. This state only changes at real recorder transitions.
|
|
2164
|
+
const [voiceRecPhase, setVoiceRecPhase] = useState('idle');
|
|
2165
|
+
|
|
2036
2166
|
// Ctrl+Space handler wired to PromptInput's onVoiceToggle. Dispatches on the
|
|
2037
2167
|
// recorder's CURRENT state (idle/recording/transcribing) — see
|
|
2038
2168
|
// src/tui/lib/voice-recorder.mjs for the state machine itself.
|
|
@@ -2048,6 +2178,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2048
2178
|
}
|
|
2049
2179
|
if (recState === 'recording') {
|
|
2050
2180
|
setVoiceStatusHint('… transcribing', 'info');
|
|
2181
|
+
setVoiceRecPhase('transcribing');
|
|
2051
2182
|
let result;
|
|
2052
2183
|
try {
|
|
2053
2184
|
result = await stopRecording();
|
|
@@ -2055,6 +2186,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2055
2186
|
result = { ok: false, reason: e?.message || String(e) };
|
|
2056
2187
|
}
|
|
2057
2188
|
setVoiceStatusHint('', 'info');
|
|
2189
|
+
setVoiceRecPhase('idle');
|
|
2058
2190
|
if (!result) return; // race: recorder was no longer RECORDING
|
|
2059
2191
|
if (!result.ok) {
|
|
2060
2192
|
store.pushNotice(`Voice: ${result.reason || 'transcription failed'}`, 'error');
|
|
@@ -2092,6 +2224,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2092
2224
|
return;
|
|
2093
2225
|
}
|
|
2094
2226
|
setVoiceStatusHint('● REC', 'error');
|
|
2227
|
+
setVoiceRecPhase('recording');
|
|
2095
2228
|
}, [store, setVoiceStatusHint, syncPromptLayoutRows]);
|
|
2096
2229
|
|
|
2097
2230
|
// Best-effort recorder teardown on unmount (component-level cleanup;
|
|
@@ -2142,6 +2275,48 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2142
2275
|
return formatImageRef(id);
|
|
2143
2276
|
}, []);
|
|
2144
2277
|
|
|
2278
|
+
const installPastedTexts = useCallback((texts, { merge = true } = {}) => {
|
|
2279
|
+
if (!texts || typeof texts !== 'object' || Object.keys(texts).length === 0) return;
|
|
2280
|
+
const next = merge ? { ...pastedTextsRef.current, ...texts } : { ...texts };
|
|
2281
|
+
pastedTextsRef.current = next;
|
|
2282
|
+
const maxId = Object.keys(next)
|
|
2283
|
+
.map((id) => Number(id) || 0)
|
|
2284
|
+
.reduce((max, id) => Math.max(max, id), 0);
|
|
2285
|
+
if (maxId >= nextPastedTextIdRef.current) nextPastedTextIdRef.current = maxId + 1;
|
|
2286
|
+
setPastedTexts(next);
|
|
2287
|
+
}, []);
|
|
2288
|
+
|
|
2289
|
+
const clearPastedTextsSnapshot = useCallback((snapshot = null) => {
|
|
2290
|
+
if (!snapshot) {
|
|
2291
|
+
if (Object.keys(pastedTextsRef.current || {}).length === 0) return;
|
|
2292
|
+
pastedTextsRef.current = {};
|
|
2293
|
+
setPastedTexts({});
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2296
|
+
if (typeof snapshot !== 'object' || Object.keys(snapshot).length === 0) return;
|
|
2297
|
+
const next = { ...pastedTextsRef.current };
|
|
2298
|
+
let changed = false;
|
|
2299
|
+
for (const [id, text] of Object.entries(snapshot)) {
|
|
2300
|
+
if (next[id] === text) {
|
|
2301
|
+
delete next[id];
|
|
2302
|
+
changed = true;
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
if (!changed) return;
|
|
2306
|
+
pastedTextsRef.current = next;
|
|
2307
|
+
setPastedTexts(next);
|
|
2308
|
+
}, []);
|
|
2309
|
+
|
|
2310
|
+
const registerPastedText = useCallback((text) => {
|
|
2311
|
+
const value = String(text ?? '');
|
|
2312
|
+
if (!value) return '';
|
|
2313
|
+
const id = nextPastedTextIdRef.current++;
|
|
2314
|
+
const entry = { id, text: value };
|
|
2315
|
+
pastedTextsRef.current = { ...pastedTextsRef.current, [id]: entry };
|
|
2316
|
+
setPastedTexts(pastedTextsRef.current);
|
|
2317
|
+
return formatPastedTextRef(id, value);
|
|
2318
|
+
}, []);
|
|
2319
|
+
|
|
2145
2320
|
const handlePromptPaste = useCallback((text, meta = {}) => {
|
|
2146
2321
|
const source = String(meta?.source || 'paste');
|
|
2147
2322
|
const value = String(text ?? '');
|
|
@@ -2163,7 +2338,17 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2163
2338
|
}
|
|
2164
2339
|
|
|
2165
2340
|
const chunks = splitPastedImagePathCandidates(value);
|
|
2166
|
-
|
|
2341
|
+
const hasImagePath = chunks.some((chunk) => chunk.imagePath);
|
|
2342
|
+
// No image paths in the paste: fold the whole text into a token when it is
|
|
2343
|
+
// large, otherwise let PromptInput insert it raw (return undefined).
|
|
2344
|
+
if (!hasImagePath) {
|
|
2345
|
+
if (shouldFoldPastedText(value)) return registerPastedText(value);
|
|
2346
|
+
return undefined;
|
|
2347
|
+
}
|
|
2348
|
+
// Mixed paste: resolve each image chunk to an image ref, then fold each
|
|
2349
|
+
// CONTIGUOUS run of non-image text into its own token (only if over
|
|
2350
|
+
// threshold) so content order around image refs is preserved. '\n'
|
|
2351
|
+
// separator chunks are plain text and stay inside their surrounding run.
|
|
2167
2352
|
return Promise.all(chunks.map(async (chunk) => {
|
|
2168
2353
|
if (!chunk.imagePath) return chunk.text;
|
|
2169
2354
|
try {
|
|
@@ -2176,8 +2361,26 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2176
2361
|
showPromptHint(`image attach failed: ${e?.message || e}`, 'warn');
|
|
2177
2362
|
return chunk.text;
|
|
2178
2363
|
}
|
|
2179
|
-
})).then((parts) =>
|
|
2180
|
-
|
|
2364
|
+
})).then((parts) => {
|
|
2365
|
+
let out = '';
|
|
2366
|
+
let run = '';
|
|
2367
|
+
const flushRun = () => {
|
|
2368
|
+
if (!run) return;
|
|
2369
|
+
out += shouldFoldPastedText(run) ? registerPastedText(run) : run;
|
|
2370
|
+
run = '';
|
|
2371
|
+
};
|
|
2372
|
+
for (let i = 0; i < chunks.length; i += 1) {
|
|
2373
|
+
if (chunks[i].imagePath) {
|
|
2374
|
+
flushRun();
|
|
2375
|
+
out += parts[i];
|
|
2376
|
+
} else {
|
|
2377
|
+
run += parts[i];
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
flushRun();
|
|
2381
|
+
return out;
|
|
2382
|
+
});
|
|
2383
|
+
}, [registerPastedImage, registerPastedText, showPromptHint, state.cwd]);
|
|
2181
2384
|
|
|
2182
2385
|
const stopSmoothScroll = useCallback(() => {
|
|
2183
2386
|
if (!scrollAnimationRef.current) return;
|
|
@@ -2388,74 +2591,18 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2388
2591
|
return Math.max(0, frameColumns - 1);
|
|
2389
2592
|
}, [store, frameColumns]);
|
|
2390
2593
|
|
|
2391
|
-
|
|
2594
|
+
// Synchronous predicate: is a transcript/status ink-grid selection live? Used
|
|
2595
|
+
// both by App (to consume Shift+Arrow even when focus clamps at an edge) and
|
|
2596
|
+
// by PromptInput (via prop) to gate its own Shift+Arrow at event time — a flag
|
|
2597
|
+
// set inside App's parent handler would be one event stale (parent handler
|
|
2598
|
+
// runs AFTER the child prompt handler for the same key).
|
|
2599
|
+
const gridSelectionActiveRef = useRef(() => {
|
|
2392
2600
|
const drag = dragRef.current;
|
|
2393
|
-
if (drag.active) return false;
|
|
2394
|
-
|
|
2395
|
-
if (region !== 'transcript' && region !== 'status') return false;
|
|
2601
|
+
if (!drag || drag.active) return false;
|
|
2602
|
+
if (drag.region !== 'transcript' && drag.region !== 'status') return false;
|
|
2396
2603
|
const rect = drag.rect;
|
|
2397
|
-
|
|
2398
|
-
|
|
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]);
|
|
2604
|
+
return Boolean(rect) && !(rect.x1 === rect.x2 && rect.y1 === rect.y2);
|
|
2605
|
+
});
|
|
2459
2606
|
|
|
2460
2607
|
useEffect(() => () => {
|
|
2461
2608
|
const paintState = selectionPaintRef.current;
|
|
@@ -2464,6 +2611,10 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2464
2611
|
paintState.pending = null;
|
|
2465
2612
|
if (selectionTextTimerRef.current) clearTimeout(selectionTextTimerRef.current);
|
|
2466
2613
|
selectionTextTimerRef.current = null;
|
|
2614
|
+
const coalesceState = scrollCoalesceRef.current;
|
|
2615
|
+
if (coalesceState.timer) clearTimeout(coalesceState.timer);
|
|
2616
|
+
coalesceState.timer = null;
|
|
2617
|
+
coalesceState.pendingRows = 0;
|
|
2467
2618
|
}, []);
|
|
2468
2619
|
|
|
2469
2620
|
const scrollTranscriptRows = useCallback((deltaRows, options = {}) => {
|
|
@@ -2538,6 +2689,146 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2538
2689
|
setScrollOffset(Math.round(target));
|
|
2539
2690
|
}, [startSmoothScroll, stopSmoothScroll, paintSelectionRect, selectionPointAtCurrentScroll, withSelectionClip, cancelTranscriptFollow, buildSpanRect]);
|
|
2540
2691
|
|
|
2692
|
+
// Leading-edge coalescer for edge-drag auto-scroll + wheel deltas: the first
|
|
2693
|
+
// delta after an idle period flushes immediately (single wheel ticks/short
|
|
2694
|
+
// drags stay responsive), while a flood of deltas within SCROLL_COALESCE_MS
|
|
2695
|
+
// accumulates into one scrollTranscriptRows call per tick instead of one per
|
|
2696
|
+
// mousemove/wheel event. Both call sites below route through this instead of
|
|
2697
|
+
// calling scrollTranscriptRows directly.
|
|
2698
|
+
const queueScrollCoalesced = useCallback((deltaRows) => {
|
|
2699
|
+
const state = scrollCoalesceRef.current;
|
|
2700
|
+
state.pendingRows += deltaRows;
|
|
2701
|
+
if (state.timer) return;
|
|
2702
|
+
const rows = state.pendingRows;
|
|
2703
|
+
state.pendingRows = 0;
|
|
2704
|
+
scrollTranscriptRows(rows);
|
|
2705
|
+
state.timer = setTimeout(() => {
|
|
2706
|
+
state.timer = null;
|
|
2707
|
+
if (state.pendingRows !== 0) {
|
|
2708
|
+
const remaining = state.pendingRows;
|
|
2709
|
+
state.pendingRows = 0;
|
|
2710
|
+
scrollTranscriptRows(remaining);
|
|
2711
|
+
}
|
|
2712
|
+
}, SCROLL_COALESCE_MS);
|
|
2713
|
+
state.timer.unref?.();
|
|
2714
|
+
}, [scrollTranscriptRows]);
|
|
2715
|
+
|
|
2716
|
+
// NOTE: declared AFTER scrollTranscriptRows — it appears in the deps array
|
|
2717
|
+
// below, and useCallback deps are evaluated at render time, so referencing
|
|
2718
|
+
// it before its const initializer would throw a TDZ ReferenceError.
|
|
2719
|
+
const moveSelectionFocus = useCallback((move) => {
|
|
2720
|
+
const drag = dragRef.current;
|
|
2721
|
+
if (drag.active) return false;
|
|
2722
|
+
const region = drag.region;
|
|
2723
|
+
if (region !== 'transcript' && region !== 'status') return false;
|
|
2724
|
+
const rect = drag.rect;
|
|
2725
|
+
if (!rect) return false;
|
|
2726
|
+
if (rect.x1 === rect.x2 && rect.y1 === rect.y2) return false;
|
|
2727
|
+
|
|
2728
|
+
let anchor = { x: rect.x1, y: rect.y1 };
|
|
2729
|
+
let col = rect.x2;
|
|
2730
|
+
let row = rect.y2;
|
|
2731
|
+
const beforeCol = col;
|
|
2732
|
+
const beforeRow = row;
|
|
2733
|
+
// Set when Shift+Up/Down hits the viewport edge and we scroll the
|
|
2734
|
+
// transcript to reveal a new row rather than moving within the current
|
|
2735
|
+
// viewport (mirrors the mouse edge-drag auto-scroll at the drag-motion
|
|
2736
|
+
// handler). In that case row/col numerically equal their "before" values
|
|
2737
|
+
// (both clamp to the same viewport edge index) even though the
|
|
2738
|
+
// underlying content changed, so the generic before/after guard below
|
|
2739
|
+
// must not treat it as a no-op.
|
|
2740
|
+
let scrolledEdge = false;
|
|
2741
|
+
|
|
2742
|
+
const { top, bottom } = region === 'status' ? statusBandRows() : transcriptViewportRows();
|
|
2743
|
+
|
|
2744
|
+
switch (move) {
|
|
2745
|
+
case 'left':
|
|
2746
|
+
if (col > 0) col -= 1;
|
|
2747
|
+
else if (row > top) {
|
|
2748
|
+
row -= 1;
|
|
2749
|
+
col = selectionMaxColAtRow(row);
|
|
2750
|
+
}
|
|
2751
|
+
break;
|
|
2752
|
+
case 'right': {
|
|
2753
|
+
const maxCol = selectionMaxColAtRow(row);
|
|
2754
|
+
if (col < maxCol) col += 1;
|
|
2755
|
+
else if (row < bottom) {
|
|
2756
|
+
row += 1;
|
|
2757
|
+
col = 0;
|
|
2758
|
+
}
|
|
2759
|
+
break;
|
|
2760
|
+
}
|
|
2761
|
+
case 'up':
|
|
2762
|
+
if (row > top) {
|
|
2763
|
+
row -= 1;
|
|
2764
|
+
} else if (region === 'transcript') {
|
|
2765
|
+
// Already at the top visible row: scroll the transcript up by one
|
|
2766
|
+
// row (same direction as the mouse edge-drag auto-scroll at the
|
|
2767
|
+
// top edge, App.jsx ~3060) instead of clamping the selection in
|
|
2768
|
+
// place, then extend the focus onto the newly revealed top row.
|
|
2769
|
+
const beforeTarget = scrollTargetRef.current;
|
|
2770
|
+
scrollTranscriptRows(1);
|
|
2771
|
+
if (scrollTargetRef.current !== beforeTarget) {
|
|
2772
|
+
scrolledEdge = true;
|
|
2773
|
+
// scrollTranscriptRows REPLACES dragRef.current with a shifted
|
|
2774
|
+
// copy — re-read it; the local `drag` binding is stale here.
|
|
2775
|
+
const shiftedRect = dragRef.current.rect;
|
|
2776
|
+
if (shiftedRect) anchor = { x: shiftedRect.x1, y: shiftedRect.y1 };
|
|
2777
|
+
row = top;
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
break;
|
|
2781
|
+
case 'down':
|
|
2782
|
+
if (row < bottom) {
|
|
2783
|
+
row += 1;
|
|
2784
|
+
} else if (region === 'transcript') {
|
|
2785
|
+
// Already at the bottom visible row: scroll down by one row (mirrors
|
|
2786
|
+
// the mouse edge-drag auto-scroll at the bottom edge, App.jsx
|
|
2787
|
+
// ~3062) instead of clamping, then extend the focus onto the newly
|
|
2788
|
+
// revealed bottom row.
|
|
2789
|
+
const beforeTarget = scrollTargetRef.current;
|
|
2790
|
+
scrollTranscriptRows(-1);
|
|
2791
|
+
if (scrollTargetRef.current !== beforeTarget) {
|
|
2792
|
+
scrolledEdge = true;
|
|
2793
|
+
// Re-read post-scroll dragRef.current (see 'up' case).
|
|
2794
|
+
const shiftedRect = dragRef.current.rect;
|
|
2795
|
+
if (shiftedRect) anchor = { x: shiftedRect.x1, y: shiftedRect.y1 };
|
|
2796
|
+
row = bottom;
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
break;
|
|
2800
|
+
case 'lineStart':
|
|
2801
|
+
col = 0;
|
|
2802
|
+
break;
|
|
2803
|
+
case 'lineEnd':
|
|
2804
|
+
col = selectionMaxColAtRow(row);
|
|
2805
|
+
break;
|
|
2806
|
+
default:
|
|
2807
|
+
return false;
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
row = Math.max(top, Math.min(bottom, row));
|
|
2811
|
+
col = Math.max(0, Math.min(selectionMaxColAtRow(row), col));
|
|
2812
|
+
|
|
2813
|
+
if (!scrolledEdge && col === beforeCol && row === beforeRow) return false;
|
|
2814
|
+
|
|
2815
|
+
// After an edge scroll dragRef.current is a NEW object; mutate the live
|
|
2816
|
+
// one, not the stale entry binding.
|
|
2817
|
+
const dragNow = dragRef.current;
|
|
2818
|
+
if (dragNow.anchorSpan) dragNow.anchorSpan = null;
|
|
2819
|
+
|
|
2820
|
+
const focus = { x: col, y: row };
|
|
2821
|
+
applySelectionRect({
|
|
2822
|
+
mode: 'linear',
|
|
2823
|
+
x1: anchor.x,
|
|
2824
|
+
y1: anchor.y,
|
|
2825
|
+
x2: focus.x,
|
|
2826
|
+
y2: focus.y,
|
|
2827
|
+
});
|
|
2828
|
+
dragNow.last = { x: focus.x, y: focus.y };
|
|
2829
|
+
return true;
|
|
2830
|
+
}, [applySelectionRect, statusBandRows, transcriptViewportRows, selectionMaxColAtRow, scrollTranscriptRows]);
|
|
2831
|
+
|
|
2541
2832
|
const passthroughCtrlWheelZoom = useCallback(() => {
|
|
2542
2833
|
if (!stdout?.write) return;
|
|
2543
2834
|
try {
|
|
@@ -2641,8 +2932,14 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2641
2932
|
if (!r || !ctl) return null;
|
|
2642
2933
|
const top = Math.max(0, Number(r.top) || 0);
|
|
2643
2934
|
const left = Math.max(0, Number(r.left) || 0);
|
|
2644
|
-
const
|
|
2645
|
-
const
|
|
2935
|
+
const height = Math.max(1, Number(r.height) || 1);
|
|
2936
|
+
const width = Math.max(1, Number(r.contentWidth) || 1);
|
|
2937
|
+
// Clamp the mapped row/col to the box's own bounds so a drag that runs
|
|
2938
|
+
// outside the prompt (above/below/left/right, e.g. onto the transcript
|
|
2939
|
+
// or off-screen) still tracks the nearest edge cell instead of jumping
|
|
2940
|
+
// to whatever offset a raw negative/overflowing row would resolve to.
|
|
2941
|
+
const row = Math.max(0, Math.min(height - 1, y - top));
|
|
2942
|
+
const col = Math.max(0, Math.min(width, x - left));
|
|
2646
2943
|
return ctl.offsetAtCell(row, col);
|
|
2647
2944
|
};
|
|
2648
2945
|
// Clear whichever selection is active (ink-grid rect AND/OR prompt engine).
|
|
@@ -2678,19 +2975,60 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2678
2975
|
// Low 2 bits = button id; bit 5 (32) = motion-while-pressed flag.
|
|
2679
2976
|
const baseButton = button & 3;
|
|
2680
2977
|
const isMotion = (button & 32) !== 0;
|
|
2978
|
+
// Shift bit must be read BEFORE baseButton drops every modifier bit
|
|
2979
|
+
// (button & 3); MOUSE_MODIFIER_MASK above is scroll-routing-only and
|
|
2980
|
+
// deliberately treats shift as noise there.
|
|
2981
|
+
const shiftHeld = (button & MOUSE_SHIFT_MASK) !== 0;
|
|
2681
2982
|
if (baseButton === 0 && press && !isMotion) {
|
|
2682
2983
|
// Region router: a press decides which surface owns this selection.
|
|
2683
2984
|
// Prompt box takes priority (it overlaps no transcript rows), then the
|
|
2684
2985
|
// transcript viewport, then the bottom statusline band. A press
|
|
2685
2986
|
// anywhere else clears any prior selection (plain click).
|
|
2686
2987
|
if (isInPromptBox(x, y)) {
|
|
2687
|
-
lastClickRef.current = { x: -1, y: -1, t: 0 };
|
|
2688
2988
|
// Clear any ink-grid selection so only one highlight is ever visible.
|
|
2689
2989
|
applySelectionRect(null);
|
|
2690
2990
|
const offset = promptOffsetAt(x, y);
|
|
2691
2991
|
stopSmoothScroll();
|
|
2692
2992
|
dragRef.current = { anchor: { x, y }, anchorScroll: 0, last: { x, y }, active: true, rect: null, region: 'prompt', anchorSpan: null };
|
|
2693
|
-
|
|
2993
|
+
const ctl = promptMouseSelectionRef.current;
|
|
2994
|
+
// Shift+click extends the EXISTING prompt selection (anchor stays
|
|
2995
|
+
// put, cursor jumps to the click) instead of starting a fresh
|
|
2996
|
+
// zero-width anchor at the click point.
|
|
2997
|
+
if (shiftHeld && ctl?.hasSelection?.()) {
|
|
2998
|
+
if (offset != null) ctl.extendTo?.(offset, true);
|
|
2999
|
+
lastClickRef.current = { x: -1, y: -1, t: 0 };
|
|
3000
|
+
continue;
|
|
3001
|
+
}
|
|
3002
|
+
// Multi-click word/line select (double = word, triple = line),
|
|
3003
|
+
// same "qualifying press" window/drift tolerance used by the
|
|
3004
|
+
// transcript/status path below. Reuses lastClickRef so a rapid
|
|
3005
|
+
// double/triple click on the prompt box behaves the same as one on
|
|
3006
|
+
// the transcript. A qualifying press advances the count; anything
|
|
3007
|
+
// else (moved too far, too slow, or count already at 3) resets to
|
|
3008
|
+
// a fresh single-click anchor.
|
|
3009
|
+
const nowPrompt = Date.now();
|
|
3010
|
+
const lcPrompt = lastClickRef.current;
|
|
3011
|
+
const qualifiesPrompt = (nowPrompt - lcPrompt.t) < 500
|
|
3012
|
+
&& Math.abs(lcPrompt.y - y) <= 1
|
|
3013
|
+
&& Math.abs(lcPrompt.x - x) <= 2;
|
|
3014
|
+
let promptClickCount = qualifiesPrompt ? (lcPrompt.count || 1) + 1 : 1;
|
|
3015
|
+
if (promptClickCount > 3) promptClickCount = 1;
|
|
3016
|
+
if ((promptClickCount === 2 || promptClickCount === 3) && offset != null) {
|
|
3017
|
+
if (promptClickCount === 2) ctl?.selectWordAt?.(offset);
|
|
3018
|
+
else ctl?.selectLineAt?.(offset);
|
|
3019
|
+
// Click-select is already final (selectWordAt/selectLineAt set the
|
|
3020
|
+
// full word/line range). Mark the drag inactive so a subsequent
|
|
3021
|
+
// release does NOT fall into the generic prompt release handler
|
|
3022
|
+
// below, which calls extendTo(releaseOffset) and would collapse
|
|
3023
|
+
// this selection back down to anchor→releaseOffset (or empty on a
|
|
3024
|
+
// no-motion release, since anchor was never re-anchored here).
|
|
3025
|
+
dragRef.current.active = false;
|
|
3026
|
+
lastClickRef.current = { x, y, t: nowPrompt, count: promptClickCount };
|
|
3027
|
+
continue;
|
|
3028
|
+
} else if (offset != null) {
|
|
3029
|
+
ctl?.anchorAt?.(offset);
|
|
3030
|
+
}
|
|
3031
|
+
lastClickRef.current = { x, y, t: nowPrompt, count: 1 };
|
|
2694
3032
|
continue;
|
|
2695
3033
|
}
|
|
2696
3034
|
const inTranscript = isInTranscriptViewport(y);
|
|
@@ -2706,15 +3044,45 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2706
3044
|
const region = inTranscript ? 'transcript' : 'status';
|
|
2707
3045
|
// A press always clears the prompt-box selection (single active region).
|
|
2708
3046
|
promptMouseSelectionRef.current?.clear?.();
|
|
3047
|
+
const now = Date.now();
|
|
3048
|
+
// Shift+click extends the existing ink-grid selection in this SAME
|
|
3049
|
+
// region from its original anchor to the new click point, instead of
|
|
3050
|
+
// starting a fresh anchor here. Only applies to a plain char-drag
|
|
3051
|
+
// selection (no anchorSpan) with a live non-empty rect; a word/line
|
|
3052
|
+
// anchorSpan or an empty/absent selection falls through to a normal
|
|
3053
|
+
// fresh press below.
|
|
3054
|
+
if (
|
|
3055
|
+
shiftHeld
|
|
3056
|
+
&& dragRef.current.region === region
|
|
3057
|
+
&& !dragRef.current.anchorSpan
|
|
3058
|
+
&& dragRef.current.anchor
|
|
3059
|
+
&& dragRef.current.rect
|
|
3060
|
+
&& !(dragRef.current.rect.x1 === dragRef.current.rect.x2 && dragRef.current.rect.y1 === dragRef.current.rect.y2)
|
|
3061
|
+
) {
|
|
3062
|
+
const selectionY = region === 'status' ? clampToStatusBand(y) : clampToTranscriptViewport(y);
|
|
3063
|
+
const anchor = region === 'status'
|
|
3064
|
+
? dragRef.current.anchor
|
|
3065
|
+
: selectionPointAtCurrentScroll(dragRef.current.anchor, dragRef.current.anchorScroll);
|
|
3066
|
+
const rect = linearSelection(anchor, { x, y: selectionY });
|
|
3067
|
+
stopSmoothScroll();
|
|
3068
|
+
dragRef.current = {
|
|
3069
|
+
...dragRef.current,
|
|
3070
|
+
last: { x, y: selectionY },
|
|
3071
|
+
active: true,
|
|
3072
|
+
region,
|
|
3073
|
+
};
|
|
3074
|
+
applySelectionRect(rect);
|
|
3075
|
+
lastClickRef.current = { x, y, t: now, count: 1 };
|
|
3076
|
+
continue;
|
|
3077
|
+
}
|
|
2709
3078
|
// Multi-click sequence: 2nd consecutive press = word (double-click),
|
|
2710
3079
|
// 3rd = whole line (triple-click). Each press must land near the prior
|
|
2711
3080
|
// one within 500ms — up to 2 columns and 1 row of drift (terminals
|
|
2712
3081
|
// often report a shifted cell on repeat clicks); tighter matching made
|
|
2713
3082
|
// word selection unreliable. A 4th qualifying press restarts the
|
|
2714
|
-
// sequence at 1 (
|
|
3083
|
+
// sequence at 1 (simplest: reset). Works for
|
|
2715
3084
|
// transcript AND status rows since getWordRectAt/getLineRectAt are
|
|
2716
3085
|
// grid-based. Copy still happens on Ctrl+C, never here.
|
|
2717
|
-
const now = Date.now();
|
|
2718
3086
|
const lc = lastClickRef.current;
|
|
2719
3087
|
const qualifies = (now - lc.t) < 500
|
|
2720
3088
|
&& Math.abs(lc.y - y) <= 1
|
|
@@ -2801,9 +3169,9 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2801
3169
|
if (region === 'transcript') {
|
|
2802
3170
|
const rows = Math.max(1, Number(resizeState.rows) || 24);
|
|
2803
3171
|
if (y <= 1) {
|
|
2804
|
-
|
|
3172
|
+
queueScrollCoalesced(3);
|
|
2805
3173
|
} else if (y >= rows - 5) {
|
|
2806
|
-
|
|
3174
|
+
queueScrollCoalesced(-3);
|
|
2807
3175
|
}
|
|
2808
3176
|
}
|
|
2809
3177
|
} else if (!press && dragRef.current.active) {
|
|
@@ -2861,15 +3229,15 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2861
3229
|
}
|
|
2862
3230
|
if (overlayBlocksGlobalTranscriptScroll(scrollFocusRef.current)) return;
|
|
2863
3231
|
const STEP = 3; // rows per wheel notch; immediate updates feel steadier in Windows Terminal
|
|
2864
|
-
|
|
3232
|
+
queueScrollCoalesced((up - down) * STEP);
|
|
2865
3233
|
}
|
|
2866
3234
|
};
|
|
2867
3235
|
inkInput.on('input', onData);
|
|
2868
3236
|
return () => { inkInput.off('input', onData); };
|
|
2869
|
-
}, [inkInput, isRawModeSupported, store, passthroughCtrlWheelZoom, resizeState.rows, scrollTranscriptRows, applySelectionRect, applySelectionRectThrottled, selectionPointAtCurrentScroll, buildSpanRect, dismissWelcomePromptHint]);
|
|
3237
|
+
}, [inkInput, isRawModeSupported, store, passthroughCtrlWheelZoom, resizeState.rows, scrollTranscriptRows, queueScrollCoalesced, applySelectionRect, applySelectionRectThrottled, selectionPointAtCurrentScroll, buildSpanRect, dismissWelcomePromptHint]);
|
|
2870
3238
|
|
|
2871
|
-
// Enable extended keyboard reporting (kitty + xterm modifyOtherKeys)
|
|
2872
|
-
//
|
|
3239
|
+
// Enable extended keyboard reporting (kitty + xterm modifyOtherKeys)
|
|
3240
|
+
// SYNCHRONOUSLY, ONCE, with NO query/round-trip. ink
|
|
2873
3241
|
// turns raw mode on during the first useInput mount (synchronously, inside
|
|
2874
3242
|
// render); this mount effect runs in the same commit phase, right after — i.e.
|
|
2875
3243
|
// before the user can realistically press a key. We write BOTH enables
|
|
@@ -2940,6 +3308,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2940
3308
|
}
|
|
2941
3309
|
if (restoreDraft) {
|
|
2942
3310
|
if (restored.pastedImages) installPastedImages(restored.pastedImages, { merge: true });
|
|
3311
|
+
if (restored.pastedTexts) installPastedTexts(restored.pastedTexts, { merge: true });
|
|
2943
3312
|
syncPromptLayoutRows(restored.text);
|
|
2944
3313
|
setPromptDraftOverride({ id: Date.now(), value: restored.text });
|
|
2945
3314
|
}
|
|
@@ -2952,6 +3321,11 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2952
3321
|
}, [store, promptDraft, showPromptHint, clearPromptHint, installPastedImages, syncPromptLayoutRows]);
|
|
2953
3322
|
|
|
2954
3323
|
const recentPromptHistory = useMemo(() => {
|
|
3324
|
+
// The engine maintains this list incrementally (rebuilt only when a user
|
|
3325
|
+
// item is appended or the transcript is bulk-swapped), so App no longer
|
|
3326
|
+
// rescans all items on every transcript change. Fall back to a local scan
|
|
3327
|
+
// only if the engine did not publish it (older snapshot).
|
|
3328
|
+
if (Array.isArray(state.promptHistoryList)) return state.promptHistoryList;
|
|
2955
3329
|
const items = Array.isArray(state.items) ? state.items : [];
|
|
2956
3330
|
const seen = new Set();
|
|
2957
3331
|
const history = [];
|
|
@@ -2965,7 +3339,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2965
3339
|
history.push(text);
|
|
2966
3340
|
}
|
|
2967
3341
|
return history;
|
|
2968
|
-
}, [state.items]);
|
|
3342
|
+
}, [state.promptHistoryList, state.items]);
|
|
2969
3343
|
|
|
2970
3344
|
const resetPromptHistoryNav = useCallback(() => {
|
|
2971
3345
|
promptHistoryNavRef.current = { active: false, index: -1, seed: '', lastValue: '' };
|
|
@@ -3044,6 +3418,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
3044
3418
|
if (getRecorderState() === 'recording') {
|
|
3045
3419
|
cancelRecording();
|
|
3046
3420
|
setVoiceStatusHint('', 'info');
|
|
3421
|
+
setVoiceRecPhase('idle');
|
|
3047
3422
|
store.pushNotice('Voice: recording cancelled', 'plain');
|
|
3048
3423
|
return true;
|
|
3049
3424
|
}
|
|
@@ -3068,6 +3443,8 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
3068
3443
|
if (result?.aborted === false) return undefined;
|
|
3069
3444
|
if (result?.pastedImages) installPastedImages(result.pastedImages, { merge: true });
|
|
3070
3445
|
if (result?.discardPastedImages) clearPastedImagesSnapshot(result.discardPastedImages);
|
|
3446
|
+
if (result?.pastedTexts) installPastedTexts(result.pastedTexts, { merge: true });
|
|
3447
|
+
if (result?.discardPastedTexts) clearPastedTextsSnapshot(result.discardPastedTexts);
|
|
3071
3448
|
const restoreText = String(result?.restoreText || '').trim();
|
|
3072
3449
|
if (!restoreText) return undefined;
|
|
3073
3450
|
const existingText = String(currentText || '').trim();
|
|
@@ -3130,6 +3507,11 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
3130
3507
|
&& key.shift
|
|
3131
3508
|
&& (key.leftArrow || key.rightArrow || key.upArrow || key.downArrow || key.home || key.end)
|
|
3132
3509
|
) {
|
|
3510
|
+
// Consume the chord whenever a transcript/status ink-grid selection is
|
|
3511
|
+
// live — even if the focus clamps at an edge (moveSelectionFocus returns
|
|
3512
|
+
// false there). PromptInput independently skips the same chord via the
|
|
3513
|
+
// shared gridSelectionActiveRef predicate, so there is no double-handling.
|
|
3514
|
+
// When no grid selection is live, fall through to PromptInput.
|
|
3133
3515
|
let move = null;
|
|
3134
3516
|
if (key.leftArrow) move = 'left';
|
|
3135
3517
|
else if (key.rightArrow) move = 'right';
|
|
@@ -3137,7 +3519,10 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
3137
3519
|
else if (key.downArrow) move = 'down';
|
|
3138
3520
|
else if (key.home) move = 'lineStart';
|
|
3139
3521
|
else if (key.end) move = 'lineEnd';
|
|
3140
|
-
if (move &&
|
|
3522
|
+
if (move && gridSelectionActiveRef.current()) {
|
|
3523
|
+
moveSelectionFocus(move);
|
|
3524
|
+
return;
|
|
3525
|
+
}
|
|
3141
3526
|
}
|
|
3142
3527
|
if (key.escape && usagePanel && !picker) {
|
|
3143
3528
|
closeUsagePanel();
|
|
@@ -4604,24 +4989,39 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
4604
4989
|
state.clientHostPid,
|
|
4605
4990
|
]);
|
|
4606
4991
|
|
|
4607
|
-
const openSettingsPicker = () => {
|
|
4992
|
+
const openSettingsPicker = (opts = {}) => {
|
|
4993
|
+
const light = opts.light === true;
|
|
4994
|
+
const overrides = opts.overrides || null;
|
|
4995
|
+
const heavyCache = light ? settingsHeavyCacheRef.current : null;
|
|
4608
4996
|
const autoClear = store.getAutoClear?.() || {};
|
|
4609
4997
|
const compaction = store.getCompactionSettings?.() || {};
|
|
4610
4998
|
const memory = store.getMemorySettings?.() || { enabled: true };
|
|
4611
4999
|
const channels = store.getChannelSettings?.({ includeStatus: false }) || { enabled: true };
|
|
4612
5000
|
const outputStyle = store.getOutputStyle?.() || store.listOutputStyles?.() || {};
|
|
4613
5001
|
const workflow = state.workflow || {};
|
|
4614
|
-
const mcp = store.mcpStatus?.() || { connectedCount: 0, configuredCount: 0, failedCount: 0 };
|
|
4615
|
-
const hooks = store.hooksStatus?.() || { ruleCount: 0 };
|
|
4616
|
-
const plugins = store.pluginsStatus?.() || { count: 0 };
|
|
4617
|
-
const skills = store.skillsStatus?.() || { count: 0 };
|
|
5002
|
+
const mcp = heavyCache ? heavyCache.mcp : (store.mcpStatus?.() || { connectedCount: 0, configuredCount: 0, failedCount: 0 });
|
|
5003
|
+
const hooks = heavyCache ? heavyCache.hooks : (store.hooksStatus?.() || { ruleCount: 0 });
|
|
5004
|
+
const plugins = heavyCache ? heavyCache.plugins : (store.pluginsStatus?.() || { count: 0 });
|
|
5005
|
+
const skills = heavyCache ? heavyCache.skills : (store.skillsStatus?.() || { count: 0 });
|
|
4618
5006
|
const channelWorker = store.getChannelWorkerStatus?.();
|
|
4619
5007
|
let channelBackend = 'discord';
|
|
4620
|
-
|
|
4621
|
-
channelBackend =
|
|
4622
|
-
}
|
|
4623
|
-
|
|
5008
|
+
if (heavyCache) {
|
|
5009
|
+
channelBackend = heavyCache.channelBackend || 'discord';
|
|
5010
|
+
} else {
|
|
5011
|
+
try {
|
|
5012
|
+
channelBackend = store.getChannelSetup?.()?.backend || 'discord';
|
|
5013
|
+
} catch {
|
|
5014
|
+
channelBackend = 'discord';
|
|
5015
|
+
}
|
|
4624
5016
|
}
|
|
5017
|
+
if (overrides && Object.prototype.hasOwnProperty.call(overrides, 'channelBackend')) {
|
|
5018
|
+
channelBackend = overrides.channelBackend;
|
|
5019
|
+
}
|
|
5020
|
+
// Refresh the cache every build (light or full) so the next light
|
|
5021
|
+
// refresh reuses whatever was most recently known, and so an
|
|
5022
|
+
// optimistic override (e.g. channel backend cycle) sticks without
|
|
5023
|
+
// re-running the heavy getter it came from.
|
|
5024
|
+
settingsHeavyCacheRef.current = { mcp, hooks, plugins, skills, channelBackend };
|
|
4625
5025
|
const channelBackendLabel = channelBackend === 'telegram' ? 'Telegram' : 'Discord';
|
|
4626
5026
|
const remoteEnabled = store.isRemoteEnabled?.() === true;
|
|
4627
5027
|
const remoteRuntimeDescription = channelWorker?.running
|
|
@@ -4645,7 +5045,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
4645
5045
|
} catch (e) {
|
|
4646
5046
|
store.pushNotice(`autoclear failed: ${e?.message || e}`, 'error');
|
|
4647
5047
|
}
|
|
4648
|
-
openSettingsPicker();
|
|
5048
|
+
openSettingsPicker({ light: true });
|
|
4649
5049
|
};
|
|
4650
5050
|
const applyCompaction = (patch = {}) => {
|
|
4651
5051
|
void Promise.resolve(store.setCompactionSettings?.(patch))
|
|
@@ -4657,7 +5057,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
4657
5057
|
store.pushNotice(`Compaction ${next.auto !== false ? 'auto on' : 'auto off'} · ${next.compactType === 'recall-fasttrack' ? 'Fast-track' : 'Default'}`, 'info');
|
|
4658
5058
|
})
|
|
4659
5059
|
.catch((e) => store.pushNotice(`compaction failed: ${e?.message || e}`, 'error'))
|
|
4660
|
-
.finally(() => openSettingsPicker());
|
|
5060
|
+
.finally(() => openSettingsPicker({ light: true }));
|
|
4661
5061
|
};
|
|
4662
5062
|
const applyMemory = (enabled) => {
|
|
4663
5063
|
void Promise.resolve(store.setMemoryEnabled?.(enabled))
|
|
@@ -4669,7 +5069,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
4669
5069
|
store.pushNotice(`Memory ${next.enabled ? 'on' : 'off'}`, 'info');
|
|
4670
5070
|
})
|
|
4671
5071
|
.catch((e) => store.pushNotice(`memory setting failed: ${e?.message || e}`, 'error'))
|
|
4672
|
-
.finally(() => openSettingsPicker());
|
|
5072
|
+
.finally(() => openSettingsPicker({ light: true }));
|
|
4673
5073
|
};
|
|
4674
5074
|
const applyChannels = (enabled) => {
|
|
4675
5075
|
void Promise.resolve(store.setChannelsEnabled?.(enabled))
|
|
@@ -4681,7 +5081,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
4681
5081
|
store.pushNotice(`Channels ${next.enabled ? 'on' : 'off'}`, 'info');
|
|
4682
5082
|
})
|
|
4683
5083
|
.catch((e) => store.pushNotice(`channel setting failed: ${e?.message || e}`, 'error'))
|
|
4684
|
-
.finally(() => openSettingsPicker());
|
|
5084
|
+
.finally(() => openSettingsPicker({ light: true }));
|
|
4685
5085
|
};
|
|
4686
5086
|
const cycleOutputStyle = (direction = 1) => {
|
|
4687
5087
|
let status = null;
|
|
@@ -4706,7 +5106,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
4706
5106
|
store.pushNotice(outputStyleNotice(result), 'info');
|
|
4707
5107
|
})
|
|
4708
5108
|
.catch((e) => store.pushNotice(`Couldn’t switch output style: ${e?.message || e}`, 'error'))
|
|
4709
|
-
.finally(() => openSettingsPicker());
|
|
5109
|
+
.finally(() => openSettingsPicker({ light: true }));
|
|
4710
5110
|
};
|
|
4711
5111
|
const cycleWorkflow = (direction = 1) => {
|
|
4712
5112
|
let workflows = [];
|
|
@@ -4730,7 +5130,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
4730
5130
|
store.pushNotice(workflowSwitchNotice(result), 'info');
|
|
4731
5131
|
})
|
|
4732
5132
|
.catch((e) => store.pushNotice(`Couldn’t switch workflow: ${e?.message || e}`, 'error'))
|
|
4733
|
-
.finally(() => openSettingsPicker());
|
|
5133
|
+
.finally(() => openSettingsPicker({ light: true }));
|
|
4734
5134
|
};
|
|
4735
5135
|
const cycleTheme = (direction = 1) => {
|
|
4736
5136
|
let themes = [];
|
|
@@ -4751,19 +5151,19 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
4751
5151
|
} catch (e) {
|
|
4752
5152
|
store.pushNotice(`Couldn’t set theme: ${e?.message || e}`, 'error');
|
|
4753
5153
|
}
|
|
4754
|
-
openSettingsPicker();
|
|
5154
|
+
openSettingsPicker({ light: true });
|
|
4755
5155
|
};
|
|
4756
5156
|
const applyRemoteRuntime = () => {
|
|
4757
5157
|
const enabled = store.toggleRemote?.() === true;
|
|
4758
5158
|
store.pushNotice(enabled ? 'Remote mode ON' : 'Remote mode OFF', 'info');
|
|
4759
|
-
openSettingsPicker();
|
|
5159
|
+
openSettingsPicker({ light: true });
|
|
4760
5160
|
};
|
|
4761
5161
|
const cycleChannelBackend = (direction = 1) => {
|
|
4762
5162
|
const backends = ['discord', 'telegram'];
|
|
4763
5163
|
const currentIndex = Math.max(0, backends.indexOf(channelBackend));
|
|
4764
5164
|
const chosen = backends[(currentIndex + direction + backends.length) % backends.length];
|
|
4765
5165
|
if (chosen === channelBackend) {
|
|
4766
|
-
openSettingsPicker();
|
|
5166
|
+
openSettingsPicker({ light: true, overrides: { channelBackend } });
|
|
4767
5167
|
return;
|
|
4768
5168
|
}
|
|
4769
5169
|
try {
|
|
@@ -4776,7 +5176,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
4776
5176
|
} catch (e) {
|
|
4777
5177
|
store.pushNotice(`channel backend failed: ${e?.message || e}`, 'error');
|
|
4778
5178
|
}
|
|
4779
|
-
openSettingsPicker();
|
|
5179
|
+
openSettingsPicker({ light: true, overrides: { channelBackend: chosen } });
|
|
4780
5180
|
};
|
|
4781
5181
|
const items = [
|
|
4782
5182
|
{
|
|
@@ -5042,6 +5442,12 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
5042
5442
|
setChannelPrompt(null);
|
|
5043
5443
|
setHookPrompt(null);
|
|
5044
5444
|
setSettingsPrompt(null);
|
|
5445
|
+
// Close full-panel overlays too: they render ahead of providerPrompt in
|
|
5446
|
+
// the floating-panel chain, so a lingering usage/context panel would mask
|
|
5447
|
+
// any text-entry prompt opened from the provider actions (e.g. the
|
|
5448
|
+
// OpenCode Go cookie prompt appeared to do nothing).
|
|
5449
|
+
setContextPanel(null);
|
|
5450
|
+
closeUsagePanel();
|
|
5045
5451
|
// Onboarding (and any caller) can pass a preloaded provider setup so we skip
|
|
5046
5452
|
// the "Checking Providers" placeholder frame that otherwise flashes before
|
|
5047
5453
|
// the real list — that swap is what looked like a jump on Step 1 entry.
|
|
@@ -5198,6 +5604,20 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
5198
5604
|
_action: 'forget-key',
|
|
5199
5605
|
});
|
|
5200
5606
|
}
|
|
5607
|
+
if (providerItem._providerId === 'opencode-go') {
|
|
5608
|
+
apiActions.push({
|
|
5609
|
+
value: 'usage-login-browser',
|
|
5610
|
+
label: 'Usage login (browser)',
|
|
5611
|
+
description: 'open browser; auth cookie captured automatically',
|
|
5612
|
+
_action: 'usage-login-browser',
|
|
5613
|
+
});
|
|
5614
|
+
apiActions.push({
|
|
5615
|
+
value: 'usage-cookie',
|
|
5616
|
+
label: 'Paste usage cookie',
|
|
5617
|
+
description: 'manual auth cookie entry (DevTools)',
|
|
5618
|
+
_action: 'usage-cookie',
|
|
5619
|
+
});
|
|
5620
|
+
}
|
|
5201
5621
|
setPicker({
|
|
5202
5622
|
title: `Provider · ${providerItem._providerName}`,
|
|
5203
5623
|
description: 'Choose an API-key action.',
|
|
@@ -5223,6 +5643,66 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
5223
5643
|
store.pushNotice(`auth-forget failed: ${e?.message || e}`, 'error');
|
|
5224
5644
|
}
|
|
5225
5645
|
}
|
|
5646
|
+
if (detail._action === 'usage-cookie') {
|
|
5647
|
+
setProviderPrompt({
|
|
5648
|
+
kind: 'opencode-go-cookie',
|
|
5649
|
+
providerId: 'opencode-go',
|
|
5650
|
+
label: 'OpenCode Go',
|
|
5651
|
+
afterSave: returnTo,
|
|
5652
|
+
});
|
|
5653
|
+
return;
|
|
5654
|
+
}
|
|
5655
|
+
if (detail._action === 'usage-login-browser') {
|
|
5656
|
+
let backedOut = false;
|
|
5657
|
+
const waitItems = [
|
|
5658
|
+
{
|
|
5659
|
+
value: 'waiting',
|
|
5660
|
+
label: 'Waiting for login',
|
|
5661
|
+
meta: 'Running',
|
|
5662
|
+
description: 'sign in via the browser window',
|
|
5663
|
+
_action: 'waiting',
|
|
5664
|
+
},
|
|
5665
|
+
{
|
|
5666
|
+
value: 'back',
|
|
5667
|
+
label: 'Back',
|
|
5668
|
+
meta: '',
|
|
5669
|
+
description: 'return to provider actions',
|
|
5670
|
+
_action: 'back',
|
|
5671
|
+
},
|
|
5672
|
+
];
|
|
5673
|
+
setPicker({
|
|
5674
|
+
title: `Provider · ${providerItem._providerName}`,
|
|
5675
|
+
description: 'Opening browser. Sign in at opencode.ai/auth; the auth cookie is captured automatically.',
|
|
5676
|
+
footer: () => providerActionFooter(provider),
|
|
5677
|
+
help: '↑/↓ Select · Enter Choose · Esc Providers',
|
|
5678
|
+
indexMode: 'never',
|
|
5679
|
+
labelWidth: 22,
|
|
5680
|
+
metaWidth: 12,
|
|
5681
|
+
pickerKey: `providers-usage-login:${providerItem.value}`,
|
|
5682
|
+
initialIndex: 0,
|
|
5683
|
+
items: waitItems,
|
|
5684
|
+
onSelect: (_value, item) => {
|
|
5685
|
+
if (item?._action === 'back') {
|
|
5686
|
+
backedOut = true;
|
|
5687
|
+
openApiProviderActions(providerItem);
|
|
5688
|
+
}
|
|
5689
|
+
},
|
|
5690
|
+
onCancel: () => {
|
|
5691
|
+
backedOut = true;
|
|
5692
|
+
openApiProviderActions(providerItem);
|
|
5693
|
+
},
|
|
5694
|
+
});
|
|
5695
|
+
void store.loginOpenCodeGoUsage()
|
|
5696
|
+
.then(() => {
|
|
5697
|
+
store.pushNotice('OpenCode Go usage auth captured', 'info');
|
|
5698
|
+
if (!backedOut) reopenProviders();
|
|
5699
|
+
})
|
|
5700
|
+
.catch((e) => {
|
|
5701
|
+
store.pushNotice(`OpenCode Go usage login failed: ${e?.message || e}`, 'error');
|
|
5702
|
+
if (!backedOut) openApiProviderActions(providerItem);
|
|
5703
|
+
});
|
|
5704
|
+
return;
|
|
5705
|
+
}
|
|
5226
5706
|
},
|
|
5227
5707
|
onCancel: reopenProviders,
|
|
5228
5708
|
});
|
|
@@ -6895,13 +7375,18 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
6895
7375
|
});
|
|
6896
7376
|
void store.memoryControl?.({ action: 'core', op: 'list', project_id: '*' }, { silent: true })
|
|
6897
7377
|
.then((result) => {
|
|
6898
|
-
const rows =
|
|
7378
|
+
const rows = [
|
|
7379
|
+
{ value: 'core-add', label: '+ Add core memory', description: 'store a new curated memory sentence', _action: 'add-core' },
|
|
7380
|
+
...parseMemoryCoreRows(result),
|
|
7381
|
+
];
|
|
6899
7382
|
setPicker({
|
|
6900
7383
|
title: 'Core Memory',
|
|
6901
7384
|
description: 'User-curated core memories across projects.',
|
|
6902
7385
|
items: rows.length ? rows : [{ value: 'empty', label: 'Core memory', description: 'empty' }],
|
|
6903
7386
|
onSelect: (_value, item) => {
|
|
6904
|
-
if (item?.
|
|
7387
|
+
if (item?._action === 'add-core') beginAddCoreMemory();
|
|
7388
|
+
else if (item?._action === 'core-entry') openCoreEntryActionsPicker(item);
|
|
7389
|
+
else if (item?._line) store.pushNotice(item._line, 'info');
|
|
6905
7390
|
},
|
|
6906
7391
|
onCancel: () => openMemoryPicker(),
|
|
6907
7392
|
});
|
|
@@ -6912,6 +7397,127 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
6912
7397
|
});
|
|
6913
7398
|
};
|
|
6914
7399
|
|
|
7400
|
+
const openCoreEntryActionsPicker = (entryItem) => {
|
|
7401
|
+
setPicker({
|
|
7402
|
+
title: `Core Memory · #${entryItem._id}`,
|
|
7403
|
+
description: entryItem._summary || entryItem._element || '',
|
|
7404
|
+
items: [
|
|
7405
|
+
{ value: 'edit', label: 'Edit', description: 'rewrite this memory sentence', _action: 'edit' },
|
|
7406
|
+
{ value: 'delete', label: 'Delete', description: 'remove this entry (confirm)', _action: 'delete' },
|
|
7407
|
+
],
|
|
7408
|
+
onSelect: (_value, detail) => {
|
|
7409
|
+
if (detail._action === 'edit') beginEditCoreMemory(entryItem);
|
|
7410
|
+
else if (detail._action === 'delete') beginDeleteCoreMemory(entryItem);
|
|
7411
|
+
},
|
|
7412
|
+
onCancel: () => openMemoryCorePicker(),
|
|
7413
|
+
});
|
|
7414
|
+
};
|
|
7415
|
+
|
|
7416
|
+
const beginAddCoreMemory = () => {
|
|
7417
|
+
setPicker(null);
|
|
7418
|
+
setSettingsPrompt({
|
|
7419
|
+
kind: 'core-add',
|
|
7420
|
+
label: 'Add core memory',
|
|
7421
|
+
hint: 'Type the memory sentence to store as a core memory.',
|
|
7422
|
+
});
|
|
7423
|
+
};
|
|
7424
|
+
|
|
7425
|
+
const beginEditCoreMemory = (entryItem) => {
|
|
7426
|
+
setPicker(null);
|
|
7427
|
+
setSettingsPrompt({
|
|
7428
|
+
kind: 'core-edit',
|
|
7429
|
+
label: `Core Memory · Edit #${entryItem._id}`,
|
|
7430
|
+
hint: 'Edit the memory sentence.',
|
|
7431
|
+
initialValue: entryItem._summary || entryItem._element || '',
|
|
7432
|
+
_id: entryItem._id,
|
|
7433
|
+
_projectId: entryItem._projectId ?? null,
|
|
7434
|
+
// Only rewrite `element` on edit when the row was already a
|
|
7435
|
+
// single-sentence entry (element === summary at load time). Otherwise
|
|
7436
|
+
// element carries distinct legacy meaning and must survive untouched.
|
|
7437
|
+
_singleSentence: entryItem._origElement === entryItem._origSummary,
|
|
7438
|
+
});
|
|
7439
|
+
};
|
|
7440
|
+
|
|
7441
|
+
const beginDeleteCoreMemory = (entryItem) => {
|
|
7442
|
+
setPicker(null);
|
|
7443
|
+
setSettingsPrompt({
|
|
7444
|
+
kind: 'core-delete-confirm',
|
|
7445
|
+
label: `Core Memory · Delete #${entryItem._id}?`,
|
|
7446
|
+
hint: 'Type "y" to delete this entry, or anything else to cancel.',
|
|
7447
|
+
_id: entryItem._id,
|
|
7448
|
+
_projectId: entryItem._projectId ?? null,
|
|
7449
|
+
});
|
|
7450
|
+
};
|
|
7451
|
+
|
|
7452
|
+
const openMemoryPromotionPicker = () => {
|
|
7453
|
+
setPicker({
|
|
7454
|
+
title: 'Promotion Candidates',
|
|
7455
|
+
description: 'Loading candidate memories.',
|
|
7456
|
+
items: [{ value: 'loading', label: 'Loading candidates', description: 'please wait' }],
|
|
7457
|
+
onSelect: () => {},
|
|
7458
|
+
onCancel: () => openMemoryPicker(),
|
|
7459
|
+
});
|
|
7460
|
+
void store.memoryControl?.({ action: 'core', op: 'candidates', project_id: '*' }, { silent: true })
|
|
7461
|
+
.then((result) => {
|
|
7462
|
+
const rows = parseMemoryCandidateRows(result);
|
|
7463
|
+
setPicker({
|
|
7464
|
+
title: 'Promotion Candidates',
|
|
7465
|
+
description: 'Memories flagged for possible promotion to core memory.',
|
|
7466
|
+
items: rows.length ? rows : [{ value: 'empty', label: 'Promotion candidates', description: 'empty' }],
|
|
7467
|
+
onSelect: (_value, item) => {
|
|
7468
|
+
if (item?._action === 'candidate-entry') openCandidateActionsPicker(item);
|
|
7469
|
+
else if (item?._line) store.pushNotice(item._line, 'info');
|
|
7470
|
+
},
|
|
7471
|
+
onCancel: () => openMemoryPicker(),
|
|
7472
|
+
});
|
|
7473
|
+
})
|
|
7474
|
+
.catch((e) => {
|
|
7475
|
+
setPicker(null);
|
|
7476
|
+
store.pushNotice(`promotion candidates unavailable: ${e?.message || e}`, 'warn');
|
|
7477
|
+
});
|
|
7478
|
+
};
|
|
7479
|
+
|
|
7480
|
+
const openCandidateActionsPicker = (candidateItem) => {
|
|
7481
|
+
setPicker({
|
|
7482
|
+
title: `Candidate · #${candidateItem._id}`,
|
|
7483
|
+
description: candidateItem._line || '',
|
|
7484
|
+
items: [
|
|
7485
|
+
{ value: 'approve', label: 'Approve', description: 'promote to core memory', _action: 'approve' },
|
|
7486
|
+
{ value: 'dismiss', label: 'Dismiss', description: 'discard this candidate', _action: 'dismiss' },
|
|
7487
|
+
],
|
|
7488
|
+
onSelect: (_value, detail) => {
|
|
7489
|
+
if (detail._action === 'approve') runCandidateAction(candidateItem._id, 'promote', candidateItem._projectId);
|
|
7490
|
+
else if (detail._action === 'dismiss') runCandidateAction(candidateItem._id, 'dismiss', candidateItem._projectId);
|
|
7491
|
+
},
|
|
7492
|
+
onCancel: () => openMemoryPromotionPicker(),
|
|
7493
|
+
});
|
|
7494
|
+
};
|
|
7495
|
+
|
|
7496
|
+
const runCandidateAction = (id, op, projectId) => {
|
|
7497
|
+
setPicker(null);
|
|
7498
|
+
// projectId is undefined when the backend candidate row carried no
|
|
7499
|
+
// per-row project marker (pre-change / not-yet-updated backend) -- omit
|
|
7500
|
+
// project_id entirely in that case so the op falls back to prior
|
|
7501
|
+
// (COMMON-default) behavior instead of sending a bogus scope.
|
|
7502
|
+
const args = projectId === undefined
|
|
7503
|
+
? { action: 'core', op, id }
|
|
7504
|
+
: { action: 'core', op, id, project_id: projectId === null ? 'common' : projectId };
|
|
7505
|
+
void store.memoryControl?.(args, { silent: true })
|
|
7506
|
+
.then((result) => {
|
|
7507
|
+
const errText = memoryCoreResultErrorText(result);
|
|
7508
|
+
if (errText) {
|
|
7509
|
+
store.pushNotice(`candidate ${op} failed: ${errText}`, 'error');
|
|
7510
|
+
} else {
|
|
7511
|
+
store.pushNotice(op === 'promote' ? 'promoted to core memory' : 'candidate dismissed', 'info');
|
|
7512
|
+
}
|
|
7513
|
+
openMemoryPromotionPicker();
|
|
7514
|
+
})
|
|
7515
|
+
.catch((e) => {
|
|
7516
|
+
store.pushNotice(`candidate ${op} failed: ${e?.message || e}`, 'error');
|
|
7517
|
+
openMemoryPromotionPicker();
|
|
7518
|
+
});
|
|
7519
|
+
};
|
|
7520
|
+
|
|
6915
7521
|
const runMemoryAction = (args, successLabel) => {
|
|
6916
7522
|
setPicker(null);
|
|
6917
7523
|
void store.memoryControl?.(args)
|
|
@@ -6955,17 +7561,9 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
6955
7561
|
value: 'auto-update',
|
|
6956
7562
|
label: 'Auto-update',
|
|
6957
7563
|
meta: upd.autoUpdate ? 'On' : 'Off',
|
|
6958
|
-
description: '
|
|
7564
|
+
description: 'Enter to toggle automatic updates.',
|
|
6959
7565
|
_action: 'auto-update',
|
|
6960
7566
|
},
|
|
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
7567
|
];
|
|
6970
7568
|
setProviderPrompt(null);
|
|
6971
7569
|
setChannelPrompt(null);
|
|
@@ -6974,25 +7572,29 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
6974
7572
|
setPicker({
|
|
6975
7573
|
title: 'Update',
|
|
6976
7574
|
description: 'Check version and update mixdog.',
|
|
6977
|
-
help: '↑/↓ Select ·
|
|
7575
|
+
help: '↑/↓ Select · Enter Open/Toggle · Esc Close',
|
|
6978
7576
|
indexMode: 'always',
|
|
6979
7577
|
labelWidth: 16,
|
|
6980
7578
|
metaWidth: 16,
|
|
6981
|
-
fillAvailable: true,
|
|
6982
7579
|
items,
|
|
6983
|
-
|
|
6984
|
-
|
|
6985
|
-
|
|
6986
|
-
|
|
6987
|
-
|
|
7580
|
+
confirmBar: {
|
|
7581
|
+
buttons: [
|
|
7582
|
+
{
|
|
7583
|
+
value: 'update-now',
|
|
7584
|
+
label: upd.updateAvailable
|
|
7585
|
+
? `Update to v${upd.latestVersion || 'latest'}`
|
|
7586
|
+
: 'Update now',
|
|
7587
|
+
},
|
|
7588
|
+
],
|
|
7589
|
+
onConfirm: (button) => {
|
|
7590
|
+
if (button?.value === 'update-now') runUpdate();
|
|
7591
|
+
},
|
|
6988
7592
|
},
|
|
6989
7593
|
onSelect: (_value, item) => {
|
|
6990
7594
|
if (item?._action === 'latest') {
|
|
6991
7595
|
recheck();
|
|
6992
7596
|
} else if (item?._action === 'auto-update') {
|
|
6993
7597
|
toggleAutoUpdate(!upd.autoUpdate);
|
|
6994
|
-
} else if (item?._action === 'update-now') {
|
|
6995
|
-
runUpdate();
|
|
6996
7598
|
}
|
|
6997
7599
|
},
|
|
6998
7600
|
onCancel: () => {
|
|
@@ -7062,6 +7664,12 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
7062
7664
|
description: 'list user-curated core memories',
|
|
7063
7665
|
_action: 'core',
|
|
7064
7666
|
},
|
|
7667
|
+
{
|
|
7668
|
+
value: 'candidates',
|
|
7669
|
+
label: 'Promotion candidates',
|
|
7670
|
+
description: 'review memories flagged for promotion to core',
|
|
7671
|
+
_action: 'candidates',
|
|
7672
|
+
},
|
|
7065
7673
|
{
|
|
7066
7674
|
value: 'cycle1',
|
|
7067
7675
|
label: 'Run cycle1',
|
|
@@ -7090,6 +7698,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
7090
7698
|
onSelect: (_value, item) => {
|
|
7091
7699
|
if (item._action === 'status') openMemoryStatusPicker();
|
|
7092
7700
|
else if (item._action === 'core') openMemoryCorePicker();
|
|
7701
|
+
else if (item._action === 'candidates') openMemoryPromotionPicker();
|
|
7093
7702
|
else if (item._action === 'cycle1') runMemoryAction({ action: 'cycle1' });
|
|
7094
7703
|
else if (item._action === 'cycle2') runMemoryAction({ action: 'cycle2' });
|
|
7095
7704
|
else if (item._action === 'cycle3') runMemoryAction({ action: 'cycle3' });
|
|
@@ -7451,7 +8060,12 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
7451
8060
|
// the missing-component install sequence + its own notices (OFF/ON/
|
|
7452
8061
|
// progress/failure). We don't push a redundant notice here; a null
|
|
7453
8062
|
// return means "already running" or "failed", both already noticed.
|
|
7454
|
-
void toggleVoice({ pushNotice: store.pushNotice })
|
|
8063
|
+
void toggleVoice({ pushNotice: store.pushNotice, setProgressHint: store.setProgressHint })
|
|
8064
|
+
.then((result) => {
|
|
8065
|
+
// true/false = new enabled state; null = failed/busy (unchanged).
|
|
8066
|
+
if (result === true || result === false) setVoiceEnabled(result);
|
|
8067
|
+
else if (result && typeof result === 'object') setVoiceEnabled(isVoiceEnabled());
|
|
8068
|
+
});
|
|
7455
8069
|
return true;
|
|
7456
8070
|
}
|
|
7457
8071
|
case 'search':
|
|
@@ -8101,6 +8715,77 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
8101
8715
|
if (accepted) armTranscriptFollow();
|
|
8102
8716
|
return accepted;
|
|
8103
8717
|
}
|
|
8718
|
+
if (settingsPrompt.kind === 'core-add') {
|
|
8719
|
+
const sentence = commandText.trim();
|
|
8720
|
+
if (!sentence) {
|
|
8721
|
+
store.pushNotice('memory sentence is required', 'warn');
|
|
8722
|
+
return false;
|
|
8723
|
+
}
|
|
8724
|
+
setSettingsPrompt(null);
|
|
8725
|
+
void store.memoryControl?.({ action: 'core', op: 'add', project_id: 'common', element: sentence, summary: sentence }, { silent: true })
|
|
8726
|
+
.then((result) => {
|
|
8727
|
+
const errText = memoryCoreResultErrorText(result);
|
|
8728
|
+
store.pushNotice(errText || 'core memory added', errText ? 'error' : 'info');
|
|
8729
|
+
openMemoryCorePicker();
|
|
8730
|
+
})
|
|
8731
|
+
.catch((e) => {
|
|
8732
|
+
store.pushNotice(`core add failed: ${e?.message || e}`, 'error');
|
|
8733
|
+
openMemoryCorePicker();
|
|
8734
|
+
});
|
|
8735
|
+
return true;
|
|
8736
|
+
}
|
|
8737
|
+
if (settingsPrompt.kind === 'core-edit') {
|
|
8738
|
+
const sentence = commandText.trim();
|
|
8739
|
+
const id = settingsPrompt._id;
|
|
8740
|
+
const projectId = settingsPrompt._projectId ?? 'common';
|
|
8741
|
+
if (!sentence) {
|
|
8742
|
+
store.pushNotice('memory sentence is required', 'warn');
|
|
8743
|
+
return false;
|
|
8744
|
+
}
|
|
8745
|
+
setSettingsPrompt(null);
|
|
8746
|
+
// Single-sentence semantics only rewrite `element` when the row was
|
|
8747
|
+
// already element===summary at load (see beginEditCoreMemory's
|
|
8748
|
+
// _singleSentence flag). A distinct legacy element carries meaning
|
|
8749
|
+
// this text prompt never captured -- clobbering it on every edit
|
|
8750
|
+
// would corrupt the entry (and re-embed/dedupe on the clobbered
|
|
8751
|
+
// value). Otherwise only `summary` is sent.
|
|
8752
|
+
const editArgs = settingsPrompt._singleSentence
|
|
8753
|
+
? { action: 'core', op: 'edit', id, project_id: projectId, element: sentence, summary: sentence }
|
|
8754
|
+
: { action: 'core', op: 'edit', id, project_id: projectId, summary: sentence };
|
|
8755
|
+
void store.memoryControl?.(editArgs, { silent: true })
|
|
8756
|
+
.then((result) => {
|
|
8757
|
+
const errText = memoryCoreResultErrorText(result);
|
|
8758
|
+
store.pushNotice(errText || 'core memory updated', errText ? 'error' : 'info');
|
|
8759
|
+
openMemoryCorePicker();
|
|
8760
|
+
})
|
|
8761
|
+
.catch((e) => {
|
|
8762
|
+
store.pushNotice(`core edit failed: ${e?.message || e}`, 'error');
|
|
8763
|
+
openMemoryCorePicker();
|
|
8764
|
+
});
|
|
8765
|
+
return true;
|
|
8766
|
+
}
|
|
8767
|
+
if (settingsPrompt.kind === 'core-delete-confirm') {
|
|
8768
|
+
const id = settingsPrompt._id;
|
|
8769
|
+
const projectId = settingsPrompt._projectId ?? 'common';
|
|
8770
|
+
const answer = String(commandText || '').trim().toLowerCase();
|
|
8771
|
+
setSettingsPrompt(null);
|
|
8772
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
8773
|
+
store.pushNotice('delete canceled', 'info');
|
|
8774
|
+
openMemoryCorePicker();
|
|
8775
|
+
return true;
|
|
8776
|
+
}
|
|
8777
|
+
void store.memoryControl?.({ action: 'core', op: 'delete', id, project_id: projectId }, { silent: true })
|
|
8778
|
+
.then((result) => {
|
|
8779
|
+
const errText = memoryCoreResultErrorText(result);
|
|
8780
|
+
store.pushNotice(errText || 'core memory deleted', errText ? 'error' : 'info');
|
|
8781
|
+
openMemoryCorePicker();
|
|
8782
|
+
})
|
|
8783
|
+
.catch((e) => {
|
|
8784
|
+
store.pushNotice(`core delete failed: ${e?.message || e}`, 'error');
|
|
8785
|
+
openMemoryCorePicker();
|
|
8786
|
+
});
|
|
8787
|
+
return true;
|
|
8788
|
+
}
|
|
8104
8789
|
} catch (e) {
|
|
8105
8790
|
store.pushNotice(`settings update failed: ${e?.message || e}`, 'error');
|
|
8106
8791
|
return false;
|
|
@@ -8122,16 +8807,33 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
8122
8807
|
const imageSnapshot = Object.fromEntries(Object.entries(pastedImagesRef.current || {})
|
|
8123
8808
|
.filter(([id]) => imageRefs.has(Number(id))));
|
|
8124
8809
|
const hasImageSnapshot = Object.keys(imageSnapshot).length > 0;
|
|
8125
|
-
|
|
8810
|
+
// Expand folded [Pasted text #N +M lines] tokens back to their original
|
|
8811
|
+
// text at the same point buildPromptContentWithImages runs. Broken /
|
|
8812
|
+
// partially-deleted tokens do not match and are left as-is.
|
|
8813
|
+
const textRefs = pastedTextReferenceIds(text);
|
|
8814
|
+
const textSnapshot = Object.fromEntries(Object.entries(pastedTextsRef.current || {})
|
|
8815
|
+
.filter(([id]) => textRefs.has(Number(id))));
|
|
8816
|
+
const hasTextSnapshot = Object.keys(textSnapshot).length > 0;
|
|
8817
|
+
const expandedText = hasTextSnapshot ? expandPastedTextTokens(text, textSnapshot) : text;
|
|
8818
|
+
const content = buildPromptContentWithImages(expandedText, imageSnapshot);
|
|
8126
8819
|
const accepted = store.submit(content, {
|
|
8127
|
-
|
|
8820
|
+
// Store the EXPANDED text in the transcript/history so a later prompt-
|
|
8821
|
+
// history recall resubmits the real content, not the literal token
|
|
8822
|
+
// (pastedTexts entries are cleared on accept). History recall therefore
|
|
8823
|
+
// shows the full original text instead of the token — acceptable.
|
|
8824
|
+
displayText: expandedText,
|
|
8128
8825
|
pastedImages: imageSnapshot,
|
|
8129
|
-
|
|
8826
|
+
pastedTexts: textSnapshot,
|
|
8827
|
+
onCommitted: (hasImageSnapshot || hasTextSnapshot)
|
|
8828
|
+
? () => { clearPastedImagesSnapshot(imageSnapshot); clearPastedTextsSnapshot(textSnapshot); }
|
|
8829
|
+
: null,
|
|
8130
8830
|
});
|
|
8131
8831
|
if (accepted) {
|
|
8132
8832
|
armTranscriptFollow();
|
|
8133
8833
|
if (imageRefs.size === 0 || (!hasImageSnapshot && !state.busy)) clearPastedImagesSnapshot();
|
|
8134
8834
|
else if (state.busy && hasImageSnapshot) clearPastedImagesSnapshot(imageSnapshot);
|
|
8835
|
+
if (textRefs.size === 0 || (!hasTextSnapshot && !state.busy)) clearPastedTextsSnapshot();
|
|
8836
|
+
else if (state.busy && hasTextSnapshot) clearPastedTextsSnapshot(textSnapshot);
|
|
8135
8837
|
}
|
|
8136
8838
|
return accepted;
|
|
8137
8839
|
};
|
|
@@ -8165,6 +8867,10 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
8165
8867
|
const onPromptDraftChange = useCallback((value) => {
|
|
8166
8868
|
if (String(value ?? '').length > 0) dismissWelcomePromptHint();
|
|
8167
8869
|
syncPromptLayoutRows(value);
|
|
8870
|
+
// NOTE: do NOT prune pasted-text entries on edit. A partially-edited token
|
|
8871
|
+
// can be undone back to its intact form, which must still expand on submit;
|
|
8872
|
+
// entries are kept until an accepted submit or an explicit clear. (Memory
|
|
8873
|
+
// cost is bounded and acceptable.)
|
|
8168
8874
|
const suppressPromptHint = promptHistoryDraftChangeRef.current;
|
|
8169
8875
|
promptHistoryDraftChangeRef.current = false;
|
|
8170
8876
|
const historyNav = promptHistoryNavRef.current;
|
|
@@ -8234,6 +8940,8 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
8234
8940
|
setSettingsPrompt(null);
|
|
8235
8941
|
if (kind === 'project-new' || kind === 'project-create-confirm' || kind === 'project-rename') {
|
|
8236
8942
|
openProjectPicker();
|
|
8943
|
+
} else if (kind === 'core-add' || kind === 'core-edit' || kind === 'core-delete-confirm') {
|
|
8944
|
+
openMemoryCorePicker();
|
|
8237
8945
|
}
|
|
8238
8946
|
}, [settingsPrompt, showPromptHint]);
|
|
8239
8947
|
|
|
@@ -8291,10 +8999,15 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
8291
8999
|
// added pending calls.) Aggregate cards carry a `categories` map; standalone
|
|
8292
9000
|
// cards carry name/args resolved
|
|
8293
9001
|
// via classifyToolCategory. Keep this CHEAP: build a primitive signature from
|
|
8294
|
-
// only the tool items
|
|
9002
|
+
// only the tool items so streaming flushes that swap
|
|
8295
9003
|
// state.items for a fresh array don't restringify the whole transcript and
|
|
8296
9004
|
// the StatusLine effect only re-fires when the numbers actually change.
|
|
8297
9005
|
const activeToolsSignature = useMemo(() => {
|
|
9006
|
+
// The engine maintains this signature incrementally (updated on tool
|
|
9007
|
+
// start/early-complete/result/turn-end), so App no longer scans every
|
|
9008
|
+
// transcript item on each change. Prefer it; fall back to the local scan
|
|
9009
|
+
// only when the engine did not publish it (older snapshot).
|
|
9010
|
+
if (state.activeToolSummary !== undefined) return state.activeToolSummary || '';
|
|
8298
9011
|
const items = state.items || [];
|
|
8299
9012
|
let exploreCount = 0;
|
|
8300
9013
|
let exploreStart = 0;
|
|
@@ -8340,7 +9053,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
8340
9053
|
}
|
|
8341
9054
|
if (!exploreCount && !searchCount) return '';
|
|
8342
9055
|
return `${exploreCount}:${exploreStart}:${searchCount}:${searchStart}`;
|
|
8343
|
-
}, [state.items]);
|
|
9056
|
+
}, [state.activeToolSummary, state.items]);
|
|
8344
9057
|
|
|
8345
9058
|
const activeTools = useMemo(() => {
|
|
8346
9059
|
if (!activeToolsSignature) return null;
|
|
@@ -8423,8 +9136,23 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
8423
9136
|
const liveSpinner = state.spinner?.active ? state.spinner : (state.commandStatus?.active ? state.commandStatus : null);
|
|
8424
9137
|
const latestToast = state.toasts?.length ? state.toasts[state.toasts.length - 1] : null;
|
|
8425
9138
|
const toastHint = latestToast ? latestToast.text : '';
|
|
8426
|
-
const
|
|
8427
|
-
const
|
|
9139
|
+
const progressHint = state.progressHint || null;
|
|
9140
|
+
const inputHint = promptHint || toastHint || (progressHint?.text || '');
|
|
9141
|
+
const inputHintTone = promptHint
|
|
9142
|
+
? promptHintTone
|
|
9143
|
+
: (latestToast?.tone || progressHint?.tone || 'info');
|
|
9144
|
+
// Voice indicator mode for the prompt-box glyph (PromptInput owns the
|
|
9145
|
+
// actual animation timer). Recording/transcribing come from the dedicated
|
|
9146
|
+
// voiceRecPhase state (NOT the promptHint text — typing clears promptHint,
|
|
9147
|
+
// which must not demote a hot mic to 'idle'); install comes from the
|
|
9148
|
+
// persistent progressHint; enabled/off from the cached voiceEnabled state.
|
|
9149
|
+
const voiceIndicatorMode = voiceRecPhase !== 'idle'
|
|
9150
|
+
? voiceRecPhase
|
|
9151
|
+
: progressHint?.text
|
|
9152
|
+
? 'installing'
|
|
9153
|
+
: voiceEnabled
|
|
9154
|
+
? 'idle'
|
|
9155
|
+
: 'off';
|
|
8428
9156
|
const latestTranscriptItem = state.items[state.items.length - 1] || null;
|
|
8429
9157
|
const latestDoneAtTail = latestTranscriptItem?.kind === 'turndone' || latestTranscriptItem?.kind === 'statusdone';
|
|
8430
9158
|
// Bottom meta band ownership is LIVE-SPINNER ONLY. A finished turn's done row
|
|
@@ -8756,7 +9484,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
8756
9484
|
&& floatingPanelRows <= 0
|
|
8757
9485
|
&& transcriptGuardRows > 0
|
|
8758
9486
|
&& !overlayHintOnLastItem;
|
|
8759
|
-
// ── App-level measured height harvest
|
|
9487
|
+
// ── App-level measured height harvest ───────────────────────────────────
|
|
8760
9488
|
// Runs after EVERY commit (no deps): Yoga has just laid out the mounted rows,
|
|
8761
9489
|
// so each tracked item Box's getComputedHeight() is its REAL terminal height.
|
|
8762
9490
|
// Fold those into transcriptMeasuredRowsCache (validated on the same variant
|
|
@@ -8791,8 +9519,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
8791
9519
|
}
|
|
8792
9520
|
if (item.kind === 'assistant' && item.streaming) continue;
|
|
8793
9521
|
// Width 0 = Yoga has not laid this node out yet this frame; skip so a
|
|
8794
|
-
// transient 0 never poisons the cache (
|
|
8795
|
-
// getComputedWidth()>0 guard).
|
|
9522
|
+
// transient 0 never poisons the cache (guard on a real positive width).
|
|
8796
9523
|
if (typeof yoga.getComputedWidth === 'function' && yoga.getComputedWidth() <= 0) continue;
|
|
8797
9524
|
const rawMeasured = Math.round(Number(yoga.getComputedHeight?.()) || 0);
|
|
8798
9525
|
if (rawMeasured <= 0) {
|
|
@@ -9045,6 +9772,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
9045
9772
|
selectionRef={promptSelectionRef}
|
|
9046
9773
|
boxRectRef={promptBoxRectRef}
|
|
9047
9774
|
mouseSelectionRef={promptMouseSelectionRef}
|
|
9775
|
+
suppressShiftNavRef={gridSelectionActiveRef}
|
|
9048
9776
|
hint=""
|
|
9049
9777
|
hintTone={inputHintTone}
|
|
9050
9778
|
mask={false}
|
|
@@ -9053,6 +9781,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
9053
9781
|
onPasteText={handlePromptPaste}
|
|
9054
9782
|
onHistoryNavigate={handlePromptHistoryNavigate}
|
|
9055
9783
|
onVoiceToggle={handleVoiceToggle}
|
|
9784
|
+
voiceIndicatorMode={voiceIndicatorMode}
|
|
9056
9785
|
commandPaletteActive={slashPaletteOpen}
|
|
9057
9786
|
onCommandPaletteNavigate={(direction) => {
|
|
9058
9787
|
setSlashIndex((index) => {
|
|
@@ -9374,6 +10103,12 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
9374
10103
|
? 'confirm'
|
|
9375
10104
|
: settingsPrompt.kind === 'project-rename'
|
|
9376
10105
|
? 'rename'
|
|
10106
|
+
: settingsPrompt.kind === 'core-add'
|
|
10107
|
+
? 'add'
|
|
10108
|
+
: settingsPrompt.kind === 'core-edit'
|
|
10109
|
+
? 'save'
|
|
10110
|
+
: settingsPrompt.kind === 'core-delete-confirm'
|
|
10111
|
+
? 'confirm'
|
|
9377
10112
|
: 'save'}
|
|
9378
10113
|
promptLabel={settingsPrompt.kind === 'skill-use'
|
|
9379
10114
|
? 'Command > '
|
|
@@ -9383,6 +10118,12 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
9383
10118
|
? 'Create? (y/n) > '
|
|
9384
10119
|
: settingsPrompt.kind === 'project-rename'
|
|
9385
10120
|
? 'Name > '
|
|
10121
|
+
: settingsPrompt.kind === 'core-add'
|
|
10122
|
+
? 'Sentence > '
|
|
10123
|
+
: settingsPrompt.kind === 'core-edit'
|
|
10124
|
+
? 'Sentence > '
|
|
10125
|
+
: settingsPrompt.kind === 'core-delete-confirm'
|
|
10126
|
+
? 'Delete? (y/n) > '
|
|
9386
10127
|
: 'Value > '}
|
|
9387
10128
|
onSubmit={onSubmit}
|
|
9388
10129
|
onCancel={cancelSettingsPrompt}
|