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.
- package/package.json +8 -1
- package/scripts/_bench-cwc.json +20 -0
- package/scripts/agent-loop-policy-test.mjs +37 -0
- package/scripts/agent-parallel-smoke.mjs +54 -10
- package/scripts/background-task-meta-smoke.mjs +1 -1
- package/scripts/bench-run.mjs +262 -0
- package/scripts/compact-smoke.mjs +12 -0
- package/scripts/compact-trigger-migration-smoke.mjs +67 -1
- package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
- package/scripts/internal-comms-bench.mjs +727 -0
- package/scripts/internal-comms-smoke.mjs +75 -0
- package/scripts/lead-workflow-smoke.mjs +4 -4
- package/scripts/live-worker-smoke.mjs +9 -9
- package/scripts/output-style-bench.mjs +285 -0
- package/scripts/output-style-smoke.mjs +13 -10
- package/scripts/patch-replay.mjs +90 -0
- package/scripts/provider-stream-stall-test.mjs +276 -0
- package/scripts/provider-toolcall-test.mjs +599 -1
- package/scripts/routing-corpus.mjs +281 -0
- package/scripts/session-bench.mjs +1526 -0
- package/scripts/session-diag.mjs +595 -0
- package/scripts/task-bench.mjs +207 -0
- package/scripts/tool-failures.mjs +6 -6
- package/scripts/tool-smoke.mjs +306 -66
- package/scripts/toolcall-args-test.mjs +81 -0
- package/src/agents/debugger/AGENT.md +4 -4
- package/src/agents/heavy-worker/AGENT.md +4 -2
- package/src/agents/reviewer/AGENT.md +4 -4
- package/src/agents/worker/AGENT.md +4 -2
- package/src/app.mjs +10 -6
- package/src/defaults/{hidden-roles.json → agents.json} +7 -7
- package/src/examples/schedules/SCHEDULE.example.md +32 -0
- package/src/examples/webhooks/WEBHOOK.example.md +40 -0
- package/src/headless-role.mjs +14 -14
- package/src/help.mjs +1 -0
- package/src/lib/rules-builder.cjs +32 -54
- package/src/mixdog-session-runtime.mjs +710 -318
- package/src/output-styles/default.md +12 -7
- package/src/output-styles/minimal.md +25 -0
- package/src/output-styles/oneline.md +21 -0
- package/src/output-styles/simple.md +10 -9
- package/src/repl.mjs +12 -2
- package/src/rules/agent/00-common.md +7 -5
- package/src/rules/agent/30-explorer.md +7 -8
- package/src/rules/lead/01-general.md +3 -1
- package/src/rules/lead/lead-tool.md +7 -0
- package/src/rules/shared/01-tool.md +17 -12
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
- package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
- package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
- package/src/runtime/agent/orchestrator/config.mjs +3 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
- package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
- package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
- package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
- package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +332 -57
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +59 -32
- package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +78 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +202 -98
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +183 -20
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
- package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
- package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -9
- package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
- package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
- package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
- package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
- package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
- package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
- package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
- package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
- package/src/runtime/channels/backends/discord.mjs +99 -9
- package/src/runtime/channels/backends/telegram.mjs +501 -0
- package/src/runtime/channels/index.mjs +441 -1224
- package/src/runtime/channels/lib/config.mjs +54 -2
- package/src/runtime/channels/lib/format.mjs +4 -2
- package/src/runtime/channels/lib/output-forwarder.mjs +80 -67
- package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
- package/src/runtime/channels/lib/scheduler.mjs +1 -1
- package/src/runtime/channels/lib/telegram-format.mjs +283 -0
- package/src/runtime/channels/lib/tool-format.mjs +1 -1
- package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
- package/src/runtime/channels/lib/webhook.mjs +59 -31
- package/src/runtime/channels/tool-defs.mjs +1 -1
- package/src/runtime/memory/index.mjs +184 -19
- package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
- package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
- package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
- package/src/runtime/memory/lib/memory.mjs +101 -4
- package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
- package/src/runtime/memory/lib/session-ingest.mjs +107 -0
- package/src/runtime/memory/lib/trace-store.mjs +69 -22
- package/src/runtime/memory/tool-defs.mjs +6 -3
- package/src/runtime/shared/channel-notification-routing.mjs +12 -0
- package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
- package/src/runtime/shared/config.mjs +9 -0
- package/src/runtime/shared/llm/http-agent.mjs +12 -5
- package/src/runtime/shared/schedules-store.mjs +21 -19
- package/src/runtime/shared/tool-surface.mjs +98 -13
- package/src/runtime/shared/transcript-writer.mjs +129 -0
- package/src/runtime/shared/update-checker.mjs +214 -0
- package/src/standalone/agent-tool.mjs +255 -109
- package/src/standalone/channel-admin.mjs +133 -40
- package/src/standalone/channel-worker.mjs +8 -291
- package/src/standalone/explore-tool.mjs +2 -2
- package/src/standalone/memory-runtime-proxy.mjs +3 -1
- package/src/standalone/provider-admin.mjs +11 -0
- package/src/standalone/usage-dashboard.mjs +1 -1
- package/src/tui/App.jsx +2096 -732
- package/src/tui/components/ConfirmBar.jsx +47 -0
- package/src/tui/components/ContextPanel.jsx +5 -3
- package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
- package/src/tui/components/Markdown.jsx +22 -98
- package/src/tui/components/Message.jsx +14 -35
- package/src/tui/components/Picker.jsx +87 -12
- package/src/tui/components/PromptInput.jsx +83 -7
- package/src/tui/components/QueuedCommands.jsx +1 -1
- package/src/tui/components/SlashCommandPalette.jsx +8 -5
- package/src/tui/components/Spinner.jsx +7 -7
- package/src/tui/components/StatusLine.jsx +40 -21
- package/src/tui/components/TextEntryPanel.jsx +51 -7
- package/src/tui/components/ToolExecution.jsx +170 -98
- package/src/tui/components/TurnDone.jsx +4 -4
- package/src/tui/components/UsagePanel.jsx +1 -1
- package/src/tui/components/tool-output-format.mjs +159 -21
- package/src/tui/components/tool-output-format.test.mjs +87 -0
- package/src/tui/display-width.mjs +69 -0
- package/src/tui/display-width.test.mjs +35 -0
- package/src/tui/dist/index.mjs +6965 -2391
- package/src/tui/engine.mjs +287 -126
- package/src/tui/index.jsx +117 -7
- package/src/tui/keyboard-protocol.mjs +42 -0
- package/src/tui/lib/voice-recorder.mjs +453 -0
- package/src/tui/markdown/format-token.mjs +129 -76
- package/src/tui/markdown/format-token.test.mjs +61 -19
- package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
- package/src/tui/markdown/render-ansi.test.mjs +1 -1
- package/src/tui/markdown/streaming-markdown.mjs +167 -0
- package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
- package/src/tui/markdown/table-layout.mjs +9 -9
- package/src/tui/paste-attachments.mjs +0 -11
- package/src/tui/prompt-history-store.mjs +129 -0
- package/src/tui/prompt-history-store.test.mjs +52 -0
- package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
- package/src/tui/theme.mjs +41 -657
- package/src/tui/themes/base.mjs +86 -0
- package/src/tui/themes/basic.mjs +85 -0
- package/src/tui/themes/catppuccin.mjs +72 -0
- package/src/tui/themes/dracula.mjs +70 -0
- package/src/tui/themes/everforest.mjs +71 -0
- package/src/tui/themes/gruvbox.mjs +71 -0
- package/src/tui/themes/index.mjs +71 -0
- package/src/tui/themes/indigo.mjs +78 -0
- package/src/tui/themes/kanagawa.mjs +80 -0
- package/src/tui/themes/light.mjs +81 -0
- package/src/tui/themes/nord.mjs +72 -0
- package/src/tui/themes/onedark.mjs +16 -0
- package/src/tui/themes/rosepine.mjs +70 -0
- package/src/tui/themes/teal.mjs +81 -0
- package/src/tui/themes/tokyonight.mjs +79 -0
- package/src/tui/themes/utils.mjs +106 -0
- package/src/tui/themes/warm.mjs +79 -0
- package/src/tui/transcript-tool-failures.mjs +13 -2
- package/src/ui/markdown.mjs +1 -1
- package/src/ui/model-display.mjs +2 -2
- package/src/ui/statusline.mjs +26 -27
- package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
- package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
- package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
- package/src/workflows/default/WORKFLOW.md +39 -12
- package/src/workflows/sequential/WORKFLOW.md +46 -0
- package/src/workflows/solo/WORKFLOW.md +7 -0
- package/vendor/ink/build/display-width.js +62 -0
- package/vendor/ink/build/ink.js +100 -12
- package/vendor/ink/build/measure-text.js +4 -1
- package/vendor/ink/build/output.js +115 -9
- package/vendor/ink/build/render-node-to-output.js +4 -1
- package/vendor/ink/build/render.js +4 -0
- package/src/output-styles/extreme-simple.md +0 -20
- package/src/rules/lead/04-workflow.md +0 -51
- package/src/workflows/default/workflow.json +0 -13
- 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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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 +=
|
|
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
|
|
495
|
+
if (rm) return displayWidth(rm[1] + rm[2] + rm[3]);
|
|
421
496
|
const gm = GREP_LINE_RE.exec(plainLine);
|
|
422
|
-
if (gm) return
|
|
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 (
|
|
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 =
|
|
564
|
+
const prefixW = displayWidth(prefix);
|
|
436
565
|
const bodyBudget = Math.max(1, maxWidth - prefixW);
|
|
437
566
|
const bodyPieces = wrapText(body, bodyBudget, { hard: true });
|
|
438
|
-
|
|
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
|
-
|
|
443
|
-
|
|
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
|
+
});
|