mixdog 0.9.0 → 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 (240) hide show
  1. package/package.json +10 -3
  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/session-ingest-smoke.mjs +2 -2
  23. package/scripts/task-bench.mjs +207 -0
  24. package/scripts/tool-failures.mjs +6 -6
  25. package/scripts/tool-smoke.mjs +306 -66
  26. package/scripts/toolcall-args-test.mjs +81 -0
  27. package/src/agents/debugger/AGENT.md +4 -4
  28. package/src/agents/heavy-worker/AGENT.md +4 -2
  29. package/src/agents/reviewer/AGENT.md +4 -4
  30. package/src/agents/worker/AGENT.md +4 -2
  31. package/src/app.mjs +10 -6
  32. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  33. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  34. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  35. package/src/headless-role.mjs +14 -14
  36. package/src/help.mjs +1 -0
  37. package/src/lib/mixdog-debug.cjs +0 -22
  38. package/src/lib/plugin-paths.cjs +1 -7
  39. package/src/lib/rules-builder.cjs +34 -56
  40. package/src/mixdog-session-runtime.mjs +710 -319
  41. package/src/output-styles/default.md +12 -7
  42. package/src/output-styles/minimal.md +25 -0
  43. package/src/output-styles/oneline.md +21 -0
  44. package/src/output-styles/simple.md +10 -9
  45. package/src/repl.mjs +12 -4
  46. package/src/rules/agent/00-common.md +7 -5
  47. package/src/rules/agent/30-explorer.md +7 -8
  48. package/src/rules/lead/01-general.md +3 -1
  49. package/src/rules/lead/lead-tool.md +7 -0
  50. package/src/rules/shared/01-tool.md +17 -12
  51. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  52. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  53. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  54. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  55. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  56. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  57. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  58. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  59. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  60. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  61. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  62. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  63. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  64. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  65. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +359 -106
  66. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +63 -51
  67. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  68. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  70. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  71. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  72. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +86 -30
  73. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +254 -280
  74. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +191 -50
  75. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  76. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  77. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  78. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +265 -1
  79. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  80. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  81. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  82. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  83. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  84. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  85. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  86. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  87. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +1 -1
  88. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -32
  89. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  90. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -44
  91. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  92. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  93. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  94. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  95. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  100. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  101. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  102. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  103. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  104. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  105. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  106. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  107. package/src/runtime/channels/backends/discord.mjs +99 -9
  108. package/src/runtime/channels/backends/telegram.mjs +501 -0
  109. package/src/runtime/channels/index.mjs +441 -1254
  110. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  111. package/src/runtime/channels/lib/config.mjs +54 -3
  112. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  113. package/src/runtime/channels/lib/executor.mjs +0 -3
  114. package/src/runtime/channels/lib/format.mjs +4 -2
  115. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  116. package/src/runtime/channels/lib/output-forwarder.mjs +77 -71
  117. package/src/runtime/channels/lib/runtime-paths.mjs +29 -6
  118. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  119. package/src/runtime/channels/lib/session-discovery.mjs +0 -4
  120. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  121. package/src/runtime/channels/lib/tool-format.mjs +1 -2
  122. package/src/runtime/channels/lib/transcript-discovery.mjs +20 -11
  123. package/src/runtime/channels/lib/webhook.mjs +59 -31
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  126. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  127. package/src/runtime/memory/index.mjs +187 -43
  128. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  129. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  130. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  131. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  132. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  133. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  134. package/src/runtime/memory/lib/memory-ops-policy.mjs +0 -1
  135. package/src/runtime/memory/lib/memory.mjs +101 -4
  136. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  137. package/src/runtime/memory/lib/runtime-fetcher.mjs +43 -18
  138. package/src/runtime/memory/lib/session-ingest.mjs +116 -7
  139. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  140. package/src/runtime/memory/tool-defs.mjs +6 -3
  141. package/src/runtime/search/index.mjs +2 -7
  142. package/src/runtime/search/lib/config.mjs +0 -4
  143. package/src/runtime/search/lib/state.mjs +1 -15
  144. package/src/runtime/search/lib/web-tools.mjs +0 -1
  145. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  146. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  147. package/src/runtime/shared/child-spawn-gate.mjs +0 -6
  148. package/src/runtime/shared/config.mjs +9 -0
  149. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  150. package/src/runtime/shared/schedules-store.mjs +21 -19
  151. package/src/runtime/shared/tool-surface.mjs +98 -13
  152. package/src/runtime/shared/transcript-writer.mjs +129 -0
  153. package/src/runtime/shared/update-checker.mjs +214 -0
  154. package/src/standalone/agent-tool.mjs +255 -109
  155. package/src/standalone/channel-admin.mjs +133 -40
  156. package/src/standalone/channel-worker.mjs +8 -291
  157. package/src/standalone/explore-tool.mjs +2 -2
  158. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  159. package/src/standalone/provider-admin.mjs +11 -0
  160. package/src/standalone/seeds.mjs +1 -11
  161. package/src/standalone/usage-dashboard.mjs +1 -1
  162. package/src/tui/App.jsx +2137 -750
  163. package/src/tui/components/ConfirmBar.jsx +47 -0
  164. package/src/tui/components/ContextPanel.jsx +5 -3
  165. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  166. package/src/tui/components/Markdown.jsx +22 -98
  167. package/src/tui/components/Message.jsx +14 -35
  168. package/src/tui/components/Picker.jsx +87 -12
  169. package/src/tui/components/PromptInput.jsx +146 -9
  170. package/src/tui/components/QueuedCommands.jsx +1 -1
  171. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  172. package/src/tui/components/Spinner.jsx +7 -7
  173. package/src/tui/components/StatusLine.jsx +40 -21
  174. package/src/tui/components/TextEntryPanel.jsx +51 -7
  175. package/src/tui/components/ToolExecution.jsx +177 -100
  176. package/src/tui/components/TurnDone.jsx +4 -4
  177. package/src/tui/components/UsagePanel.jsx +1 -1
  178. package/src/tui/components/tool-output-format.mjs +312 -40
  179. package/src/tui/components/tool-output-format.test.mjs +180 -1
  180. package/src/tui/display-width.mjs +69 -0
  181. package/src/tui/display-width.test.mjs +35 -0
  182. package/src/tui/dist/index.mjs +7324 -2393
  183. package/src/tui/engine.mjs +287 -126
  184. package/src/tui/index.jsx +117 -7
  185. package/src/tui/keyboard-protocol.mjs +42 -0
  186. package/src/tui/lib/voice-recorder.mjs +453 -0
  187. package/src/tui/markdown/format-token.mjs +354 -142
  188. package/src/tui/markdown/format-token.test.mjs +155 -17
  189. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  190. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  191. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  192. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  193. package/src/tui/markdown/table-layout.mjs +9 -9
  194. package/src/tui/paste-attachments.mjs +0 -11
  195. package/src/tui/prompt-history-store.mjs +129 -0
  196. package/src/tui/prompt-history-store.test.mjs +52 -0
  197. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  198. package/src/tui/theme.mjs +41 -647
  199. package/src/tui/themes/base.mjs +86 -0
  200. package/src/tui/themes/basic.mjs +85 -0
  201. package/src/tui/themes/catppuccin.mjs +72 -0
  202. package/src/tui/themes/dracula.mjs +70 -0
  203. package/src/tui/themes/everforest.mjs +71 -0
  204. package/src/tui/themes/gruvbox.mjs +71 -0
  205. package/src/tui/themes/index.mjs +71 -0
  206. package/src/tui/themes/indigo.mjs +78 -0
  207. package/src/tui/themes/kanagawa.mjs +80 -0
  208. package/src/tui/themes/light.mjs +81 -0
  209. package/src/tui/themes/nord.mjs +72 -0
  210. package/src/tui/themes/onedark.mjs +16 -0
  211. package/src/tui/themes/rosepine.mjs +70 -0
  212. package/src/tui/themes/teal.mjs +81 -0
  213. package/src/tui/themes/tokyonight.mjs +79 -0
  214. package/src/tui/themes/utils.mjs +106 -0
  215. package/src/tui/themes/warm.mjs +79 -0
  216. package/src/tui/transcript-tool-failures.mjs +13 -2
  217. package/src/ui/markdown.mjs +1 -1
  218. package/src/ui/model-display.mjs +2 -2
  219. package/src/ui/statusline.mjs +26 -27
  220. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -623
  221. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  222. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  223. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  224. package/src/workflows/default/WORKFLOW.md +39 -12
  225. package/src/workflows/sequential/WORKFLOW.md +46 -0
  226. package/src/workflows/solo/WORKFLOW.md +7 -0
  227. package/vendor/ink/build/display-width.js +62 -0
  228. package/vendor/ink/build/ink.js +154 -20
  229. package/vendor/ink/build/measure-text.js +4 -1
  230. package/vendor/ink/build/output.js +115 -9
  231. package/vendor/ink/build/render-node-to-output.js +4 -1
  232. package/vendor/ink/build/render.js +4 -0
  233. package/src/hooks/lib/permission-rules.cjs +0 -170
  234. package/src/hooks/lib/settings-loader.cjs +0 -112
  235. package/src/lib/hook-pipe-path.cjs +0 -10
  236. package/src/output-styles/extreme-simple.md +0 -20
  237. package/src/rules/lead/04-workflow.md +0 -51
  238. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  239. package/src/workflows/default/workflow.json +0 -13
  240. package/src/workflows/solo/workflow.json +0 -7
