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.
- package/package.json +10 -3
- 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/session-ingest-smoke.mjs +2 -2
- 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/mixdog-debug.cjs +0 -22
- package/src/lib/plugin-paths.cjs +1 -7
- package/src/lib/rules-builder.cjs +34 -56
- package/src/mixdog-session-runtime.mjs +710 -319
- 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 -4
- 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 +359 -106
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +63 -51
- 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 +86 -30
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +254 -280
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +191 -50
- 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 +265 -1
- 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/bash-session.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -32
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -44
- 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 -1254
- package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
- package/src/runtime/channels/lib/config.mjs +54 -3
- package/src/runtime/channels/lib/drop-trace.mjs +1 -1
- package/src/runtime/channels/lib/executor.mjs +0 -3
- package/src/runtime/channels/lib/format.mjs +4 -2
- package/src/runtime/channels/lib/memory-client.mjs +0 -38
- package/src/runtime/channels/lib/output-forwarder.mjs +77 -71
- package/src/runtime/channels/lib/runtime-paths.mjs +29 -6
- package/src/runtime/channels/lib/scheduler.mjs +1 -1
- package/src/runtime/channels/lib/session-discovery.mjs +0 -4
- package/src/runtime/channels/lib/telegram-format.mjs +283 -0
- package/src/runtime/channels/lib/tool-format.mjs +1 -2
- package/src/runtime/channels/lib/transcript-discovery.mjs +20 -11
- package/src/runtime/channels/lib/webhook.mjs +59 -31
- package/src/runtime/channels/tool-defs.mjs +1 -1
- package/src/runtime/lib/keychain-cjs.cjs +0 -1
- package/src/runtime/memory/data/runtime-manifest.json +6 -7
- package/src/runtime/memory/index.mjs +187 -43
- 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/llm-worker-host.mjs +0 -4
- 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-ops-policy.mjs +0 -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/runtime-fetcher.mjs +43 -18
- package/src/runtime/memory/lib/session-ingest.mjs +116 -7
- package/src/runtime/memory/lib/trace-store.mjs +69 -22
- package/src/runtime/memory/tool-defs.mjs +6 -3
- package/src/runtime/search/index.mjs +2 -7
- package/src/runtime/search/lib/config.mjs +0 -4
- package/src/runtime/search/lib/state.mjs +1 -15
- package/src/runtime/search/lib/web-tools.mjs +0 -1
- 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/child-spawn-gate.mjs +0 -6
- 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/seeds.mjs +1 -11
- package/src/standalone/usage-dashboard.mjs +1 -1
- package/src/tui/App.jsx +2137 -750
- 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 +146 -9
- 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 +177 -100
- package/src/tui/components/TurnDone.jsx +4 -4
- package/src/tui/components/UsagePanel.jsx +1 -1
- package/src/tui/components/tool-output-format.mjs +312 -40
- package/src/tui/components/tool-output-format.test.mjs +180 -1
- package/src/tui/display-width.mjs +69 -0
- package/src/tui/display-width.test.mjs +35 -0
- package/src/tui/dist/index.mjs +7324 -2393
- 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 +354 -142
- package/src/tui/markdown/format-token.test.mjs +155 -17
- 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 -647
- 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-lib.mjs +0 -623
- 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 +154 -20
- 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/hooks/lib/permission-rules.cjs +0 -170
- package/src/hooks/lib/settings-loader.cjs +0 -112
- package/src/lib/hook-pipe-path.cjs +0 -10
- package/src/output-styles/extreme-simple.md +0 -20
- package/src/rules/lead/04-workflow.md +0 -51
- package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
- package/src/workflows/default/workflow.json +0 -13
- 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 =
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
240
|
-
function
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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 +=
|
|
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
|
|
495
|
+
if (rm) return displayWidth(rm[1] + rm[2] + rm[3]);
|
|
326
496
|
const gm = GREP_LINE_RE.exec(plainLine);
|
|
327
|
-
if (gm) return
|
|
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 (
|
|
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 =
|
|
564
|
+
const prefixW = displayWidth(prefix);
|
|
341
565
|
const bodyBudget = Math.max(1, maxWidth - prefixW);
|
|
342
566
|
const bodyPieces = wrapText(body, bodyBudget, { hard: true });
|
|
343
|
-
|
|
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
|
-
|
|
348
|
-
|
|
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
|
-
|
|
362
|
-
|
|
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 <=
|
|
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
|
+
});
|