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
|
@@ -3,10 +3,15 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { marked } from 'marked';
|
|
4
4
|
import stripAnsi from 'strip-ansi';
|
|
5
5
|
import stringWidth from 'string-width';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
formatToken,
|
|
8
|
+
highlightCodeBlockToLines,
|
|
9
|
+
extraColorizers,
|
|
10
|
+
_highlightCacheSizeForTests,
|
|
11
|
+
} from './format-token.mjs';
|
|
7
12
|
import { setThemeSetting, theme } from '../theme.mjs';
|
|
8
13
|
|
|
9
|
-
setThemeSetting('
|
|
14
|
+
setThemeSetting('basic', { persist: false });
|
|
10
15
|
|
|
11
16
|
function rgbSgr(value) {
|
|
12
17
|
const m = /^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/.exec(String(value || ''));
|
|
@@ -18,22 +23,42 @@ function lexFirst(md, type) {
|
|
|
18
23
|
return tokens.find((t) => t.type === type) ?? tokens[0];
|
|
19
24
|
}
|
|
20
25
|
|
|
21
|
-
test('fenced code block
|
|
26
|
+
test('fenced code block has no ``` fences and shows a language label row', () => {
|
|
22
27
|
const token = lexFirst('```js\nconst x = 1;\n```\n', 'code');
|
|
23
|
-
const out = formatToken(token);
|
|
28
|
+
const out = formatToken(token, 0, null, null, 60);
|
|
24
29
|
const plain = stripAnsi(out);
|
|
25
|
-
assert.ok(plain.includes('js'), 'language label visible');
|
|
26
30
|
assert.ok(!plain.includes('```'), 'no literal fence markers');
|
|
27
|
-
assert.ok(plain.includes('
|
|
28
|
-
|
|
31
|
+
assert.ok(plain.includes('const x = 1;'), 'body present');
|
|
32
|
+
// The first non-empty row is the subtle language label; its trimmed text is
|
|
33
|
+
// exactly the bare lang token (gutter trims away).
|
|
34
|
+
const rows = plain.split('\n').filter((l) => l.trim().length > 0);
|
|
35
|
+
assert.equal(rows[0].trim(), 'js', 'first row is the language label');
|
|
29
36
|
});
|
|
30
37
|
|
|
31
|
-
test('short code line
|
|
38
|
+
test('short code line is gutter-indented with no background band', () => {
|
|
39
|
+
const width = 60;
|
|
32
40
|
const token = lexFirst('```js\nshort\n```\n', 'code');
|
|
33
|
-
const out = formatToken(token, 0, null, null,
|
|
41
|
+
const out = formatToken(token, 0, null, null, width);
|
|
42
|
+
// No background band: codex/claude-code render code with no `48;2;` bg.
|
|
43
|
+
assert.ok(!out.includes('48;2;'), 'no background SGR');
|
|
34
44
|
const bodyLine = stripAnsi(out).split('\n').find((l) => l.includes('short'));
|
|
35
45
|
assert.ok(bodyLine, 'body line present');
|
|
36
|
-
assert.
|
|
46
|
+
assert.ok(bodyLine.startsWith(' short'), 'body is gutter-indented');
|
|
47
|
+
// Content width only — NOT padded out to the full render width.
|
|
48
|
+
assert.equal(stringWidth(bodyLine), ' short'.length, 'body row follows content width');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('code block body lines have no background band and follow content width', () => {
|
|
52
|
+
const width = 80;
|
|
53
|
+
const token = lexFirst('```js\na\n\nlonger line\n```\n', 'code');
|
|
54
|
+
const out = formatToken(token, 0, null, null, width);
|
|
55
|
+
assert.ok(!out.includes('48;2;'), 'no background SGR anywhere');
|
|
56
|
+
const plainLines = stripAnsi(out).split('\n').filter((l) => l.length > 0);
|
|
57
|
+
const widths = plainLines.map((l) => stringWidth(l));
|
|
58
|
+
assert.ok(widths.length >= 2, 'body lines rendered');
|
|
59
|
+
// Widths follow content (gutter + visible text), not forced equal to render
|
|
60
|
+
// width: the longest row stays well under the 80-col render width.
|
|
61
|
+
assert.ok(Math.max(...widths) < width, 'rows are not padded to the render width');
|
|
37
62
|
});
|
|
38
63
|
|
|
39
64
|
test('long code line wraps within requested body width', () => {
|
|
@@ -41,7 +66,7 @@ test('long code line wraps within requested body width', () => {
|
|
|
41
66
|
const token = lexFirst(`\`\`\`js\n${long}\n\`\`\`\n`, 'code');
|
|
42
67
|
const width = 20;
|
|
43
68
|
const out = formatToken(token, 0, null, null, width);
|
|
44
|
-
const bodyLines = stripAnsi(out).split('\n').filter((l) => l.
|
|
69
|
+
const bodyLines = stripAnsi(out).split('\n').filter((l) => l.includes('a'));
|
|
45
70
|
assert.ok(bodyLines.length > 1, 'long line is wrapped into multiple rows');
|
|
46
71
|
for (const line of bodyLines) {
|
|
47
72
|
assert.ok(line.length <= width, `wrapped line "${line}" fits width ${width}`);
|
|
@@ -49,6 +74,33 @@ test('long code line wraps within requested body width', () => {
|
|
|
49
74
|
assert.equal(bodyLines.join('').replace(/\s/g, ''), long, 'wrapped segments preserve content');
|
|
50
75
|
});
|
|
51
76
|
|
|
77
|
+
test('fenced code expands tabs so width math matches the terminal', () => {
|
|
78
|
+
// A raw \t counts as ZERO cells in string-width ("Tabs are ignored by
|
|
79
|
+
// design"), so without normalization the wrap/row math under-counts a
|
|
80
|
+
// tab-bearing line and the terminal-expanded line bleeds past the viewport
|
|
81
|
+
// clip. After normalization every rendered row must contain no raw \t and
|
|
82
|
+
// its visible width must equal its character length (tabs → spaces).
|
|
83
|
+
const token = lexFirst('```\n\tindented\n```\n', 'code');
|
|
84
|
+
const out = formatToken(token, 0, null, null, 60);
|
|
85
|
+
const plain = stripAnsi(out);
|
|
86
|
+
assert.ok(!plain.includes('\t'), 'no raw tab survives into the rendered string');
|
|
87
|
+
const bodyLine = plain.split('\n').find((l) => l.includes('indented'));
|
|
88
|
+
assert.ok(bodyLine, 'body line present');
|
|
89
|
+
assert.equal(stringWidth(bodyLine), bodyLine.length, 'visible width equals char length (no zero-width tab)');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('fenced code strips stray C0 control chars except newline', () => {
|
|
93
|
+
// A bare \x07 (BELL) etc. measures as zero width but can move the terminal
|
|
94
|
+
// cursor; replace with a space so ink's row accounting holds.
|
|
95
|
+
const token = lexFirst('```\na\x07b\n```\n', 'code');
|
|
96
|
+
const out = formatToken(token, 0, null, null, 60);
|
|
97
|
+
const plain = stripAnsi(out);
|
|
98
|
+
assert.ok(!/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(plain), 'no raw C0/DEL control survives');
|
|
99
|
+
const bodyLine = plain.split('\n').find((l) => l.includes('a') && l.includes('b'));
|
|
100
|
+
assert.ok(bodyLine, 'body line present');
|
|
101
|
+
assert.equal(stringWidth(bodyLine), bodyLine.length, 'control char became a real space cell');
|
|
102
|
+
});
|
|
103
|
+
|
|
52
104
|
test('list item fenced code block respects narrowed width and has no fences', () => {
|
|
53
105
|
const long = 'b'.repeat(36);
|
|
54
106
|
const codeTok = lexFirst(`\`\`\`js\n${long}\n\`\`\`\n`, 'code');
|
|
@@ -83,6 +135,52 @@ test('blockquote fenced code block respects narrowed width and has no fences', (
|
|
|
83
135
|
}
|
|
84
136
|
});
|
|
85
137
|
|
|
138
|
+
test('blockquote fenced code with blank line keeps quote prefix on every code row', () => {
|
|
139
|
+
const codeTok = lexFirst('```js\na\n\nb\n```\n', 'code');
|
|
140
|
+
const quoteTok = { type: 'blockquote', tokens: [codeTok] };
|
|
141
|
+
const outerWidth = 40;
|
|
142
|
+
const out = formatToken(quoteTok, 0, null, null, outerWidth);
|
|
143
|
+
const plain = stripAnsi(out);
|
|
144
|
+
assert.ok(!plain.includes('```'), 'no literal fence markers');
|
|
145
|
+
const bar = '\u258e';
|
|
146
|
+
const visibleLines = plain.split('\n').filter((l) => stringWidth(l) > 0);
|
|
147
|
+
assert.ok(visibleLines.length >= 3, 'two body lines + blank padded row');
|
|
148
|
+
for (const line of visibleLines) {
|
|
149
|
+
assert.ok(line.startsWith(bar), `quote bar on every visible row: "${line}"`);
|
|
150
|
+
assert.ok(stringWidth(line) <= outerWidth, `line fits outer width: "${line}"`);
|
|
151
|
+
}
|
|
152
|
+
const blankPadded = visibleLines.find((l) => !l.includes('a') && !l.includes('b'));
|
|
153
|
+
assert.ok(blankPadded, 'background-padded blank code row is rendered');
|
|
154
|
+
assert.ok(blankPadded.startsWith(bar), 'blank padded code row keeps quote prefix');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('two-level nested list code block does not double-subtract list prefix width', () => {
|
|
158
|
+
const long = 'd'.repeat(40);
|
|
159
|
+
const codeTok = lexFirst(`\`\`\`js\n${long}\n\`\`\`\n`, 'code');
|
|
160
|
+
const innerList = {
|
|
161
|
+
type: 'list',
|
|
162
|
+
ordered: false,
|
|
163
|
+
items: [{ type: 'list_item', tokens: [codeTok] }],
|
|
164
|
+
};
|
|
165
|
+
const outerList = {
|
|
166
|
+
type: 'list',
|
|
167
|
+
ordered: false,
|
|
168
|
+
items: [{ type: 'list_item', tokens: [innerList] }],
|
|
169
|
+
};
|
|
170
|
+
const outerWidth = 30;
|
|
171
|
+
const singleLevelNested = formatToken(innerList, 2, null, null, outerWidth);
|
|
172
|
+
const twoLevelNested = formatToken(outerList, 0, null, null, outerWidth);
|
|
173
|
+
const codeLines = (plain) => plain.split('\n').filter((l) => l.includes('d'));
|
|
174
|
+
const singleLines = codeLines(stripAnsi(singleLevelNested));
|
|
175
|
+
const nestedLines = codeLines(stripAnsi(twoLevelNested));
|
|
176
|
+
assert.equal(
|
|
177
|
+
nestedLines.length,
|
|
178
|
+
singleLines.length,
|
|
179
|
+
'nested list code wraps like one-level nested list at the same outer width',
|
|
180
|
+
);
|
|
181
|
+
assert.ok(nestedLines.length > 1, 'code is wrapped under narrow width');
|
|
182
|
+
});
|
|
183
|
+
|
|
86
184
|
test('js highlighting colors keyword, string, number distinctly', () => {
|
|
87
185
|
const token = lexFirst('```js\nconst n = 42;\nlet s = "hi";\n```\n', 'code');
|
|
88
186
|
const out = formatToken(token);
|
|
@@ -91,6 +189,44 @@ test('js highlighting colors keyword, string, number distinctly', () => {
|
|
|
91
189
|
assert.ok(out.includes(rgbSgr(theme.syntaxString)), 'string colored');
|
|
92
190
|
});
|
|
93
191
|
|
|
192
|
+
test('highlight cache serves repeat block highlight without changing output', () => {
|
|
193
|
+
const c = extraColorizers();
|
|
194
|
+
const text = `const cacheProbe_${Date.now()} = 1;\nlet y = 2;\n`;
|
|
195
|
+
const sizeBefore = _highlightCacheSizeForTests();
|
|
196
|
+
const first = highlightCodeBlockToLines(text, 'js', c);
|
|
197
|
+
assert.equal(_highlightCacheSizeForTests(), sizeBefore + 1, 'first highlight populates cache');
|
|
198
|
+
const second = highlightCodeBlockToLines(text, 'js', c);
|
|
199
|
+
assert.deepEqual(second, first);
|
|
200
|
+
assert.equal(_highlightCacheSizeForTests(), sizeBefore + 1, 'cache hit does not grow entries');
|
|
201
|
+
assert.ok(first[0].includes('38;2;'), 'highlighted ANSI present');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('inline codespan is accent-colored with no background band', () => {
|
|
205
|
+
const tokens = marked.lexer('`hello`');
|
|
206
|
+
const codespan = tokens[0].tokens[0];
|
|
207
|
+
assert.equal(codespan.type, 'codespan');
|
|
208
|
+
const out = formatToken(codespan);
|
|
209
|
+
// Inline code uses accent color only — no bg box behind the span (a tinted
|
|
210
|
+
// band behind inline text reads as awkward against body prose).
|
|
211
|
+
assert.ok(!out.includes('48;2;'), 'no background SGR');
|
|
212
|
+
assert.ok(stripAnsi(out).includes('hello'));
|
|
213
|
+
assert.ok(out.includes(rgbSgr(theme.mdCode)), 'inline code accent');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('kotlin fence resolves and highlights', () => {
|
|
217
|
+
const token = lexFirst('```kotlin\nfun main() {}\n```\n', 'code');
|
|
218
|
+
const out = formatToken(token);
|
|
219
|
+
assert.ok(out.includes(rgbSgr(theme.syntaxKeyword)) || out.includes(rgbSgr(theme.mdCodeBlock)));
|
|
220
|
+
assert.ok(stripAnsi(out).includes('fun main'));
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('typescript fence uses typescript highlighter', () => {
|
|
224
|
+
const token = lexFirst('```ts\nconst x: number = 1;\n```\n', 'code');
|
|
225
|
+
const out = formatToken(token);
|
|
226
|
+
assert.ok(out.includes('38;2;'));
|
|
227
|
+
assert.ok(stripAnsi(out).includes('const x'));
|
|
228
|
+
});
|
|
229
|
+
|
|
94
230
|
test('diff fenced block colors add/remove/hunk/header separately', () => {
|
|
95
231
|
const diff = [
|
|
96
232
|
'```diff',
|
|
@@ -142,7 +278,7 @@ test('unknown language falls back to flat code block color', () => {
|
|
|
142
278
|
const token = lexFirst('```foobarlang\nsome text\n```\n', 'code');
|
|
143
279
|
const out = formatToken(token);
|
|
144
280
|
assert.ok(out.includes(rgbSgr(theme.mdCodeBlock)), 'flat code-block color used');
|
|
145
|
-
assert.ok(stripAnsi(out).includes('
|
|
281
|
+
assert.ok(stripAnsi(out).includes('some text'), 'body is flush-left');
|
|
146
282
|
});
|
|
147
283
|
|
|
148
284
|
test('inline link emits OSC 8 hyperlink with styled label (URL hidden)', () => {
|
|
@@ -169,10 +305,12 @@ test('strong and em use distinct mdStrong / mdEmph colors', () => {
|
|
|
169
305
|
const emTok = lexFirst('*ital*', 'paragraph');
|
|
170
306
|
const strongOut = formatToken(strongTok);
|
|
171
307
|
const emOut = formatToken(emTok);
|
|
172
|
-
|
|
173
|
-
|
|
308
|
+
// Bold/italic carry weight/style only — NO color tint (codex/claude-code
|
|
309
|
+
// convention; a colored strong/em clashes per-theme and reads too loud).
|
|
174
310
|
assert.ok(strongOut.includes('\x1b[1m'), 'strong is bold');
|
|
175
311
|
assert.ok(emOut.includes('\x1b[3m'), 'em is italic');
|
|
312
|
+
assert.ok(!strongOut.includes(rgbSgr(theme.mdStrong)), 'strong is not color-tinted');
|
|
313
|
+
assert.ok(!emOut.includes(rgbSgr(theme.mdEmph)), 'em is not color-tinted');
|
|
176
314
|
});
|
|
177
315
|
|
|
178
316
|
test('h1 heading is bold, italic, and underlined', () => {
|
|
@@ -200,17 +338,17 @@ test('ordered list markers follow nesting depth (1. / a. / i.)', () => {
|
|
|
200
338
|
|
|
201
339
|
test('every palette defines the full extended key set', () => {
|
|
202
340
|
const required = [
|
|
203
|
-
'mdCodeBlockBorder', 'mdCodeBlockBg', 'mdLink', 'mdLinkText', 'mdStrong', 'mdEmph',
|
|
341
|
+
'mdCodeBlockBorder', 'mdCodeBlockBg', 'mdCodeSpanBg', 'mdLink', 'mdLinkText', 'mdStrong', 'mdEmph',
|
|
204
342
|
'mdDiffAdded', 'mdDiffRemoved', 'mdDiffHunk', 'mdDiffHeader', 'mdDiffContext',
|
|
205
343
|
'mdDiffAddedBg', 'mdDiffRemovedBg',
|
|
206
344
|
'syntaxComment', 'syntaxKeyword', 'syntaxFunction', 'syntaxVariable',
|
|
207
345
|
'syntaxString', 'syntaxNumber', 'syntaxType', 'syntaxOperator', 'syntaxPunctuation',
|
|
208
346
|
];
|
|
209
|
-
for (const id of ['
|
|
347
|
+
for (const id of ['basic', 'indigo', 'warm', 'light', 'teal', 'onedark', 'tokyonight', 'kanagawa', 'catppuccin', 'dracula', 'rosepine', 'nord', 'gruvbox', 'everforest']) {
|
|
210
348
|
setThemeSetting(id, { persist: false });
|
|
211
349
|
for (const key of required) {
|
|
212
350
|
assert.ok(/^rgb\(\d+,\s*\d+,\s*\d+\)$/.test(String(theme[key])), `${id}.${key} is an rgb() string`);
|
|
213
351
|
}
|
|
214
352
|
}
|
|
215
|
-
setThemeSetting('
|
|
353
|
+
setThemeSetting('basic', { persist: false });
|
|
216
354
|
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row-height measurement lockstep with Markdown.jsx / StreamingMarkdown.jsx.
|
|
3
|
+
*/
|
|
4
|
+
import stripAnsi from 'strip-ansi';
|
|
5
|
+
import { displayWidth } from '../display-width.mjs';
|
|
6
|
+
import { assistantBodyWidth, measureMarkdownTableRows } from './table-layout.mjs';
|
|
7
|
+
import { renderTokenAnsiSegments } from './render-ansi.mjs';
|
|
8
|
+
import { resolveStreamingMarkdownParts } from './streaming-markdown.mjs';
|
|
9
|
+
|
|
10
|
+
function wrappedLineRows(line, width) {
|
|
11
|
+
const text = String(line);
|
|
12
|
+
const full = displayWidth(text);
|
|
13
|
+
if (full === 0) return 1;
|
|
14
|
+
if (full <= width) return 1;
|
|
15
|
+
let rows = 1;
|
|
16
|
+
let col = 0;
|
|
17
|
+
for (const token of text.split(/(\s+)/)) {
|
|
18
|
+
if (!token) continue;
|
|
19
|
+
const tw = displayWidth(token);
|
|
20
|
+
if (tw === 0) continue;
|
|
21
|
+
if (tw > width) {
|
|
22
|
+
if (col > 0) { rows++; col = 0; }
|
|
23
|
+
rows += Math.ceil(tw / width) - 1;
|
|
24
|
+
col = tw % width || width;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (col + tw > width) { rows++; col = tw; }
|
|
28
|
+
else { col += tw; }
|
|
29
|
+
}
|
|
30
|
+
return Math.max(1, rows);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function estimateWrappedRowsFallback(text, columns, reserve = 3) {
|
|
34
|
+
const width = Math.max(8, Number(columns || 80) - reserve);
|
|
35
|
+
const lines = String(text ?? '').split('\n');
|
|
36
|
+
return Math.max(1, lines.reduce((sum, line) => sum + wrappedLineRows(line, width), 0));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function measureMarkdownRenderedRows(text, columns, { trimPartialFences = false } = {}) {
|
|
40
|
+
const value = String(text ?? '');
|
|
41
|
+
if (!value) return 1;
|
|
42
|
+
const bodyWidth = assistantBodyWidth(columns);
|
|
43
|
+
let segments;
|
|
44
|
+
try {
|
|
45
|
+
segments = renderTokenAnsiSegments(value, { width: bodyWidth, trimPartialFences });
|
|
46
|
+
} catch {
|
|
47
|
+
return Math.max(1, estimateWrappedRowsFallback(value, columns, 3));
|
|
48
|
+
}
|
|
49
|
+
if (!segments.length) return 1;
|
|
50
|
+
let rows = 0;
|
|
51
|
+
for (const seg of segments) {
|
|
52
|
+
if (seg.type === 'table') {
|
|
53
|
+
rows += Math.max(1, measureMarkdownTableRows(seg.token, bodyWidth));
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const plain = stripAnsi(String(seg.ansi ?? ''));
|
|
57
|
+
for (const line of plain.split('\n')) {
|
|
58
|
+
rows += wrappedLineRows(line, bodyWidth);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
rows += segments.length - 1;
|
|
62
|
+
return Math.max(1, rows);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function measureStreamingMarkdownRenderedRows(text, columns, streamKey) {
|
|
66
|
+
const value = String(text ?? '');
|
|
67
|
+
if (!value) return 1;
|
|
68
|
+
const parts = resolveStreamingMarkdownParts(value, streamKey);
|
|
69
|
+
if (parts.plain) {
|
|
70
|
+
return measureMarkdownRenderedRows(value, columns, { trimPartialFences: false });
|
|
71
|
+
}
|
|
72
|
+
let rows = 0;
|
|
73
|
+
let childCount = 0;
|
|
74
|
+
if (parts.stablePrefix) {
|
|
75
|
+
rows += measureMarkdownRenderedRows(parts.stablePrefix, columns, { trimPartialFences: false });
|
|
76
|
+
childCount += 1;
|
|
77
|
+
}
|
|
78
|
+
if (parts.unstableSuffix) {
|
|
79
|
+
rows += measureMarkdownRenderedRows(parts.unstableForRender, columns, { trimPartialFences: true });
|
|
80
|
+
childCount += 1;
|
|
81
|
+
}
|
|
82
|
+
if (childCount === 2) rows += 1;
|
|
83
|
+
if (childCount === 0) return 1;
|
|
84
|
+
return Math.max(1, rows);
|
|
85
|
+
}
|
|
@@ -5,7 +5,7 @@ import { assistantBodyWidth } from './table-layout.mjs';
|
|
|
5
5
|
import { renderTokenAnsiSegments, lexMarkdown, hasMarkdownSyntax } from './render-ansi.mjs';
|
|
6
6
|
import { setThemeSetting } from '../theme.mjs';
|
|
7
7
|
|
|
8
|
-
setThemeSetting('
|
|
8
|
+
setThemeSetting('basic', { persist: false });
|
|
9
9
|
|
|
10
10
|
// Mirrors the streaming suffix the <Markdown trimPartialFences> path renders.
|
|
11
11
|
const STREAMING = '```js\nconst x = 1;\n`';
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure streaming-markdown layout shared by StreamingMarkdown (render) and row
|
|
3
|
+
* estimation (measure). Stable-prefix state is keyed by streamKey (assistant id).
|
|
4
|
+
*/
|
|
5
|
+
import { marked } from 'marked';
|
|
6
|
+
import { configureMarked, hasMarkdownSyntax } from './render-ansi.mjs';
|
|
7
|
+
import { trimPartialClosingFences } from './stream-fence.mjs';
|
|
8
|
+
|
|
9
|
+
const stablePrefixByStreamKey = new Map();
|
|
10
|
+
const STABLE_PREFIX_LRU_MAX = 32;
|
|
11
|
+
|
|
12
|
+
/** Lockstep with App streaming row estimate (leading/trailing newline trim). */
|
|
13
|
+
export function streamingLayoutText(text) {
|
|
14
|
+
return String(text ?? '').replace(/^\n+|\n+$/g, '');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isWhitespaceOnlyText(text) {
|
|
18
|
+
return !String(text ?? '').trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function touchStablePrefixKey(key, value) {
|
|
22
|
+
if (!key) return;
|
|
23
|
+
if (stablePrefixByStreamKey.has(key)) stablePrefixByStreamKey.delete(key);
|
|
24
|
+
stablePrefixByStreamKey.set(key, value);
|
|
25
|
+
while (stablePrefixByStreamKey.size > STABLE_PREFIX_LRU_MAX) {
|
|
26
|
+
const oldest = stablePrefixByStreamKey.keys().next().value;
|
|
27
|
+
if (oldest === undefined) break;
|
|
28
|
+
stablePrefixByStreamKey.delete(oldest);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getStablePrefixKey(key) {
|
|
33
|
+
if (!key || !stablePrefixByStreamKey.has(key)) return '';
|
|
34
|
+
const value = stablePrefixByStreamKey.get(key);
|
|
35
|
+
stablePrefixByStreamKey.delete(key);
|
|
36
|
+
stablePrefixByStreamKey.set(key, value);
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hasOpenFence(text) {
|
|
41
|
+
let ticks = 0;
|
|
42
|
+
let tildes = 0;
|
|
43
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
44
|
+
if (/^\s*```/.test(line)) ticks += 1;
|
|
45
|
+
if (/^\s*~~~/.test(line)) tildes += 1;
|
|
46
|
+
}
|
|
47
|
+
return ticks % 2 === 1 || tildes % 2 === 1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hasOpenInlineCode(text) {
|
|
51
|
+
let count = 0;
|
|
52
|
+
const value = String(text ?? '');
|
|
53
|
+
for (let i = 0; i < value.length; i++) {
|
|
54
|
+
const ch = value[i];
|
|
55
|
+
if (ch === '\\') {
|
|
56
|
+
i += 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (ch !== '`') continue;
|
|
60
|
+
let run = 1;
|
|
61
|
+
while (value[i + run] === '`') run += 1;
|
|
62
|
+
if (run === 1) count += 1;
|
|
63
|
+
i += run - 1;
|
|
64
|
+
}
|
|
65
|
+
return count % 2 === 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hasUnclosedDelimiter(text, marker) {
|
|
69
|
+
let count = 0;
|
|
70
|
+
const value = String(text ?? '');
|
|
71
|
+
for (let i = 0; i < value.length; i++) {
|
|
72
|
+
if (value[i] === '\\') {
|
|
73
|
+
i += 1;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (value.startsWith(marker, i)) {
|
|
77
|
+
count += 1;
|
|
78
|
+
i += marker.length - 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return count % 2 === 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function balanceStreamingMarkdown(text) {
|
|
85
|
+
const value = String(text ?? '');
|
|
86
|
+
if (!value || hasOpenFence(value)) return value;
|
|
87
|
+
if (hasOpenInlineCode(value)) return `${value}\``;
|
|
88
|
+
let rendered = value;
|
|
89
|
+
if (hasUnclosedDelimiter(rendered, '**')) rendered += '**';
|
|
90
|
+
if (hasUnclosedDelimiter(rendered, '__')) rendered += '__';
|
|
91
|
+
return rendered;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function resetStreamingMarkdownStablePrefix(streamKey) {
|
|
95
|
+
if (streamKey == null || streamKey === '') return;
|
|
96
|
+
stablePrefixByStreamKey.delete(String(streamKey));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function resetAllStreamingMarkdownStablePrefixes() {
|
|
100
|
+
stablePrefixByStreamKey.clear();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function resolveStreamingMarkdownParts(text, streamKey) {
|
|
104
|
+
const t = streamingLayoutText(text);
|
|
105
|
+
const key = streamKey == null || streamKey === '' ? null : String(streamKey);
|
|
106
|
+
|
|
107
|
+
if (!t) {
|
|
108
|
+
if (key) stablePrefixByStreamKey.delete(key);
|
|
109
|
+
return {
|
|
110
|
+
plain: true,
|
|
111
|
+
stablePrefix: '',
|
|
112
|
+
unstableSuffix: '',
|
|
113
|
+
unstableForRender: '',
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!hasMarkdownSyntax(t)) {
|
|
118
|
+
if (key) stablePrefixByStreamKey.delete(key);
|
|
119
|
+
return {
|
|
120
|
+
plain: true,
|
|
121
|
+
stablePrefix: '',
|
|
122
|
+
unstableSuffix: t,
|
|
123
|
+
unstableForRender: t,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let stablePrefix = key ? getStablePrefixKey(key) : '';
|
|
128
|
+
if (!t.startsWith(stablePrefix)) {
|
|
129
|
+
stablePrefix = '';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
configureMarked();
|
|
134
|
+
const boundary = stablePrefix.length;
|
|
135
|
+
const tokens = marked.lexer(t.substring(boundary));
|
|
136
|
+
trimPartialClosingFences(tokens);
|
|
137
|
+
let lastContentIdx = tokens.length - 1;
|
|
138
|
+
while (lastContentIdx >= 0 && tokens[lastContentIdx]?.type === 'space') lastContentIdx -= 1;
|
|
139
|
+
let firstContentIdx = 0;
|
|
140
|
+
while (firstContentIdx < tokens.length && tokens[firstContentIdx]?.type === 'space') {
|
|
141
|
+
firstContentIdx += 1;
|
|
142
|
+
}
|
|
143
|
+
let advance = 0;
|
|
144
|
+
for (let i = firstContentIdx; i < lastContentIdx; i++) {
|
|
145
|
+
advance += tokens[i]?.raw?.length ?? 0;
|
|
146
|
+
}
|
|
147
|
+
if (advance > 0) {
|
|
148
|
+
stablePrefix = t.substring(0, boundary + advance);
|
|
149
|
+
if (isWhitespaceOnlyText(stablePrefix)) stablePrefix = '';
|
|
150
|
+
if (key && stablePrefix) touchStablePrefixKey(key, stablePrefix);
|
|
151
|
+
else if (key && !stablePrefix) stablePrefixByStreamKey.delete(key);
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
stablePrefix = '';
|
|
155
|
+
if (key) stablePrefixByStreamKey.delete(key);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (isWhitespaceOnlyText(stablePrefix)) stablePrefix = '';
|
|
159
|
+
|
|
160
|
+
const unstableSuffix = t.substring(stablePrefix.length);
|
|
161
|
+
return {
|
|
162
|
+
plain: false,
|
|
163
|
+
stablePrefix,
|
|
164
|
+
unstableSuffix,
|
|
165
|
+
unstableForRender: balanceStreamingMarkdown(unstableSuffix),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { assistantBodyWidth } from './table-layout.mjs';
|
|
4
|
+
import {
|
|
5
|
+
measureMarkdownRenderedRows,
|
|
6
|
+
measureStreamingMarkdownRenderedRows,
|
|
7
|
+
} from './measure-rendered-rows.mjs';
|
|
8
|
+
import { resolveStreamingMarkdownParts, streamingLayoutText } from './streaming-markdown.mjs';
|
|
9
|
+
import { setThemeSetting } from '../theme.mjs';
|
|
10
|
+
|
|
11
|
+
setThemeSetting('basic', { persist: false });
|
|
12
|
+
|
|
13
|
+
function measureStreamingLayoutFromParts(text, columns, streamKey) {
|
|
14
|
+
const parts = resolveStreamingMarkdownParts(text, streamKey);
|
|
15
|
+
if (parts.plain) {
|
|
16
|
+
return measureMarkdownRenderedRows(text, columns);
|
|
17
|
+
}
|
|
18
|
+
let rows = 0;
|
|
19
|
+
let childCount = 0;
|
|
20
|
+
if (parts.stablePrefix) {
|
|
21
|
+
rows += measureMarkdownRenderedRows(parts.stablePrefix, columns);
|
|
22
|
+
childCount += 1;
|
|
23
|
+
}
|
|
24
|
+
if (parts.unstableSuffix) {
|
|
25
|
+
rows += measureMarkdownRenderedRows(parts.unstableForRender, columns, { trimPartialFences: true });
|
|
26
|
+
childCount += 1;
|
|
27
|
+
}
|
|
28
|
+
if (childCount === 2) rows += 1;
|
|
29
|
+
return Math.max(1, rows);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test('streaming row estimate matches shared layout parts at body width', () => {
|
|
33
|
+
const columns = 40;
|
|
34
|
+
const bodyWidth = assistantBodyWidth(columns);
|
|
35
|
+
const cases = [
|
|
36
|
+
{ key: 'bold-wrap', text: `**${'m'.repeat(bodyWidth + 12)}` },
|
|
37
|
+
{ key: 'partial-fence', text: `\`\`\`js\n${'w'.repeat(bodyWidth + 6)}\n\`` },
|
|
38
|
+
];
|
|
39
|
+
for (const { key, text } of cases) {
|
|
40
|
+
const estimate = measureStreamingMarkdownRenderedRows(text, columns, key);
|
|
41
|
+
const layout = measureStreamingLayoutFromParts(text, columns, key);
|
|
42
|
+
assert.strictEqual(estimate, layout, `${key}: estimate must match render layout parts`);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('partial code fence streaming measure uses trimPartialFences on unstable suffix', () => {
|
|
47
|
+
const columns = 48;
|
|
48
|
+
const bodyWidth = assistantBodyWidth(columns);
|
|
49
|
+
const streamKey = 'test-stream-fence';
|
|
50
|
+
const text = `\`\`\`js\n${'n'.repeat(bodyWidth + 8)}\n\``;
|
|
51
|
+
const estimate = measureStreamingMarkdownRenderedRows(text, columns, streamKey);
|
|
52
|
+
const parts = resolveStreamingMarkdownParts(text, streamKey);
|
|
53
|
+
const manual = measureMarkdownRenderedRows(parts.unstableForRender, columns, { trimPartialFences: true });
|
|
54
|
+
assert.strictEqual(estimate, Math.max(1, manual));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('leading blank lines do not create an empty stable child or extra gap row', () => {
|
|
58
|
+
const columns = 40;
|
|
59
|
+
const streamKey = 'test-leading-blank';
|
|
60
|
+
const raw = '\n\n**streaming body**';
|
|
61
|
+
const trimmed = streamingLayoutText(raw);
|
|
62
|
+
const parts = resolveStreamingMarkdownParts(raw, streamKey);
|
|
63
|
+
assert.strictEqual(parts.stablePrefix, '', 'leading newlines must not promote whitespace-only stable');
|
|
64
|
+
assert.ok(parts.unstableSuffix.includes('streaming body'));
|
|
65
|
+
const estimate = measureStreamingMarkdownRenderedRows(raw, columns, streamKey);
|
|
66
|
+
const trimmedEstimate = measureStreamingMarkdownRenderedRows(trimmed, columns, streamKey);
|
|
67
|
+
assert.strictEqual(estimate, trimmedEstimate, 'raw vs trimmed leading newlines must match');
|
|
68
|
+
const layout = measureStreamingLayoutFromParts(raw, columns, streamKey);
|
|
69
|
+
assert.strictEqual(estimate, layout);
|
|
70
|
+
});
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
* caller (the component resolves it from useStdout()/forceWidth).
|
|
16
16
|
*/
|
|
17
17
|
import stripAnsi from 'strip-ansi';
|
|
18
|
-
import stringWidth from 'string-width';
|
|
19
18
|
import wrapAnsi from 'wrap-ansi';
|
|
20
19
|
import { formatToken, padAligned } from './format-token.mjs';
|
|
20
|
+
import { displayWidth } from '../display-width.mjs';
|
|
21
21
|
|
|
22
22
|
export const SAFETY_MARGIN = 4;
|
|
23
23
|
export const MIN_COLUMN_WIDTH = 3;
|
|
@@ -51,10 +51,10 @@ export function hardWrapLines(text, width) {
|
|
|
51
51
|
const out = [];
|
|
52
52
|
for (const softLine of wrapText(input, max, { hard: true })) {
|
|
53
53
|
let rest = softLine;
|
|
54
|
-
while (rest.length > 0 &&
|
|
54
|
+
while (rest.length > 0 && displayWidth(rest) > max) {
|
|
55
55
|
let take = 1;
|
|
56
56
|
for (let i = 1; i <= rest.length; i++) {
|
|
57
|
-
if (
|
|
57
|
+
if (displayWidth(rest.slice(0, i)) <= max) take = i;
|
|
58
58
|
else break;
|
|
59
59
|
}
|
|
60
60
|
out.push(rest.slice(0, take));
|
|
@@ -81,10 +81,10 @@ export function buildTableRender(token, terminalWidth) {
|
|
|
81
81
|
const text = getPlainText(tokens);
|
|
82
82
|
const words = text.split(/\s+/).filter((w) => w.length > 0);
|
|
83
83
|
if (words.length === 0) return MIN_COLUMN_WIDTH;
|
|
84
|
-
return Math.max(...words.map((w) =>
|
|
84
|
+
return Math.max(...words.map((w) => displayWidth(w)), MIN_COLUMN_WIDTH);
|
|
85
85
|
};
|
|
86
86
|
const getIdealWidth = (tokens) =>
|
|
87
|
-
Math.max(
|
|
87
|
+
Math.max(displayWidth(getPlainText(tokens)), MIN_COLUMN_WIDTH);
|
|
88
88
|
|
|
89
89
|
// Step 1: min (longest word) and ideal (full content) widths per column.
|
|
90
90
|
const minWidths = token.header.map((header, colIndex) => {
|
|
@@ -157,7 +157,7 @@ export function buildTableRender(token, terminalWidth) {
|
|
|
157
157
|
const lineText = contentLineIdx >= 0 && contentLineIdx < lines.length ? lines[contentLineIdx] : '';
|
|
158
158
|
const colWidth = columnWidths[colIndex];
|
|
159
159
|
const align = isHeader ? 'center' : token.align?.[colIndex] ?? 'left';
|
|
160
|
-
line += ' ' + padAligned(lineText,
|
|
160
|
+
line += ' ' + padAligned(lineText, displayWidth(lineText), colWidth, align) + ' │';
|
|
161
161
|
}
|
|
162
162
|
result.push(line);
|
|
163
163
|
}
|
|
@@ -184,7 +184,7 @@ export function buildTableRender(token, terminalWidth) {
|
|
|
184
184
|
const separatorWidth = Math.min(Math.max(0, width - 1), 40);
|
|
185
185
|
const separator = '─'.repeat(separatorWidth);
|
|
186
186
|
const wrapIndent = ' ';
|
|
187
|
-
const indentWidth =
|
|
187
|
+
const indentWidth = displayWidth(wrapIndent);
|
|
188
188
|
const pushFitted = (rawLine) => {
|
|
189
189
|
for (const part of hardWrapLines(rawLine, width)) lines.push(part);
|
|
190
190
|
};
|
|
@@ -195,7 +195,7 @@ export function buildTableRender(token, terminalWidth) {
|
|
|
195
195
|
const rawValue = formatCell(cell.tokens).trimEnd();
|
|
196
196
|
const value = rawValue.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
197
197
|
const prefix = `${ANSI_BOLD_START}${label}:${ANSI_BOLD_END} `;
|
|
198
|
-
const prefixWidth =
|
|
198
|
+
const prefixWidth = displayWidth(stripAnsi(prefix));
|
|
199
199
|
const firstValueWidth = Math.max(1, width - prefixWidth);
|
|
200
200
|
const contValueWidth = Math.max(1, width - indentWidth);
|
|
201
201
|
const firstValueLines = hardWrapLines(value, firstValueWidth);
|
|
@@ -231,7 +231,7 @@ export function buildTableRender(token, terminalWidth) {
|
|
|
231
231
|
tableLines.push(renderBorderLine('bottom'));
|
|
232
232
|
|
|
233
233
|
// Safety: if any line would overflow (resize race), fall back to vertical.
|
|
234
|
-
const maxLineWidth = Math.max(...tableLines.map((l) =>
|
|
234
|
+
const maxLineWidth = Math.max(...tableLines.map((l) => displayWidth(stripAnsi(l))));
|
|
235
235
|
if (maxLineWidth > width - SAFETY_MARGIN) {
|
|
236
236
|
return { lines: renderVerticalLines(), useVerticalFormat: true };
|
|
237
237
|
}
|
|
@@ -67,17 +67,6 @@ export function imageReferenceIds(input) {
|
|
|
67
67
|
return new Set([...String(input || '').matchAll(re)].map((m) => Number(m[1]) || 0).filter(Boolean));
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
export function promptContentText(content) {
|
|
71
|
-
if (typeof content === 'string') return content;
|
|
72
|
-
if (!Array.isArray(content)) return String(content ?? '');
|
|
73
|
-
return content.map((part) => {
|
|
74
|
-
if (typeof part === 'string') return part;
|
|
75
|
-
if (part?.type === 'text') return part.text || '';
|
|
76
|
-
if (part?.type === 'image') return `[image: ${part.mimeType || part.mediaType || part.source?.media_type || 'image'}]`;
|
|
77
|
-
return part?.text || '';
|
|
78
|
-
}).filter(Boolean).join('\n');
|
|
79
|
-
}
|
|
80
|
-
|
|
81
70
|
export function buildPromptContentWithImages(text, pastedImages = {}) {
|
|
82
71
|
const value = String(text ?? '');
|
|
83
72
|
const refs = imageReferenceIds(value);
|