mixdog 0.8.0 → 0.9.0
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/README.md +47 -23
- package/package.json +33 -27
- package/scripts/_test-folder-dialog.mjs +30 -0
- package/scripts/agent-parallel-smoke.mjs +388 -0
- package/scripts/agent-tag-reuse-smoke.mjs +183 -0
- package/scripts/background-task-meta-smoke.mjs +38 -0
- package/scripts/boot-smoke.mjs +52 -9
- package/scripts/build-runtime-linux.sh +348 -0
- package/scripts/build-runtime-macos.sh +217 -0
- package/scripts/build-runtime-windows.ps1 +242 -0
- package/scripts/compact-active-turn-test.mjs +68 -0
- package/scripts/compact-smoke.mjs +859 -129
- package/scripts/compact-trigger-migration-smoke.mjs +187 -0
- package/scripts/fix-brief-fn.mjs +35 -0
- package/scripts/fix-format-tool-surface.mjs +24 -0
- package/scripts/fix-tool-exec-visible.mjs +42 -0
- package/scripts/generate-runtime-manifest.mjs +166 -0
- package/scripts/hook-bus-test.mjs +330 -0
- package/scripts/lead-workflow-smoke.mjs +33 -39
- package/scripts/live-worker-smoke.mjs +43 -37
- package/scripts/llm-trace-summary.mjs +315 -0
- package/scripts/memory-meta-concurrency-test.mjs +20 -0
- package/scripts/output-style-smoke.mjs +56 -15
- package/scripts/parent-abort-link-test.mjs +44 -0
- package/scripts/patch-agent-brief.mjs +48 -0
- package/scripts/patch-app.mjs +21 -0
- package/scripts/patch-app2.mjs +18 -0
- package/scripts/patch-dist-brief.mjs +96 -0
- package/scripts/patch-tool-exec.mjs +70 -0
- package/scripts/pretool-ask-runtime-test.mjs +54 -0
- package/scripts/provider-toolcall-test.mjs +376 -0
- package/scripts/reactive-compact-persist-smoke.mjs +124 -0
- package/scripts/sanitize-tool-pairs-test.mjs +260 -0
- package/scripts/session-context-bench.mjs +344 -0
- package/scripts/session-ingest-smoke.mjs +177 -0
- package/scripts/set-effort-config-test.mjs +41 -0
- package/scripts/smoke-runtime-negative.ps1 +106 -0
- package/scripts/smoke-runtime-negative.sh +97 -0
- package/scripts/smoke.mjs +25 -0
- package/scripts/tool-result-hook-test.mjs +48 -0
- package/scripts/tool-smoke.mjs +1223 -95
- package/scripts/toolcall-args-test.mjs +150 -0
- package/scripts/tui-background-failure-smoke.mjs +73 -0
- package/scripts/usage-metrics-epoch-smoke.mjs +114 -0
- package/src/agents/debugger/AGENT.md +8 -0
- package/src/agents/explore/AGENT.md +4 -0
- package/src/agents/heavy-worker/AGENT.md +9 -3
- package/src/agents/maintainer/AGENT.md +4 -0
- package/src/agents/reviewer/AGENT.md +8 -0
- package/src/agents/scheduler-task/AGENT.md +12 -0
- package/src/agents/scheduler-task/agent.json +6 -0
- package/src/agents/webhook-handler/AGENT.md +12 -0
- package/src/agents/webhook-handler/agent.json +6 -0
- package/src/agents/worker/AGENT.md +9 -3
- package/src/app.mjs +77 -3
- package/src/defaults/hidden-roles.json +17 -12
- package/src/headless-role.mjs +117 -0
- package/src/help.mjs +30 -0
- package/src/hooks/lib/permission-evaluator.cjs +11 -475
- package/src/lib/keychain-cjs.cjs +9 -1
- package/src/lib/mixdog-debug.cjs +0 -7
- package/src/lib/rules-builder.cjs +240 -96
- package/src/lib/text-utils.cjs +1 -1
- package/src/mixdog-session-runtime.mjs +2325 -450
- package/src/output-styles/default.md +12 -28
- package/src/output-styles/extreme-simple.md +9 -6
- package/src/output-styles/simple.md +22 -9
- package/src/repl.mjs +118 -59
- package/src/rules/agent/00-common.md +15 -0
- package/src/rules/{bridge → agent}/20-skip-protocol.md +1 -2
- package/src/rules/agent/30-explorer.md +22 -0
- package/src/rules/{bridge → agent}/40-cycle1-agent.md +7 -0
- package/src/rules/{bridge → agent}/41-cycle2-agent.md +7 -0
- package/src/rules/{bridge → agent}/42-cycle3-agent.md +7 -0
- package/src/rules/lead/01-general.md +9 -5
- package/src/rules/lead/04-workflow.md +51 -12
- package/src/rules/lead/lead-tool.md +6 -0
- package/src/rules/shared/01-tool.md +12 -1
- package/src/runtime/agent/orchestrator/activity-bus.mjs +7 -18
- package/src/runtime/agent/orchestrator/agent-owner.mjs +11 -0
- package/src/runtime/agent/orchestrator/{smart-bridge/bridge-llm.mjs → agent-runtime/agent-dispatch.mjs} +138 -111
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +94 -0
- package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/cache-strategy.mjs +32 -23
- package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/session-builder.mjs +33 -27
- package/src/runtime/agent/orchestrator/{bridge-trace.mjs → agent-trace.mjs} +132 -81
- package/src/runtime/agent/orchestrator/cache-mtime.mjs +0 -21
- package/src/runtime/agent/orchestrator/config.mjs +174 -55
- package/src/runtime/agent/orchestrator/context/collect.mjs +195 -487
- package/src/runtime/agent/orchestrator/dispatch-persist.mjs +1 -1
- package/src/runtime/agent/orchestrator/internal-roles.mjs +77 -29
- package/src/runtime/agent/orchestrator/internal-tools.mjs +5 -6
- package/src/runtime/agent/orchestrator/mcp/client.mjs +15 -9
- package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +377 -243
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +146 -93
- package/src/runtime/agent/orchestrator/providers/api-usage.mjs +236 -4
- package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +49 -0
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +58 -13
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -149
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +132 -2
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +4 -1
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +45 -0
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +61 -116
- package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +25 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +79 -255
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +221 -70
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +477 -147
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +343 -496
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/registry.mjs +88 -51
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +31 -11
- package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +41 -8
- package/src/runtime/agent/orchestrator/session/cache/post-edit-marks.mjs +4 -4
- package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/compact.mjs +1173 -267
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
- package/src/runtime/agent/orchestrator/session/loop.mjs +851 -674
- package/src/runtime/agent/orchestrator/session/manager.mjs +1593 -466
- package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +291 -46
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
- package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
- package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +34 -7
- package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +9 -9
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
- package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
- package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
- package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
- package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
- package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
- package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
- package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
- package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
- package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
- package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
- package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
- package/src/runtime/channels/index.mjs +152 -24
- package/src/runtime/channels/lib/scheduler.mjs +18 -14
- package/src/runtime/channels/lib/session-discovery.mjs +3 -2
- package/src/runtime/channels/lib/tool-format.mjs +0 -2
- package/src/runtime/channels/lib/transcript-discovery.mjs +3 -2
- package/src/runtime/channels/lib/webhook.mjs +1 -1
- package/src/runtime/channels/tool-defs.mjs +29 -29
- package/src/runtime/memory/index.mjs +635 -107
- package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
- package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
- package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
- package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
- package/src/runtime/memory/lib/memory-cycle1.mjs +38 -17
- package/src/runtime/memory/lib/memory-cycle2.mjs +6 -7
- package/src/runtime/memory/lib/memory-cycle3.mjs +4 -4
- package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -1
- package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
- package/src/runtime/memory/lib/memory.mjs +88 -9
- package/src/runtime/memory/lib/model-profile.mjs +1 -1
- package/src/runtime/memory/lib/pg/adapter.mjs +15 -1
- package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
- package/src/runtime/memory/lib/runtime-fetcher.mjs +37 -3
- package/src/runtime/memory/lib/session-ingest.mjs +194 -0
- package/src/runtime/memory/lib/trace-store.mjs +96 -51
- package/src/runtime/memory/tool-defs.mjs +46 -37
- package/src/runtime/search/index.mjs +102 -466
- package/src/runtime/search/lib/web-tools.mjs +45 -25
- package/src/runtime/search/tool-defs.mjs +16 -23
- package/src/runtime/shared/abort-controller.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +4 -3
- package/src/runtime/shared/background-tasks.mjs +122 -11
- package/src/runtime/shared/child-spawn-gate.mjs +145 -0
- package/src/runtime/shared/config.mjs +7 -4
- package/src/runtime/shared/err-text.mjs +131 -4
- package/src/runtime/shared/llm/cost.mjs +2 -2
- package/src/runtime/shared/llm/http-agent.mjs +23 -7
- package/src/runtime/shared/llm/index.mjs +34 -11
- package/src/runtime/shared/llm/usage-log.mjs +4 -4
- package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
- package/src/runtime/shared/singleton-owner.mjs +104 -0
- package/src/runtime/shared/tool-execution-contract.mjs +199 -20
- package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
- package/src/runtime/shared/tool-surface.mjs +624 -98
- package/src/runtime/shared/user-data-guard.mjs +0 -2
- package/src/standalone/agent-task-status.mjs +203 -0
- package/src/standalone/agent-task-status.test.mjs +76 -0
- package/src/standalone/agent-tool.mjs +1913 -0
- package/src/standalone/channel-worker.mjs +370 -14
- package/src/standalone/explore-tool.mjs +165 -70
- package/src/standalone/folder-dialog.mjs +314 -0
- package/src/standalone/hook-bus.mjs +898 -22
- package/src/standalone/memory-runtime-proxy.mjs +320 -0
- package/src/standalone/projects.mjs +226 -0
- package/src/standalone/provider-admin.mjs +41 -24
- package/src/standalone/seeds.mjs +2 -69
- package/src/standalone/usage-dashboard.mjs +96 -8
- package/src/tui/App.jsx +4798 -2153
- package/src/tui/components/AnsiText.jsx +39 -28
- package/src/tui/components/ContextPanel.jsx +87 -29
- package/src/tui/components/Markdown.jsx +43 -77
- package/src/tui/components/MarkdownTable.jsx +9 -184
- package/src/tui/components/Message.jsx +28 -11
- package/src/tui/components/Picker.jsx +95 -56
- package/src/tui/components/PromptInput.jsx +367 -239
- package/src/tui/components/QueuedCommands.jsx +1 -1
- package/src/tui/components/SlashCommandPalette.jsx +27 -21
- package/src/tui/components/Spinner.jsx +67 -38
- package/src/tui/components/StatusLine.jsx +606 -38
- package/src/tui/components/TextEntryPanel.jsx +128 -9
- package/src/tui/components/ToolExecution.jsx +617 -368
- package/src/tui/components/TurnDone.jsx +3 -3
- package/src/tui/components/UsagePanel.jsx +3 -5
- package/src/tui/components/tool-output-format.mjs +365 -0
- package/src/tui/components/tool-output-format.test.mjs +220 -0
- package/src/tui/dist/index.mjs +8915 -2418
- package/src/tui/engine-runtime-notification.test.mjs +115 -0
- package/src/tui/engine-tool-result-text.test.mjs +75 -0
- package/src/tui/engine.mjs +1455 -279
- package/src/tui/figures.mjs +21 -40
- package/src/tui/index.jsx +75 -31
- package/src/tui/input-editing.mjs +25 -0
- package/src/tui/markdown/format-token.mjs +511 -68
- package/src/tui/markdown/format-token.test.mjs +216 -0
- package/src/tui/markdown/render-ansi.mjs +94 -0
- package/src/tui/markdown/render-ansi.test.mjs +108 -0
- package/src/tui/markdown/stream-fence.mjs +34 -0
- package/src/tui/markdown/stream-fence.test.mjs +26 -0
- package/src/tui/markdown/table-layout.mjs +250 -0
- package/src/tui/paste-attachments.mjs +0 -7
- package/src/tui/spinner-verbs.mjs +1 -2
- package/src/tui/statusline-ansi-bridge.mjs +172 -0
- package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
- package/src/tui/theme.mjs +746 -24
- package/src/tui/time-format.mjs +1 -1
- package/src/tui/transcript-tool-failures.mjs +67 -0
- package/src/tui/transcript-tool-failures.test.mjs +111 -0
- package/src/ui/ansi.mjs +1 -2
- package/src/ui/markdown.mjs +85 -26
- package/src/ui/markdown.test.mjs +70 -0
- package/src/ui/model-display.mjs +121 -0
- package/src/ui/session-stats.mjs +44 -0
- package/src/ui/statusline-context-label.test.mjs +15 -0
- package/src/ui/statusline.mjs +386 -178
- package/src/ui/tool-card.mjs +3 -16
- package/src/vendor/statusline/bin/statusline-lib.mjs +8 -4
- package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
- package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
- package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
- package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
- package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
- package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
- package/src/workflows/default/WORKFLOW.md +12 -5
- package/src/workflows/default/workflow.json +0 -1
- package/src/workflows/solo/WORKFLOW.md +15 -0
- package/src/workflows/solo/workflow.json +7 -0
- package/vendor/ink/build/output.js +6 -1
- package/src/agents/scheduler-task.md +0 -3
- package/src/agents/web-researcher/AGENT.md +0 -3
- package/src/agents/web-researcher/agent.json +0 -6
- package/src/agents/webhook-handler.md +0 -3
- package/src/rules/bridge/00-common.md +0 -5
- package/src/rules/bridge/30-explorer.md +0 -4
- package/src/rules/lead/00-tool-lead.md +0 -5
- package/src/rules/shared/00-language.md +0 -3
- package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
- package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
- package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
- package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
- package/src/standalone/bridge-tool.mjs +0 -1414
- package/src/tui/runtime/shared/process-shutdown.mjs +0 -1
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Pinned into the transcript right after a turn's output (an `item.kind ===
|
|
8
8
|
* 'turndone'` entry), so it scrolls up with the answer and stays in the
|
|
9
|
-
* scrollback
|
|
9
|
+
* scrollback as a post-think summary line. It leaves a
|
|
10
10
|
* quiet, dim record of how long the turn took next to the answer it belongs to.
|
|
11
11
|
*/
|
|
12
12
|
import React from 'react';
|
|
@@ -45,7 +45,7 @@ export function TurnDone({ elapsedMs = 0, status = 'done', verb = 'Thought', rig
|
|
|
45
45
|
</Text>
|
|
46
46
|
</Box>
|
|
47
47
|
{rightText ? (
|
|
48
|
-
<Box flexShrink={0} width={rightWidth} marginLeft={1} justifyContent="flex-end" overflow="hidden">
|
|
48
|
+
<Box flexShrink={0} width={rightWidth} marginLeft={1} marginRight={1} justifyContent="flex-end" overflow="hidden">
|
|
49
49
|
<Text color={statusMessageColor(rightTone)} wrap="truncate">{rightText}</Text>
|
|
50
50
|
</Box>
|
|
51
51
|
) : null}
|
|
@@ -69,7 +69,7 @@ export function StatusDone({ label = 'Complete', detail = '', rightMessage = '',
|
|
|
69
69
|
</Text>
|
|
70
70
|
</Box>
|
|
71
71
|
{rightText ? (
|
|
72
|
-
<Box flexShrink={0} width={rightWidth} marginLeft={1} justifyContent="flex-end" overflow="hidden">
|
|
72
|
+
<Box flexShrink={0} width={rightWidth} marginLeft={1} marginRight={1} justifyContent="flex-end" overflow="hidden">
|
|
73
73
|
<Text color={statusMessageColor(rightTone)} wrap="truncate">{rightText}</Text>
|
|
74
74
|
</Box>
|
|
75
75
|
) : null}
|
|
@@ -148,10 +148,11 @@ function windowSegmentParts(w) {
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
function creditSegmentParts(value) {
|
|
151
|
+
const creditColor = Number(value) === 0 ? theme.text : remainingUsdColor(value);
|
|
151
152
|
return [
|
|
152
153
|
{ text: CREDIT_LABEL, color: theme.subtle },
|
|
153
154
|
{ text: ' ', color: theme.inactive },
|
|
154
|
-
{ text: money(value), color:
|
|
155
|
+
{ text: money(value), color: creditColor },
|
|
155
156
|
];
|
|
156
157
|
}
|
|
157
158
|
|
|
@@ -185,13 +186,10 @@ function rowStatusParts(row, columns = 80, statusWidth = 0) {
|
|
|
185
186
|
case 'checking':
|
|
186
187
|
return [{ text: 'checking...', color: theme.inactive }];
|
|
187
188
|
case 'hidden':
|
|
188
|
-
return row?.detail ? [{ text: row.detail, color: theme.inactive }] : [];
|
|
189
189
|
case 'missing':
|
|
190
|
-
return row?.primary && row.primary !== 'not configured' ? [{ text: row.primary, color: theme.inactive }] : [];
|
|
191
190
|
case 'local':
|
|
192
|
-
return row?.primary ? [{ text: row.primary, color: theme.inactive }] : [];
|
|
193
191
|
case 'error':
|
|
194
|
-
return
|
|
192
|
+
return [];
|
|
195
193
|
default:
|
|
196
194
|
return row?.primary ? [{ text: row.primary, color: theme.text }] : [];
|
|
197
195
|
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* components/tool-output-format.mjs — shared post-processing for EXPANDED tool
|
|
3
|
+
* result bodies (ctrl+o). Mirrors the Claude Code output pipeline so every tool
|
|
4
|
+
* surface renders the same way instead of a flat run of plain lines:
|
|
5
|
+
*
|
|
6
|
+
* - language inference from the read/grep path argument (file extension)
|
|
7
|
+
* - JSON auto pretty-print (precision-safe, size-capped)
|
|
8
|
+
* - URL → OSC 8 hyperlink
|
|
9
|
+
* - stray underline-ANSI removal (shell output leaks these)
|
|
10
|
+
* - oversized-output guard (never wrap/scan a 64 MB dump line-by-line)
|
|
11
|
+
* - per-line render: split the `<n>→` / `<file>:<n>:` line-number gutter into a
|
|
12
|
+
* dim column, then syntax-highlight the body with the shared markdown
|
|
13
|
+
* code-block highlighter so colors track the active theme.
|
|
14
|
+
*
|
|
15
|
+
* This module returns ANSI STRINGS (one per logical line). ToolExecution
|
|
16
|
+
* runs `wrapExpandedResultLines` so each physical terminal row maps 1:1 to the
|
|
17
|
+
* left result rail before ink draws the body (no ink `wrap` on expanded rows).
|
|
18
|
+
*/
|
|
19
|
+
import stringWidth from 'string-width';
|
|
20
|
+
import stripAnsi from 'strip-ansi';
|
|
21
|
+
import {
|
|
22
|
+
extraColorizers,
|
|
23
|
+
highlightCodeLine,
|
|
24
|
+
colorizeDiffLine,
|
|
25
|
+
looksLikeUnifiedDiff,
|
|
26
|
+
LANG_FAMILY,
|
|
27
|
+
} from '../markdown/format-token.mjs';
|
|
28
|
+
import { wrapText } from '../markdown/table-layout.mjs';
|
|
29
|
+
import { RESULT_GUTTER } from '../theme.mjs';
|
|
30
|
+
import { hasMarkdownSyntax, renderTokenAnsiSegments } from '../markdown/render-ansi.mjs';
|
|
31
|
+
import { buildTableRender } from '../markdown/table-layout.mjs';
|
|
32
|
+
|
|
33
|
+
const DEFAULT_MARKDOWN_WIDTH = 80;
|
|
34
|
+
|
|
35
|
+
// Hard ceilings so a pathological tool result can never lock the render loop.
|
|
36
|
+
// CC uses ~MAX_LINES*width*4 for the collapsed fold; for the EXPANDED body we
|
|
37
|
+
// cap total characters processed and total lines kept, with an explicit marker.
|
|
38
|
+
const MAX_EXPANDED_CHARS = 256 * 1024; // 256 KB of text gets per-line processing
|
|
39
|
+
const MAX_EXPANDED_LINES = 80; // keep at most this many rendered lines
|
|
40
|
+
const MAX_JSON_FORMAT_LENGTH = 10_000; // mirror CC's tryJsonFormatContent cap
|
|
41
|
+
const MAX_HIGHLIGHT_LINE_CHARS = 2000; // skip token-scan on absurdly long lines
|
|
42
|
+
// Lockstep with ToolExecution collapsed fit budget (MIN_RESULT_LINE_CHARS).
|
|
43
|
+
const MIN_EXPANDED_BODY_COLS = 24;
|
|
44
|
+
|
|
45
|
+
// `<n>→<content>` (read) OR `<n>:<content>` / `<path>:<n>:<content>` (grep).
|
|
46
|
+
const READ_LINE_RE = /^(\s*)(\d+)(\u2192)(.*)$/;
|
|
47
|
+
// Gutter ends with `:<line>:`. Windows absolute paths use `X:\...` so the drive
|
|
48
|
+
// colon must not terminate the path prefix (unlike the old `[^:\n]*:` rule).
|
|
49
|
+
const GREP_LINE_RE = /^(\s*)((?:[A-Za-z]:[\\/](?:[^\n:])*|[^\n:]*):\d+:|\d+:)(\s?)(.*)$/;
|
|
50
|
+
|
|
51
|
+
// http(s) URLs not wrapped in quotes/brackets/whitespace (conservative).
|
|
52
|
+
const URL_RE = /https?:\/\/[^\s"'<>\x1b\\)\]]+/g;
|
|
53
|
+
// CSI SGR and OSC sequences (BEL- or ST-terminated) — linkify plain text only.
|
|
54
|
+
// eslint-disable-next-line no-control-regex
|
|
55
|
+
const ANSI_ESCAPE_RE = /\x1b(?:\[[0-9;]*m|\][\s\S]*?(?:\x07|\x1b\\))/g;
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
/** Infer a highlighter family from a read/grep path arg's extension. */
|
|
59
|
+
export function inferLangFamily(pathArg) {
|
|
60
|
+
const p = String(pathArg || '').trim().toLowerCase();
|
|
61
|
+
if (!p) return null;
|
|
62
|
+
const m = /\.([a-z0-9]+)$/.exec(p);
|
|
63
|
+
if (!m) return null;
|
|
64
|
+
return LANG_FAMILY[m[1]] || null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Strip ONLY underline SGR (4 / 24) — other styles/colors are preserved. */
|
|
68
|
+
export function stripUnderlineAnsi(text) {
|
|
69
|
+
return String(text ?? '').replace(
|
|
70
|
+
// eslint-disable-next-line no-control-regex
|
|
71
|
+
/\x1b\[([0-9;]*)m/g,
|
|
72
|
+
(seq, params) => {
|
|
73
|
+
if (!params) return seq;
|
|
74
|
+
const kept = params.split(';').filter((p) => p !== '' && p !== '4' && p !== '24');
|
|
75
|
+
if (kept.length === 0) return '';
|
|
76
|
+
return `\x1b[${kept.join(';')}m`;
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Wrap bare URLs in OSC 8 hyperlinks (terminals that ignore it show the URL). */
|
|
82
|
+
export function linkifyUrls(text) {
|
|
83
|
+
const src = String(text ?? '');
|
|
84
|
+
if (!/https?:\/\//.test(src)) return src;
|
|
85
|
+
const chunks = [];
|
|
86
|
+
let last = 0;
|
|
87
|
+
for (const match of src.matchAll(ANSI_ESCAPE_RE)) {
|
|
88
|
+
if (match.index > last) chunks.push(src.slice(last, match.index));
|
|
89
|
+
chunks.push(match[0]);
|
|
90
|
+
last = match.index + match[0].length;
|
|
91
|
+
}
|
|
92
|
+
if (last < src.length) chunks.push(src.slice(last));
|
|
93
|
+
return chunks
|
|
94
|
+
.map((part) => (part.startsWith('\x1b')
|
|
95
|
+
? part
|
|
96
|
+
: part.replace(URL_RE, (url) => `\x1b]8;;${url}\x07${url}\x1b]8;;\x07`)))
|
|
97
|
+
.join('');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Precision-safe JSON pretty-print for a single line; returns input on miss. */
|
|
101
|
+
function tryFormatJsonLine(line) {
|
|
102
|
+
const t = String(line ?? '').trim();
|
|
103
|
+
if (!t || (t[0] !== '{' && t[0] !== '[')) return line;
|
|
104
|
+
try {
|
|
105
|
+
const parsed = JSON.parse(t);
|
|
106
|
+
if (parsed === null || typeof parsed !== 'object') return line;
|
|
107
|
+
const pretty = JSON.stringify(parsed, null, 2);
|
|
108
|
+
// Bail if a big-int lost precision on the round-trip.
|
|
109
|
+
const a = t.replace(/\\\//g, '/').replace(/\s+/g, '');
|
|
110
|
+
const b = JSON.stringify(parsed).replace(/\s+/g, '');
|
|
111
|
+
return a === b ? pretty : line;
|
|
112
|
+
} catch {
|
|
113
|
+
return line;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Auto pretty-print whole-result JSON (size-capped, per-line). */
|
|
118
|
+
export function tryFormatJson(text) {
|
|
119
|
+
const src = String(text ?? '');
|
|
120
|
+
if (src.length > MAX_JSON_FORMAT_LENGTH) return src;
|
|
121
|
+
return src.split('\n').map(tryFormatJsonLine).join('\n');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** True when the whole body is a JSON object/array (not markdown prose). */
|
|
125
|
+
function isJsonDocument(text) {
|
|
126
|
+
const t = String(text ?? '').trim();
|
|
127
|
+
if (!t || (t[0] !== '{' && t[0] !== '[')) return false;
|
|
128
|
+
try {
|
|
129
|
+
const parsed = JSON.parse(t);
|
|
130
|
+
return parsed !== null && typeof parsed === 'object';
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** True when text already carries SGR escapes (e.g. shell color output). */
|
|
137
|
+
function hasAnsi(text) {
|
|
138
|
+
return /\x1b\[/.test(String(text ?? ''));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** True when a line carries a read/grep tool line-number gutter. */
|
|
142
|
+
function lineHasToolGutter(line) {
|
|
143
|
+
return READ_LINE_RE.test(line) || GREP_LINE_RE.test(line);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Read/grep bodies are source lines — never whole-document markdown. */
|
|
147
|
+
function contentHasToolGutters(lines) {
|
|
148
|
+
for (const line of lines) {
|
|
149
|
+
if (lineHasToolGutter(line)) return true;
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Whole-result markdown render for prose-y tool output (headings, emphasis,
|
|
156
|
+
* fences). Skipped for shell/ANSI/diff, read/grep gutters, and non-md path
|
|
157
|
+
* language inference (those stay per-line syntax highlight).
|
|
158
|
+
*/
|
|
159
|
+
function shouldRenderExpandedMarkdown({ src, lines, carriesAnsi, isShell, diffMode, family }) {
|
|
160
|
+
if (carriesAnsi || isShell || diffMode) return false;
|
|
161
|
+
if (isJsonDocument(src)) return false;
|
|
162
|
+
if (contentHasToolGutters(lines)) return false;
|
|
163
|
+
if (family && family !== 'md') return false;
|
|
164
|
+
if (family === 'md') return true;
|
|
165
|
+
return hasMarkdownSyntax(src);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Markdown lexer → one ANSI string per terminal row (tables via table-layout). */
|
|
169
|
+
function formatExpandedMarkdownLines(src, { width = DEFAULT_MARKDOWN_WIDTH } = {}) {
|
|
170
|
+
const segments = renderTokenAnsiSegments(src, { width });
|
|
171
|
+
const out = [];
|
|
172
|
+
for (const seg of segments) {
|
|
173
|
+
if (seg.type === 'table') {
|
|
174
|
+
const { lines } = buildTableRender(seg.token, width);
|
|
175
|
+
for (const line of lines) out.push(linkifyUrls(line));
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (seg.type !== 'ansi' || !seg.ansi) continue;
|
|
179
|
+
for (const line of String(seg.ansi).split('\n')) {
|
|
180
|
+
out.push(linkifyUrls(line));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Process an expanded tool-result body into an array of ANSI line strings.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} text raw tool result
|
|
190
|
+
* @param {object} opts
|
|
191
|
+
* @param {string} [opts.pathArg] read/grep path for language inference
|
|
192
|
+
* @param {boolean} [opts.isShell] shell surface (preserve existing ANSI, no highlight)
|
|
193
|
+
* @returns {string[]} one ANSI string per visible line
|
|
194
|
+
*/
|
|
195
|
+
export function formatExpandedResult(text, { pathArg = '', isShell = false } = {}) {
|
|
196
|
+
let src = String(text ?? '');
|
|
197
|
+
if (!src) return [];
|
|
198
|
+
|
|
199
|
+
// Oversized guard: slice before any O(n) per-line work, append a marker.
|
|
200
|
+
let truncatedChars = false;
|
|
201
|
+
if (src.length > MAX_EXPANDED_CHARS) {
|
|
202
|
+
src = src.slice(0, MAX_EXPANDED_CHARS);
|
|
203
|
+
truncatedChars = true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const carriesAnsi = hasAnsi(src);
|
|
207
|
+
// JSON pretty only when the text is not already colored (don't reflow ANSI).
|
|
208
|
+
if (!carriesAnsi) src = tryFormatJson(src);
|
|
209
|
+
|
|
210
|
+
let lines = src.split('\n');
|
|
211
|
+
let truncatedLines = false;
|
|
212
|
+
if (lines.length > MAX_EXPANDED_LINES) {
|
|
213
|
+
lines = lines.slice(0, MAX_EXPANDED_LINES);
|
|
214
|
+
truncatedLines = true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const c = extraColorizers();
|
|
218
|
+
// Diff bodies get diff coloring regardless of file family.
|
|
219
|
+
const diffMode = !carriesAnsi && !isShell && looksLikeUnifiedDiff(src);
|
|
220
|
+
const family = isShell || carriesAnsi ? null : inferLangFamily(pathArg);
|
|
221
|
+
|
|
222
|
+
let out;
|
|
223
|
+
if (shouldRenderExpandedMarkdown({ src, lines, carriesAnsi, isShell, diffMode, family })) {
|
|
224
|
+
out = formatExpandedMarkdownLines(src);
|
|
225
|
+
if (out.length > MAX_EXPANDED_LINES) {
|
|
226
|
+
out = out.slice(0, MAX_EXPANDED_LINES);
|
|
227
|
+
truncatedLines = true;
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
out = lines.map((line) => formatLine(line, { c, family, diffMode, carriesAnsi, isShell }));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (truncatedChars || truncatedLines) {
|
|
234
|
+
out.push(c.synComment('… [output truncated for display — re-read a narrower range]'));
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Format ONE line: split line-number gutter, then highlight/linkify the body. */
|
|
240
|
+
function formatLine(line, { c, family, diffMode, carriesAnsi, isShell }) {
|
|
241
|
+
// Shell / already-colored output: keep ANSI verbatim, only fix underline leaks
|
|
242
|
+
// and linkify URLs. No gutter split (shell has no <n>→ prefix).
|
|
243
|
+
if (carriesAnsi || isShell) {
|
|
244
|
+
return linkifyUrls(stripUnderlineAnsi(line));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Split a read (`<n>→`) or grep (`<file>:<n>:` / `<n>:`) gutter into a dim
|
|
248
|
+
// column so the body can be highlighted independently.
|
|
249
|
+
let indent = '';
|
|
250
|
+
let gutter = '';
|
|
251
|
+
let body = line;
|
|
252
|
+
const rm = READ_LINE_RE.exec(line);
|
|
253
|
+
if (rm) {
|
|
254
|
+
indent = rm[1];
|
|
255
|
+
gutter = `${rm[2]}${rm[3]}`;
|
|
256
|
+
body = rm[4];
|
|
257
|
+
} else {
|
|
258
|
+
const gm = GREP_LINE_RE.exec(line);
|
|
259
|
+
if (gm) {
|
|
260
|
+
indent = gm[1];
|
|
261
|
+
gutter = `${gm[2]}${gm[3]}`;
|
|
262
|
+
body = gm[4];
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const coloredBody = highlightBody(body, { c, family, diffMode });
|
|
267
|
+
const linked = linkifyUrls(coloredBody);
|
|
268
|
+
return gutter ? `${indent}${c.synComment(gutter)}${linked}` : linked;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Color a body string per the active mode (diff / language / plain). */
|
|
272
|
+
function highlightBody(body, { c, family, diffMode }) {
|
|
273
|
+
if (!body) return '';
|
|
274
|
+
if (body.length > MAX_HIGHLIGHT_LINE_CHARS) return c.body(body);
|
|
275
|
+
if (diffMode) return colorizeDiffLine(body, c);
|
|
276
|
+
if (family && family !== 'md') return highlightCodeLine(body, family, c);
|
|
277
|
+
return c.body(body);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Body text width for expanded tool results (terminal cols minus the ⎿ rail). */
|
|
281
|
+
export function expandedResultBodyWidth(columns = 80) {
|
|
282
|
+
const cols = Math.max(1, Number(columns) || 80);
|
|
283
|
+
const budget = cols - stringWidth(RESULT_GUTTER);
|
|
284
|
+
return Math.max(MIN_EXPANDED_BODY_COLS, budget);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function padDisplaySpaces(width) {
|
|
288
|
+
const w = Math.max(0, Math.floor(Number(width) || 0));
|
|
289
|
+
return w ? ' '.repeat(w) : '';
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Split an ANSI string after `plainTarget` display columns of visible text. */
|
|
293
|
+
function splitAnsiByPlainWidth(text, plainTarget) {
|
|
294
|
+
const src = String(text ?? '');
|
|
295
|
+
const target = Math.max(0, Math.floor(Number(plainTarget) || 0));
|
|
296
|
+
if (!src) return ['', ''];
|
|
297
|
+
if (target <= 0) return ['', src];
|
|
298
|
+
let i = 0;
|
|
299
|
+
let plain = 0;
|
|
300
|
+
while (i < src.length) {
|
|
301
|
+
if (plain >= target) break;
|
|
302
|
+
if (src[i] === '\x1b') {
|
|
303
|
+
const rest = src.slice(i);
|
|
304
|
+
const sgr = rest.match(/^\x1b\[[0-9;]*m/);
|
|
305
|
+
if (sgr) {
|
|
306
|
+
i += sgr[0].length;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const osc = rest.match(/^\x1b\]8;;[^\x07]*\x07/);
|
|
310
|
+
if (osc) {
|
|
311
|
+
i += osc[0].length;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const cp = src.codePointAt(i);
|
|
316
|
+
const ch = String.fromCodePoint(cp);
|
|
317
|
+
plain += stringWidth(ch);
|
|
318
|
+
i += cp > 0xffff ? 2 : 1;
|
|
319
|
+
}
|
|
320
|
+
return [src.slice(0, i), src.slice(i)];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function leadingPrefixPlainWidth(plainLine) {
|
|
324
|
+
const rm = READ_LINE_RE.exec(plainLine);
|
|
325
|
+
if (rm) return stringWidth(rm[1] + rm[2] + rm[3]);
|
|
326
|
+
const gm = GREP_LINE_RE.exec(plainLine);
|
|
327
|
+
if (gm) return stringWidth(gm[1] + gm[2] + gm[3]);
|
|
328
|
+
return 0;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function wrapOneExpandedLogicalLine(line, maxWidth) {
|
|
332
|
+
const src = String(line ?? '');
|
|
333
|
+
if (!src) return [' '];
|
|
334
|
+
if (stringWidth(src) <= maxWidth) return [src];
|
|
335
|
+
|
|
336
|
+
const prefixPlainW = leadingPrefixPlainWidth(stripAnsi(src));
|
|
337
|
+
const [prefix, body] = prefixPlainW > 0
|
|
338
|
+
? splitAnsiByPlainWidth(src, prefixPlainW)
|
|
339
|
+
: ['', src];
|
|
340
|
+
const prefixW = stringWidth(prefix);
|
|
341
|
+
const bodyBudget = Math.max(1, maxWidth - prefixW);
|
|
342
|
+
const bodyPieces = wrapText(body, bodyBudget, { hard: true });
|
|
343
|
+
if (bodyPieces.length <= 1) return [src];
|
|
344
|
+
|
|
345
|
+
const out = [];
|
|
346
|
+
for (let i = 0; i < bodyPieces.length; i++) {
|
|
347
|
+
if (i === 0) out.push(`${prefix}${bodyPieces[i]}`);
|
|
348
|
+
else out.push(`${padDisplaySpaces(prefixW)}${bodyPieces[i]}`);
|
|
349
|
+
}
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Turn logical expanded lines into physical rows that fit the body column.
|
|
355
|
+
* One output row per left-rail row in ToolExecution (lockstep with App row est.).
|
|
356
|
+
*/
|
|
357
|
+
export function wrapExpandedResultLines(logicalLines, columns = 80) {
|
|
358
|
+
const maxWidth = expandedResultBodyWidth(columns);
|
|
359
|
+
const lines = Array.isArray(logicalLines) ? logicalLines : [];
|
|
360
|
+
const out = [];
|
|
361
|
+
for (const line of lines) {
|
|
362
|
+
out.push(...wrapOneExpandedLogicalLine(line, maxWidth));
|
|
363
|
+
}
|
|
364
|
+
return out.length > 0 ? out : [' '];
|
|
365
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
formatExpandedResult,
|
|
5
|
+
wrapExpandedResultLines,
|
|
6
|
+
expandedResultBodyWidth,
|
|
7
|
+
inferLangFamily,
|
|
8
|
+
stripUnderlineAnsi,
|
|
9
|
+
linkifyUrls,
|
|
10
|
+
tryFormatJson,
|
|
11
|
+
} from './tool-output-format.mjs';
|
|
12
|
+
import stringWidth from 'string-width';
|
|
13
|
+
|
|
14
|
+
const stripAnsi = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '').replace(/\x1b\]8;;[^\x07]*\x07/g, '');
|
|
15
|
+
|
|
16
|
+
test('inferLangFamily maps known extensions, null otherwise', () => {
|
|
17
|
+
assert.equal(inferLangFamily('src/x.mjs'), 'js');
|
|
18
|
+
assert.equal(inferLangFamily('a.py'), 'py');
|
|
19
|
+
assert.equal(inferLangFamily('x.json'), 'json');
|
|
20
|
+
assert.equal(inferLangFamily('run.sh'), 'sh');
|
|
21
|
+
assert.equal(inferLangFamily('readme'), null);
|
|
22
|
+
assert.equal(inferLangFamily(''), null);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('read line-number gutter is split into a dim column and body is highlighted', () => {
|
|
26
|
+
const read = '900\u2192function f() {\n901\u2192 return 1;\n902\u2192}';
|
|
27
|
+
const out = formatExpandedResult(read, { pathArg: 'a.mjs' });
|
|
28
|
+
assert.equal(out.length, 3);
|
|
29
|
+
// gutter text survives, body keyword colored (output carries SGR escapes).
|
|
30
|
+
assert.ok(stripAnsi(out[0]).startsWith('900\u2192function f() {'));
|
|
31
|
+
assert.ok(out[0].includes('\x1b['), 'first line carries color escapes');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('grep file:line: gutter is split too', () => {
|
|
35
|
+
const grep = 'src/a.mjs:5581: value: foo,';
|
|
36
|
+
const out = formatExpandedResult(grep, { pathArg: 'src/a.mjs' });
|
|
37
|
+
assert.equal(out.length, 1);
|
|
38
|
+
assert.ok(stripAnsi(out[0]).startsWith('src/a.mjs:5581:'));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('grep Windows absolute path:line: gutter is split', () => {
|
|
42
|
+
const grep = 'C:\\Project\\mixdog\\src\\App.jsx:12: const x = 1;';
|
|
43
|
+
const out = formatExpandedResult(grep, { pathArg: 'src/App.jsx' });
|
|
44
|
+
assert.equal(out.length, 1);
|
|
45
|
+
const plain = stripAnsi(out[0]);
|
|
46
|
+
assert.ok(plain.startsWith('C:\\Project\\mixdog\\src\\App.jsx:12:'));
|
|
47
|
+
assert.ok(plain.includes('const x = 1;'), 'body preserved after gutter');
|
|
48
|
+
assert.ok(out[0].includes('\x1b['), 'body carries highlight escapes');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('grep bare line-number gutter still works', () => {
|
|
52
|
+
const grep = '12: return null;';
|
|
53
|
+
const out = formatExpandedResult(grep, { pathArg: 'a.mjs' });
|
|
54
|
+
assert.equal(out.length, 1);
|
|
55
|
+
assert.ok(stripAnsi(out[0]).startsWith('12:'));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('https URLs are not treated as grep path:line: gutters', () => {
|
|
59
|
+
const line = 'see https://example.com:443/path for docs';
|
|
60
|
+
const out = formatExpandedResult(line, {});
|
|
61
|
+
assert.equal(out.length, 1);
|
|
62
|
+
assert.equal(stripAnsi(out[0]), line);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('JSON result is auto pretty-printed (size-capped, precision-safe)', () => {
|
|
66
|
+
const out = formatExpandedResult('{"a":1,"b":[2,3]}', {});
|
|
67
|
+
assert.ok(out.length > 1, 'single-line JSON expands to multiple rows');
|
|
68
|
+
assert.equal(stripAnsi(out[0]), '{');
|
|
69
|
+
// precision loss → keep original line
|
|
70
|
+
const big = '{"id":123456789012345678901234567890}';
|
|
71
|
+
assert.equal(tryFormatJson(big), big);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('JSON with markdown-like string values stays JSON (no markdown lexer)', () => {
|
|
75
|
+
const raw = '["**literal**","`code`"]';
|
|
76
|
+
const out = formatExpandedResult(raw, {});
|
|
77
|
+
const visible = out.map(stripAnsi).join('\n');
|
|
78
|
+
assert.ok(visible.includes('**literal**'), 'array string keeps ** delimiters');
|
|
79
|
+
assert.ok(visible.includes('`code`'), 'array string keeps backticks');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('JSON object with markdown-like values is not rendered as markdown', () => {
|
|
83
|
+
const raw = '{"msg":"**bold**","x":"`y`"}';
|
|
84
|
+
const out = formatExpandedResult(raw, {});
|
|
85
|
+
const visible = out.map(stripAnsi).join('\n');
|
|
86
|
+
assert.ok(visible.includes('**bold**'));
|
|
87
|
+
assert.ok(visible.includes('`y`'));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('shell output keeps color ANSI but strips underline and never highlights', () => {
|
|
91
|
+
const sh = 'PASS \x1b[32mok\x1b[0m\n\x1b[4mfile.js\x1b[24m done';
|
|
92
|
+
const out = formatExpandedResult(sh, { isShell: true });
|
|
93
|
+
assert.ok(out[0].includes('\x1b[32m'), 'color ANSI preserved');
|
|
94
|
+
assert.ok(!out[1].includes('\x1b[4m'), 'underline stripped');
|
|
95
|
+
assert.equal(stripAnsi(out[1]), 'file.js done');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('unified diff is colored by line class', () => {
|
|
99
|
+
const diff = 'diff --git a/x b/x\n@@ -1 +1 @@\n-old\n+new\n ctx';
|
|
100
|
+
const out = formatExpandedResult(diff, {});
|
|
101
|
+
assert.equal(out.length, 5);
|
|
102
|
+
assert.ok(out.every((l) => l.includes('\x1b[')), 'every diff line colored');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('oversize output is capped with a truncation marker', () => {
|
|
106
|
+
const many = Array.from({ length: 5000 }, (_, i) => `line${i}`).join('\n');
|
|
107
|
+
const out = formatExpandedResult(many, {});
|
|
108
|
+
assert.ok(out.length <= 81);
|
|
109
|
+
assert.ok(/truncated/.test(stripAnsi(out[out.length - 1])));
|
|
110
|
+
|
|
111
|
+
const huge = 'x'.repeat(300 * 1024);
|
|
112
|
+
const out2 = formatExpandedResult(huge, {});
|
|
113
|
+
assert.ok(/truncated/.test(stripAnsi(out2[out2.length - 1])));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('stripUnderlineAnsi removes only underline SGR', () => {
|
|
117
|
+
assert.equal(stripUnderlineAnsi('a\x1b[4mu\x1b[24mb'), 'aub');
|
|
118
|
+
assert.equal(stripUnderlineAnsi('a\x1b[32mg\x1b[0mb'), 'a\x1b[32mg\x1b[0mb');
|
|
119
|
+
assert.equal(stripUnderlineAnsi('x\x1b[4;31my'), 'x\x1b[31my');
|
|
120
|
+
assert.equal(stripUnderlineAnsi('x\x1b[42my'), 'x\x1b[42my');
|
|
121
|
+
assert.equal(stripUnderlineAnsi('x\x1b[31;4my'), 'x\x1b[31my');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('linkifyUrls wraps bare URLs in OSC 8', () => {
|
|
125
|
+
const out = linkifyUrls('see https://x.com/y here');
|
|
126
|
+
assert.ok(out.includes('\x1b]8;;https://x.com/y\x07'));
|
|
127
|
+
assert.equal(linkifyUrls('no url here'), 'no url here');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('linkifyUrls keeps ANSI out of OSC 8 URL targets', () => {
|
|
131
|
+
const colored = 'go \x1b[31mhttps://example.com/a\x1b[0m end';
|
|
132
|
+
const out = linkifyUrls(colored);
|
|
133
|
+
const m = /\x1b]8;;([^\x07]+)\x07/.exec(out);
|
|
134
|
+
assert.ok(m, 'OSC 8 opener present');
|
|
135
|
+
assert.equal(m[1], 'https://example.com/a');
|
|
136
|
+
assert.ok(!m[1].includes('\x1b'), 'target URL has no escape bytes');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
function osc8UrlTargets(text) {
|
|
140
|
+
const targets = [];
|
|
141
|
+
const bel = /\x1b]8;;([^\x07]+)\x07/g;
|
|
142
|
+
const st = /\x1b]8;;((?:[^\x1b]|\x1b(?![\\]))+)\x1b\\/g;
|
|
143
|
+
let m;
|
|
144
|
+
while ((m = bel.exec(text)) !== null) targets.push(m[1]);
|
|
145
|
+
while ((m = st.exec(text)) !== null) targets.push(m[1]);
|
|
146
|
+
return targets;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
test('linkifyUrls does not re-linkify existing BEL OSC 8 hyperlinks', () => {
|
|
150
|
+
const belLink = '\x1b]8;;https://a.test\x07label\x1b]8;;\x07';
|
|
151
|
+
const out = linkifyUrls(belLink);
|
|
152
|
+
assert.equal(out, belLink);
|
|
153
|
+
assert.equal((out.match(/\x1b]8;;/g) || []).length, 2, 'single open/close pair');
|
|
154
|
+
for (const target of osc8UrlTargets(out)) {
|
|
155
|
+
assert.ok(!target.includes('\x1b'), 'target has no escape bytes');
|
|
156
|
+
assert.ok(!target.includes('\x1b]8;;'), 'no nested OSC in target');
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('linkifyUrls does not re-linkify existing ST OSC 8 hyperlinks', () => {
|
|
161
|
+
const stLink = '\x1b]8;;https://a.test\x1b\\label\x1b]8;;\x1b\\';
|
|
162
|
+
const out = linkifyUrls(stLink);
|
|
163
|
+
assert.equal(out, stLink);
|
|
164
|
+
assert.equal((out.match(/\x1b]8;;/g) || []).length, 2, 'single open/close pair');
|
|
165
|
+
for (const target of osc8UrlTargets(out)) {
|
|
166
|
+
assert.ok(!target.includes('\x1b'), 'target has no escape bytes');
|
|
167
|
+
assert.ok(!target.includes('\x1b]8;;'), 'no nested OSC in target');
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('empty input yields no lines', () => {
|
|
172
|
+
assert.deepEqual(formatExpandedResult('', {}), []);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('wrapExpandedResultLines splits long logical rows to body width', () => {
|
|
176
|
+
const columns = 48;
|
|
177
|
+
const maxW = expandedResultBodyWidth(columns);
|
|
178
|
+
const longBody = 'alpha_beta_gamma_delta '.repeat(6).trimEnd();
|
|
179
|
+
const logical = formatExpandedResult(`900\u2192${longBody}`, { pathArg: 'a.mjs' });
|
|
180
|
+
assert.equal(logical.length, 1);
|
|
181
|
+
const physical = wrapExpandedResultLines(logical, columns);
|
|
182
|
+
assert.ok(physical.length > 1, 'long read line becomes multiple physical rows');
|
|
183
|
+
for (const row of physical) {
|
|
184
|
+
assert.ok(
|
|
185
|
+
stringWidth(stripAnsi(row)) <= maxW,
|
|
186
|
+
`row width ${stringWidth(stripAnsi(row))} exceeds budget ${maxW}`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
for (let i = 1; i < physical.length; i++) {
|
|
190
|
+
assert.ok(/^\s+/.test(stripAnsi(physical[i])), 'wrapped continuations are indented');
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('expanded markdown prose renders headings and emphasis (not raw #/**)', () => {
|
|
195
|
+
const md = '# Title\n\nSome **bold** and `inline`.\n';
|
|
196
|
+
const out = formatExpandedResult(md, {});
|
|
197
|
+
const visible = out.map(stripAnsi).join('\n');
|
|
198
|
+
assert.ok(!visible.includes('# Title'), 'ATX heading marker not shown raw');
|
|
199
|
+
assert.ok(visible.includes('Title'), 'heading text preserved');
|
|
200
|
+
assert.ok(visible.includes('bold'), 'strong text preserved');
|
|
201
|
+
assert.ok(!visible.includes('**'), 'emphasis delimiters stripped');
|
|
202
|
+
assert.ok(out.some((l) => l.includes('\x1b[')), 'markdown path applies theme colors');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('expanded fenced code in markdown path is highlighted', () => {
|
|
206
|
+
const md = '```js\nconst x = 1;\n```\n';
|
|
207
|
+
const out = formatExpandedResult(md, {});
|
|
208
|
+
const visible = out.map(stripAnsi).join('\n');
|
|
209
|
+
assert.ok(visible.includes('const x = 1;'), 'fence body preserved');
|
|
210
|
+
assert.ok(out.some((l) => l.includes('\x1b[')), 'fenced block carries ANSI');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('read gutter lines skip markdown mode and keep syntax highlight', () => {
|
|
214
|
+
const read = '1\u2192# not a heading\n2\u2192const y = 2;\n';
|
|
215
|
+
const out = formatExpandedResult(read, { pathArg: 'a.mjs' });
|
|
216
|
+
const visible = out.map(stripAnsi);
|
|
217
|
+
assert.ok(visible.some((l) => l.includes('1\u2192# not a heading')), 'source # kept on read lines');
|
|
218
|
+
assert.ok(visible.some((l) => l.includes('const y = 2;')), 'read body preserved');
|
|
219
|
+
assert.ok(out.some((l) => stripAnsi(l).includes('const y = 2;') && l.includes('\x1b[')), 'code line still highlighted');
|
|
220
|
+
});
|