mixdog 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/package.json +8 -1
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/background-task-meta-smoke.mjs +1 -1
  6. package/scripts/bench-run.mjs +262 -0
  7. package/scripts/compact-smoke.mjs +12 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  9. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  10. package/scripts/internal-comms-bench.mjs +727 -0
  11. package/scripts/internal-comms-smoke.mjs +75 -0
  12. package/scripts/lead-workflow-smoke.mjs +4 -4
  13. package/scripts/live-worker-smoke.mjs +9 -9
  14. package/scripts/output-style-bench.mjs +285 -0
  15. package/scripts/output-style-smoke.mjs +13 -10
  16. package/scripts/patch-replay.mjs +90 -0
  17. package/scripts/provider-stream-stall-test.mjs +276 -0
  18. package/scripts/provider-toolcall-test.mjs +599 -1
  19. package/scripts/routing-corpus.mjs +281 -0
  20. package/scripts/session-bench.mjs +1526 -0
  21. package/scripts/session-diag.mjs +595 -0
  22. package/scripts/task-bench.mjs +207 -0
  23. package/scripts/tool-failures.mjs +6 -6
  24. package/scripts/tool-smoke.mjs +306 -66
  25. package/scripts/toolcall-args-test.mjs +81 -0
  26. package/src/agents/debugger/AGENT.md +4 -4
  27. package/src/agents/heavy-worker/AGENT.md +4 -2
  28. package/src/agents/reviewer/AGENT.md +4 -4
  29. package/src/agents/worker/AGENT.md +4 -2
  30. package/src/app.mjs +10 -6
  31. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  32. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  33. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  34. package/src/headless-role.mjs +14 -14
  35. package/src/help.mjs +1 -0
  36. package/src/lib/rules-builder.cjs +32 -54
  37. package/src/mixdog-session-runtime.mjs +710 -318
  38. package/src/output-styles/default.md +12 -7
  39. package/src/output-styles/minimal.md +25 -0
  40. package/src/output-styles/oneline.md +21 -0
  41. package/src/output-styles/simple.md +10 -9
  42. package/src/repl.mjs +12 -2
  43. package/src/rules/agent/00-common.md +7 -5
  44. package/src/rules/agent/30-explorer.md +7 -8
  45. package/src/rules/lead/01-general.md +3 -1
  46. package/src/rules/lead/lead-tool.md +7 -0
  47. package/src/rules/shared/01-tool.md +17 -12
  48. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  49. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  50. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  51. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  52. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  53. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  54. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  55. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  56. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  57. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  58. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  59. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  60. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  61. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  62. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +332 -57
  63. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +59 -32
  64. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  65. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  66. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  67. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  68. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  69. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +78 -3
  70. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +202 -98
  71. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +183 -20
  72. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  73. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  74. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  75. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -9
  76. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  77. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  78. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  79. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  80. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  81. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  82. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  83. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  84. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
  85. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  86. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  87. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  88. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  89. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  90. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  91. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  92. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  93. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  94. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  95. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  96. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  97. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  98. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  99. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  100. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  101. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  102. package/src/runtime/channels/backends/discord.mjs +99 -9
  103. package/src/runtime/channels/backends/telegram.mjs +501 -0
  104. package/src/runtime/channels/index.mjs +441 -1224
  105. package/src/runtime/channels/lib/config.mjs +54 -2
  106. package/src/runtime/channels/lib/format.mjs +4 -2
  107. package/src/runtime/channels/lib/output-forwarder.mjs +80 -67
  108. package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
  109. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  110. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  111. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  112. package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
  113. package/src/runtime/channels/lib/webhook.mjs +59 -31
  114. package/src/runtime/channels/tool-defs.mjs +1 -1
  115. package/src/runtime/memory/index.mjs +184 -19
  116. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  117. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  118. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  119. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  120. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  121. package/src/runtime/memory/lib/memory.mjs +101 -4
  122. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  123. package/src/runtime/memory/lib/session-ingest.mjs +107 -0
  124. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  125. package/src/runtime/memory/tool-defs.mjs +6 -3
  126. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  127. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  128. package/src/runtime/shared/config.mjs +9 -0
  129. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  130. package/src/runtime/shared/schedules-store.mjs +21 -19
  131. package/src/runtime/shared/tool-surface.mjs +98 -13
  132. package/src/runtime/shared/transcript-writer.mjs +129 -0
  133. package/src/runtime/shared/update-checker.mjs +214 -0
  134. package/src/standalone/agent-tool.mjs +255 -109
  135. package/src/standalone/channel-admin.mjs +133 -40
  136. package/src/standalone/channel-worker.mjs +8 -291
  137. package/src/standalone/explore-tool.mjs +2 -2
  138. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  139. package/src/standalone/provider-admin.mjs +11 -0
  140. package/src/standalone/usage-dashboard.mjs +1 -1
  141. package/src/tui/App.jsx +2096 -732
  142. package/src/tui/components/ConfirmBar.jsx +47 -0
  143. package/src/tui/components/ContextPanel.jsx +5 -3
  144. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  145. package/src/tui/components/Markdown.jsx +22 -98
  146. package/src/tui/components/Message.jsx +14 -35
  147. package/src/tui/components/Picker.jsx +87 -12
  148. package/src/tui/components/PromptInput.jsx +83 -7
  149. package/src/tui/components/QueuedCommands.jsx +1 -1
  150. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  151. package/src/tui/components/Spinner.jsx +7 -7
  152. package/src/tui/components/StatusLine.jsx +40 -21
  153. package/src/tui/components/TextEntryPanel.jsx +51 -7
  154. package/src/tui/components/ToolExecution.jsx +170 -98
  155. package/src/tui/components/TurnDone.jsx +4 -4
  156. package/src/tui/components/UsagePanel.jsx +1 -1
  157. package/src/tui/components/tool-output-format.mjs +159 -21
  158. package/src/tui/components/tool-output-format.test.mjs +87 -0
  159. package/src/tui/display-width.mjs +69 -0
  160. package/src/tui/display-width.test.mjs +35 -0
  161. package/src/tui/dist/index.mjs +6965 -2391
  162. package/src/tui/engine.mjs +287 -126
  163. package/src/tui/index.jsx +117 -7
  164. package/src/tui/keyboard-protocol.mjs +42 -0
  165. package/src/tui/lib/voice-recorder.mjs +453 -0
  166. package/src/tui/markdown/format-token.mjs +129 -76
  167. package/src/tui/markdown/format-token.test.mjs +61 -19
  168. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  169. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  170. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  171. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  172. package/src/tui/markdown/table-layout.mjs +9 -9
  173. package/src/tui/paste-attachments.mjs +0 -11
  174. package/src/tui/prompt-history-store.mjs +129 -0
  175. package/src/tui/prompt-history-store.test.mjs +52 -0
  176. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  177. package/src/tui/theme.mjs +41 -657
  178. package/src/tui/themes/base.mjs +86 -0
  179. package/src/tui/themes/basic.mjs +85 -0
  180. package/src/tui/themes/catppuccin.mjs +72 -0
  181. package/src/tui/themes/dracula.mjs +70 -0
  182. package/src/tui/themes/everforest.mjs +71 -0
  183. package/src/tui/themes/gruvbox.mjs +71 -0
  184. package/src/tui/themes/index.mjs +71 -0
  185. package/src/tui/themes/indigo.mjs +78 -0
  186. package/src/tui/themes/kanagawa.mjs +80 -0
  187. package/src/tui/themes/light.mjs +81 -0
  188. package/src/tui/themes/nord.mjs +72 -0
  189. package/src/tui/themes/onedark.mjs +16 -0
  190. package/src/tui/themes/rosepine.mjs +70 -0
  191. package/src/tui/themes/teal.mjs +81 -0
  192. package/src/tui/themes/tokyonight.mjs +79 -0
  193. package/src/tui/themes/utils.mjs +106 -0
  194. package/src/tui/themes/warm.mjs +79 -0
  195. package/src/tui/transcript-tool-failures.mjs +13 -2
  196. package/src/ui/markdown.mjs +1 -1
  197. package/src/ui/model-display.mjs +2 -2
  198. package/src/ui/statusline.mjs +26 -27
  199. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  200. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  201. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  202. package/src/workflows/default/WORKFLOW.md +39 -12
  203. package/src/workflows/sequential/WORKFLOW.md +46 -0
  204. package/src/workflows/solo/WORKFLOW.md +7 -0
  205. package/vendor/ink/build/display-width.js +62 -0
  206. package/vendor/ink/build/ink.js +100 -12
  207. package/vendor/ink/build/measure-text.js +4 -1
  208. package/vendor/ink/build/output.js +115 -9
  209. package/vendor/ink/build/render-node-to-output.js +4 -1
  210. package/vendor/ink/build/render.js +4 -0
  211. package/src/output-styles/extreme-simple.md +0 -20
  212. package/src/rules/lead/04-workflow.md +0 -51
  213. package/src/workflows/default/workflow.json +0 -13
  214. package/src/workflows/solo/workflow.json +0 -7
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import stringWidth from 'string-width';
20
20
  import stripAnsi from 'strip-ansi';