@@ -18,9 +18,11 @@
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,
25
+ highlightCodeBlockToLines,
24
26
  colorizeDiffLine,
25
27
  looksLikeUnifiedDiff,
26
28
  LANG_FAMILY,
@@ -35,13 +37,72 @@ const DEFAULT_MARKDOWN_WIDTH = 80;
35
37
  // Hard ceilings so a pathological tool result can never lock the render loop.
36
38
  // CC uses ~MAX_LINES*width*4 for the collapsed fold; for the EXPANDED body we
37
39
  // cap total characters processed and total lines kept, with an explicit marker.
38
- const MAX_EXPANDED_CHARS = 256 * 1024; // 256 KB of text gets per-line processing
39
- const MAX_EXPANDED_LINES = 80; // keep at most this many rendered lines
40
+ export const MAX_EXPANDED_CHARS = 256 * 1024; // 256 KB of text gets per-line processing
41
+ const MAX_EXPANDED_LINES = 4000; // logical-line safety ceiling; physical mount cap is separate
40
42
  const MAX_JSON_FORMAT_LENGTH = 10_000; // mirror CC's tryJsonFormatContent cap
41
43
  const MAX_HIGHLIGHT_LINE_CHARS = 2000; // skip token-scan on absurdly long lines
42
44
  // Lockstep with ToolExecution collapsed fit budget (MIN_RESULT_LINE_CHARS).
