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.
Files changed (154) hide show
  1. package/package.json +2 -1
  2. package/scripts/anthropic-maxtokens-test.mjs +119 -0
  3. package/scripts/build-tui.mjs +13 -1
  4. package/scripts/explore-bench.mjs +124 -0
  5. package/scripts/hook-bus-test.mjs +191 -0
  6. package/scripts/path-suffix-test.mjs +57 -0
  7. package/scripts/recall-bench.mjs +207 -0
  8. package/scripts/tool-smoke.mjs +7 -4
  9. package/src/agents/debugger/AGENT.md +2 -2
  10. package/src/agents/heavy-worker/AGENT.md +20 -11
  11. package/src/agents/reviewer/AGENT.md +2 -2
  12. package/src/agents/worker/AGENT.md +17 -11
  13. package/src/mixdog-session-runtime.mjs +424 -1812
  14. package/src/repl.mjs +5 -5
  15. package/src/rules/agent/30-explorer.md +8 -11
  16. package/src/rules/lead/lead-tool.md +9 -5
  17. package/src/rules/shared/01-tool.md +11 -5
  18. package/src/runtime/agent/orchestrator/context/collect.mjs +51 -0
  19. package/src/runtime/agent/orchestrator/mcp/client.mjs +6 -2
  20. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +1 -1
  21. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +22 -68
  23. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +46 -7
  24. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +1 -13
  25. package/src/runtime/agent/orchestrator/providers/gemini.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -1
  27. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  28. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  29. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +19 -12
  30. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +7 -5
  31. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +33 -23
  32. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +31 -14
  33. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +38 -12
  34. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +7 -8
  35. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  36. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
  37. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  38. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  39. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  40. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
  41. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  42. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  43. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  44. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  45. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  46. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  47. package/src/runtime/agent/orchestrator/session/loop.mjs +169 -918
  48. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
  49. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  50. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
  51. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  52. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  53. package/src/runtime/agent/orchestrator/session/manager.mjs +65 -1032
  54. package/src/runtime/agent/orchestrator/stall-policy.mjs +3 -3
  55. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +2 -2
  56. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  57. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
  58. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  59. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  62. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +5 -0
  63. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
  64. package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -0
  65. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  66. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  67. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  68. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
  69. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  70. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  71. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  72. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  73. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  74. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
  75. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  76. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  77. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  78. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  79. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +36 -4277
  80. package/src/runtime/agent/orchestrator/tools/patch.mjs +3 -3
  81. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
  82. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
  83. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  84. package/src/runtime/channels/index.mjs +14 -233
  85. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  86. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  87. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  88. package/src/runtime/channels/lib/output-forwarder.mjs +8 -0
  89. package/src/runtime/channels/lib/telegram-format.mjs +19 -22
  90. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  91. package/src/runtime/memory/index.mjs +314 -359
  92. package/src/runtime/memory/lib/core-memory-store.mjs +351 -1
  93. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  94. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  95. package/src/runtime/memory/lib/memory-cycle2.mjs +56 -3
  96. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  97. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  98. package/src/runtime/memory/lib/memory.mjs +20 -0
  99. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  100. package/src/runtime/memory/lib/recall-format.mjs +183 -0
  101. package/src/runtime/memory/tool-defs.mjs +4 -4
  102. package/src/runtime/shared/abort-controller.mjs +1 -1
  103. package/src/runtime/shared/background-tasks.mjs +2 -3
  104. package/src/runtime/shared/buffered-appender.mjs +149 -0
  105. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  106. package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
  107. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  108. package/src/runtime/shared/transcript-writer.mjs +29 -2
  109. package/src/session-runtime/config-helpers.mjs +209 -0
  110. package/src/session-runtime/effort.mjs +128 -0
  111. package/src/session-runtime/fs-utils.mjs +10 -0
  112. package/src/session-runtime/model-capabilities.mjs +130 -0
  113. package/src/session-runtime/output-styles.mjs +124 -0
  114. package/src/session-runtime/plugin-mcp.mjs +114 -0
  115. package/src/session-runtime/session-text.mjs +100 -0
  116. package/src/session-runtime/statusline-route.mjs +35 -0
  117. package/src/session-runtime/tool-catalog.mjs +720 -0
  118. package/src/session-runtime/workflow.mjs +358 -0
  119. package/src/standalone/agent-tool.mjs +49 -10
  120. package/src/standalone/channel-worker.mjs +3 -2
  121. package/src/standalone/explore-tool.mjs +10 -3
  122. package/src/standalone/hook-bus.mjs +165 -8
  123. package/src/standalone/opencode-go-login.mjs +121 -0
  124. package/src/standalone/provider-admin.mjs +14 -3
  125. package/src/tui/App.jsx +884 -143
  126. package/src/tui/components/PromptInput.jsx +272 -15
  127. package/src/tui/components/ToolExecution.jsx +20 -10
  128. package/src/tui/components/tool-output-format.mjs +2 -2
  129. package/src/tui/dist/index.mjs +1771 -1947
  130. package/src/tui/engine/agent-envelope.mjs +296 -0
  131. package/src/tui/engine/boot-profile.mjs +21 -0
  132. package/src/tui/engine/labels.mjs +67 -0
  133. package/src/tui/engine/notice-text.mjs +112 -0
  134. package/src/tui/engine/queue-helpers.mjs +161 -0
  135. package/src/tui/engine/session-stats.mjs +46 -0
  136. package/src/tui/engine/tool-call-fields.mjs +23 -0
  137. package/src/tui/engine/tool-result-text.mjs +126 -0
  138. package/src/tui/engine.mjs +311 -851
  139. package/src/tui/input-editing.mjs +58 -8
  140. package/src/tui/input-editing.selection.test.mjs +75 -0
  141. package/src/tui/keyboard-protocol.mjs +2 -2
  142. package/src/tui/lib/voice-recorder.mjs +35 -19
  143. package/src/tui/markdown/format-token.mjs +7 -8
  144. package/src/tui/markdown/format-token.test.mjs +3 -3
  145. package/src/tui/paste-attachments.mjs +38 -0
  146. package/src/tui/paste-fix.test.mjs +119 -0
  147. package/src/tui/themes/base.mjs +2 -2
  148. package/src/tui/themes/kanagawa.mjs +4 -4
  149. package/src/tui/themes/teal.mjs +4 -5
  150. package/src/tui/themes/utils.mjs +1 -1
  151. package/src/ui/statusline.mjs +49 -0
  152. package/src/workflows/default/WORKFLOW.md +16 -9
  153. package/src/workflows/sequential/WORKFLOW.md +16 -11
  154. 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