21
+ import { displayWidth } from '../display-width.mjs';
21
22
  import {
22
23
  extraColorizers,
23
24
  highlightCodeLine,
@@ -114,6 +115,77 @@ const URL_RE = /https?:\/\/[^\s"'<>\x1b\\)\]]+/g;
114
115
  // eslint-disable-next-line no-control-regex
115
116
  const ANSI_ESCAPE_RE = /\x1b(?:\[[0-9;]*m|\][\s\S]*?(?:\x07|\x1b\\))/g;
116
117
 
118
+ // Visible-text tab stop for expanded tool output (cosmetic; what matters is that
119
+ // no raw \t survives the width math).
120
+ const TOOL_OUTPUT_TAB_SIZE = 2;
121
+ // Match ONE CSI-SGR or OSC sequence anchored at the string start (for the
122
+ // ANSI-aware control normalizer below). Kept separate from the global
123
+ // ANSI_ESCAPE_RE so neither one's lastIndex perturbs the other.
124
+ // eslint-disable-next-line no-control-regex
125
+ const ANSI_SEQ_AT_START_RE = /^\x1b(?:\[[0-9;]*m|\][\s\S]*?(?:\x07|\x1b\\))/;
126
+
127
+ /**
128
+ * ANSI-aware control normalization for expanded tool output.
129
+ *
130
+ * Why: string-width counts a raw \t (and other C0 controls) as ZERO cells, so
131
+ * `wrapExpandedResultLines` under-wraps a tab-bearing line; the row then renders
132
+ * past the truncate width and the terminal EXPANDS the surviving tab to a tab
133
+ * stop, bleeding the line THROUGH the bottom prompt box on scroll. We must
134
+ * expand tabs / strip stray controls in the VISIBLE text — but tool output can
135
+ * also carry legitimate color (SGR) and OSC-8 hyperlink escapes, so those
136
+ * sequences are copied through verbatim (zero visible columns) instead of being
137
+ * mangled. CRLF/lone CR → LF; other C0 + DEL (except LF) → a single space.
138
+ */
139
+ function normalizeToolOutputControls(text) {
140
+ const input = String(text ?? '');
141
+ if (input.length === 0) return input;
142
+ let out = '';
143
+ let col = 0;
144
+ let i = 0;
145
+ while (i < input.length) {
146
+ const ch = input[i];
147
+ if (ch === '\x1b') {
148
+ // Copy a recognized SGR/OSC escape verbatim (no visible width); only a
149
+ // lone/unknown ESC falls through to the control-strip branch below.
150
+ const m = ANSI_SEQ_AT_START_RE.exec(input.slice(i));
151
+ if (m) {
152
+ out += m[0];
153
+ i += m[0].length;
154
+ continue;
155
+ }
156
+ }
157
+ if (ch === '\n') { out += '\n'; col = 0; i += 1; continue; }
158
+ if (ch === '\r') {
159
+ // CR or CRLF → a single LF.
160
+ out += '\n'; col = 0; i += 1;
161
+ if (input[i] === '\n') i += 1;
162
+ continue;
163
+ }
164
+ if (ch === '\t') {
165
+ const advance = TOOL_OUTPUT_TAB_SIZE - (col % TOOL_OUTPUT_TAB_SIZE);
166
+ out += ' '.repeat(advance);
167
+ col += advance;
168
+ i += 1;
169
+ continue;
170
+ }
171
+ const cp = input.codePointAt(i);
172
+ const step = cp > 0xffff ? 2 : 1;
173
+ if (cp <= 0x1f || cp === 0x7f) {
174
+ // Remaining C0 control / DEL (and lone ESC): a space so it cannot move the
175
+ // terminal cursor after ink has already accounted for the row.
176
+ out += ' ';
177
+ col += 1;
178
+ i += step;
179
+ continue;
180
+ }
181
+ const chr = String.fromCodePoint(cp);
182
+ out += chr;
183
+ col += stringWidth(chr);
184
+ i += step;
185
+ }
186
+ return out;
187
+ }
188
+
117
189
 
118
190
  /** Infer a highlighter family from a read/grep path arg's extension. */
119
191
  export function inferLangFamily(pathArg) {
@@ -267,6 +339,13 @@ export function formatExpandedResult(text, { pathArg = '', isShell = false } = {
267
339
  }
268
340
 
269
341
  const carriesAnsi = hasAnsi(src);
342
+ // Expand tabs / strip stray controls so a tab-bearing line cannot bleed
343
+ // through the bottom prompt box on scroll (string-width measures \t as zero
344
+ // cells; the terminal expands it after ink's row accounting). ANSI-aware:
345
+ // SGR/OSC escapes are preserved verbatim, so this runs unconditionally —
346
+ // colored shell output and OSC-8 links keep their escapes while their VISIBLE
347
+ // text is normalized.
348
+ src = normalizeToolOutputControls(src);
270
349
  // JSON pretty only when the text is not already colored (don't reflow ANSI).
271
350
  if (!carriesAnsi) src = tryFormatJson(src);
272
351
 
@@ -395,21 +474,17 @@ function splitAnsiByPlainWidth(text, plainTarget) {
395
474
  while (i < src.length) {
396
475
  if (plain >= target) break;
397
476
  if (src[i] === '\x1b') {
398
- const rest = src.slice(i);
399
- const sgr = rest.match(/^\x1b\[[0-9;]*m/);
400
- if (sgr) {
401
- i += sgr[0].length;
402
- continue;
403
- }
404
- const osc = rest.match(/^\x1b\]8;;[^\x07]*\x07/);
405
- if (osc) {
406
- i += osc[0].length;
477
+ // Recognize SGR + OSC-8 (BEL- OR ST-terminated) via the shared matcher so
478
+ // the split never lands inside an escape sequence for either terminator.
479
+ const m = ANSI_SEQ_AT_START_RE.exec(src.slice(i));
480
+ if (m) {
481
+ i += m[0].length;
407
482
  continue;
408
483
  }
409
484
  }
410
485
  const cp = src.codePointAt(i);
411
486
  const ch = String.fromCodePoint(cp);
412
- plain += stringWidth(ch);
487
+ plain += displayWidth(ch);
413
488
  i += cp > 0xffff ? 2 : 1;
414
489
  }
415
490
  return [src.slice(0, i), src.slice(i)];
@@ -417,30 +492,88 @@ function splitAnsiByPlainWidth(text, plainTarget) {
417
492
 
418
493
  function leadingPrefixPlainWidth(plainLine) {
419
494
  const rm = READ_LINE_RE.exec(plainLine);
420
- if (rm) return stringWidth(rm[1] + rm[2] + rm[3]);
495
+ if (rm) return displayWidth(rm[1] + rm[2] + rm[3]);
421
496
  const gm = GREP_LINE_RE.exec(plainLine);
422
- if (gm) return stringWidth(gm[1] + gm[2] + gm[3]);
497
+ if (gm) return displayWidth(gm[1] + gm[2] + gm[3]);
423
498
  return 0;
424
499
  }
425
500
 
501
+ /**
502
+ * Hard-cut an ANSI string so its VISIBLE (displayWidth) width is <= maxWidth.
503
+ * ANSI/OSC-8 escapes are preserved verbatim (zero visible width) and never cut
504
+ * mid-sequence. This is the final safety clamp: `wrapText` measures with
505
+ * string-width, so an arrow-bearing row it accepts as fitting can still render
506
+ * 1+ cells wider under the wide policy — this guarantees no emitted row can
507
+ * exceed maxWidth in real terminal cells and trigger terminal autowrap.
508
+ *
509
+ * Single forward pass: accumulate visible code points until the next one would
510
+ * overflow `max`, then drop the remaining VISIBLE glyphs while still copying
511
+ * every trailing escape (SGR resets, OSC-8 closers) verbatim — so a clamped
512
+ * colored/hyperlinked row can never leak an unbalanced/open sequence into later
513
+ * TUI cells. Escapes are zero-width, so keeping all of them can never push the
514
+ * result past `max`; the pass always terminates (i advances every iteration).
515
+ */
516
+ function clampRowToDisplayWidth(row, maxWidth) {
517
+ const src = String(row ?? '');
518
+ const max = Math.max(0, Math.floor(Number(maxWidth) || 0));
519
+ if (max <= 0) return src;
520
+ if (displayWidth(src) <= max) return src;
521
+ let out = '';
522
+ let plain = 0;
523
+ let overflowed = false;
524
+ let i = 0;
525
+ while (i < src.length) {
526
+ if (src[i] === '\x1b') {
527
+ // Copy any recognized escape (SGR / OSC-8 BEL or ST) verbatim — including
528
+ // trailing resets/closers AFTER the visible cut point.
529
+ const m = ANSI_SEQ_AT_START_RE.exec(src.slice(i));
530
+ if (m) {
531
+ out += m[0];
532
+ i += m[0].length;
533
+ continue;
534
+ }
535
+ }
536
+ const cp = src.codePointAt(i);
537
+ const step = cp > 0xffff ? 2 : 1;
538
+ if (!overflowed) {
539
+ const ch = String.fromCodePoint(cp);
540
+ const w = displayWidth(ch);
541
+ if (plain + w > max) {
542
+ // This glyph would overflow: stop emitting visible text but keep
543
+ // scanning so trailing escapes are still preserved.
544
+ overflowed = true;
545
+ } else {
546
+ out += ch;
547
+ plain += w;
548
+ }
549
+ }
550
+ i += step;
551
+ }
552
+ return out;
553
+ }
554
+
426
555
  function wrapOneExpandedLogicalLine(line, maxWidth) {
427
556
  const src = String(line ?? '');
428
557
  if (!src) return [' '];
429
- if (stringWidth(src) <= maxWidth) return [src];
558
+ if (displayWidth(src) <= maxWidth) return [src];
430
559
 
431
560
  const prefixPlainW = leadingPrefixPlainWidth(stripAnsi(src));
432
561
  const [prefix, body] = prefixPlainW > 0
433
562
  ? splitAnsiByPlainWidth(src, prefixPlainW)
434
563
  : ['', src];
435
- const prefixW = stringWidth(prefix);
564
+ const prefixW = displayWidth(prefix);
436
565
  const bodyBudget = Math.max(1, maxWidth - prefixW);
437
566
  const bodyPieces = wrapText(body, bodyBudget, { hard: true });
438
- if (bodyPieces.length <= 1) return [src];
567
+ // wrapText measures with string-width, so even the single-piece fast path can
568
+ // exceed maxWidth under the wide policy — always run the display-width clamp.
569
+ if (bodyPieces.length <= 1) return [clampRowToDisplayWidth(src, maxWidth)];
439
570
 
440
571
  const out = [];
441
572
  for (let i = 0; i < bodyPieces.length; i++) {
442
- if (i === 0) out.push(`${prefix}${bodyPieces[i]}`);
443
- else out.push(`${padDisplaySpaces(prefixW)}${bodyPieces[i]}`);
573
+ const row = i === 0
574
+ ? `${prefix}${bodyPieces[i]}`
575
+ : `${padDisplaySpaces(prefixW)}${bodyPieces[i]}`;
576
+ out.push(clampRowToDisplayWidth(row, maxWidth));
444
577
  }
445
578
  return out;
446
579
  }
@@ -456,11 +589,16 @@ export function wrapExpandedResultLines(logicalLines, columns = 80, { isShell =
456
589
  const lines = Array.isArray(logicalLines) ? logicalLines : [];
457
590
  const out = [];
458
591
 
592
+ // Safety net: EVERY emitted row (including omitted/oversize markers that
593
+ // bypass wrapOneExpandedLogicalLine) must satisfy the display-width clamp so
594
+ // no physical row can exceed maxWidth and trigger terminal autowrap.
595
+ const clampAll = (rows) => rows.map((row) => clampRowToDisplayWidth(row, maxWidth));
596
+
459
597
  if (!capOn) {
460
598
  for (const line of lines) {
461
599
  for (const row of wrapOneExpandedLogicalLine(line, maxWidth)) out.push(row);
462
600
  }
463
- return out.length > 0 ? out : [' '];
601
+ return out.length > 0 ? clampAll(out) : [' '];
464
602
  }
465
603
 
466
604
  if (isShell) {
@@ -474,7 +612,7 @@ export function wrapExpandedResultLines(logicalLines, columns = 80, { isShell =
474
612
  }
475
613
  }
476
614
  }
477
- return finalizeShellPhysicalCap(out, omitted, maxRows);
615
+ return clampAll(finalizeShellPhysicalCap(out, omitted, maxRows));
478
616
  }
479
617
 
480
618
  // Non-shell surfaces keep the head of the expanded body (read/grep/json).
@@ -493,7 +631,7 @@ export function wrapExpandedResultLines(logicalLines, columns = 80, { isShell =
493
631
  const bodySlots = Math.max(0, maxRows - 1);
494
632
  if (out.length > bodySlots) out.length = bodySlots;
495
633
  if (maxRows > 0) out.push(omittedPhysicalRowsMarker(1, false));
496
- return out.length > 0 ? out.slice(0, maxRows) : [' '];
634
+ return out.length > 0 ? clampAll(out.slice(0, maxRows)) : [' '];
497
635
  }
498
- return out.length > 0 ? out : [' '];
636
+ return out.length > 0 ? clampAll(out) : [' '];
499
637
  }
@@ -10,6 +10,7 @@ import {
10
10
  tryFormatJson,
11
11
  } from './tool-output-format.mjs';
12
12
  import stringWidth from 'string-width';
13
+ import { displayWidthWith } from '../display-width.mjs';
13
14
 
14
15
  const stripAnsi = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '').replace(/\x1b\]8;;[^\x07]*\x07/g, '');
15
16
 
@@ -46,6 +47,41 @@ test('read line-number gutter is split into a dim column and body is highlighted
46
47
  assert.ok(out[0].includes('\x1b['), 'first line carries color escapes');
47
48
  });
48
49
 
50
+ test('non-ANSI tool output expands tabs so width math matches the terminal', () => {
51
+ // A raw \t is ZERO-width to string-width, so wrapExpandedResultLines would
52
+ // under-wrap a tab-bearing line and the terminal-expanded tab would bleed
53
+ // the row through the bottom prompt box on scroll. formatExpandedResult must
54
+ // normalize tabs to spaces for non-ANSI output.
55
+ const out = formatExpandedResult('\tindented line', { pathArg: 'a.txt' });
56
+ const plain = stripAnsi(out.join('\n'));
57
+ assert.ok(!plain.includes('\t'), 'no raw tab survives non-ANSI tool output');
58
+ const body = plain.split('\n').find((l) => l.includes('indented line'));
59
+ assert.ok(body, 'body line present');
60
+ assert.equal(stringWidth(body), body.length, 'visible width equals char length');
61
+ });
62
+
63
+ test('shell ANSI output keeps raw control bytes (not normalized)', () => {
64
+ // ANSI/shell output legitimately carries control bytes in escape sequences;
65
+ // normalization must be skipped so those survive verbatim.
66
+ const sh = '\x1b[31mred\x1b[0m';
67
+ const out = formatExpandedResult(sh, { isShell: true });
68
+ assert.ok(out.join('').includes('\x1b['), 'escape sequences preserved');
69
+ });
70
+
71
+ test('colored shell output still expands visible tabs (ANSI-aware normalize)', () => {
72
+ // A colored line with a raw \t in its VISIBLE text: the SGR escapes must be
73
+ // preserved verbatim while the tab is expanded, so the visible width matches
74
+ // the terminal and the row cannot bleed through the prompt box on scroll.
75
+ const sh = '\x1b[32m\tgreen tabbed\x1b[0m';
76
+ const out = formatExpandedResult(sh, { isShell: true });
77
+ const joined = out.join('\n');
78
+ assert.ok(joined.includes('\x1b['), 'color escapes preserved');
79
+ assert.ok(!joined.includes('\t'), 'visible tab expanded even in colored output');
80
+ const body = stripAnsi(joined).split('\n').find((l) => l.includes('green tabbed'));
81
+ assert.ok(body, 'body line present');
82
+ assert.equal(stringWidth(body), body.length, 'visible width equals char length');
83
+ });
84
+
49
85
  test('block syntax highlight skips tokenizer for over-long lines (index aligned)', () => {
50
86
  // MAX_HIGHLIGHT_LINE_CHARS is 2000 in tool-output-format.mjs
51
87
  const giant = 'x'.repeat(2001);
@@ -310,3 +346,54 @@ test('shell logical truncation marker precedes retained tail', () => {
310
346
  assert.ok(visible[visible.length - 1].includes('line4999'), 'newest logical line is last');
311
347
  assert.ok(!visible[visible.length - 1].includes('re-read a narrower range'));
312
348
  });
349
+
350
+ test('wide policy OFF: displayWidth is byte-for-byte identical to string-width for arrow lines', () => {
351
+ // Guards the "non-WT terminals unchanged" invariant: with the policy OFF
352
+ // (default in this test process — no WT_SESSION, no override), the arrow is
353
+ // still 1 cell, so wrap output must be identical to the string-width world.
354
+ const arrowLine = ' 12\u2192const x = 1; \u2190 done';
355
+ assert.equal(displayWidthWith(arrowLine, false), stringWidth(arrowLine),
356
+ 'policy OFF must equal plain string-width');
357
+ const logical = formatExpandedResult(arrowLine, { pathArg: 'a.mjs' });
358
+ const physical = wrapExpandedResultLines(logical, 40);
359
+ const maxW = expandedResultBodyWidth(40);
360
+ for (const row of physical) {
361
+ assert.ok(stringWidth(stripAnsi(row)) <= maxW,
362
+ `row width ${stringWidth(stripAnsi(row))} exceeds ${maxW} (policy OFF)`);
363
+ }
364
+ });
365
+
366
+ test('wide policy ON (forced): every emitted arrow row fits the display-width budget', async () => {
367
+ // The policy is resolved ONCE at module load, so forcing it requires a fresh
368
+ // process. Spawn a child with MIXDOG_TUI_AMBIGUOUS_WIDE=1 and assert that
369
+ // every wrapped read row (containing U+2192) measures <= expandedResultBodyWidth
370
+ // under the SAME wide policy the terminal will use. Without the clamp,
371
+ // string-width accepts arrow rows as fitting while the terminal renders them
372
+ // one cell wider, bleeding into the prompt box.
373
+ const { execFileSync } = await import('node:child_process');
374
+ const { fileURLToPath } = await import('node:url');
375
+ const modUrl = new URL('./tool-output-format.mjs', import.meta.url).href;
376
+ const dwUrl = new URL('../display-width.mjs', import.meta.url).href;
377
+ const script = `
378
+ import { formatExpandedResult, wrapExpandedResultLines, expandedResultBodyWidth } from ${JSON.stringify(modUrl)};
379
+ import { displayWidth, AMBIGUOUS_WIDE } from ${JSON.stringify(dwUrl)};
380
+ const strip = (s) => String(s).replace(/\\x1b\\[[0-9;]*m/g, '').replace(/\\x1b\\]8;;[^\\x07]*\\x07/g, '');
381
+ if (AMBIGUOUS_WIDE !== true) { console.log('POLICY_OFF'); process.exit(2); }
382
+ const columns = 30;
383
+ // Many arrows so a naive string-width fit accepts an over-wide row.
384
+ const body = 'x'.repeat(24) + ' ' + '\\u2192'.repeat(12) + ' tail';
385
+ const logical = formatExpandedResult('42\\u2192' + body, { pathArg: 'a.mjs' });
386
+ const physical = wrapExpandedResultLines(logical, columns);
387
+ const maxW = expandedResultBodyWidth(columns);
388
+ let bad = 0;
389
+ for (const row of physical) {
390
+ if (displayWidth(strip(row)) > maxW) bad++;
391
+ }
392
+ console.log(bad === 0 ? 'OK ' + physical.length : 'BAD ' + bad);
393
+ `;
394
+ const out = execFileSync(process.execPath, ['--input-type=module', '-e', script], {
395
+ env: { ...process.env, MIXDOG_TUI_AMBIGUOUS_WIDE: '1', WT_SESSION: '' },
396
+ encoding: 'utf8',
397
+ }).trim();
398
+ assert.ok(out.startsWith('OK'), `expected all rows within budget, got: ${out}`);
399
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * display-width.mjs — shared visible display-width helper for the assistant
3
+ * render path.
4
+ *
5
+ * Why this exists: string-width@8 defaults East-Asian-Width "Ambiguous" glyphs
6
+ * to NARROW (1 cell). A specific, common problem set renders 2 cells wide in
7
+ * Windows Terminal — circled digits / enclosed alphanumerics (U+2460–U+24FF)
8
+ * and arrows (U+2190–U+21FF). When our width math counts them as 1 but the
9
+ * terminal draws 2, following text overlaps.
10
+ *
11
+ * Policy (narrow + gated + consistent):
12
+ * - Widen ONLY the two problem ranges below. NEVER widen box-drawing
13
+ * (U+2500–U+257F └ │ ⎿ ─), block elements, or the figures.mjs glyphs —
14
+ * those are also EAW-ambiguous but must stay 1 cell.
15
+ * - Enabled by default ONLY under Windows Terminal (WT_SESSION set).
16
+ * - MIXDOG_TUI_AMBIGUOUS_WIDE overrides the default: '1' forces on, '0'
17
+ * forces off; the override always wins.
18
+ * - When OFF, behaviour is byte-for-byte identical to plain string-width.
19
+ *
20
+ * IMPORTANT: vendor/ink/build/display-width.js replicates this exact policy so
21
+ * ink's MEASUREMENT agrees with OUR wrap/row math. If you change the ranges or
22
+ * the gate here, change them THERE too (see the sync note in that file).
23
+ */
24
+ import stringWidth from 'string-width';
25
+
26
+ /**
27
+ * True for code points we treat as wide (2 cells) when the policy is ON:
28
+ * - U+2460–U+24FF Enclosed Alphanumerics (① ② ③ …)
29
+ * - U+2190–U+21FF Arrows (→ ← ↑ ↓ …)
30
+ * Deliberately excludes box-drawing / blocks / figures.
31
+ */
32
+ export function isProblemCodePoint(cp) {
33
+ return (cp >= 0x2460 && cp <= 0x24ff) || (cp >= 0x2190 && cp <= 0x21ff);
34
+ }
35
+
36
+ /** Resolve the wide policy from env. Override wins over the WT_SESSION default. */
37
+ export function resolveAmbiguousWidePolicy(env = process.env) {
38
+ const override = env?.MIXDOG_TUI_AMBIGUOUS_WIDE;
39
+ if (override === '1') return true;
40
+ if (override === '0') return false;
41
+ return Boolean(env?.WT_SESSION);
42
+ }
43
+
44
+ /** Resolved once at module load (matches the "computed once" requirement). */
45
+ export const AMBIGUOUS_WIDE = resolveAmbiguousWidePolicy();
46
+
47
+ /**
48
+ * Pure width with an explicit policy flag (used by tests and by `displayWidth`).
49
+ * base = string-width (ANSI-aware). When `wide` is on, add +1 for each problem
50
+ * code point that string-width counted as a single cell, turning it into 2.
51
+ * ANSI escape bytes are all ASCII and never fall in the problem ranges, so the
52
+ * raw code-point scan adds nothing for them.
53
+ */
54
+ export function displayWidthWith(str, wide) {
55
+ const s = String(str ?? '');
56
+ const base = stringWidth(s);
57
+ if (!wide) return base;
58
+ let extra = 0;
59
+ for (const ch of s) {
60
+ const cp = ch.codePointAt(0);
61
+ if (isProblemCodePoint(cp) && stringWidth(ch) === 1) extra += 1;
62
+ }
63
+ return base + extra;
64
+ }
65
+
66
+ /** Visible display width under the resolved policy. */
67
+ export function displayWidth(str) {
68
+ return displayWidthWith(str, AMBIGUOUS_WIDE);
69
+ }
@@ -0,0 +1,35 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import stringWidth from 'string-width';
4
+ import { displayWidthWith, isProblemCodePoint } from './display-width.mjs';
5
+
6
+ // Policy ON: the two problem ranges become 2 cells; everything else unchanged.
7
+ test('policy ON widens circled digits and arrows only', () => {
8
+ assert.equal(displayWidthWith('\u2460', true), 2); // ① circled digit
9
+ assert.equal(displayWidthWith('\u2192', true), 2); // → rightwards arrow
10
+ assert.equal(displayWidthWith('a', true), 1); // ASCII unchanged
11
+ // Box-drawing / figures must NOT be widened by the policy.
12
+ assert.equal(displayWidthWith('\u2514', true), stringWidth('\u2514')); // └
13
+ assert.equal(displayWidthWith('\u2502', true), stringWidth('\u2502')); // │
14
+ assert.equal(displayWidthWith('\u2500', true), stringWidth('\u2500')); // ─
15
+ assert.equal(displayWidthWith('\u25cf', true), stringWidth('\u25cf')); // ●
16
+ // Mixed string: base + one +1 per problem code point.
17
+ assert.equal(displayWidthWith('a\u2460b\u2192', true), stringWidth('a\u2460b\u2192') + 2);
18
+ });
19
+
20
+ // Policy OFF: byte-for-byte identical to plain string-width.
21
+ test('policy OFF equals string-width', () => {
22
+ for (const s of ['\u2460', '\u2192', 'a', '\u2514', 'hello \u2192 world', '']) {
23
+ assert.equal(displayWidthWith(s, false), stringWidth(s));
24
+ }
25
+ });
26
+
27
+ test('problem range membership', () => {
28
+ assert.equal(isProblemCodePoint(0x2460), true);
29
+ assert.equal(isProblemCodePoint(0x24ff), true);
30
+ assert.equal(isProblemCodePoint(0x2190), true);
31
+ assert.equal(isProblemCodePoint(0x21ff), true);
32
+ assert.equal(isProblemCodePoint(0x2500), false); // box-drawing
33
+ assert.equal(isProblemCodePoint(0x245f), false); // just below range
34
+ assert.equal(isProblemCodePoint(0x2200), false); // just above arrows
35
+ });