mixdog 0.9.1 → 0.9.3
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 +9 -1
- package/scripts/_bench-cwc.json +20 -0
- package/scripts/agent-loop-policy-test.mjs +37 -0
- package/scripts/agent-parallel-smoke.mjs +54 -10
- package/scripts/anthropic-maxtokens-test.mjs +119 -0
- package/scripts/background-task-meta-smoke.mjs +1 -1
- package/scripts/bench-run.mjs +262 -0
- package/scripts/build-tui.mjs +13 -1
- package/scripts/compact-smoke.mjs +12 -0
- package/scripts/compact-trigger-migration-smoke.mjs +67 -1
- package/scripts/explore-bench.mjs +124 -0
- package/scripts/hook-bus-test.mjs +191 -0
- 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/path-suffix-test.mjs +57 -0
- package/scripts/provider-stream-stall-test.mjs +276 -0
- package/scripts/provider-toolcall-test.mjs +599 -1
- package/scripts/recall-bench.mjs +207 -0
- package/scripts/routing-corpus.mjs +281 -0
- package/scripts/session-bench.mjs +1526 -0
- package/scripts/session-diag.mjs +595 -0
- package/scripts/task-bench.mjs +207 -0
- package/scripts/tool-failures.mjs +6 -6
- package/scripts/tool-smoke.mjs +310 -67
- package/scripts/toolcall-args-test.mjs +81 -0
- package/src/agents/debugger/AGENT.md +4 -4
- package/src/agents/heavy-worker/AGENT.md +20 -9
- package/src/agents/reviewer/AGENT.md +4 -4
- package/src/agents/worker/AGENT.md +17 -9
- package/src/app.mjs +10 -6
- package/src/defaults/{hidden-roles.json → agents.json} +7 -7
- package/src/examples/schedules/SCHEDULE.example.md +32 -0
- package/src/examples/webhooks/WEBHOOK.example.md +40 -0
- package/src/headless-role.mjs +14 -14
- package/src/help.mjs +1 -0
- package/src/lib/rules-builder.cjs +32 -54
- package/src/mixdog-session-runtime.mjs +1040 -2036
- 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 +17 -7
- package/src/rules/agent/00-common.md +7 -5
- package/src/rules/agent/30-explorer.md +8 -12
- package/src/rules/lead/01-general.md +3 -1
- package/src/rules/lead/lead-tool.md +13 -2
- package/src/rules/shared/01-tool.md +23 -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 +182 -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 +100 -18
- 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-max-tokens.mjs +93 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +313 -84
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +104 -38
- package/src/runtime/agent/orchestrator/providers/api-usage.mjs +28 -33
- 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/lib/usage-primitives.mjs +32 -0
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +227 -31
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +83 -6
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +229 -115
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +213 -33
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
- package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +49 -17
- package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +22 -17
- 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/compact-debug.mjs +28 -0
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
- package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
- package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
- package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
- package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
- package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
- package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
- package/src/runtime/agent/orchestrator/session/loop.mjs +513 -1000
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
- package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +232 -1152
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
- package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
- package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
- 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-diagnostics.mjs +42 -2
- 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 +14 -5
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
- 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 +70 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/code-graph.mjs +39 -4189
- package/src/runtime/agent/orchestrator/tools/patch.mjs +119 -5
- package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
- package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
- package/src/runtime/channels/backends/discord.mjs +99 -9
- package/src/runtime/channels/backends/telegram.mjs +501 -0
- package/src/runtime/channels/index.mjs +437 -1439
- package/src/runtime/channels/lib/boot-profile.mjs +23 -0
- package/src/runtime/channels/lib/config.mjs +54 -2
- package/src/runtime/channels/lib/crash-log.mjs +106 -0
- package/src/runtime/channels/lib/format.mjs +4 -2
- package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
- package/src/runtime/channels/lib/output-forwarder.mjs +88 -67
- package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
- package/src/runtime/channels/lib/scheduler.mjs +1 -1
- package/src/runtime/channels/lib/telegram-format.mjs +280 -0
- package/src/runtime/channels/lib/tool-format.mjs +1 -1
- package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
- package/src/runtime/channels/lib/webhook.mjs +59 -31
- package/src/runtime/channels/lib/whisper-language.mjs +42 -0
- package/src/runtime/channels/tool-defs.mjs +1 -1
- package/src/runtime/memory/index.mjs +465 -345
- package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
- package/src/runtime/memory/lib/core-memory-store.mjs +352 -2
- package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
- package/src/runtime/memory/lib/http-wire.mjs +57 -0
- package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle2.mjs +65 -9
- package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
- package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
- package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
- package/src/runtime/memory/lib/memory.mjs +121 -4
- package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
- package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
- package/src/runtime/memory/lib/recall-format.mjs +183 -0
- package/src/runtime/memory/lib/session-ingest.mjs +107 -0
- package/src/runtime/memory/lib/trace-store.mjs +69 -22
- package/src/runtime/memory/tool-defs.mjs +10 -7
- package/src/runtime/shared/abort-controller.mjs +1 -1
- package/src/runtime/shared/background-tasks.mjs +2 -3
- package/src/runtime/shared/buffered-appender.mjs +149 -0
- package/src/runtime/shared/channel-notification-routing.mjs +12 -0
- package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
- package/src/runtime/shared/config.mjs +9 -0
- package/src/runtime/shared/llm/http-agent.mjs +12 -5
- package/src/runtime/shared/schedules-store.mjs +21 -19
- package/src/runtime/shared/task-notification-envelope.mjs +98 -0
- package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
- package/src/runtime/shared/tool-execution-contract.mjs +2 -2
- package/src/runtime/shared/tool-surface.mjs +98 -13
- package/src/runtime/shared/transcript-writer.mjs +156 -0
- package/src/runtime/shared/update-checker.mjs +214 -0
- package/src/session-runtime/config-helpers.mjs +209 -0
- package/src/session-runtime/effort.mjs +128 -0
- package/src/session-runtime/fs-utils.mjs +10 -0
- package/src/session-runtime/model-capabilities.mjs +130 -0
- package/src/session-runtime/output-styles.mjs +124 -0
- package/src/session-runtime/plugin-mcp.mjs +114 -0
- package/src/session-runtime/session-text.mjs +100 -0
- package/src/session-runtime/statusline-route.mjs +35 -0
- package/src/session-runtime/tool-catalog.mjs +720 -0
- package/src/session-runtime/workflow.mjs +358 -0
- package/src/standalone/agent-tool.mjs +302 -117
- package/src/standalone/channel-admin.mjs +133 -40
- package/src/standalone/channel-worker.mjs +10 -292
- package/src/standalone/explore-tool.mjs +12 -5
- package/src/standalone/hook-bus.mjs +165 -8
- package/src/standalone/memory-runtime-proxy.mjs +3 -1
- package/src/standalone/opencode-go-login.mjs +121 -0
- package/src/standalone/provider-admin.mjs +25 -3
- package/src/standalone/usage-dashboard.mjs +1 -1
- package/src/tui/App.jsx +2883 -778
- 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 +355 -22
- 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 +183 -101
- package/src/tui/components/TurnDone.jsx +4 -4
- package/src/tui/components/UsagePanel.jsx +1 -1
- package/src/tui/components/tool-output-format.mjs +161 -23
- package/src/tui/components/tool-output-format.test.mjs +87 -0
- package/src/tui/display-width.mjs +69 -0
- package/src/tui/display-width.test.mjs +35 -0
- package/src/tui/dist/index.mjs +6731 -2333
- package/src/tui/engine/agent-envelope.mjs +296 -0
- package/src/tui/engine/boot-profile.mjs +21 -0
- package/src/tui/engine/labels.mjs +67 -0
- package/src/tui/engine/notice-text.mjs +112 -0
- package/src/tui/engine/queue-helpers.mjs +161 -0
- package/src/tui/engine/session-stats.mjs +46 -0
- package/src/tui/engine/tool-call-fields.mjs +23 -0
- package/src/tui/engine/tool-result-text.mjs +126 -0
- package/src/tui/engine.mjs +569 -948
- package/src/tui/index.jsx +117 -7
- package/src/tui/input-editing.mjs +58 -8
- package/src/tui/input-editing.selection.test.mjs +75 -0
- package/src/tui/keyboard-protocol.mjs +42 -0
- package/src/tui/lib/voice-recorder.mjs +469 -0
- package/src/tui/markdown/format-token.mjs +128 -76
- package/src/tui/markdown/format-token.test.mjs +61 -19
- package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
- package/src/tui/markdown/render-ansi.test.mjs +1 -1
- package/src/tui/markdown/streaming-markdown.mjs +167 -0
- package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
- package/src/tui/markdown/table-layout.mjs +9 -9
- package/src/tui/paste-attachments.mjs +36 -9
- package/src/tui/paste-fix.test.mjs +119 -0
- package/src/tui/prompt-history-store.mjs +129 -0
- package/src/tui/prompt-history-store.test.mjs +52 -0
- package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
- package/src/tui/theme.mjs +41 -657
- package/src/tui/themes/base.mjs +86 -0
- package/src/tui/themes/basic.mjs +85 -0
- package/src/tui/themes/catppuccin.mjs +72 -0
- package/src/tui/themes/dracula.mjs +70 -0
- package/src/tui/themes/everforest.mjs +71 -0
- package/src/tui/themes/gruvbox.mjs +71 -0
- package/src/tui/themes/index.mjs +71 -0
- package/src/tui/themes/indigo.mjs +78 -0
- package/src/tui/themes/kanagawa.mjs +80 -0
- package/src/tui/themes/light.mjs +81 -0
- package/src/tui/themes/nord.mjs +72 -0
- package/src/tui/themes/onedark.mjs +16 -0
- package/src/tui/themes/rosepine.mjs +70 -0
- package/src/tui/themes/teal.mjs +80 -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 +75 -27
- package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
- package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
- package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
- package/src/workflows/default/WORKFLOW.md +46 -12
- package/src/workflows/sequential/WORKFLOW.md +51 -0
- package/src/workflows/solo/WORKFLOW.md +12 -1
- package/vendor/ink/build/display-width.js +62 -0
- package/vendor/ink/build/ink.js +100 -12
- package/vendor/ink/build/measure-text.js +4 -1
- package/vendor/ink/build/output.js +115 -9
- package/vendor/ink/build/render-node-to-output.js +4 -1
- package/vendor/ink/build/render.js +4 -0
- package/src/output-styles/extreme-simple.md +0 -20
- package/src/rules/lead/04-workflow.md +0 -51
- package/src/workflows/default/workflow.json +0 -13
- package/src/workflows/solo/workflow.json +0 -7
|
@@ -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,15 +67,42 @@ 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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
70
|
+
// --- Large pasted-text folding ------------------------------------------
|
|
71
|
+
// A big paste is replaced in the prompt by a compact token so the editor does
|
|
72
|
+
// not choke on thousands of raw characters; the original text is stored in an
|
|
73
|
+
// App-level map (mirroring pastedImages) and expanded back on submit.
|
|
74
|
+
const PASTE_TOKEN_MIN_LINES = 3;
|
|
75
|
+
const PASTE_TOKEN_MIN_CHARS = 200;
|
|
76
|
+
|
|
77
|
+
export function shouldFoldPastedText(text) {
|
|
78
|
+
const value = String(text ?? '');
|
|
79
|
+
if (!value) return false;
|
|
80
|
+
const lineCount = value.split('\n').length;
|
|
81
|
+
return lineCount >= PASTE_TOKEN_MIN_LINES || value.length > PASTE_TOKEN_MIN_CHARS;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function formatPastedTextRef(id, text) {
|
|
85
|
+
const lines = String(text ?? '').split('\n').length;
|
|
86
|
+
return `[Pasted text #${id} +${lines} lines]`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function pastedTextReferenceIds(input) {
|
|
90
|
+
const re = /\[Pasted text #(\d+) \+\d+ lines\]/g;
|
|
91
|
+
return new Set([...String(input || '').matchAll(re)].map((m) => Number(m[1]) || 0).filter(Boolean));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Expand every intact pasted-text token in `value` back to its original text.
|
|
95
|
+
// Broken / partially-deleted tokens simply do not match and are left as-is.
|
|
96
|
+
export function expandPastedTextTokens(value, pastedTexts = {}) {
|
|
97
|
+
let out = String(value ?? '');
|
|
98
|
+
if (!pastedTexts || typeof pastedTexts !== 'object') return out;
|
|
99
|
+
const re = /\[Pasted text #(\d+) \+\d+ lines\]/g;
|
|
100
|
+
out = out.replace(re, (match, idRaw) => {
|
|
101
|
+
const entry = pastedTexts[Number(idRaw)] || pastedTexts[idRaw];
|
|
102
|
+
if (entry && typeof entry.text === 'string') return entry.text;
|
|
103
|
+
return match;
|
|
104
|
+
});
|
|
105
|
+
return out;
|
|
79
106
|
}
|
|
80
107
|
|
|
81
108
|
export function buildPromptContentWithImages(text, pastedImages = {}) {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import stringWidth from 'string-width';
|
|
4
|
+
import {
|
|
5
|
+
shouldFoldPastedText,
|
|
6
|
+
formatPastedTextRef,
|
|
7
|
+
pastedTextReferenceIds,
|
|
8
|
+
expandPastedTextTokens,
|
|
9
|
+
} from './paste-attachments.mjs';
|
|
10
|
+
import { caretPosition } from './input-editing.mjs';
|
|
11
|
+
|
|
12
|
+
// --- Part 1: pasted-text token folding ----------------------------------
|
|
13
|
+
|
|
14
|
+
test('shouldFoldPastedText fires on >=3 lines or >200 chars', () => {
|
|
15
|
+
assert.equal(shouldFoldPastedText('a\nb\nc'), true); // 3 lines
|
|
16
|
+
assert.equal(shouldFoldPastedText('a\nb'), false); // 2 lines, short
|
|
17
|
+
assert.equal(shouldFoldPastedText('x'.repeat(201)), true); // > 200 chars
|
|
18
|
+
assert.equal(shouldFoldPastedText('x'.repeat(200)), false); // == 200, not over
|
|
19
|
+
assert.equal(shouldFoldPastedText(''), false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('token round-trips through format + expand', () => {
|
|
23
|
+
const original = 'line1\nline2\nline3';
|
|
24
|
+
const token = formatPastedTextRef(7, original);
|
|
25
|
+
assert.equal(token, '[Pasted text #7 +3 lines]');
|
|
26
|
+
const refs = pastedTextReferenceIds(`before ${token} after`);
|
|
27
|
+
assert.deepEqual([...refs], [7]);
|
|
28
|
+
const map = { 7: { id: 7, text: original } };
|
|
29
|
+
assert.equal(
|
|
30
|
+
expandPastedTextTokens(`before ${token} after`, map),
|
|
31
|
+
`before ${original} after`,
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('broken / partially-deleted tokens do not expand', () => {
|
|
36
|
+
const map = { 3: { id: 3, text: 'HIDDEN' } };
|
|
37
|
+
// Missing the "+N lines" tail — not a valid token, left verbatim.
|
|
38
|
+
assert.equal(expandPastedTextTokens('[Pasted text #3]', map), '[Pasted text #3]');
|
|
39
|
+
// Truncated bracket.
|
|
40
|
+
assert.equal(expandPastedTextTokens('[Pasted text #3 +2 lines', map), '[Pasted text #3 +2 lines');
|
|
41
|
+
// Unknown id stays as-is.
|
|
42
|
+
assert.equal(expandPastedTextTokens('[Pasted text #9 +2 lines]', map), '[Pasted text #9 +2 lines]');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// --- Part 2: caret row math vs ink wrap-ansi (hard, wordWrap:false) -------
|
|
46
|
+
|
|
47
|
+
// Faithful re-implementation of wrap-ansi's wrapWord loop for a single
|
|
48
|
+
// space-free line (the prompt caret math walks graphemes the same way). ink
|
|
49
|
+
// renders <Text wrap="hard"> which calls wrapAnsi(text, w, {hard,wordWrap:false}).
|
|
50
|
+
// For a run with no spaces the whole line goes through wrapWord, so the visual
|
|
51
|
+
// row count is what this produces.
|
|
52
|
+
function inkWrapWordRows(line, columns) {
|
|
53
|
+
const chars = Array.from(new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(line), (s) => s.segment);
|
|
54
|
+
let rows = 1;
|
|
55
|
+
let visible = 0;
|
|
56
|
+
for (let i = 0; i < chars.length; i += 1) {
|
|
57
|
+
const cw = stringWidth(chars[i]);
|
|
58
|
+
if (visible + cw <= columns) {
|
|
59
|
+
// stays on current row
|
|
60
|
+
} else {
|
|
61
|
+
rows += 1;
|
|
62
|
+
visible = 0;
|
|
63
|
+
}
|
|
64
|
+
visible += cw;
|
|
65
|
+
if (visible === columns && i < chars.length - 1) {
|
|
66
|
+
rows += 1;
|
|
67
|
+
visible = 0;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return rows;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// caretPosition row for the END of the string == (visual row count - 1).
|
|
74
|
+
function caretRowsForWholeString(text, w) {
|
|
75
|
+
return caretPosition(text, text.length, w).row + 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
test('caret rows agree with ink wrap for wide-char lines at odd widths', () => {
|
|
79
|
+
// A width where a width-2 glyph cannot sit flush on the last column exposes
|
|
80
|
+
// the mismatch: caretPosition must NOT advance an extra row when the char is
|
|
81
|
+
// simply pushed to the next line (ink does the same — it never leaves a
|
|
82
|
+
// half-column then over-counts).
|
|
83
|
+
const cases = [
|
|
84
|
+
{ text: '가'.repeat(30), w: 41 },
|
|
85
|
+
{ text: '가'.repeat(30), w: 40 },
|
|
86
|
+
{ text: '가나다'.repeat(10) + 'ascii', w: 13 },
|
|
87
|
+
{ text: '한글'.repeat(8), w: 7 },
|
|
88
|
+
{ text: '가'.repeat(5), w: 2 },
|
|
89
|
+
];
|
|
90
|
+
for (const { text, w } of cases) {
|
|
91
|
+
assert.equal(
|
|
92
|
+
caretRowsForWholeString(text, w),
|
|
93
|
+
inkWrapWordRows(text, w),
|
|
94
|
+
`width ${w} on ${JSON.stringify(text.slice(0, 6))}…`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Cursor exactly at a soft-wrap boundary MID-text: ink renders the following
|
|
100
|
+
// glyph on the next row, so the caret must report {row N+1, col 0}, never
|
|
101
|
+
// {row N, col == w} (which would sit outside the box).
|
|
102
|
+
test('caret at mid-text wrap boundary rolls to next row (col 0)', () => {
|
|
103
|
+
const value = '가나다라마'; // 5 wide glyphs, width 2 each
|
|
104
|
+
const w = 4; // 2 glyphs per row
|
|
105
|
+
// offset 2 = after '나' (each glyph is ONE UTF-16 unit) = end of visual
|
|
106
|
+
// row 0; content ('다라마') follows, so the caret rolls to the next row.
|
|
107
|
+
const at = caretPosition(value, 2, w);
|
|
108
|
+
assert.deepEqual(at, { row: 1, col: 0 }, 'boundary caret must sit on next row');
|
|
109
|
+
// offset 4 = after '라' = end of visual row 1 (rows 0-1 are full).
|
|
110
|
+
assert.deepEqual(caretPosition(value, 4, w), { row: 2, col: 0 }, 'second boundary rolls too');
|
|
111
|
+
// Cursor at the very end of a FLUSH-ending text ('가나다라' fills rows 0-1
|
|
112
|
+
// exactly) with a trailing cell (PromptInput appends one) also rolls.
|
|
113
|
+
const flush = '가나다라';
|
|
114
|
+
const end = caretPosition(flush, flush.length, w, true);
|
|
115
|
+
assert.deepEqual(end, { row: 2, col: 0 }, 'flush end with trailing cell rolls to next row');
|
|
116
|
+
// Non-flush end ('마' leaves col 2) must NOT roll even with a trailing cell.
|
|
117
|
+
const nonFlush = caretPosition(value, value.length, w, true);
|
|
118
|
+
assert.deepEqual(nonFlush, { row: 2, col: 2 }, 'non-flush end stays on its row');
|
|
119
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent TUI prompt input history, scoped by project working directory.
|
|
3
|
+
* Stored under MIXDOG data dir (not the repo working tree).
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, join, resolve } from 'node:path';
|
|
8
|
+
import { resolvePluginData } from '../runtime/shared/plugin-paths.mjs';
|
|
9
|
+
|
|
10
|
+
export const PROMPT_HISTORY_LIMIT = 50;
|
|
11
|
+
|
|
12
|
+
export function promptHistoryKey(value) {
|
|
13
|
+
return String(value || '').trim().replace(/\s+/g, ' ');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Stable map key for cwd-scoped prompt history buckets. */
|
|
17
|
+
export function promptHistoryCwdKey(rawPath) {
|
|
18
|
+
const text = String(rawPath || '').trim();
|
|
19
|
+
if (!text) return '';
|
|
20
|
+
const abs = resolve(text);
|
|
21
|
+
return process.platform === 'win32'
|
|
22
|
+
? abs.replace(/[\\/]+$/, '').toLowerCase()
|
|
23
|
+
: abs.replace(/\/+$/, '');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function historyFilePath(cwd) {
|
|
27
|
+
const key = promptHistoryCwdKey(cwd);
|
|
28
|
+
if (!key) return '';
|
|
29
|
+
const digest = createHash('sha256').update(key, 'utf8').digest('hex').slice(0, 32);
|
|
30
|
+
return join(resolvePluginData(), 'tui-prompt-history', `${digest}.json`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readEntries(filePath) {
|
|
34
|
+
if (!filePath || !existsSync(filePath)) return [];
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
37
|
+
const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
|
|
38
|
+
return entries
|
|
39
|
+
.map((entry) => String(entry || '').trim())
|
|
40
|
+
.filter((entry) => promptHistoryKey(entry));
|
|
41
|
+
} catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function writeEntries(filePath, entries) {
|
|
47
|
+
if (!filePath) return false;
|
|
48
|
+
try {
|
|
49
|
+
// Prompt text may be sensitive; restrict directory/file to the owning user.
|
|
50
|
+
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
|
|
51
|
+
const payload = { version: 1, entries };
|
|
52
|
+
const tmp = `${filePath}.${process.pid}.tmp`;
|
|
53
|
+
writeFileSync(tmp, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
54
|
+
renameSync(tmp, filePath);
|
|
55
|
+
try {
|
|
56
|
+
chmodSync(filePath, 0o600);
|
|
57
|
+
} catch {
|
|
58
|
+
/* best-effort; Windows may ignore mode bits */
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* In-memory session list (newest first). Dedupes by promptHistoryKey.
|
|
68
|
+
*/
|
|
69
|
+
export function pushSessionPromptHistory(sessionTexts, value, limit = PROMPT_HISTORY_LIMIT) {
|
|
70
|
+
const text = String(value || '').trim();
|
|
71
|
+
const key = promptHistoryKey(text);
|
|
72
|
+
if (!key) return Array.isArray(sessionTexts) ? sessionTexts : [];
|
|
73
|
+
const base = Array.isArray(sessionTexts) ? sessionTexts : [];
|
|
74
|
+
const next = base.filter((entry) => promptHistoryKey(entry) !== key);
|
|
75
|
+
next.unshift(text);
|
|
76
|
+
return next.length > limit ? next.slice(0, limit) : next;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Merge session-derived prompts (newest first) with persisted prompts from
|
|
81
|
+
* older sessions. Dedupes by promptHistoryKey; session wins ordering.
|
|
82
|
+
*/
|
|
83
|
+
export function buildMergedPromptHistory(sessionTexts, persistedTexts, limit = PROMPT_HISTORY_LIMIT) {
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
const history = [];
|
|
86
|
+
|
|
87
|
+
const push = (raw) => {
|
|
88
|
+
if (history.length >= limit) return;
|
|
89
|
+
const text = String(raw || '').trim();
|
|
90
|
+
const key = promptHistoryKey(text);
|
|
91
|
+
if (!key || seen.has(key)) return;
|
|
92
|
+
seen.add(key);
|
|
93
|
+
history.push(text);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
for (const text of sessionTexts) push(text);
|
|
97
|
+
|
|
98
|
+
const persisted = Array.isArray(persistedTexts) ? persistedTexts : [];
|
|
99
|
+
for (let i = persisted.length - 1; i >= 0; i -= 1) {
|
|
100
|
+
push(persisted[i]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return history;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function loadPromptHistory(cwd) {
|
|
107
|
+
const filePath = historyFilePath(cwd);
|
|
108
|
+
const entries = readEntries(filePath);
|
|
109
|
+
if (entries.length <= PROMPT_HISTORY_LIMIT) return entries;
|
|
110
|
+
return entries.slice(-PROMPT_HISTORY_LIMIT);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Append a submitted prompt for cwd. Skips empty keys; removes prior duplicate
|
|
115
|
+
* keys (promptHistoryKey); caps at PROMPT_HISTORY_LIMIT.
|
|
116
|
+
* Returns the saved entry list (oldest → newest) on success, or null on failure.
|
|
117
|
+
*/
|
|
118
|
+
export function appendPromptHistory(cwd, value) {
|
|
119
|
+
const key = promptHistoryKey(value);
|
|
120
|
+
if (!key) return null;
|
|
121
|
+
const filePath = historyFilePath(cwd);
|
|
122
|
+
const entries = readEntries(filePath).filter((entry) => promptHistoryKey(entry) !== key);
|
|
123
|
+
entries.push(String(value).trim());
|
|
124
|
+
const trimmed = entries.length > PROMPT_HISTORY_LIMIT
|
|
125
|
+
? entries.slice(-PROMPT_HISTORY_LIMIT)
|
|
126
|
+
: entries;
|
|
127
|
+
return writeEntries(filePath, trimmed) ? trimmed : null;
|
|
128
|
+
}
|
|
129
|
+
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import {
|
|
7
|
+
appendPromptHistory,
|
|
8
|
+
buildMergedPromptHistory,
|
|
9
|
+
loadPromptHistory,
|
|
10
|
+
pushSessionPromptHistory,
|
|
11
|
+
promptHistoryKey,
|
|
12
|
+
} from './prompt-history-store.mjs';
|
|
13
|
+
|
|
14
|
+
test('promptHistoryKey normalizes whitespace', () => {
|
|
15
|
+
assert.equal(promptHistoryKey(' hello world '), 'hello world');
|
|
16
|
+
assert.equal(promptHistoryKey(' '), '');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('buildMergedPromptHistory prefers session order and dedupes', () => {
|
|
20
|
+
const merged = buildMergedPromptHistory(
|
|
21
|
+
['newest', 'older'],
|
|
22
|
+
['stale', 'newest', 'from-disk'],
|
|
23
|
+
50,
|
|
24
|
+
);
|
|
25
|
+
assert.deepEqual(merged, ['newest', 'older', 'from-disk', 'stale']);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('appendPromptHistory persists per cwd across reload', () => {
|
|
29
|
+
const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-prompt-history-'));
|
|
30
|
+
const previous = process.env.MIXDOG_DATA_DIR;
|
|
31
|
+
process.env.MIXDOG_DATA_DIR = dataDir;
|
|
32
|
+
try {
|
|
33
|
+
const cwd = join(dataDir, 'project-a');
|
|
34
|
+
assert.deepEqual(appendPromptHistory(cwd, 'first prompt'), ['first prompt']);
|
|
35
|
+
assert.deepEqual(appendPromptHistory(cwd, 'second prompt'), ['first prompt', 'second prompt']);
|
|
36
|
+
assert.deepEqual(appendPromptHistory(cwd, 'first prompt'), ['second prompt', 'first prompt']);
|
|
37
|
+
const loaded = loadPromptHistory(cwd);
|
|
38
|
+
assert.deepEqual(loaded, ['second prompt', 'first prompt']);
|
|
39
|
+
} finally {
|
|
40
|
+
if (previous === undefined) delete process.env.MIXDOG_DATA_DIR;
|
|
41
|
+
else process.env.MIXDOG_DATA_DIR = previous;
|
|
42
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('pushSessionPromptHistory is cwd-session newest-first with dedupe', () => {
|
|
47
|
+
let session = pushSessionPromptHistory([], 'alpha');
|
|
48
|
+
session = pushSessionPromptHistory(session, 'beta');
|
|
49
|
+
session = pushSessionPromptHistory(session, 'alpha');
|
|
50
|
+
assert.deepEqual(session, ['alpha', 'beta']);
|
|
51
|
+
});
|
|
52
|
+
|