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
@@ -38,6 +38,7 @@ import {
38
38
  replaceSelection,
39
39
  selectionRange,
40
40
  verticalOffset,
41
+ wordRangeAt,
41
42
  } from '../input-editing.mjs';
42
43
 
43
44
  function hintStyle(tone) {
@@ -52,6 +53,26 @@ function hintStyle(tone) {
52
53
  // paddingX of 1, so the typing start can sit directly against that padding
53
54
  // without an extra guard column.
54
55
  const IME_LEFT_GUARD_COLUMNS = 0;
56
+
57
+ // Monotone voice indicator glyphs — right end of the prompt box. All frames
58
+ // are 1-cell-wide (no emoji, no string-width special-casing needed). Modes:
59
+ // 'off' — voice disabled and no install in flight: renders nothing,
60
+ // zero reserved columns.
61
+ // 'installing' — voice-runtime download in progress (progressHint active):
62
+ // braille spinner only.
63
+ // 'idle' — voice enabled, recorder idle: a slow "breathing" pulse
64
+ // (◎ ◍ ◉ ◍) so the indicator isn't a dead static glyph.
65
+ // 'recording' — steady ◉ + a braille spinner riding next to it (2 cells).
66
+ // 'transcribing' — braille spinner only (recording just stopped, awaiting
67
+ // the whisper-server round-trip).
68
+ // A single ~120ms interval drives BOTH the fast braille cycle and the slower
69
+ // idle pulse (idle advances one frame every VOICE_IDLE_FRAME_TICKS ticks) —
70
+ // one timer instead of two, and it only exists while the indicator is
71
+ // visible (mode !== 'off'); it's torn down otherwise, never a permanent timer.
72
+ const VOICE_BRAILLE_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'];
73
+ const VOICE_IDLE_FRAMES = ['◎', '◍', '◉', '◍'];
74
+ const VOICE_TICK_MS = 120;
75
+ const VOICE_IDLE_FRAME_TICKS = 5; // ~600ms per idle frame (5 * 120ms)
55
76
  // Coalesce prompt mouse-drag extend commits (SGR motion can fire faster than ink
56
77
  // needs to immediate-render). Matches transcript selection paint cadence.
57
78
  const MOUSE_EXTEND_COALESCE_MS = 24;
@@ -150,10 +171,12 @@ export function PromptInput({
150
171
  onHistoryNavigate,
151
172
  onPasteText,
152
173
  onVoiceToggle,
174
+ voiceIndicatorMode = 'off',
153
175
  selectionRef,
154
176
  valueRef,
155
177
  boxRectRef,
156
178
  mouseSelectionRef,
179
+ suppressShiftNavRef,
157
180
  }) {
158
181
  const [draft, setDraft] = useState(() => {
159
182
  const value = String(initialValue || '');
@@ -171,8 +194,31 @@ export function PromptInput({
171
194
  const boxRef = useRef(null);
172
195
  const cursorEnabledRef = useRef(false); // latest enabled state, read by the anchor fn at render time
173
196
  const contentWidthRef = useRef(80);
197
+ // Cells reserved on the right for the voice indicator glyph(s) (0 when
198
+ // hidden, 1 idle/spinner-only, 2 recording's "◉"+braille). Read by the
199
+ // cursor-anchor closure below (installed ONCE, so it must read live state
200
+ // via a ref rather than close over a render-local variable) to keep the
201
+ // caret/wrap math in sync with the actual reserved column, same pattern as
202
+ // IME_LEFT_GUARD_COLUMNS on the left edge.
203
+ const voiceIndicatorWidthRef = useRef(0);
174
204
  const preferredColumnRef = useRef(null);
175
205
  const mouseExtendCoalesceRef = useRef({ pendingNext: null, timer: null, t: 0 });
206
+ // Voice indicator animation tick. Runs a single ~120ms interval ONLY while
207
+ // voiceIndicatorMode !== 'off' (installing/idle/recording/transcribing) —
208
+ // torn down the instant the mode goes 'off', so no permanent timer exists
209
+ // when voice is disabled and no install is in flight.
210
+ const [voiceTick, setVoiceTick] = useState(0);
211
+ useEffect(() => {
212
+ if (voiceIndicatorMode === 'off') return undefined;
213
+ const timer = setInterval(() => setVoiceTick((t) => (t + 1) % 1000000), VOICE_TICK_MS);
214
+ timer.unref?.();
215
+ return () => clearInterval(timer);
216
+ }, [voiceIndicatorMode]);
217
+ // Undo/redo snapshot stack. Each entry is a { value, cursor, selectionAnchor }
218
+ // draft snapshot. Continuous typing coalesces (see UNDO_COALESCE_MS) so a run
219
+ // of characters collapses into one undo step; cursor-only moves are NOT
220
+ // snapshotted. Reset on submit / draftOverride.
221
+ const undoRef = useRef({ past: [], future: [], lastPushAt: 0, lastValue: null });
176
222
  const { value, cursor } = draft;
177
223
  draftRef.current = draft;
178
224
  if (selectionRef) {
@@ -204,6 +250,7 @@ export function PromptInput({
204
250
  const sameDraft = draftStateEqual(draftRef.current, next);
205
251
  if (!options.keepPreferredColumn) preferredColumnRef.current = null;
206
252
  if (sameDraft) return;
253
+ if (!options.skipHistory) recordUndoSnapshot(draftRef.current, next, options);
207
254
  draftRef.current = next;
208
255
  setDraft(next);
209
256
  queueMicrotask(flushImmediate);
@@ -248,10 +295,19 @@ export function PromptInput({
248
295
  const d = draftRef.current;
249
296
  const w = yogaNode?.getComputedWidth?.() ?? 0;
250
297
  const guardColumns = w > IME_LEFT_GUARD_COLUMNS ? IME_LEFT_GUARD_COLUMNS : 0;
251
- const contentWidth = Math.max(1, (w ? w - guardColumns : contentWidthRef.current) || 80);
298
+ // Reserve the voice-indicator's own cells (+1 gap column when visible)
299
+ // out of the SAME width the caret/wrap math uses, mirroring the left
300
+ // guard above — otherwise a visible indicator would silently overlap
301
+ // the caret/typed text at the right edge instead of pushing content in.
302
+ const voiceReserve = voiceIndicatorWidthRef.current > 0 ? voiceIndicatorWidthRef.current + 1 : 0;
303
+ const contentWidth = Math.max(1, (w ? w - guardColumns - voiceReserve : contentWidthRef.current) || 80);
252
304
  contentWidthRef.current = contentWidth;
253
305
  const caret = w > 0
254
- ? caretPosition(d.value, d.cursor, contentWidth)
306
+ // PromptInput renders a trailing space cell when the cursor is at
307
+ // end-of-input, so a caret flush on the last column there still has a
308
+ // following cell — pass hasTrailingContent=true so it rolls to row N+1
309
+ // exactly as ink wraps the trailing space.
310
+ ? caretPosition(d.value, d.cursor, contentWidth, d.cursor >= d.value.length ? true : undefined)
255
311
  : { row: 0, col: stringWidth(d.value.slice(0, d.cursor)) };
256
312
  return w > 0
257
313
  ? { ...caret, col: caret.col + guardColumns }
@@ -264,6 +320,70 @@ export function PromptInput({
264
320
  commitDraft(fn(draftRef.current), options);
265
321
  };
266
322
 
323
+ // --- undo/redo -----------------------------------------------------------
324
+ // Continuous-typing coalesce window: successive value-changing edits within
325
+ // this window collapse into a single undo step.
326
+ const UNDO_COALESCE_MS = 500;
327
+ const UNDO_MAX = 100;
328
+
329
+ const snapshotOf = (d) => ({ value: d.value, cursor: d.cursor, selectionAnchor: d.selectionAnchor ?? null });
330
+
331
+ const resetUndo = () => {
332
+ undoRef.current = { past: [], future: [], lastPushAt: 0, lastValue: null };
333
+ };
334
+
335
+ // Record a snapshot of the PREVIOUS state before applying `next`. Cursor-only
336
+ // moves (value unchanged) are never snapshotted. Consecutive value edits
337
+ // within UNDO_COALESCE_MS coalesce (we keep only the first snapshot of the
338
+ // run, so a single undo reverts the whole burst).
339
+ const recordUndoSnapshot = (prev, next, options = {}) => {
340
+ const stack = undoRef.current;
341
+ if (prev.value === next.value) {
342
+ // Cursor/selection-only move: don't snapshot, but BREAK the coalesce run
343
+ // so a following edit starts a fresh undo step (typing→move→typing must
344
+ // not collapse into one undo).
345
+ stack.lastPushAt = 0;
346
+ stack.lastValue = next.value;
347
+ return;
348
+ }
349
+ const now = Date.now();
350
+ const coalesce = !options.undoBreak
351
+ && stack.past.length > 0
352
+ && (now - stack.lastPushAt) < UNDO_COALESCE_MS
353
+ && stack.lastValue === prev.value;
354
+ if (!coalesce) {
355
+ stack.past.push(snapshotOf(prev));
356
+ if (stack.past.length > UNDO_MAX) stack.past.shift();
357
+ }
358
+ stack.lastPushAt = now;
359
+ stack.lastValue = next.value;
360
+ stack.future = [];
361
+ };
362
+
363
+ const performUndo = () => {
364
+ const stack = undoRef.current;
365
+ if (stack.past.length === 0) return false;
366
+ const prev = stack.past.pop();
367
+ stack.future.push(snapshotOf(draftRef.current));
368
+ stack.lastPushAt = 0;
369
+ stack.lastValue = prev.value;
370
+ commitDraft(prev, { skipHistory: true });
371
+ return true;
372
+ };
373
+
374
+ const performRedo = () => {
375
+ const stack = undoRef.current;
376
+ if (stack.future.length === 0) return false;
377
+ const next = stack.future.pop();
378
+ stack.past.push(snapshotOf(draftRef.current));
379
+ if (stack.past.length > UNDO_MAX) stack.past.shift();
380
+ stack.lastPushAt = 0;
381
+ stack.lastValue = next.value;
382
+ commitDraft(next, { skipHistory: true });
383
+ return true;
384
+ };
385
+ // -------------------------------------------------------------------------
386
+
267
387
  const cancelMouseExtendCoalesce = () => {
268
388
  const state = mouseExtendCoalesceRef.current;
269
389
  if (state.timer) {
@@ -332,6 +452,28 @@ export function PromptInput({
332
452
  const anchor = Number.isFinite(d.selectionAnchor) ? d.selectionAnchor : d.cursor;
333
453
  queueMouseExtendCommit({ ...d, cursor: off, selectionAnchor: anchor }, immediate);
334
454
  },
455
+ hasSelection: () => selectionRange(draftRef.current) != null,
456
+ // Double-click word select: pick the word/punctuation run under the
457
+ // clicked offset and set selectionAnchor/cursor to its bounds so it
458
+ // paints via the normal selection highlight. Triple-click line select
459
+ // uses lineStart/lineEnd instead (see selectLineAt).
460
+ selectWordAt: (offset) => {
461
+ cancelMouseExtendCoalesce();
462
+ const value = draftRef.current.value;
463
+ const off = Math.max(0, Math.min(value.length, Math.floor(Number(offset) || 0)));
464
+ const { start, end } = wordRangeAt(value, off);
465
+ commitDraft({ ...draftRef.current, cursor: end, selectionAnchor: start });
466
+ mouseExtendCoalesceRef.current.t = Date.now();
467
+ },
468
+ selectLineAt: (offset) => {
469
+ cancelMouseExtendCoalesce();
470
+ const value = draftRef.current.value;
471
+ const off = Math.max(0, Math.min(value.length, Math.floor(Number(offset) || 0)));
472
+ const start = lineStart(value, off);
473
+ const end = lineEnd(value, off);
474
+ commitDraft({ ...draftRef.current, cursor: end, selectionAnchor: start });
475
+ mouseExtendCoalesceRef.current.t = Date.now();
476
+ },
335
477
  clear: () => {
336
478
  cancelMouseExtendCoalesce();
337
479
  if (selectionRange(draftRef.current)) commitDraft(clearSelection(draftRef.current));
@@ -405,7 +547,8 @@ export function PromptInput({
405
547
 
406
548
  useEffect(() => {
407
549
  if (!draftOverride || typeof draftOverride.value !== 'string') return;
408
- commitDraft({ value: draftOverride.value, cursor: draftOverride.value.length, selectionAnchor: null });
550
+ commitDraft({ value: draftOverride.value, cursor: draftOverride.value.length, selectionAnchor: null }, { skipHistory: true });
551
+ resetUndo();
409
552
  }, [draftOverride?.id]);
410
553
 
411
554
  useEffect(() => () => {
@@ -419,7 +562,8 @@ export function PromptInput({
419
562
  commitDraft(next);
420
563
  return;
421
564
  }
422
- commitDraft({ value: '', cursor: 0, selectionAnchor: null });
565
+ commitDraft({ value: '', cursor: 0, selectionAnchor: null }, { skipHistory: true });
566
+ resetUndo();
423
567
  };
424
568
 
425
569
  const submitEnterChunk = (prefix = '') => {
@@ -450,6 +594,21 @@ export function PromptInput({
450
594
  const rawInput = String(input ?? '');
451
595
  const inputKey = rawInput.toLowerCase();
452
596
 
597
+ // App owns Shift+Arrow when a transcript/status ink-grid selection is live.
598
+ // Because the parent (App) useInput handler fires AFTER this child handler
599
+ // for the same event, a flag SET in App's handler is always one event stale.
600
+ // Instead call a synchronous predicate derived from dragRef at event time.
601
+ const gridSelectionActive = typeof suppressShiftNavRef === 'function'
602
+ ? suppressShiftNavRef()
603
+ : (typeof suppressShiftNavRef?.current === 'function'
604
+ ? suppressShiftNavRef.current()
605
+ : Boolean(suppressShiftNavRef?.current));
606
+ if (gridSelectionActive) {
607
+ const isShiftArrow = key.shift
608
+ && (key.leftArrow || key.rightArrow || key.upArrow || key.downArrow || key.home || key.end);
609
+ if (isShiftArrow) return;
610
+ }
611
+
453
612
  // Drop SGR mouse-tracking sequences (wheel/click). When app mouse tracking
454
613
  // is explicitly enabled, App parses these off raw stdin itself;
455
614
  // ink still forwards the bytes here as "input", which would otherwise type
@@ -482,7 +641,22 @@ export function PromptInput({
482
641
  const rawShiftDown = rawInput === '\x1b[1;2B' || rawInput === '\x1b[b' || rawInput === '[1;2B';
483
642
  const rawShiftRight = rawInput === '\x1b[1;2C' || rawInput === '\x1b[c' || rawInput === '[1;2C';
484
643
  const rawShiftLeft = rawInput === '\x1b[1;2D' || rawInput === '\x1b[d' || rawInput === '[1;2D';
485
- const shiftHeld = key.shift || rawShiftUp || rawShiftDown || rawShiftLeft || rawShiftRight;
644
+ // Ctrl+Shift+Arrow modifier sequences: xterm mod=6 (1 + shift(1) + ctrl(4))
645
+ // arrives as `\x1b[1;6<dir>`; kitty keyboard protocol reports the same chord
646
+ // as `\x1b[<code>;6<dir>` (also mod=6). Ink decodes neither the `;6` for
647
+ // arrows, so the bytes arrive raw. Fold into a ctrlShiftHeld signal used to
648
+ // drive whole-word selection-extend below. `\x1b[1;6<dir>` covers both the
649
+ // classic xterm form and kitty's default (which emits the legacy arrow form
650
+ // with the CSI-u modifier param for arrow keys).
651
+ const rawCtrlShiftUp = rawInput === '\x1b[1;6A' || rawInput === '[1;6A';
652
+ const rawCtrlShiftDown = rawInput === '\x1b[1;6B' || rawInput === '[1;6B';
653
+ const rawCtrlShiftRight = rawInput === '\x1b[1;6C' || rawInput === '[1;6C';
654
+ const rawCtrlShiftLeft = rawInput === '\x1b[1;6D' || rawInput === '[1;6D';
655
+ const ctrlShiftHeld =
656
+ rawCtrlShiftUp || rawCtrlShiftDown || rawCtrlShiftLeft || rawCtrlShiftRight
657
+ || (key.shift && key.ctrl && (key.leftArrow || key.rightArrow || key.upArrow || key.downArrow));
658
+ const shiftHeld = key.shift || rawShiftUp || rawShiftDown || rawShiftLeft || rawShiftRight
659
+ || rawCtrlShiftUp || rawCtrlShiftDown || rawCtrlShiftLeft || rawCtrlShiftRight;
486
660
  const lineBreakIndex = rawInput.search(/[\r\n]/);
487
661
  const rawEnter = rawInput === '\r' || rawInput === '\n' || rawInput === '\r\n';
488
662
  const trailingEnterPrefix = singleTrailingLineBreakPrefix(rawInput);
@@ -566,25 +740,58 @@ export function PromptInput({
566
740
  return;
567
741
  }
568
742
 
569
- if (key.upArrow || rawUpArrow || rawShiftUp) {
743
+ // Ctrl+Shift+Left/Right → extend selection whole-word. Kept before the plain
744
+ // shift-arrow branches so the ctrl+shift chord never falls through to a
745
+ // char-wise extend. (Up/Down ctrl+shift extend to line-relative vertical
746
+ // move with extend — same as shift alone; handled in the vertical branch.)
747
+ if (ctrlShiftHeld && (rawCtrlShiftLeft || (key.ctrl && key.shift && key.leftArrow))) {
748
+ if (!commandPaletteActive) {
749
+ updateDraft((d) => moveCursor(d, previousWordOffset(d.value, d.cursor), { extend: true }));
750
+ }
751
+ return;
752
+ }
753
+ if (ctrlShiftHeld && (rawCtrlShiftRight || (key.ctrl && key.shift && key.rightArrow))) {
754
+ if (!commandPaletteActive) {
755
+ updateDraft((d) => moveCursor(d, nextWordOffset(d.value, d.cursor), { extend: true }));
756
+ }
757
+ return;
758
+ }
759
+
760
+ if (key.upArrow || rawUpArrow || rawShiftUp || rawCtrlShiftUp) {
570
761
  if (commandPaletteActive) {
571
762
  onCommandPaletteNavigate?.(-1);
572
763
  } else {
573
- const hasDraftText = String(draftRef.current.value || '').trim().length > 0;
574
- if (!hasDraftText) {
575
- if (!restoreQueuedToDraft()) applyHistoryNavigation('up', { emptyDraft: true });
576
- } else if (!moveDraftVertically(-1, { extend: shiftHeld })) {
577
- applyHistoryNavigation('up', { emptyDraft: false });
764
+ // A Shift-held Up is a SELECTION gesture, never history navigation:
765
+ // extend the selection up one visual line, and if already on the first
766
+ // line extend all the way to document start (offset 0). History
767
+ // navigation (restoreQueued / applyHistoryNavigation) MUST NOT fire.
768
+ if (shiftHeld) {
769
+ if (!moveDraftVertically(-1, { extend: true })) {
770
+ updateDraft((d) => moveCursor(d, 0, { extend: true }));
771
+ }
772
+ } else {
773
+ const hasDraftText = String(draftRef.current.value || '').trim().length > 0;
774
+ if (!hasDraftText) {
775
+ if (!restoreQueuedToDraft()) applyHistoryNavigation('up', { emptyDraft: true });
776
+ } else if (!moveDraftVertically(-1, { extend: false })) {
777
+ applyHistoryNavigation('up', { emptyDraft: false });
778
+ }
578
779
  }
579
780
  }
580
781
  return;
581
782
  }
582
783
 
583
- if (key.downArrow || rawDownArrow || rawShiftDown) {
784
+ if (key.downArrow || rawDownArrow || rawShiftDown || rawCtrlShiftDown) {
584
785
  if (commandPaletteActive) {
585
786
  onCommandPaletteNavigate?.(1);
586
787
  } else {
587
- if (!moveDraftVertically(1, { extend: shiftHeld })) {
788
+ // Shift-held Down: extend selection down one line, or to document end
789
+ // (value.length) when already on the last line. Never history nav.
790
+ if (shiftHeld) {
791
+ if (!moveDraftVertically(1, { extend: true })) {
792
+ updateDraft((d) => moveCursor(d, d.value.length, { extend: true }));
793
+ }
794
+ } else if (!moveDraftVertically(1, { extend: false })) {
588
795
  applyHistoryNavigation('down', { emptyDraft: String(draftRef.current.value || '').trim().length === 0 });
589
796
  }
590
797
  }
@@ -641,8 +848,8 @@ export function PromptInput({
641
848
  commitDraft({ value: '', cursor: 0, selectionAnchor: null });
642
849
  return;
643
850
  }
644
- // Active turn takes precedence over queue restore (matches claude-code
645
- // useCancelRequest priority): Esc during a running turn interrupts the
851
+ // Active turn takes precedence over queue restore: Esc during a
852
+ // running turn interrupts the
646
853
  // turn and leaves queued steering prompts intact to run afterward. Queued
647
854
  // messages only pop back into the draft when the turn is idle.
648
855
  if (interruptActive) {
@@ -702,6 +909,27 @@ export function PromptInput({
702
909
 
703
910
  const editingKey = String(input || '').toLowerCase();
704
911
 
912
+ // Undo / redo. Covered encodings:
913
+ // • kitty protocol: ctrl+z → input 'z' + key.ctrl; ctrl+y → 'y' + key.ctrl;
914
+ // ctrl+shift+z → 'z' + key.ctrl + key.shift (redo).
915
+ // • legacy control bytes: Ctrl+Z is \x1a (SUB, 0x1A), Ctrl+Y is \x19 (EM,
916
+ // 0x19). ink may deliver these as raw input without key.ctrl on some
917
+ // terminals, so match the byte directly too.
918
+ const isCtrlZ = (key.ctrl && editingKey === 'z') || rawInput === '\x1a';
919
+ const isCtrlY = (key.ctrl && editingKey === 'y') || rawInput === '\x19';
920
+ if (isCtrlZ && (key.shift || shiftHeld)) {
921
+ performRedo();
922
+ return;
923
+ }
924
+ if (isCtrlZ) {
925
+ performUndo();
926
+ return;
927
+ }
928
+ if (isCtrlY) {
929
+ performRedo();
930
+ return;
931
+ }
932
+
705
933
  // ctrl+a selects all like a normal text box; ctrl+e keeps readline line-end.
706
934
  if (key.ctrl && editingKey === 'a') {
707
935
  updateDraft((d) => (d.value ? { ...d, cursor: d.value.length, selectionAnchor: 0 } : clearSelection(d)));
@@ -834,6 +1062,24 @@ export function PromptInput({
834
1062
  const displayValue = mask ? value.replace(/[^\n]/g, '*') : value;
835
1063
  const renderedValue = renderSelectedText(displayValue, selectionRange(draft), cursor === value.length);
836
1064
  const hintMeta = hintStyle(hintTone);
1065
+ // Monotone voice indicator glyph text — zero-width (rendered as null) when
1066
+ // mode is 'off'. Braille frame drives installing/recording/transcribing;
1067
+ // the idle pulse advances one frame every VOICE_IDLE_FRAME_TICKS ticks of
1068
+ // the SAME interval (slower "breathing" cadence off one shared timer).
1069
+ const voiceBrailleFrame = VOICE_BRAILLE_FRAMES[voiceTick % VOICE_BRAILLE_FRAMES.length];
1070
+ const voiceIdleFrame = VOICE_IDLE_FRAMES[Math.floor(voiceTick / VOICE_IDLE_FRAME_TICKS) % VOICE_IDLE_FRAMES.length];
1071
+ const voiceIndicatorText = voiceIndicatorMode === 'installing' || voiceIndicatorMode === 'transcribing'
1072
+ ? voiceBrailleFrame
1073
+ : voiceIndicatorMode === 'recording'
1074
+ ? `◉${voiceBrailleFrame}`
1075
+ : voiceIndicatorMode === 'idle'
1076
+ ? voiceIdleFrame
1077
+ : '';
1078
+ // stringWidth is unnecessary here — every glyph above is a single
1079
+ // 1-column codepoint (◎◍◉ and the braille frames), and 'recording'
1080
+ // concatenates exactly two 1-cell glyphs, so plain .length gives the
1081
+ // correct cell count without pulling in emoji-width special-casing.
1082
+ voiceIndicatorWidthRef.current = voiceIndicatorText.length;
837
1083
 
838
1084
  return (
839
1085
  <Box ref={boxRef} flexDirection="row" width="100%" flexGrow={1} flexShrink={1} backgroundColor={surfaceBackground()}>
@@ -844,6 +1090,17 @@ export function PromptInput({
844
1090
  <Text color={hintMeta.textColor}>{hint}</Text>
845
1091
  </Box>
846
1092
  ) : null}
1093
+ {voiceIndicatorText ? (
1094
+ <>
1095
+ {/* flexGrow spacer pins the indicator to the RIGHT edge of the row
1096
+ even when the typed text is short; without it the glyph would
1097
+ ride directly after the caret instead of the box edge. */}
1098
+ <Box flexGrow={1} backgroundColor={surfaceBackground()} />
1099
+ <Box flexShrink={0} width={voiceIndicatorText.length + 1} justifyContent="flex-end" backgroundColor={surfaceBackground()}>
1100
+ <Text color={theme.subtle}>{voiceIndicatorText}</Text>
1101
+ </Box>
1102
+ </>
1103
+ ) : null}
847
1104
  </Box>
848
1105
  );
849
1106
  }
@@ -571,7 +571,10 @@ function toolSearchLoadedSummary(resultText) {
571
571
  try {
572
572
  parsed = JSON.parse(String(resultText || ''));
573
573
  } catch {
574
- return '';
574
+ const text = String(resultText || '');
575
+ const match = /^Loaded deferred tools:\s*(.+)$/m.exec(text);
576
+ if (!match) return '';
577
+ return [...new Set(match[1].split(',').map((name) => name.trim()).filter(Boolean))].join(', ');
575
578
  }
576
579
  const tools = parsed?.selected?.tools;
577
580
  if (!tools || typeof tools !== 'object') return '';
@@ -610,17 +613,21 @@ function clampFailureCount(errorCount, groupCount, isError) {
610
613
  // Single source of truth for the tool-card dot (●) color. Both the aggregate
611
614
  // and normal (single-tool) render paths must call this with a resolved
612
615
  // `terminalStatus` — do not recompute color inline elsewhere.
613
- // running/pending -> mixdogOrange || warning (blink handled by caller)
614
- // success -> theme.success
615
- // partial failure -> mixdogOrange (some, not all, of the group failed)
616
- // all failed -> theme.error
617
- // cancelled -> theme.warning
616
+ // running/pending -> theme.text (white; blink handled by caller)
617
+ // success -> theme.success
618
+ // partial failure -> mixdogOrange || warning (some, not all, of the group failed)
619
+ // all failed -> theme.error
620
+ // cancelled -> theme.warning
621
+ // Note: callers resolve terminalStatus to 'failed' whenever failedCount > 0
622
+ // (they cannot tell partial from total failure), so a 'failed' status must
623
+ // still fall through to the failedCount check below rather than short-circuit
624
+ // to error — otherwise a mixed-success batch (e.g. 1 of 3 failed) renders as
625
+ // full-failure red instead of partial orange.
618
626
  function toolStatusColor({ pending, groupCount, failedCount, terminalStatus = '' }) {
619
- if (pending) return theme.mixdogOrange || theme.warning;
627
+ if (pending) return theme.text;
620
628
  const status = normalizeTerminalStatus(terminalStatus);
621
629
  if (status === 'cancelled') return theme.warning;
622
- if (status === 'failed') return theme.error;
623
- if (failedCount <= 0) return theme.success;
630
+ if (failedCount <= 0) return status === 'failed' ? theme.error : theme.success;
624
631
  if (groupCount > 1 && failedCount < groupCount) return theme.mixdogOrange || theme.warning;
625
632
  return theme.error;
626
633
  }
@@ -809,7 +816,10 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
809
816
  // Aggregate cards intentionally omit elapsed time once grouped. A brief
810
817
  // `Running · 1s` tick during the grouped→finished handoff reads as visual
811
818
  // noise, and the grouped header already communicates that work is active.
812
- const pendingPlaceholder = headerPending
819
+ // The placeholder tracks `pending` (real completion), NOT headerPending:
820
+ // the header verb stays active until the block seals, but the detail row
821
+ // must not keep saying "Running" after every call already resolved.
822
+ const pendingPlaceholder = pending
813
823
  ? 'Running'
814
824
  : 'Finished';
815
825
  const detailLines = showRawAggregate
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * components/tool-output-format.mjs — shared post-processing for EXPANDED tool
3
- * result bodies (ctrl+o). Mirrors the Claude Code output pipeline so every tool
4
- * surface renders the same way instead of a flat run of plain lines:
3
+ * result bodies (ctrl+o), so every tool surface renders the same way instead
4
+ * of a flat run of plain lines:
5
5
  *
6
6
  * - language inference from the read/grep path argument (file extension)
7
7
  * - JSON auto pretty-print (precision-safe, size-capped)