43
45
  const MIN_EXPANDED_BODY_COLS = 24;
44
46
 
47
+ // Hard cap on the number of PHYSICAL (wrapped) rows a single expanded tool /
48
+ // script body may mount. MAX_EXPANDED_LINES bounds LOGICAL lines, but a single
49
+ // very long line with no newlines (minified JSON, a 256 KB single-line blob)
50
+ // wraps into thousands of physical rows — and EACH physical row is a mounted
51
+ // Ink <Text> node that ink re-serializes every frame even while clipped off-
52
+ // screen (clipping only trims write coords, not the serialize pass). So a lone
53
+ // huge expanded item could mount thousands of nodes and stall typing/drag even
54
+ // though it is the only visible transcript item. This cap bounds the mounted
55
+ // node count regardless of line shape; the omitted tail gets a clear marker.
56
+ // It is set generously above what any viewport needs so normal multi-line
57
+ // output (already capped at MAX_EXPANDED_LINES logical lines) is byte-for-byte
58
+ // unchanged — only pathological wide/unwrapped blobs are trimmed. Env-tunable
59
+ // via MIXDOG_TUI_TOOL_OUTPUT_MAX_RENDER_LINES (0 disables); legacy
60
+ // MIXDOG_TUI_EXPANDED_MAX_ROWS is still honored when the primary var is unset.
61
+ export function resolveToolOutputMaxRenderLines() {
62
+ const raw = process.env.MIXDOG_TUI_TOOL_OUTPUT_MAX_RENDER_LINES;
63
+ if (raw !== undefined && String(raw).trim() !== '') {
64
+ const v = Number(raw);
65
+ if (Number.isFinite(v) && v <= 0) return 0;
66
+ if (Number.isFinite(v) && v > 0) return Math.floor(v);
67
+ }
68
+ const legacy = process.env.MIXDOG_TUI_EXPANDED_MAX_ROWS;
69
+ if (legacy !== undefined && String(legacy).trim() !== '') {
70
+ const v = Number(legacy);
71
+ if (Number.isFinite(v) && v > 0) return Math.floor(v);
72
+ }
73
+ return 600;
74
+ }
75
+
76
+ function omittedPhysicalRowsMarker(omitted, isShell) {
77
+ if (isShell) {
78
+ const n = Math.max(1, Math.floor(Number(omitted) || 0));
79
+ return `\u2026 [${n} line${n === 1 ? '' : 's'} omitted above \u2014 showing newest output below]`;
80
+ }
81
+ return '\u2026 [output truncated for display \u2014 collapse (ctrl+o) or re-read a narrower range]';
82
+ }
83
+
84
+ function shellLogicalOmittedMarker({ omittedLines = 0, omittedChars = false }, c) {
85
+ if (omittedLines > 0) {
86
+ return c.synComment(omittedPhysicalRowsMarker(omittedLines, true));
87
+ }
88
+ if (omittedChars) {
89
+ return c.synComment('\u2026 [earlier output omitted above \u2014 showing newest output below]');
90
+ }
91
+ return null;
92
+ }
93
+
94
+ function finalizeShellPhysicalCap(buffer, omitted, maxRows) {
95
+ const bodySlots = Math.max(0, maxRows - 1);
96
+ if (omitted <= 0) {
97
+ return buffer.length > 0 ? buffer : [' '];
98
+ }
99
+ const tail = bodySlots > 0 ? buffer.slice(-bodySlots) : [];
100
+ const extraHidden = Math.max(0, buffer.length - bodySlots);
101
+ const totalOmitted = omitted + extraHidden;
102
+ const out = [omittedPhysicalRowsMarker(totalOmitted, true), ...tail];
103
+ return out.length > 0 ? out.slice(0, maxRows) : [' '];
104
+ }
105
+
45
106
  // `<n>→<content>` (read) OR `<n>:<content>` / `<path>:<n>:<content>` (grep).
