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
@@ -122,14 +122,52 @@ export function lineEnd(text, offset) {
122
122
  return end === -1 ? value.length : end;
123
123
  }
124
124
 
125
- export function caretPosition(text, offset, width) {
125
+ // Return the [start,end) run of same-class characters (word-like vs
126
+ // non-word-like) surrounding `offset`, used by double-click word selection.
127
+ // Mirrors typical desktop double-click behavior: clicking inside a run of
128
+ // letters/digits selects that whole run; clicking on a run of punctuation or
129
+ // whitespace selects that run instead (still deterministic, never empty).
130
+ export function wordRangeAt(text, offset) {
131
+ const value = String(text || '');
132
+ const units = graphemeUnits(value);
133
+ if (units.length === 0) return { start: 0, end: 0 };
134
+ const off = clampOffset(value, offset);
135
+ let idx = units.findIndex((u) => off >= u.start && off < u.end);
136
+ if (idx === -1) {
137
+ idx = units.length - 1;
138
+ for (let i = 0; i < units.length; i += 1) {
139
+ if (units[i].start >= off) { idx = i > 0 ? i - 1 : 0; break; }
140
+ }
141
+ }
142
+ const targetIsWord = wordLike(units[idx].segment);
143
+ let start = idx;
144
+ while (start > 0 && wordLike(units[start - 1].segment) === targetIsWord) start -= 1;
145
+ let end = idx;
146
+ while (end < units.length - 1 && wordLike(units[end + 1].segment) === targetIsWord) end += 1;
147
+ return { start: units[start].start, end: units[end].end };
148
+ }
149
+
150
+ // `hasTrailingContent` (default derived from the full text) decides whether a
151
+ // caret that lands FLUSH on the last column (col === w) rolls to the next row.
152
+ // It must reflect whether ANY rendered cell follows this offset in the FULL
153
+ // text — not just the sliced prefix — otherwise a caret at a mid-text soft-wrap
154
+ // boundary reports {row N, col w} while ink already rendered the next glyph on
155
+ // row N+1, dropping the caret outside the box. PromptInput also appends a
156
+ // trailing space cell when the cursor sits at end-of-input, so "end of text"
157
+ // still has a following cell there; callers pass hasTrailingContent=true for
158
+ // that case.
159
+ export function caretPosition(text, offset, width, hasTrailingContent = undefined) {
126
160
  const value = String(text || '');
127
161
  const before = value.slice(0, offset);
128
162
  const w = safeWidth(width);
163
+ const followsInFullText = hasTrailingContent === undefined
164
+ ? offset < value.length
165
+ : hasTrailingContent === true;
129
166
  let row = 0;
130
167
  let col = 0;
131
-
132
- for (const { segment } of graphemeSegmenter.segment(before)) {
168
+ const segments = [...graphemeSegmenter.segment(before)];
169
+ for (let i = 0; i < segments.length; i += 1) {
170
+ const { segment } = segments[i];
133
171
  if (segment === '\n') {
134
172
  row += 1;
135
173
  col = 0;
@@ -139,15 +177,24 @@ export function caretPosition(text, offset, width) {
139
177
  const segmentWidth = stringWidth(segment);
140
178
  if (segmentWidth === 0) continue;
141
179
 
180
+ // Mirror ink's wrap-ansi wrapWord (wrap="hard", wordWrap:false): a glyph
181
+ // that would overflow the row is pushed WHOLE to the next row (col never
182
+ // exceeds w for wide chars — no half-column left behind), and a glyph that
183
+ // lands flush on the last column starts a new row ONLY when more rendered
184
+ // content follows. Whether content follows is decided from the FULL text
185
+ // (followsInFullText), not the sliced prefix, so a caret at a mid-text wrap
186
+ // boundary rolls to row N+1 exactly as ink renders it. The old
187
+ // `col >= w → row += floor(col/w)` over-counted a row for width-2 glyphs at
188
+ // odd widths (col could reach w+1), landing the cursor one row too low.
142
189
  if (col > 0 && col + segmentWidth > w) {
143
190
  row += 1;
144
191
  col = 0;
145
192
  }
146
-
147
193
  col += segmentWidth;
148
- if (col >= w) {
149
- row += Math.floor(col / w);
150
- col %= w;
194
+ const moreFollows = i < segments.length - 1 || followsInFullText;
195
+ if (col === w && moreFollows) {
196
+ row += 1;
197
+ col = 0;
151
198
  }
152
199
  }
153
200
 
@@ -185,13 +232,16 @@ function boundaryPositions(text, width) {
185
232
  for (const unit of graphemeUnits(value)) offsets.push(unit.end);
186
233
 
187
234
  const seen = new Set();
188
- return offsets
235
+ const positions = offsets
189
236
  .filter((offset) => {
190
237
  if (seen.has(offset)) return false;
191
238
  seen.add(offset);
192
239
  return true;
193
240
  })
241
+ // Decide the flush-boundary advance from the FULL text so a soft-wrap
242
+ // offset reports {row N+1, col 0} (matching ink) instead of {row N, col w}.
194
243
  .map((offset) => ({ offset, ...caretPosition(value, offset, width) }));
244
+ return positions;
195
245
  }
196
246
 
197
247
  export function verticalOffset(text, offset, width, direction, preferredColumn = null) {
@@ -0,0 +1,75 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ moveCursor,
5
+ previousWordOffset,
6
+ nextWordOffset,
7
+ selectionRange,
8
+ verticalOffset,
9
+ wordRangeAt,
10
+ lineStart,
11
+ lineEnd,
12
+ } from './input-editing.mjs';
13
+
14
+ // Whole-word extend (Ctrl+Shift+Left/Right) relies on moveCursor(extend:true)
15
+ // starting/keeping an anchor while jumping by word.
16
+ test('ctrl+shift+right extends selection by whole word', () => {
17
+ const draft = { value: 'hello world foo', cursor: 0, selectionAnchor: null };
18
+ const next = moveCursor(draft, nextWordOffset(draft.value, draft.cursor), { extend: true });
19
+ assert.equal(next.cursor, 'hello'.length);
20
+ assert.equal(next.selectionAnchor, 0);
21
+ assert.deepEqual(selectionRange(next), { start: 0, end: 5 });
22
+ });
23
+
24
+ test('ctrl+shift+left extends selection backward by whole word', () => {
25
+ const value = 'hello world';
26
+ const draft = { value, cursor: value.length, selectionAnchor: null };
27
+ const next = moveCursor(draft, previousWordOffset(value, value.length), { extend: true });
28
+ assert.equal(next.selectionAnchor, value.length);
29
+ assert.deepEqual(selectionRange(next), { start: 'hello '.length, end: value.length });
30
+ });
31
+
32
+ // Shift+Up on the first line yields no vertical move (targetRow < 0) → callers
33
+ // then extend to document start (offset 0).
34
+ test('verticalOffset returns same cursor on first line (up)', () => {
35
+ const value = 'line1\nline2';
36
+ const moved = verticalOffset(value, 2, 80, -1, null);
37
+ assert.equal(moved.cursor, 2, 'no vertical move available above first line');
38
+ });
39
+
40
+ test('shift+up to document start extends selection to offset 0', () => {
41
+ const value = 'abc';
42
+ const draft = { value, cursor: 2, selectionAnchor: null };
43
+ const next = moveCursor(draft, 0, { extend: true });
44
+ assert.deepEqual(selectionRange(next), { start: 0, end: 2 });
45
+ });
46
+
47
+ test('shift+down to document end extends selection to value.length', () => {
48
+ const value = 'abc';
49
+ const draft = { value, cursor: 1, selectionAnchor: null };
50
+ const next = moveCursor(draft, value.length, { extend: true });
51
+ assert.deepEqual(selectionRange(next), { start: 1, end: 3 });
52
+ });
53
+
54
+ // Double-click word select: clicking anywhere inside a word run selects the
55
+ // whole run, and a click on punctuation/whitespace selects that run instead.
56
+ test('wordRangeAt selects the word run under the offset', () => {
57
+ const value = 'hello world foo';
58
+ assert.deepEqual(wordRangeAt(value, 2), { start: 0, end: 5 });
59
+ assert.deepEqual(wordRangeAt(value, 6), { start: 6, end: 11 });
60
+ assert.deepEqual(wordRangeAt(value, 0), { start: 0, end: 5 });
61
+ assert.deepEqual(wordRangeAt(value, value.length), { start: 12, end: 15 });
62
+ });
63
+
64
+ test('wordRangeAt selects a punctuation/whitespace run distinctly from words', () => {
65
+ const value = 'foo---bar';
66
+ assert.deepEqual(wordRangeAt(value, 4), { start: 3, end: 6 });
67
+ });
68
+
69
+ // Triple-click line select uses lineStart/lineEnd directly.
70
+ test('lineStart/lineEnd bound the current line for triple-click select', () => {
71
+ const value = 'line1\nline2\nline3';
72
+ const offset = value.indexOf('line2') + 2;
73
+ assert.equal(lineStart(value, offset), 6);
74
+ assert.equal(lineEnd(value, offset), 11);
75
+ });
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * src/tui/keyboard-protocol.mjs — terminal extended-keys capability gate.
3
3
  *
4
- * We do NOT query the terminal. Instead, mirroring Claude Code, we enable the
4
+ * We do NOT query the terminal. Instead, we enable the
5
5
  * kitty keyboard protocol AND xterm modifyOtherKeys SYNCHRONOUSLY at the moment
6
6
  * raw mode turns on (see App.jsx's mount effect), gated by the allowlist below.
7
7
  * Writing both enables unconditionally is safe — a terminal honors whichever it
@@ -22,7 +22,7 @@ export const POP_KITTY = '\x1b[<u';
22
22
  export const DISABLE_MODIFY_OTHER_KEYS = '\x1b[>4;0m';
23
23
 
24
24
  // Allowlist of terminals known to honor kitty and/or xterm modifyOtherKeys.
25
- // Mirrors Claude Code's supportsExtendedKeys allowlist. VS Code's xterm.js
25
+ // VS Code's xterm.js
26
26
  // integrated terminal mishandles these sequences, so it is excluded first.
27
27
  // Honors the MIXDOG_TUI_EXTENDED_KEYS opt-out (=0) / override (=1).
28
28
  export function supportsExtendedKeys() {
@@ -80,28 +80,42 @@ export function isVoiceEnabled() {
80
80
  }
81
81
 
82
82
  // Coalesce ensure*'s onProgress ({ phase, downloaded, total } | { phase:'extra', ... })
83
- // into a throttled (2s) human-readable pushNotice call so a multi-hundred-MB
84
- // model download doesn't spam the notice toast on every chunk.
85
- function makeThrottledProgressNotice(pushNotice, intervalMs = 2000) {
83
+ // into a sticky, in-place input-hint-line progress bar (setProgressHint) so a
84
+ // multi-hundred-MB model download doesn't spam the notice toast on every
85
+ // chunk. Falls back to the old throttled (2s) toast-spam behavior when the
86
+ // caller (e.g. the channels pipeline) doesn't have a progress-hint slot to
87
+ // render into — only pushNotice is guaranteed to exist everywhere.
88
+ const PROGRESS_BAR_CELLS = 6;
89
+ function renderProgressBarText(phase, downloaded, total) {
90
+ const label = phaseLabel(phase);
91
+ if (total > 0) {
92
+ const ratio = Math.max(0, Math.min(1, downloaded / total));
93
+ const filled = Math.round(ratio * PROGRESS_BAR_CELLS);
94
+ const bar = '▓'.repeat(filled) + '░'.repeat(PROGRESS_BAR_CELLS - filled);
95
+ return `⬇ Voice ${label} ${bar} ${Math.round(ratio * 100)}%`;
96
+ }
97
+ return `⬇ Voice ${label} …`;
98
+ }
99
+ function phaseLabel(phase) {
100
+ if (phase === 'model') return 'model';
101
+ if (phase === 'ffmpeg') return 'ffmpeg';
102
+ if (phase === 'extra') return 'extra data';
103
+ return 'whisper runtime';
104
+ }
105
+ function makeThrottledProgressNotice({ pushNotice, setProgressHint } = {}, intervalMs = 500) {
86
106
  let lastEmitAt = 0;
87
- const phaseLabel = (phase) => {
88
- if (phase === 'model') return 'model';
89
- if (phase === 'ffmpeg') return 'ffmpeg';
90
- if (phase === 'extra') return 'extra data';
91
- return 'whisper runtime';
92
- };
93
107
  return (progress = {}) => {
94
- if (typeof pushNotice !== 'function') return;
95
108
  const now = Date.now();
96
109
  if (now - lastEmitAt < intervalMs) return;
97
110
  lastEmitAt = now;
98
111
  const total = Number(progress.total) || 0;
99
112
  const downloaded = Number(progress.downloaded) || 0;
100
- const label = phaseLabel(progress.phase);
101
- const text = total > 0
102
- ? `Voice: downloading ${label}… ${Math.min(100, Math.round((downloaded / total) * 100))}%`
103
- : `Voice: downloading ${label}…`;
104
- pushNotice(text, 'info');
113
+ const text = renderProgressBarText(progress.phase, downloaded, total);
114
+ if (typeof setProgressHint === 'function') {
115
+ setProgressHint(text, 'info');
116
+ return;
117
+ }
118
+ if (typeof pushNotice === 'function') pushNotice(text, 'info');
105
119
  };
106
120
  }
107
121
 
@@ -112,12 +126,12 @@ function makeThrottledProgressNotice(pushNotice, intervalMs = 2000) {
112
126
  * runtime descriptor (resolveVoiceRuntime shape) once `installed` is true;
113
127
  * throws on any ensure* failure (manifest fetch, sha256 mismatch, etc.).
114
128
  */
115
- export async function ensureVoiceRuntimeReady({ dataDir = resolvePluginData(), pushNotice } = {}) {
129
+ export async function ensureVoiceRuntimeReady({ dataDir = resolvePluginData(), pushNotice, setProgressHint } = {}) {
116
130
  const fetcher = await loadVoiceRuntimeFetcher();
117
131
  let runtime = fetcher.resolveVoiceRuntime(dataDir);
118
132
  if (runtime.installed) return runtime;
119
133
 
120
- const onProgress = makeThrottledProgressNotice(pushNotice);
134
+ const onProgress = makeThrottledProgressNotice({ pushNotice, setProgressHint });
121
135
  if (!runtime.binary || !runtime.serverCmd) {
122
136
  await fetcher.ensureWhisperRuntime(dataDir, onProgress);
123
137
  }
@@ -147,7 +161,7 @@ export async function ensureVoiceRuntimeReady({ dataDir = resolvePluginData(), p
147
161
  * Returns the NEW enabled state (true/false) on success, or null when the
148
162
  * toggle could not run (install already in flight) or failed.
149
163
  */
150
- export async function toggleVoice({ pushNotice } = {}) {
164
+ export async function toggleVoice({ pushNotice, setProgressHint } = {}) {
151
165
  const dataDir = resolvePluginData();
152
166
  if (isVoiceEnabled()) {
153
167
  // Med-5: updateSection is a synchronous file write (readAllForRmW +
@@ -172,11 +186,13 @@ export async function toggleVoice({ pushNotice } = {}) {
172
186
  }
173
187
  _voiceInstallBusy = true;
174
188
  try {
175
- await ensureVoiceRuntimeReady({ dataDir, pushNotice });
189
+ await ensureVoiceRuntimeReady({ dataDir, pushNotice, setProgressHint });
176
190
  updateSection('voice', (current) => ({ ...current, enabled: true }));
191
+ setProgressHint?.('');
177
192
  pushNotice?.('Voice ON — Ctrl+Space to record', 'info');
178
193
  return true;
179
194
  } catch (err) {
195
+ setProgressHint?.('');
180
196
  pushNotice?.(`Voice setup failed: ${err?.message || err}`, 'error');
181
197
  return null;
182
198
  } finally {
@@ -108,8 +108,8 @@ function decodeEntities(s) {
108
108
 
109
109
  // ── Fenced code block rendering ─────────────────────────────────────────────
110
110
  // Gutter-indented, syntax-highlighted body with NO background band and no ```
111
- // fences (codex/claude-code convention). Diff/patch languages (and bodies that
112
- // look like unified diffs) keep the diff highlighter; other languages use
111
+ // fences. Diff/patch languages (and bodies that look like unified diffs) keep
112
+ // the diff highlighter; other languages use
113
113
  // cli-highlight (highlight.js) themed from our syntax* palette; unknown
114
114
  // languages fall back to flat `mdCodeBlock` body color.
115
115
  const DIFF_LANGS = new Set(['diff', 'patch', 'udiff', 'git-diff', 'gitdiff']);
@@ -567,7 +567,7 @@ function collectHighlightedBodyLines(text, hljsLang, bandWidth) {
567
567
  const CODE_GUTTER = ' ';
568
568
 
569
569
  /**
570
- * Render a fenced `code` token (codex/claude-code convention): an optional
570
+ * Render a fenced `code` token: an optional
571
571
  * subtle language label row, then the wrapped, syntax/diff/flat-highlighted
572
572
  * body. Every row is left-indented by a 2-col gutter. NO background band, no
573
573
  * full-width padding, and no visible ``` fence lines.
@@ -653,7 +653,7 @@ export function formatToken(token, listBaseIndent = 0, orderedListNumber = null,
653
653
  const ex = extraColorizers();
654
654
  switch (token.type) {
655
655
  case 'blockquote': {
656
- // Block structure carries no color (claude-code policy): a dim bar plus
656
+ // Block structure carries no color: a dim bar plus
657
657
  // body-colored italic text. Color is reserved for inline codespan/link.
658
658
  const bar = chalk.dim(BLOCKQUOTE_BAR);
659
659
  const quotePrefix = `${BLOCKQUOTE_BAR} `;
@@ -673,12 +673,11 @@ export function formatToken(token, listBaseIndent = 0, orderedListNumber = null,
673
673
  return renderCodeBlock(token, width);
674
674
  case 'codespan':
675
675
  // inline code — accent color only (no background tint; a bg box behind
676
- // inline spans reads as awkward against body text, matches claude-code's
677
- // codespan = color-only treatment).
676
+ // inline spans reads as awkward against body text).
678
677
  return accent(decodeEntities(token.text));
679
678
  case 'em':
680
- // Italic only — no color tint (matches codex/claude-code; a colored em
681
- // clashes per-theme and reads loud against body prose).
679
+ // Italic only — no color tint (a colored em clashes per-theme and
680
+ // reads loud against body prose).
682
681
  return chalk.italic((token.tokens ?? []).map((t) => formatToken(t, 0, null, parent)).join(''));
683
682
  case 'strong':
684
683
  // Bold only — no color tint (stays body-colored, just heavier weight).
@@ -39,7 +39,7 @@ test('short code line is gutter-indented with no background band', () => {
39
39
  const width = 60;
40
40
  const token = lexFirst('```js\nshort\n```\n', 'code');
41
41
  const out = formatToken(token, 0, null, null, width);
42
- // No background band: codex/claude-code render code with no `48;2;` bg.
42
+ // No background band: fenced code renders with no `48;2;` bg SGR.
43
43
  assert.ok(!out.includes('48;2;'), 'no background SGR');
44
44
  const bodyLine = stripAnsi(out).split('\n').find((l) => l.includes('short'));
45
45
  assert.ok(bodyLine, 'body line present');
@@ -305,8 +305,8 @@ test('strong and em use distinct mdStrong / mdEmph colors', () => {
305
305
  const emTok = lexFirst('*ital*', 'paragraph');
306
306
  const strongOut = formatToken(strongTok);
307
307
  const emOut = formatToken(emTok);
308
- // Bold/italic carry weight/style only — NO color tint (codex/claude-code
309
- // convention; a colored strong/em clashes per-theme and reads too loud).
308
+ // Bold/italic carry weight/style only — NO color tint (a colored
309
+ // strong/em clashes per-theme and reads too loud).
310
310
  assert.ok(strongOut.includes('\x1b[1m'), 'strong is bold');
311
311
  assert.ok(emOut.includes('\x1b[3m'), 'em is italic');
312
312
  assert.ok(!strongOut.includes(rgbSgr(theme.mdStrong)), 'strong is not color-tinted');
@@ -67,6 +67,44 @@ export function imageReferenceIds(input) {
67
67
  return new Set([...String(input || '').matchAll(re)].map((m) => Number(m[1]) || 0).filter(Boolean));
68
68
  }
69
69
 
70
+ // --- Large pasted-text folding ------------------------------------------
71
+ // A big paste is replaced in the prompt by a compact token so the editor does
72
+ // not choke on thousands of raw characters; the original text is stored in an
73
+ // App-level map (mirroring pastedImages) and expanded back on submit.
74
+ const PASTE_TOKEN_MIN_LINES = 3;
75
+ const PASTE_TOKEN_MIN_CHARS = 200;
76
+
77
+ export function shouldFoldPastedText(text) {
78
+ const value = String(text ?? '');
79
+ if (!value) return false;
80
+ const lineCount = value.split('\n').length;
81
+ return lineCount >= PASTE_TOKEN_MIN_LINES || value.length > PASTE_TOKEN_MIN_CHARS;
82
+ }
83
+
84
+ export function formatPastedTextRef(id, text) {
85
+ const lines = String(text ?? '').split('\n').length;
86
+ return `[Pasted text #${id} +${lines} lines]`;
87
+ }
88
+
89
+ export function pastedTextReferenceIds(input) {
90
+ const re = /\[Pasted text #(\d+) \+\d+ lines\]/g;
91
+ return new Set([...String(input || '').matchAll(re)].map((m) => Number(m[1]) || 0).filter(Boolean));
92
+ }
93
+
94
+ // Expand every intact pasted-text token in `value` back to its original text.
95
+ // Broken / partially-deleted tokens simply do not match and are left as-is.
96
+ export function expandPastedTextTokens(value, pastedTexts = {}) {
97
+ let out = String(value ?? '');
98
+ if (!pastedTexts || typeof pastedTexts !== 'object') return out;
99
+ const re = /\[Pasted text #(\d+) \+\d+ lines\]/g;
100
+ out = out.replace(re, (match, idRaw) => {
101
+ const entry = pastedTexts[Number(idRaw)] || pastedTexts[idRaw];
102
+ if (entry && typeof entry.text === 'string') return entry.text;
103
+ return match;
104
+ });
105
+ return out;
106
+ }
107
+
70
108
  export function buildPromptContentWithImages(text, pastedImages = {}) {
71
109
  const value = String(text ?? '');
72
110
  const refs = imageReferenceIds(value);
@@ -0,0 +1,119 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import stringWidth from 'string-width';
4
+ import {
5
+ shouldFoldPastedText,
6
+ formatPastedTextRef,
7
+ pastedTextReferenceIds,
8
+ expandPastedTextTokens,
9
+ } from './paste-attachments.mjs';
10
+ import { caretPosition } from './input-editing.mjs';
11
+
12
+ // --- Part 1: pasted-text token folding ----------------------------------
13
+
14
+ test('shouldFoldPastedText fires on >=3 lines or >200 chars', () => {
15
+ assert.equal(shouldFoldPastedText('a\nb\nc'), true); // 3 lines
16
+ assert.equal(shouldFoldPastedText('a\nb'), false); // 2 lines, short
17
+ assert.equal(shouldFoldPastedText('x'.repeat(201)), true); // > 200 chars
18
+ assert.equal(shouldFoldPastedText('x'.repeat(200)), false); // == 200, not over
19
+ assert.equal(shouldFoldPastedText(''), false);
20
+ });
21
+
22
+ test('token round-trips through format + expand', () => {
23
+ const original = 'line1\nline2\nline3';
24
+ const token = formatPastedTextRef(7, original);
25
+ assert.equal(token, '[Pasted text #7 +3 lines]');
26
+ const refs = pastedTextReferenceIds(`before ${token} after`);
27
+ assert.deepEqual([...refs], [7]);
28
+ const map = { 7: { id: 7, text: original } };
29
+ assert.equal(
30
+ expandPastedTextTokens(`before ${token} after`, map),
31
+ `before ${original} after`,
32
+ );
33
+ });
34
+
35
+ test('broken / partially-deleted tokens do not expand', () => {
36
+ const map = { 3: { id: 3, text: 'HIDDEN' } };
37
+ // Missing the "+N lines" tail — not a valid token, left verbatim.
38
+ assert.equal(expandPastedTextTokens('[Pasted text #3]', map), '[Pasted text #3]');
39
+ // Truncated bracket.
40
+ assert.equal(expandPastedTextTokens('[Pasted text #3 +2 lines', map), '[Pasted text #3 +2 lines');
41
+ // Unknown id stays as-is.
42
+ assert.equal(expandPastedTextTokens('[Pasted text #9 +2 lines]', map), '[Pasted text #9 +2 lines]');
43
+ });
44
+
45
+ // --- Part 2: caret row math vs ink wrap-ansi (hard, wordWrap:false) -------
46
+
47
+ // Faithful re-implementation of wrap-ansi's wrapWord loop for a single
48
+ // space-free line (the prompt caret math walks graphemes the same way). ink
49
+ // renders <Text wrap="hard"> which calls wrapAnsi(text, w, {hard,wordWrap:false}).
50
+ // For a run with no spaces the whole line goes through wrapWord, so the visual
51
+ // row count is what this produces.
52
+ function inkWrapWordRows(line, columns) {
53
+ const chars = Array.from(new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(line), (s) => s.segment);
54
+ let rows = 1;
55
+ let visible = 0;
56
+ for (let i = 0; i < chars.length; i += 1) {
57
+ const cw = stringWidth(chars[i]);
58
+ if (visible + cw <= columns) {
59
+ // stays on current row
60
+ } else {
61
+ rows += 1;
62
+ visible = 0;
63
+ }
64
+ visible += cw;
65
+ if (visible === columns && i < chars.length - 1) {
66
+ rows += 1;
67
+ visible = 0;
68
+ }
69
+ }
70
+ return rows;
71
+ }
72
+
73
+ // caretPosition row for the END of the string == (visual row count - 1).
74
+ function caretRowsForWholeString(text, w) {
75
+ return caretPosition(text, text.length, w).row + 1;
76
+ }
77
+
78
+ test('caret rows agree with ink wrap for wide-char lines at odd widths', () => {
79
+ // A width where a width-2 glyph cannot sit flush on the last column exposes
80
+ // the mismatch: caretPosition must NOT advance an extra row when the char is
81
+ // simply pushed to the next line (ink does the same — it never leaves a
82
+ // half-column then over-counts).
83
+ const cases = [
84
+ { text: '가'.repeat(30), w: 41 },
85
+ { text: '가'.repeat(30), w: 40 },
86
+ { text: '가나다'.repeat(10) + 'ascii', w: 13 },
87
+ { text: '한글'.repeat(8), w: 7 },
88
+ { text: '가'.repeat(5), w: 2 },
89
+ ];
90
+ for (const { text, w } of cases) {
91
+ assert.equal(
92
+ caretRowsForWholeString(text, w),
93
+ inkWrapWordRows(text, w),
94
+ `width ${w} on ${JSON.stringify(text.slice(0, 6))}…`,
95
+ );
96
+ }
97
+ });
98
+
99
+ // Cursor exactly at a soft-wrap boundary MID-text: ink renders the following
100
+ // glyph on the next row, so the caret must report {row N+1, col 0}, never
101
+ // {row N, col == w} (which would sit outside the box).
102
+ test('caret at mid-text wrap boundary rolls to next row (col 0)', () => {
103
+ const value = '가나다라마'; // 5 wide glyphs, width 2 each
104
+ const w = 4; // 2 glyphs per row
105
+ // offset 2 = after '나' (each glyph is ONE UTF-16 unit) = end of visual
106
+ // row 0; content ('다라마') follows, so the caret rolls to the next row.
107
+ const at = caretPosition(value, 2, w);
108
+ assert.deepEqual(at, { row: 1, col: 0 }, 'boundary caret must sit on next row');
109
+ // offset 4 = after '라' = end of visual row 1 (rows 0-1 are full).
110
+ assert.deepEqual(caretPosition(value, 4, w), { row: 2, col: 0 }, 'second boundary rolls too');
111
+ // Cursor at the very end of a FLUSH-ending text ('가나다라' fills rows 0-1
112
+ // exactly) with a trailing cell (PromptInput appends one) also rolls.
113
+ const flush = '가나다라';
114
+ const end = caretPosition(flush, flush.length, w, true);
115
+ assert.deepEqual(end, { row: 2, col: 0 }, 'flush end with trailing cell rolls to next row');
116
+ // Non-flush end ('마' leaves col 2) must NOT roll even with a trailing cell.
117
+ const nonFlush = caretPosition(value, value.length, w, true);
118
+ assert.deepEqual(nonFlush, { row: 2, col: 2 }, 'non-flush end stays on its row');
119
+ });
@@ -35,8 +35,8 @@ export const basePalette = {
35
35
  // Picker/list focus row. This is intentionally stronger than text selection.
36
36
  selectionText: 'rgb(230,238,255)', // readable on selectionBackground
37
37
  selectionBackground: 'rgb(38,79,120)', // classic dark-mode selection blue
38
- // Mouse/drag text-selection overlay. Claude Code keeps this role separate:
39
- // it replaces the cell background while text remains copy-highlight readable.
38
+ // Mouse/drag text-selection overlay: a separate role from selectionBackground
39
+ // so it replaces the cell background while text remains copy-highlight readable.
40
40
  selectionHighlightText: 'rgb(255,255,255)',
41
41
  selectionHighlightBackground: 'rgb(38,79,120)',
42
42
  inactive: 'rgb(132,139,152)', // light_grey
@@ -13,10 +13,10 @@ import { basePalette } from './base.mjs';
13
13
  export const kanagawaPalette = {
14
14
  ...basePalette,
15
15
  background: 'transparent', // sumiInk3 #1F1F28
16
- claude: 'rgb(210,126,153)', // sakuraPink accent (opencode)
16
+ claude: 'rgb(210,126,153)', // sakuraPink accent
17
17
  logo: 'rgb(232,156,182)', // welcome banner — brighter than accent
18
18
  claudeShimmer: 'rgb(156,171,202)', // springViolet2 #9CABCA (lighter accent)
19
- mixdogOrange: 'rgb(210,126,153)', // sakuraPink accent (opencode)
19
+ mixdogOrange: 'rgb(210,126,153)', // sakuraPink accent
20
20
  mixdogAmber: 'rgb(156,171,202)', // lighter accent
21
21
  mixdogIvory: 'rgb(220,215,186)', // fujiWhite #DCD7BA
22
22
  spinnerGlyph: 'rgb(228,104,118)', // most-emphasized: waveRed
@@ -57,7 +57,7 @@ export const kanagawaPalette = {
57
57
  mdLink: 'rgb(126,156,216)', // crystalBlue
58
58
  mdLinkText: 'rgb(118,148,106)', // waveAqua #76946A
59
59
  mdStrong: 'rgb(215,166,87)', // roninYellow #D7A657
60
- mdEmph: 'rgb(195,141,157)', // carpYellow opencode #C38D9D
60
+ mdEmph: 'rgb(195,141,157)', // carpYellow #C38D9D
61
61
  mdDiffAdded: 'rgb(152,187,108)', // springGreen
62
62
  mdDiffRemoved: 'rgb(255,93,98)', // peachRed
63
63
  mdDiffHunk: 'rgb(45,79,103)', // waveBlue #2D4F67
@@ -71,7 +71,7 @@ export const kanagawaPalette = {
71
71
  syntaxVariable: 'rgb(220,215,186)', // fujiWhite
72
72
  syntaxString: 'rgb(152,187,108)', // springGreen
73
73
  syntaxNumber: 'rgb(215,166,87)', // roninYellow #D7A657
74
- syntaxType: 'rgb(195,141,157)', // carpYellow opencode #C38D9D
74
+ syntaxType: 'rgb(195,141,157)', // carpYellow #C38D9D
75
75
  syntaxOperator: 'rgb(210,126,153)', // sakuraPink
76
76
  syntaxPunctuation: 'rgb(220,215,186)', // fg
77
77
  userMessageBackground: 'rgb(42,42,55)', // sumiInk4 #2A2A37
@@ -1,16 +1,15 @@
1
1
  /**
2
- * src/tui/themes/teal.mjs — Teal (pi style).
2
+ * src/tui/themes/teal.mjs — Teal.
3
3
  *
4
- * Faithful port of pi's `dark.json` (refs/pi .../theme/dark.json). Values are
5
- * taken verbatim: accent teal #8abeb7, body text #d4d4d4, page bg #18181e,
4
+ * Values: accent teal #8abeb7, body text #d4d4d4, page bg #18181e,
6
5
  * green #b5bd68, red #cc6666, warning pure yellow #ffff00, blue #81a2be,
7
- * mdHeading #f0c674, gray #808080. Syntax uses pi's VS Code Dark+ palette
6
+ * mdHeading #f0c674, gray #808080. Syntax uses a VS Code Dark+-style palette
8
7
  * (keyword #569CD6, string #CE9178, function #DCDCAA, etc.). Spreads
9
8
  * `basePalette` first so the full key set is always present.
10
9
  */
11
10
  import { basePalette } from './base.mjs';
12
11
 
13
- /** Teal — pi-style teal accent with soft body text. */
12
+ /** Teal — teal accent with soft body text. */
14
13
  export const tealPalette = {
15
14
  ...basePalette,
16
15
  background: 'transparent', // export.pageBg #18181e
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * src/tui/themes/utils.mjs — palette post-processing helpers.
3
3
  *
4
- * Reference pattern (Claude Code / opencode / pi):
4
+ * Design goals:
5
5
  * - keep body text neutral and highly readable;
6
6
  * - keep strong live-state colors for spinners/status;
7
7
  * - use accent colors sparingly for headings, links and syntax, preferably