- for (const { segment } of graphemeSegmenter.segment(String(value ?? ''))) {
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 >= w) {
222
- row += Math.floor(col / w);
223
- col %= w;
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: raw.slice(0, -1),
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 (ScrollBox/useVirtualScroll-inspired
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 (ScrollBox/useVirtualScroll-inspired). The map of
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
- const promptContentColumns = Math.max(1, frameColumns - 4);
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 (claude-code cycles; simplest reset). Any non-qualifying
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
- if (!chunks.some((chunk) => chunk.imagePath)) return undefined;
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) => parts.join(''));
2180
- }, [registerPastedImage, showPromptHint, state.cwd]);
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
- const moveSelectionFocus = useCallback((move) => {
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
- const region = drag.region;
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
- if (!rect) return false;
2398
- if (rect.x1 === rect.x2 && rect.y1 === rect.y2) return false;
2399
-
2400
- const anchor = { x: rect.x1, y: rect.y1 };
2401
- let col = rect.x2;
2402
- let row = rect.y2;
2403
- const beforeCol = col;
2404
- const beforeRow = row;
2405
-
2406
- const { top, bottom } = region === 'status' ? statusBandRows() : transcriptViewportRows();
2407
-
2408
- switch (move) {
2409
- case 'left':
2410
- if (col > 0) col -= 1;
2411
- else if (row > top) {
2412
- row -= 1;
2413
- col = selectionMaxColAtRow(row);
2414
- }
2415
- break;
2416
- case 'right': {
2417
- const maxCol = selectionMaxColAtRow(row);
2418
- if (col < maxCol) col += 1;
2419
- else if (row < bottom) {
2420
- row += 1;
2421
- col = 0;
2422
- }
2423
- break;
2424
- }
2425
- case 'up':
2426
- if (row > top) row -= 1;
2427
- break;
2428
- case 'down':
2429
- if (row < bottom) row += 1;
2430
- break;
2431
- case 'lineStart':
2432
- col = 0;
2433
- break;
2434
- case 'lineEnd':
2435
- col = selectionMaxColAtRow(row);
2436
- break;
2437
- default:
2438
- return false;
2439
- }
2440
-
2441
- row = Math.max(top, Math.min(bottom, row));
2442
- col = Math.max(0, Math.min(selectionMaxColAtRow(row), col));
2443
-
2444
- if (col === beforeCol && row === beforeRow) return false;
2445
-
2446
- if (drag.anchorSpan) drag.anchorSpan = null;
2447
-
2448
- const focus = { x: col, y: row };
2449
- applySelectionRect({
2450
- mode: 'linear',
2451
- x1: anchor.x,
2452
- y1: anchor.y,
2453
- x2: focus.x,
2454
- y2: focus.y,
2455
- });
2456
- drag.last = { x: focus.x, y: focus.y };
2457
- return true;
2458
- }, [applySelectionRect, statusBandRows, transcriptViewportRows, selectionMaxColAtRow]);
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 row = Math.max(0, y - top);
2645
- const col = Math.max(0, x - left);
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
- if (offset != null) promptMouseSelectionRef.current?.anchorAt?.(offset);
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 (claude-code cycles; simplest: reset). Works for
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
- scrollTranscriptRows(3);
3172
+ queueScrollCoalesced(3);
2805
3173
  } else if (y >= rows - 5) {
2806
- scrollTranscriptRows(-3);
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
- scrollTranscriptRows((up - down) * STEP);
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) the same
2872
- // way Claude Code does: SYNCHRONOUSLY, ONCE, with NO query/round-trip. ink
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 && moveSelectionFocus(move)) return;
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
- try {
4621
- channelBackend = store.getChannelSetup?.()?.backend || 'discord';
4622
- } catch {
4623
- channelBackend = 'discord';
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 = parseMemoryCoreRows(result);
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?._line) store.pushNotice(item._line, 'info');
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: '←/→ or Enter to toggle automatic updates.',
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 · ←/→ Change · Enter Open/Toggle · Esc Close',
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
- onLeft: (item) => {
6984
- if (item?._action === 'auto-update') toggleAutoUpdate(!upd.autoUpdate);
6985
- },
6986
- onRight: (item) => {
6987
- if (item?._action === 'auto-update') toggleAutoUpdate(!upd.autoUpdate);
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
- const content = buildPromptContentWithImages(text, imageSnapshot);
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
- displayText: text,
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
- onCommitted: hasImageSnapshot ? () => clearPastedImagesSnapshot(imageSnapshot) : null,
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 (mirrors agentRevision) so streaming flushes that swap
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 inputHint = promptHint || toastHint;
8427
- const inputHintTone = promptHint ? promptHintTone : (latestToast?.tone || 'info');
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 (ScrollBox/useVirtualScroll-inspired) ─
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 (mirrors useVirtualScroll's
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}