46
107
  const READ_LINE_RE = /^(\s*)(\d+)(\u2192)(.*)$/;
47
108
  // Gutter ends with `:<line>:`. Windows absolute paths use `X:\...` so the drive
@@ -54,6 +115,77 @@ const URL_RE = /https?:\/\/[^\s"'<>\x1b\\)\]]+/g;
54
115
  // eslint-disable-next-line no-control-regex
55
116
  const ANSI_ESCAPE_RE = /\x1b(?:\[[0-9;]*m|\][\s\S]*?(?:\x07|\x1b\\))/g;
56
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
+
57
189
 
58
190
  /** Infer a highlighter family from a read/grep path arg's extension. */
59
191
  export function inferLangFamily(pathArg) {
@@ -198,20 +330,31 @@ export function formatExpandedResult(text, { pathArg = '', isShell = false } = {
198
330
 
199
331
  // Oversized guard: slice before any O(n) per-line work, append a marker.
200
332
  let truncatedChars = false;
333
+ let shellOmittedLines = 0;
334
+ let shellOmittedChars = false;
201
335
  if (src.length > MAX_EXPANDED_CHARS) {
202
- src = src.slice(0, MAX_EXPANDED_CHARS);
203
- truncatedChars = true;
336
+ src = isShell ? src.slice(-MAX_EXPANDED_CHARS) : src.slice(0, MAX_EXPANDED_CHARS);
337
+ if (isShell) shellOmittedChars = true;
338
+ else truncatedChars = true;
204
339
  }
205
340
 
206
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);
207
349
  // JSON pretty only when the text is not already colored (don't reflow ANSI).
208
350
  if (!carriesAnsi) src = tryFormatJson(src);
209
351
 
210
352
  let lines = src.split('\n');
211
353
  let truncatedLines = false;
212
354
  if (lines.length > MAX_EXPANDED_LINES) {
213
- lines = lines.slice(0, MAX_EXPANDED_LINES);
214
- truncatedLines = true;
355
+ if (isShell) shellOmittedLines = lines.length - MAX_EXPANDED_LINES;
356
+ lines = isShell ? lines.slice(-MAX_EXPANDED_LINES) : lines.slice(0, MAX_EXPANDED_LINES);
357
+ if (!isShell) truncatedLines = true;
215
358
  }
216
359
 
217
360
  const c = extraColorizers();
@@ -223,29 +366,31 @@ export function formatExpandedResult(text, { pathArg = '', isShell = false } = {
223
366
  if (shouldRenderExpandedMarkdown({ src, lines, carriesAnsi, isShell, diffMode, family })) {
224
367
  out = formatExpandedMarkdownLines(src);
225
368
  if (out.length > MAX_EXPANDED_LINES) {
226
- out = out.slice(0, MAX_EXPANDED_LINES);
227
- truncatedLines = true;
369
+ if (isShell) shellOmittedLines += out.length - MAX_EXPANDED_LINES;
370
+ out = isShell ? out.slice(-MAX_EXPANDED_LINES) : out.slice(0, MAX_EXPANDED_LINES);
371
+ if (!isShell) truncatedLines = true;
228
372
  }
229
373
  } else {
230
- out = lines.map((line) => formatLine(line, { c, family, diffMode, carriesAnsi, isShell }));
374
+ if (family && family !== 'md' && !diffMode && !carriesAnsi && !isShell) {
375
+ out = formatSyntaxBlock(lines, { c, family });
376
+ } else {
377
+ out = lines.map((line) => formatLine(line, { c, family, diffMode, carriesAnsi, isShell }));
378
+ }
231
379
  }
232
380
 
381
+ if (isShell) {
382
+ const marker = shellLogicalOmittedMarker({ omittedLines: shellOmittedLines, omittedChars: shellOmittedChars }, c);
383
+ if (marker) return [marker, ...out];
384
+ return out;
385
+ }
233
386
  if (truncatedChars || truncatedLines) {
234
387
  out.push(c.synComment('… [output truncated for display — re-read a narrower range]'));
235
388
  }
236
389
  return out;
237
390
  }
238
391
 
239
- /** Format ONE line: split line-number gutter, then highlight/linkify the body. */
240
- function formatLine(line, { c, family, diffMode, carriesAnsi, isShell }) {
241
- // Shell / already-colored output: keep ANSI verbatim, only fix underline leaks
242
- // and linkify URLs. No gutter split (shell has no <n>→ prefix).
243
- if (carriesAnsi || isShell) {
244
- return linkifyUrls(stripUnderlineAnsi(line));
245
- }
246
-
247
- // Split a read (`<n>→`) or grep (`<file>:<n>:` / `<n>:`) gutter into a dim
248
- // column so the body can be highlighted independently.
392
+ /** Split read/grep line-number gutter from body (shared by per-line and block highlight). */
393
+ function splitGutter(line) {
249
394
  let indent = '';
250
395
  let gutter = '';
251
396
  let body = line;
@@ -262,6 +407,35 @@ function formatLine(line, { c, family, diffMode, carriesAnsi, isShell }) {
262
407
  body = gm[4];
263
408
  }
264
409
  }
410
+ return { indent, gutter, body };
411
+ }
412
+
413
+ /** Block syntax highlight for non-diff code bodies (multi-line tokens color correctly). */
414
+ function formatSyntaxBlock(lines, { c, family }) {
415
+ const parts = lines.map(splitGutter);
416
+ const bodies = parts.map((p) =>
417
+ p.body.length > MAX_HIGHLIGHT_LINE_CHARS ? '' : p.body,
418
+ );
419
+ const highlighted = highlightCodeBlockToLines(bodies.join('\n'), family, c);
420
+ return parts.map((p, i) => {
421
+ const rawBody = p.body;
422
+ const bodyOut = rawBody.length > MAX_HIGHLIGHT_LINE_CHARS
423
+ ? c.body(rawBody)
424
+ : (highlighted[i] ?? '');
425
+ const linked = linkifyUrls(bodyOut);
426
+ return p.gutter ? `${p.indent}${c.synComment(p.gutter)}${linked}` : linked;
427
+ });
428
+ }
429
+
430
+ /** Format ONE line: split line-number gutter, then highlight/linkify the body. */
431
+ function formatLine(line, { c, family, diffMode, carriesAnsi, isShell }) {
432
+ // Shell / already-colored output: keep ANSI verbatim, only fix underline leaks
433
+ // and linkify URLs. No gutter split (shell has no <n>→ prefix).
434
+ if (carriesAnsi || isShell) {
435
+ return linkifyUrls(stripUnderlineAnsi(line));
436
+ }
437
+
438
+ const { indent, gutter, body } = splitGutter(line);
265
439
 
266
440
  const coloredBody = highlightBody(body, { c, family, diffMode });
267
441
  const linked = linkifyUrls(coloredBody);
@@ -300,21 +474,17 @@ function splitAnsiByPlainWidth(text, plainTarget) {
300
474
  while (i < src.length) {
301
475
  if (plain >= target) break;
302
476
  if (src[i] === '\x1b') {
303
- const rest = src.slice(i);
304
- const sgr = rest.match(/^\x1b\[[0-9;]*m/);
305
- if (sgr) {
306
- i += sgr[0].length;
307
- continue;
308
- }
309
- const osc = rest.match(/^\x1b\]8;;[^\x07]*\x07/);
310
- if (osc) {
311
- 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;
312
482
  continue;
313
483
  }
314
484
  }
315
485
  const cp = src.codePointAt(i);
316
486
  const ch = String.fromCodePoint(cp);
317
- plain += stringWidth(ch);
487
+ plain += displayWidth(ch);
318
488
  i += cp > 0xffff ? 2 : 1;
319
489
  }
320
490
  return [src.slice(0, i), src.slice(i)];
@@ -322,30 +492,88 @@ function splitAnsiByPlainWidth(text, plainTarget) {
322
492
 
323
493
  function leadingPrefixPlainWidth(plainLine) {
324
494
  const rm = READ_LINE_RE.exec(plainLine);
325
- if (rm) return stringWidth(rm[1] + rm[2] + rm[3]);
495
+ if (rm) return displayWidth(rm[1] + rm[2] + rm[3]);
326
496
  const gm = GREP_LINE_RE.exec(plainLine);
327
- if (gm) return stringWidth(gm[1] + gm[2] + gm[3]);
497
+ if (gm) return displayWidth(gm[1] + gm[2] + gm[3]);
328
498
  return 0;
329
499
  }
330
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
+
331
555
  function wrapOneExpandedLogicalLine(line, maxWidth) {
332
556
  const src = String(line ?? '');
333
557
  if (!src) return [' '];
334
- if (stringWidth(src) <= maxWidth) return [src];
558
+ if (displayWidth(src) <= maxWidth) return [src];
335
559
 
336
560
  const prefixPlainW = leadingPrefixPlainWidth(stripAnsi(src));
337
561
  const [prefix, body] = prefixPlainW > 0
338
562
  ? splitAnsiByPlainWidth(src, prefixPlainW)
339
563
  : ['', src];
340
- const prefixW = stringWidth(prefix);
564
+ const prefixW = displayWidth(prefix);
341
565
  const bodyBudget = Math.max(1, maxWidth - prefixW);
342
566
  const bodyPieces = wrapText(body, bodyBudget, { hard: true });
343
- 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)];
344
570
 
345
571
  const out = [];
346
572
  for (let i = 0; i < bodyPieces.length; i++) {
347
- if (i === 0) out.push(`${prefix}${bodyPieces[i]}`);
348
- 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));
349
577
  }
350
578
  return out;
351
579
  }
@@ -354,12 +582,56 @@ function wrapOneExpandedLogicalLine(line, maxWidth) {
354
582
  * Turn logical expanded lines into physical rows that fit the body column.
355
583
  * One output row per left-rail row in ToolExecution (lockstep with App row est.).
356
584
  */
357
- export function wrapExpandedResultLines(logicalLines, columns = 80) {
585
+ export function wrapExpandedResultLines(logicalLines, columns = 80, { isShell = false } = {}) {
586
+ const maxRows = resolveToolOutputMaxRenderLines();
587
+ const capOn = maxRows > 0;
358
588
  const maxWidth = expandedResultBodyWidth(columns);
359
589
  const lines = Array.isArray(logicalLines) ? logicalLines : [];
360
590
  const out = [];
361
- for (const line of lines) {
362
- out.push(...wrapOneExpandedLogicalLine(line, maxWidth));
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
+
597
+ if (!capOn) {
598
+ for (const line of lines) {
599
+ for (const row of wrapOneExpandedLogicalLine(line, maxWidth)) out.push(row);
600
+ }
601
+ return out.length > 0 ? clampAll(out) : [' '];
602
+ }
603
+
604
+ if (isShell) {
605
+ let omitted = 0;
606
+ for (const line of lines) {
607
+ for (const row of wrapOneExpandedLogicalLine(line, maxWidth)) {
608
+ out.push(row);
609
+ if (out.length > maxRows) {
610
+ out.shift();
611
+ omitted += 1;
612
+ }
613
+ }
614
+ }
615
+ return clampAll(finalizeShellPhysicalCap(out, omitted, maxRows));
616
+ }
617
+
618
+ // Non-shell surfaces keep the head of the expanded body (read/grep/json).
619
+ let truncated = false;
620
+ outer: for (const line of lines) {
621
+ for (const row of wrapOneExpandedLogicalLine(line, maxWidth)) {
622
+ if (out.length < maxRows) {
623
+ out.push(row);
624
+ } else {
625
+ truncated = true;
626
+ break outer;
627
+ }
628
+ }
629
+ }
630
+ if (truncated) {
631
+ const bodySlots = Math.max(0, maxRows - 1);
632
+ if (out.length > bodySlots) out.length = bodySlots;
633
+ if (maxRows > 0) out.push(omittedPhysicalRowsMarker(1, false));
634
+ return out.length > 0 ? clampAll(out.slice(0, maxRows)) : [' '];
363
635
  }
364
- return out.length > 0 ? out : [' '];
636
+ return out.length > 0 ? clampAll(out) : [' '];
365
637
  }
@@ -10,9 +10,25 @@ 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
 
17
+ function withRenderLineCap(cap, fn) {
18
+ const prev = process.env.MIXDOG_TUI_TOOL_OUTPUT_MAX_RENDER_LINES;
19
+ const legacy = process.env.MIXDOG_TUI_EXPANDED_MAX_ROWS;
20
+ process.env.MIXDOG_TUI_TOOL_OUTPUT_MAX_RENDER_LINES = String(cap);
21
+ delete process.env.MIXDOG_TUI_EXPANDED_MAX_ROWS;
22
+ try {
23
+ return fn();
24
+ } finally {
25
+ if (prev === undefined) delete process.env.MIXDOG_TUI_TOOL_OUTPUT_MAX_RENDER_LINES;
26
+ else process.env.MIXDOG_TUI_TOOL_OUTPUT_MAX_RENDER_LINES = prev;
27
+ if (legacy === undefined) delete process.env.MIXDOG_TUI_EXPANDED_MAX_ROWS;
28
+ else process.env.MIXDOG_TUI_EXPANDED_MAX_ROWS = legacy;
29
+ }
30
+ }
31
+
16
32
  test('inferLangFamily maps known extensions, null otherwise', () => {
17
33
  assert.equal(inferLangFamily('src/x.mjs'), 'js');
18
34
  assert.equal(inferLangFamily('a.py'), 'py');
@@ -31,6 +47,57 @@ test('read line-number gutter is split into a dim column and body is highlighted
31
47
  assert.ok(out[0].includes('\x1b['), 'first line carries color escapes');
32
48
  });
33
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
+
85
+ test('block syntax highlight skips tokenizer for over-long lines (index aligned)', () => {
86
+ // MAX_HIGHLIGHT_LINE_CHARS is 2000 in tool-output-format.mjs
87
+ const giant = 'x'.repeat(2001);
88
+ const read = `1\u2192const a = 1;\n2\u2192${giant}\n3\u2192const b = 2;`;
89
+ const out = formatExpandedResult(read, { pathArg: 'a.mjs' });
90
+ assert.equal(out.length, 3);
91
+ assert.ok(out[0].includes('\x1b['), 'short line before giant is highlighted');
92
+ assert.ok(out[2].includes('\x1b['), 'short line after giant is highlighted');
93
+ const plainMid = stripAnsi(out[1]);
94
+ assert.ok(plainMid.startsWith('2\u2192'), 'gutter preserved on giant line');
95
+ assert.ok(plainMid.endsWith(giant), 'giant body preserved');
96
+ const hljsSpans = (out[1].match(/\x1b\[[0-9;]*m/g) || []).length;
97
+ const shortSpans = (out[0].match(/\x1b\[[0-9;]*m/g) || []).length;
98
+ assert.ok(hljsSpans <= shortSpans + 2, 'giant line not fully tokenized like normal code');
99
+ });
100
+
34
101
  test('grep file:line: gutter is split too', () => {
35
102
  const grep = 'src/a.mjs:5581: value: foo,';
36
103
  const out = formatExpandedResult(grep, { pathArg: 'src/a.mjs' });
@@ -105,9 +172,13 @@ test('unified diff is colored by line class', () => {
105
172
  test('oversize output is capped with a truncation marker', () => {
106
173
  const many = Array.from({ length: 5000 }, (_, i) => `line${i}`).join('\n');
107
174
  const out = formatExpandedResult(many, {});
108
- assert.ok(out.length <= 81);
175
+ assert.ok(out.length <= 4001, 'logical cap keeps at most MAX_EXPANDED_LINES plus marker');
109
176
  assert.ok(/truncated/.test(stripAnsi(out[out.length - 1])));
110
177
 
178
+ const sixHundred = Array.from({ length: 600 }, (_, i) => `row${i}`).join('\n');
179
+ const out600 = formatExpandedResult(sixHundred, {});
180
+ assert.equal(out600.length, 600, 'normal expanded output is not truncated at 80 logical lines');
181
+
111
182
  const huge = 'x'.repeat(300 * 1024);
112
183
  const out2 = formatExpandedResult(huge, {});
113
184
  assert.ok(/truncated/.test(stripAnsi(out2[out2.length - 1])));
@@ -218,3 +289,111 @@ test('read gutter lines skip markdown mode and keep syntax highlight', () => {
218
289
  assert.ok(visible.some((l) => l.includes('const y = 2;')), 'read body preserved');
219
290
  assert.ok(out.some((l) => stripAnsi(l).includes('const y = 2;') && l.includes('\x1b[')), 'code line still highlighted');
220
291
  });
292
+
293
+ test('shell physical cap keeps newest rows across logical lines', () => {
294
+ withRenderLineCap(5, () => {
295
+ const long = 'W'.repeat(240);
296
+ const logical = formatExpandedResult(`${long}\nNEWEST_TAIL_LINE`, { isShell: true });
297
+ const physical = wrapExpandedResultLines(logical, 32, { isShell: true });
298
+ const visible = physical.map(stripAnsi);
299
+ assert.ok(visible.some((l) => l.includes('NEWEST_TAIL_LINE')), 'newest logical line survives rolling cap');
300
+ assert.ok(physical.length <= 5);
301
+ assert.ok(visible.some((l) => /omitted above/i.test(l)));
302
+ });
303
+ });
304
+
305
+ test('exact physical cap row count is not truncated', () => {
306
+ withRenderLineCap(10, () => {
307
+ const logical = Array.from({ length: 10 }, (_, i) => `row-${i}`).map((l) => formatExpandedResult(l, { isShell: true })[0]);
308
+ const physical = wrapExpandedResultLines(logical, 80, { isShell: true });
309
+ assert.equal(physical.length, 10);
310
+ assert.ok(!physical.some((l) => /omitted above/i.test(stripAnsi(l))));
311
+ });
312
+ });
313
+
314
+ test('physical cap of 1 never returns more than one row', () => {
315
+ withRenderLineCap(1, () => {
316
+ const physical = wrapExpandedResultLines(formatExpandedResult('a\nb\nc', { isShell: true }), 80, { isShell: true });
317
+ assert.ok(physical.length <= 1);
318
+ const physicalNonShell = wrapExpandedResultLines(formatExpandedResult('a\nb\nc', {}), 80, { isShell: false });
319
+ assert.ok(physicalNonShell.length <= 1);
320
+ });
321
+ });
322
+
323
+ test('shell physical cap omitted count includes marker slot', () => {
324
+ withRenderLineCap(5, () => {
325
+ const logical = ['r0', 'r1', 'r2', 'r3', 'r4', 'r5'].map((l) => formatExpandedResult(l, { isShell: true })[0]);
326
+ const physical = wrapExpandedResultLines(logical, 80, { isShell: true });
327
+ const visible = physical.map(stripAnsi);
328
+ assert.equal(physical.length, 5);
329
+ assert.match(visible[0], /2 lines omitted above/);
330
+ assert.deepEqual(visible.slice(1), ['r2', 'r3', 'r4', 'r5']);
331
+ });
332
+ withRenderLineCap(1, () => {
333
+ const logical = Array.from({ length: 6 }, (_, i) => `row${i}`).map((l) => formatExpandedResult(l, { isShell: true })[0]);
334
+ const physical = wrapExpandedResultLines(logical, 80, { isShell: true });
335
+ assert.equal(physical.length, 1);
336
+ assert.match(stripAnsi(physical[0]), /6 lines omitted above/);
337
+ });
338
+ });
339
+
340
+ test('shell logical truncation marker precedes retained tail', () => {
341
+ const many = Array.from({ length: 5000 }, (_, i) => `line${i}`).join('\n');
342
+ const out = formatExpandedResult(many, { isShell: true });
343
+ const visible = out.map(stripAnsi);
344
+ assert.ok(visible.length <= 4001);
345
+ assert.ok(/omitted above/i.test(visible[0]), 'shell marker is first row');
346
+ assert.ok(visible[visible.length - 1].includes('line4999'), 'newest logical line is last');
347
+ assert.ok(!visible[visible.length - 1].includes('re-read a narrower range'));
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
+ });