mixdog 0.9.26 → 0.9.28
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 +7 -2
- package/scripts/arg-guard-test.mjs +68 -0
- package/scripts/compacted-placeholder-scrub-test.mjs +77 -0
- package/src/app.mjs +14 -0
- package/src/cli.mjs +40 -4
- package/src/rules/shared/01-tool.md +3 -0
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +3 -1
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +30 -0
- package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +5 -0
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +14 -1
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
- package/src/runtime/memory/tool-defs.mjs +3 -3
- package/src/runtime/shared/staged-install-worker.mjs +14 -0
- package/src/runtime/shared/staged-update.mjs +530 -0
- package/src/runtime/shared/update-checker.mjs +1 -1
- package/src/session-runtime/lifecycle-api.mjs +9 -11
- package/src/session-runtime/runtime-core.mjs +61 -40
- package/src/standalone/agent-tool/tool-def.mjs +2 -2
- package/src/tui/App.jsx +2 -1
- package/src/tui/app/transcript-window.mjs +26 -6
- package/src/tui/components/Spinner.jsx +5 -2
- package/src/tui/components/StatusLine.jsx +9 -9
- package/src/tui/components/ToolExecution.jsx +34 -59
- package/src/tui/display-width.mjs +8 -4
- package/src/tui/dist/index.mjs +545 -425
- package/src/tui/engine/notification-plan.mjs +5 -1
- package/src/tui/hooks/useSharedTick.mjs +103 -0
- package/src/tui/index.jsx +2 -1
- package/src/tui/markdown/format-token.mjs +46 -8
- package/src/tui/markdown/render-ansi.mjs +24 -1
- package/src/tui/markdown/stream-fence.mjs +60 -0
- package/src/tui/markdown/streaming-markdown.mjs +22 -1
- package/src/workflows/default/WORKFLOW.md +12 -8
- package/vendor/ink/build/display-width.js +5 -4
|
@@ -15,7 +15,8 @@ import { writeLastSessionCwd } from '../runtime/shared/user-cwd.mjs';
|
|
|
15
15
|
import { cancelBackgroundTasks } from '../runtime/shared/background-tasks.mjs';
|
|
16
16
|
import { createTranscriptWriter } from '../runtime/shared/transcript-writer.mjs';
|
|
17
17
|
import { mixdogHome } from '../runtime/shared/plugin-paths.mjs';
|
|
18
|
-
import { checkLatestVersion,
|
|
18
|
+
import { checkLatestVersion, localPackageVersion, isDevInstall } from '../runtime/shared/update-checker.mjs';
|
|
19
|
+
import { spawnStagedInstall, runStagedInstall, isStagedComplete } from '../runtime/shared/staged-update.mjs';
|
|
19
20
|
import {
|
|
20
21
|
modelVisibleToolCompletionMessage,
|
|
21
22
|
shouldPersistModelVisibleToolCompletion,
|
|
@@ -634,12 +635,14 @@ export async function createMixdogSessionRuntime({
|
|
|
634
635
|
};
|
|
635
636
|
// phase: 'idle' | 'checking' | 'installing' | 'installed' | 'failed'
|
|
636
637
|
let updateProcessState = { phase: 'idle', version: null, error: null };
|
|
637
|
-
// Boot detects an available update
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
|
|
638
|
+
// Boot detects an available update and STAGES it in the background (a hidden,
|
|
639
|
+
// detached npm install into ~/.mixdog/data/staging/<ver>). The staged package
|
|
640
|
+
// is swapped into the global dir on the next clean launch (cli.mjs
|
|
641
|
+
// pre-import), never while a session is live — so npm never overwrites the
|
|
642
|
+
// .mjs files node currently holds (the old shutdown `npm install -g` did, and
|
|
643
|
+
// caused Windows TAR_ENTRY_ERROR / ENOENT). The live-session refcount that a
|
|
644
|
+
// concurrent launch consults to defer its swap is registered earlier, in
|
|
645
|
+
// cli.mjs (pre-import), so this process is visible before any runtime loads.
|
|
643
646
|
|
|
644
647
|
function autoUpdateEnabled() {
|
|
645
648
|
return config?.update?.auto !== false;
|
|
@@ -668,14 +671,23 @@ export async function createMixdogSessionRuntime({
|
|
|
668
671
|
if (updateProcessState.phase === 'installing') {
|
|
669
672
|
return { ...updateProcessState, alreadyInstalling: true, error: 'update already in progress' };
|
|
670
673
|
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
674
|
+
if (isDevInstall()) {
|
|
675
|
+
updateProcessState = { phase: 'failed', version: null, error: 'dev install — update skipped' };
|
|
676
|
+
return updateProcessState;
|
|
677
|
+
}
|
|
678
|
+
const ver = updateCheckState.latestVersion;
|
|
679
|
+
if (!ver || !updateCheckState.updateAvailable) {
|
|
680
|
+
updateProcessState = { phase: 'idle', version: null, error: null };
|
|
681
|
+
return { ...updateProcessState, error: 'no update available' };
|
|
682
|
+
}
|
|
683
|
+
// "Update now" stages the new version (verified, self-contained) rather than
|
|
684
|
+
// installing over the live global dir; the swap applies on the next launch.
|
|
685
|
+
// phase 'installed' here means "staged & ready — restart to apply".
|
|
686
|
+
updateProcessState = { phase: 'installing', version: ver, error: null };
|
|
675
687
|
try {
|
|
676
|
-
const result = await
|
|
688
|
+
const result = await runStagedInstall(ver);
|
|
677
689
|
if (result?.ok) {
|
|
678
|
-
updateProcessState = { phase: 'installed', version: result.version ||
|
|
690
|
+
updateProcessState = { phase: 'installed', version: result.version || ver, error: null };
|
|
679
691
|
} else {
|
|
680
692
|
updateProcessState = { phase: 'failed', version: null, error: result?.error || 'update failed' };
|
|
681
693
|
}
|
|
@@ -689,43 +701,53 @@ export async function createMixdogSessionRuntime({
|
|
|
689
701
|
// constructed (setTimeout(0) defers past the synchronous return), so a
|
|
690
702
|
// slow/hanging registry request can never delay session boot. The check
|
|
691
703
|
// ALWAYS runs (populates updateCheckState for the maintenance picker), but
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
//
|
|
704
|
+
// when an update is available it kicks off a hidden BACKGROUND staging
|
|
705
|
+
// install (spawnStagedInstall) into ~/.mixdog/data/staging/<ver>. The actual
|
|
706
|
+
// swap into the global dir happens on the next clean launch (cli.mjs
|
|
707
|
+
// pre-import), so npm never overwrites files this live process holds.
|
|
695
708
|
// force:true — always hit the registry at boot (the 24h disk cache went
|
|
696
709
|
// stale-visible: it kept reporting an older "latest" than the installed
|
|
697
710
|
// version). checkLatestVersion() still falls back to the cache offline.
|
|
698
711
|
// isDevInstall() gate: a git checkout / clone (or non-node_modules install)
|
|
699
|
-
// must never self-update —
|
|
712
|
+
// must never self-update — staging + swap would fight the working tree.
|
|
700
713
|
const updateBootTimer = setTimeout(() => {
|
|
701
714
|
void (async () => {
|
|
702
715
|
await checkForUpdateInternal({ force: true });
|
|
703
|
-
if (autoUpdateEnabled() && !isDevInstall() && updateCheckState.updateAvailable)
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
716
|
+
if (!(autoUpdateEnabled() && !isDevInstall() && updateCheckState.updateAvailable)) return;
|
|
717
|
+
const ver = updateCheckState.latestVersion;
|
|
718
|
+
if (!ver) return;
|
|
719
|
+
// The notice fires ONLY once staging has completed (a ready-to-apply
|
|
720
|
+
// package sits on disk) — never upfront — so the user sees no "update
|
|
721
|
+
// available / installs on quit" nag while the background stage runs
|
|
722
|
+
// silently. The wording lives in the notice surface (notification-plan):
|
|
723
|
+
// this emit only carries meta.version. TUI maps meta.kind 'update-notice'
|
|
724
|
+
// to a transcript notice, never a model-visible message; tone 'info' =
|
|
725
|
+
// non-urgent, applies on the next launch.
|
|
726
|
+
const announceReady = () => {
|
|
727
|
+
emitRuntimeNotification('update ready', { kind: 'update-notice', version: ver, tone: 'info' });
|
|
728
|
+
};
|
|
729
|
+
// Already staged in a prior session → announce immediately.
|
|
730
|
+
if (isStagedComplete(ver)) { announceReady(); return; }
|
|
731
|
+
try { spawnStagedInstall(ver); } catch { /* best-effort background stage */ }
|
|
732
|
+
// Poll for staging completion, then announce once. The interval is
|
|
733
|
+
// unref'd so it never holds the process open, and gives up silently
|
|
734
|
+
// after the cap — the next launch retries.
|
|
735
|
+
const POLL_MS = 3_000;
|
|
736
|
+
const MAX_MS = 10 * 60 * 1000;
|
|
737
|
+
const startedAt = Date.now();
|
|
738
|
+
const poll = setInterval(() => {
|
|
739
|
+
if (isStagedComplete(ver)) {
|
|
740
|
+
clearInterval(poll);
|
|
741
|
+
announceReady();
|
|
742
|
+
} else if (Date.now() - startedAt > MAX_MS) {
|
|
743
|
+
clearInterval(poll);
|
|
744
|
+
}
|
|
745
|
+
}, POLL_MS);
|
|
746
|
+
poll.unref?.();
|
|
711
747
|
})().catch(() => {});
|
|
712
748
|
}, 0);
|
|
713
749
|
updateBootTimer.unref?.();
|
|
714
750
|
|
|
715
|
-
// Invoked from the graceful shutdown path (lifecycle close() on exit/quit).
|
|
716
|
-
// Spawns the deferred npm install fire-and-forget: runGlobalUpdate() detaches
|
|
717
|
-
// (POSIX) / unrefs its child, so we neither await it nor block teardown —
|
|
718
|
-
// the parent exits, releasing file handles, while npm installs in the hidden
|
|
719
|
-
// background child. No-op unless an update was armed at boot.
|
|
720
|
-
function flushPendingUpdate() {
|
|
721
|
-
if (!pendingUpdateInstall) return;
|
|
722
|
-
pendingUpdateInstall = false;
|
|
723
|
-
// Re-check at flush time: the user may have turned update.auto off after
|
|
724
|
-
// the boot notice armed the install — respect the current setting.
|
|
725
|
-
if (!autoUpdateEnabled()) return;
|
|
726
|
-
try { void runGlobalUpdate(); } catch { /* best-effort: exit must not wedge */ }
|
|
727
|
-
}
|
|
728
|
-
|
|
729
751
|
function emitRuntimeNotification(content, meta = {}) {
|
|
730
752
|
const text = String(content || '').trim();
|
|
731
753
|
if (!text) return false;
|
|
@@ -2003,7 +2025,6 @@ export async function createMixdogSessionRuntime({
|
|
|
2003
2025
|
resolveRoute,
|
|
2004
2026
|
applyDeferredToolSurface,
|
|
2005
2027
|
standaloneTools,
|
|
2006
|
-
flushPendingUpdate,
|
|
2007
2028
|
});
|
|
2008
2029
|
const resourceApi = createResourceApi({
|
|
2009
2030
|
getConfig: () => config,
|
|
@@ -35,12 +35,12 @@ export const AGENT_TOOL = {
|
|
|
35
35
|
openWorldHint: true,
|
|
36
36
|
agentHidden: true,
|
|
37
37
|
},
|
|
38
|
-
description: 'Delegate scoped work. Agent handoffs always start background tasks and return task IDs immediately. Spawn independent
|
|
38
|
+
description: 'Delegate scoped work. Agent handoffs always start background tasks and return task IDs immediately. Spawn every independent scope in parallel in the same turn, each with a distinct tag; for the same scope, use send or spawn with the same tag to reuse the live session. Wait for the completion notification before dependent work. Do not call status/read after spawn; they are manual recovery only.',
|
|
39
39
|
inputSchema: {
|
|
40
40
|
type: 'object',
|
|
41
41
|
properties: {
|
|
42
42
|
type: { type: 'string', enum: ['spawn', 'send', 'list', 'close', 'cancel', 'status', 'read', 'cleanup'], description: 'Action. Default spawn.' },
|
|
43
|
-
task_id: { type: 'string', description: 'Manual recovery task ID.' },
|
|
43
|
+
task_id: { type: 'string', description: 'Manual recovery task ID. Required for read/status.' },
|
|
44
44
|
agent: { type: 'string', description: 'Workflow agent id.' },
|
|
45
45
|
tag: { type: 'string', description: 'Stable scope handle. Reuse the same tag for follow-up on the same scope; use distinct tags only for independent scopes.' },
|
|
46
46
|
sessionId: { type: 'string', description: 'Raw sess_ id.' },
|
package/src/tui/App.jsx
CHANGED
|
@@ -21,6 +21,7 @@ import { Box, Text, useApp, useInput, useStdin, useStdout } from 'ink';
|
|
|
21
21
|
import { theme, surfaceBackground } from './theme.mjs';
|
|
22
22
|
import { useEngine } from './hooks/useEngine.mjs';
|
|
23
23
|
import { classifyToolCategory } from '../runtime/shared/tool-surface.mjs';
|
|
24
|
+
import { localPackageVersion } from '../runtime/shared/update-checker.mjs';
|
|
24
25
|
import { Spinner } from './components/Spinner.jsx';
|
|
25
26
|
import { StatusLine } from './components/StatusLine.jsx';
|
|
26
27
|
import { PromptInput } from './components/PromptInput.jsx';
|
|
@@ -3058,7 +3059,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
3058
3059
|
<Text color={theme.logo ?? theme.claude} bold>{centerLine('██║╚██╔╝██║██║ ██╔██╗ ██║ ██║██║ ██║██║ ██║', frameColumns)}</Text>
|
|
3059
3060
|
<Text color={theme.logo ?? theme.claude} bold>{centerLine('██║ ╚═╝ ██║██║██╔╝ ██╗██████╔╝╚██████╔╝╚██████╔╝', frameColumns)}</Text>
|
|
3060
3061
|
<Box height={1} flexShrink={0} />
|
|
3061
|
-
<Text color={theme.inactive}>{centerLine(`mixdog coding agent · ${state.cwd}`, frameColumns, 4)}</Text>
|
|
3062
|
+
<Text color={theme.inactive}>{centerLine(`mixdog coding agent · v${localPackageVersion()} · ${state.cwd}`, frameColumns, 4)}</Text>
|
|
3062
3063
|
</Box>
|
|
3063
3064
|
) : null}
|
|
3064
3065
|
|
|
@@ -50,6 +50,16 @@ export const TRANSCRIPT_WINDOW_OVERSCAN_ROWS = positiveIntEnv('MIXDOG_TUI_TRANSC
|
|
|
50
50
|
// tail of off-screen rows, so lower it to a value that still comfortably covers
|
|
51
51
|
// viewport + overscan on a large terminal. Env-tunable for A/B / revert.
|
|
52
52
|
export const TRANSCRIPT_WINDOW_MAX_ITEMS = positiveIntEnv('MIXDOG_TUI_TRANSCRIPT_WINDOW_ITEMS', 80);
|
|
53
|
+
// When the transcript is anchored to the bottom (live tail / streaming view,
|
|
54
|
+
// effectiveScrollOffset === 0), everything above the viewport is off-screen and
|
|
55
|
+
// cannot be revealed without a scroll action — which sets scrollOffset > 0 and
|
|
56
|
+
// re-renders through the FULL overscan/cap constants above, i.e. scrolled-up
|
|
57
|
+
// history is byte-for-byte unchanged. So in the at-bottom case we mount only
|
|
58
|
+
// viewport + a small overscan, sharply cutting ink's per-frame serialize cost
|
|
59
|
+
// during streaming (bench: 80→20 mounted rows ≈ 4.07ms/render, 109 CPU-ms/s).
|
|
60
|
+
// Env-tunable for A/B / revert.
|
|
61
|
+
export const TRANSCRIPT_WINDOW_TAIL_OVERSCAN_ROWS = positiveIntEnv('MIXDOG_TUI_TRANSCRIPT_TAIL_OVERSCAN_ROWS', 4);
|
|
62
|
+
export const TRANSCRIPT_WINDOW_TAIL_MAX_ITEMS = positiveIntEnv('MIXDOG_TUI_TRANSCRIPT_WINDOW_TAIL_ITEMS', 20);
|
|
53
63
|
export const SELECTION_PAINT_INTERVAL_MS = positiveIntEnv('MIXDOG_TUI_SELECTION_PAINT_MS', 24);
|
|
54
64
|
// Frame-coalesce edge-drag auto-scroll + wheel scroll: both paths accumulate
|
|
55
65
|
// deltas into one pending total and flush via a single scrollTranscriptRows
|
|
@@ -904,12 +914,16 @@ export function transcriptRenderWindow(items, { scrollOffset = 0, viewportHeight
|
|
|
904
914
|
}
|
|
905
915
|
|
|
906
916
|
const minItems = Math.min(TRANSCRIPT_WINDOW_MIN_ITEMS, itemCount);
|
|
907
|
-
|
|
917
|
+
// At-bottom (live tail) mounts only viewport + a small overscan; scrolled-up
|
|
918
|
+
// views keep the full overscan/cap so history renders exactly as before.
|
|
919
|
+
const atTail = effectiveScrollOffset === 0;
|
|
920
|
+
const overscanRows = atTail ? TRANSCRIPT_WINDOW_TAIL_OVERSCAN_ROWS : TRANSCRIPT_WINDOW_OVERSCAN_ROWS;
|
|
921
|
+
const maxItems = Math.max(minItems, atTail ? TRANSCRIPT_WINDOW_TAIL_MAX_ITEMS : TRANSCRIPT_WINDOW_MAX_ITEMS);
|
|
908
922
|
const prefixRows = fallbackIndex.prefixRows;
|
|
909
923
|
const visibleTop = Math.max(0, totalRows - effectiveScrollOffset - viewRows);
|
|
910
924
|
const visibleBottom = Math.min(totalRows, totalRows - effectiveScrollOffset);
|
|
911
|
-
const desiredTop = Math.max(0, visibleTop -
|
|
912
|
-
const desiredBottom = Math.min(totalRows, visibleBottom +
|
|
925
|
+
const desiredTop = Math.max(0, visibleTop - overscanRows);
|
|
926
|
+
const desiredBottom = Math.min(totalRows, visibleBottom + overscanRows);
|
|
913
927
|
|
|
914
928
|
let startIndex = Math.max(0, upperBound(prefixRows, desiredTop) - 1);
|
|
915
929
|
let endIndex = Math.min(itemCount, Math.max(startIndex + 1, lowerBound(prefixRows, Math.max(desiredBottom, desiredTop + 1))));
|
|
@@ -920,9 +934,15 @@ export function transcriptRenderWindow(items, { scrollOffset = 0, viewportHeight
|
|
|
920
934
|
if (endIndex - startIndex > maxItems) {
|
|
921
935
|
const visibleStartIndex = Math.max(0, upperBound(prefixRows, visibleTop) - 1);
|
|
922
936
|
const visibleEndIndex = Math.min(itemCount, Math.max(visibleStartIndex + 1, lowerBound(prefixRows, Math.max(visibleBottom, visibleTop + 1))));
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
937
|
+
// The cap must never cut into rows needed to fill the viewport: floor it at
|
|
938
|
+
// the item span the visible viewport actually covers, so a run of many short
|
|
939
|
+
// (e.g. one-line) items can't leave the top of the viewport unmounted under
|
|
940
|
+
// the small tail cap. Full-view (scrolled-up) behavior is unchanged: there
|
|
941
|
+
// maxItems already exceeds the visible span, so the floor is a no-op.
|
|
942
|
+
const effectiveMaxItems = Math.max(maxItems, visibleEndIndex - visibleStartIndex);
|
|
943
|
+
startIndex = Math.max(0, Math.min(visibleStartIndex, itemCount - effectiveMaxItems));
|
|
944
|
+
endIndex = Math.min(itemCount, Math.max(visibleEndIndex, startIndex + effectiveMaxItems));
|
|
945
|
+
if (endIndex - startIndex > effectiveMaxItems) startIndex = Math.max(0, endIndex - effectiveMaxItems);
|
|
926
946
|
}
|
|
927
947
|
|
|
928
948
|
const bottomSpacerRows = Math.max(0, totalRows - (prefixRows[endIndex] || totalRows));
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
* 'requesting' | 'compacting' | 'resuming' (default 'responding').
|
|
19
19
|
*/
|
|
20
20
|
import React, { useRef } from 'react';
|
|
21
|
-
import { Box, Text
|
|
21
|
+
import { Box, Text } from 'ink';
|
|
22
|
+
import { useSharedTick } from '../hooks/useSharedTick.mjs';
|
|
22
23
|
import { theme } from '../theme.mjs';
|
|
23
24
|
import { SPINNER_FRAMES } from '../spinner-verbs.mjs';
|
|
24
25
|
import { DOWN_ARROW, UP_ARROW } from '../figures.mjs';
|
|
@@ -170,7 +171,9 @@ function tokenModeGlyph(mode) {
|
|
|
170
171
|
}
|
|
171
172
|
|
|
172
173
|
export function Spinner({ verb = 'Working', startedAt, outputTokens = 0, tokens = 0, thinking = false, thinkingActiveSince = 0, mode = 'responding', columns = 80, marginTop = 1 }) {
|
|
173
|
-
|
|
174
|
+
// Re-render at the frame cadence off the shared tick (no dedicated timer).
|
|
175
|
+
// Glyph/shimmer/token/elapsed values are all derived from Date.now() below.
|
|
176
|
+
useSharedTick(FRAME_MS);
|
|
174
177
|
const { TEXT_RGB, SHIMMER_RGB, SPINNER_GLYPH_RGB, THINKING_INACTIVE, THINKING_SHIMMER, STALL_RGB } = spinnerRgb();
|
|
175
178
|
const now = Date.now();
|
|
176
179
|
const elapsedMs = startedAt ? Math.max(0, now - startedAt) : 0;
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
statuslineFooterCacheKey,
|
|
16
16
|
statuslineFooterIdentityChanged,
|
|
17
17
|
} from '../statusline-ansi-bridge.mjs';
|
|
18
|
+
import { useSharedTick } from '../hooks/useSharedTick.mjs';
|
|
18
19
|
|
|
19
20
|
// Loaded at RUNTIME (not bundled) so its vendored statusline-lib relative
|
|
20
21
|
// imports resolve from the real src/ui location, not the dist/ bundle dir.
|
|
@@ -514,13 +515,12 @@ function StatusLineView({ sessionId, clientHostPid, provider, model, effort, fas
|
|
|
514
515
|
scheduleStatuslineModulePrewarm();
|
|
515
516
|
}, []);
|
|
516
517
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
}, [refreshMs]);
|
|
518
|
+
// Refresh cadence rides the shared animation tick (no dedicated interval).
|
|
519
|
+
// The callback bumps refreshTick so the async full-render effect below still
|
|
520
|
+
// re-fires on schedule; the tick timer stops when nothing is subscribed.
|
|
521
|
+
useSharedTick(refreshMs, true, () => {
|
|
522
|
+
setRefreshTick((tick) => (tick + 1) % 1_000_000);
|
|
523
|
+
});
|
|
524
524
|
|
|
525
525
|
useEffect(() => {
|
|
526
526
|
let alive = true;
|
|
@@ -709,7 +709,7 @@ function StatusLineView({ sessionId, clientHostPid, provider, model, effort, fas
|
|
|
709
709
|
return (
|
|
710
710
|
<Box flexDirection="column" width="100%" height={3} overflow="hidden" justifyContent="flex-start" paddingLeft={2} backgroundColor={surfaceBackground()}>
|
|
711
711
|
<Box flexDirection="row" width="100%" overflow="hidden">
|
|
712
|
-
<Box flexGrow={1} flexShrink={1} overflow="hidden">
|
|
712
|
+
<Box flexGrow={1} flexShrink={1} flexBasis={0} overflow="hidden">
|
|
713
713
|
<Text wrap="truncate">{lines[0] || ' '}</Text>
|
|
714
714
|
</Box>
|
|
715
715
|
<Box flexShrink={0} marginLeft={1} marginRight={1}>
|
|
@@ -717,7 +717,7 @@ function StatusLineView({ sessionId, clientHostPid, provider, model, effort, fas
|
|
|
717
717
|
</Box>
|
|
718
718
|
</Box>
|
|
719
719
|
<Box flexDirection="row" width="100%" overflow="hidden">
|
|
720
|
-
<Box flexGrow={1} flexShrink={1} overflow="hidden">
|
|
720
|
+
<Box flexGrow={1} flexShrink={1} flexBasis={0} overflow="hidden">
|
|
721
721
|
<Text wrap="truncate">{lines[1] || ' '}</Text>
|
|
722
722
|
</Box>
|
|
723
723
|
</Box>
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* - The result hangs under a single dim ` ⎿ ` gutter — the gutter is placed
|
|
9
9
|
* once, not repeated per wrapped line.
|
|
10
10
|
*/
|
|
11
|
-
import React
|
|
11
|
+
import React from 'react';
|
|
12
12
|
import { Box, Text } from 'ink';
|
|
13
|
+
import { useSharedTick } from '../hooks/useSharedTick.mjs';
|
|
13
14
|
import stringWidth from 'string-width';
|
|
14
15
|
import { theme, TURN_MARKER, AGENT_CALL_MARKER, AGENT_RESPONSE_MARKER } from '../theme.mjs';
|
|
15
16
|
import { formatElapsed } from '../time-format.mjs';
|
|
@@ -74,6 +75,9 @@ export function displayToolName(name, args) {
|
|
|
74
75
|
const TOOL_BLINK_MS = 500;
|
|
75
76
|
const TOOL_BLINK_LIMIT_MS = 3000;
|
|
76
77
|
const TOOL_PENDING_SHOW_DELAY_MS = 1000;
|
|
78
|
+
// One shared-tick cadence covers both the 500ms blink and per-second elapsed;
|
|
79
|
+
// finer than either boundary so both stay crisp off a single timer.
|
|
80
|
+
const TOOL_ANIM_TICK_MS = TOOL_BLINK_MS;
|
|
77
81
|
function statusCopy(name, label, count, doneCount, pending, isError, args = {}) {
|
|
78
82
|
// No stableVerbWidth padding: it padded the done verb to the active ("-ing")
|
|
79
83
|
// width, which Ink trims at the line END (vendor output trimEnd) so it never
|
|
@@ -85,10 +89,6 @@ function statusCopy(name, label, count, doneCount, pending, isError, args = {})
|
|
|
85
89
|
}
|
|
86
90
|
export function ToolExecution({ name, args, result, rawResult, isError, errorCount, expanded, columns = 80, attached = false, count = 1, completedCount = 0, startedAt = 0, completedAt = 0, aggregate = false, categories = {}, doneCategories = null, headerFinalized = true, deferredDisplayReady = false }) {
|
|
87
91
|
const rowWidth = Math.max(1, Number(columns || 80));
|
|
88
|
-
const [blinkOn, setBlinkOn] = useState(true);
|
|
89
|
-
const [blinkExpired, setBlinkExpired] = useState(false);
|
|
90
|
-
const [pendingDelayElapsed, setPendingDelayElapsed] = useState(false);
|
|
91
|
-
const [, setElapsedTick] = useState(0);
|
|
92
92
|
const groupCount = Math.max(1, Number(count || 1));
|
|
93
93
|
const doneCount = Math.max(0, Math.min(groupCount, Number(completedCount || (result == null ? 0 : groupCount))));
|
|
94
94
|
const rt = result == null ? null : String(result).replace(/\s+$/, '');
|
|
@@ -96,7 +96,15 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
|
|
|
96
96
|
const pending = doneCount < groupCount;
|
|
97
97
|
const startedAtMs = Number(startedAt || 0);
|
|
98
98
|
const completedAtMs = Number(completedAt || 0);
|
|
99
|
-
const
|
|
99
|
+
const nowMs = Date.now();
|
|
100
|
+
// Single shared tick drives the blink + elapsed re-renders while pending; all
|
|
101
|
+
// phase/elapsed values below are derived from nowMs, so no per-card timers.
|
|
102
|
+
useSharedTick(TOOL_ANIM_TICK_MS, pending);
|
|
103
|
+
const pendingAgeMs = pending && startedAtMs ? Math.max(0, nowMs - startedAtMs) : 0;
|
|
104
|
+
// Derived (was a per-card setTimeout): the pending-show delay has elapsed.
|
|
105
|
+
const pendingDelayElapsed = pending
|
|
106
|
+
? (!startedAtMs || pendingAgeMs >= TOOL_PENDING_SHOW_DELAY_MS)
|
|
107
|
+
: false;
|
|
100
108
|
// A card that is still pending but already has something to paint (a result
|
|
101
109
|
// landed, or at least one of an aggregate's parallel calls completed) must
|
|
102
110
|
// SKIP the blank placeholder: it was pushed early (engine ensureVisible on a
|
|
@@ -108,6 +116,15 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
|
|
|
108
116
|
// land — no empty band.
|
|
109
117
|
const hasVisibleProgress = doneCount > 0 || Boolean(String(rt || '').trim());
|
|
110
118
|
const pendingDisplayReady = !pending || !startedAtMs || pendingDelayElapsed || pendingAgeMs >= TOOL_PENDING_SHOW_DELAY_MS || hasVisibleProgress || deferredDisplayReady;
|
|
119
|
+
// Derived blink (was two per-card setIntervals + a setTimeout): the dot blinks
|
|
120
|
+
// at TOOL_BLINK_MS while a display-ready pending card is fresh, then goes solid
|
|
121
|
+
// once TOOL_BLINK_LIMIT_MS elapses. Phase comes from Date.now() so the cadence
|
|
122
|
+
// is identical to the old interval without owning a timer.
|
|
123
|
+
const blinkActive = pending && pendingDisplayReady;
|
|
124
|
+
const blinkExpired = blinkActive && startedAtMs > 0 && (nowMs - startedAtMs) >= TOOL_BLINK_LIMIT_MS;
|
|
125
|
+
const blinkOn = !blinkActive || blinkExpired
|
|
126
|
+
? true
|
|
127
|
+
: Math.floor(nowMs / TOOL_BLINK_MS) % 2 === 0;
|
|
111
128
|
// Keep the action verb in its active form until the engine explicitly seals
|
|
112
129
|
// the tool block. Fast tool batches often complete before the next provider
|
|
113
130
|
// iteration decides whether to call more tools or emit assistant text; flipping
|
|
@@ -115,7 +132,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
|
|
|
115
132
|
const headerPending = pending || headerFinalized === false;
|
|
116
133
|
const hasResult = result != null && Boolean(String(rt || '').trim());
|
|
117
134
|
const hasRawResult = rawResult != null && Boolean(String(rawRt || '').trim());
|
|
118
|
-
const elapsedMs = startedAtMs ? Math.max(0, (pending ?
|
|
135
|
+
const elapsedMs = startedAtMs ? Math.max(0, (pending ? nowMs : (completedAtMs || nowMs)) - startedAtMs) : 0;
|
|
119
136
|
const elapsed = elapsedMs >= 1000 ? formatElapsed(elapsedMs) : '';
|
|
120
137
|
const failedCount = clampFailureCount(errorCount, groupCount, isError);
|
|
121
138
|
const displayGroupCount = groupCount;
|
|
@@ -133,57 +150,6 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
|
|
|
133
150
|
);
|
|
134
151
|
const displayDoneCategories = hasDoneCounts ? normalizedDoneCategories : displayCategories;
|
|
135
152
|
|
|
136
|
-
useEffect(() => {
|
|
137
|
-
if (!pending) {
|
|
138
|
-
setPendingDelayElapsed(false);
|
|
139
|
-
return undefined;
|
|
140
|
-
}
|
|
141
|
-
const started = Number(startedAt || 0);
|
|
142
|
-
if (!started) {
|
|
143
|
-
setPendingDelayElapsed(true);
|
|
144
|
-
return undefined;
|
|
145
|
-
}
|
|
146
|
-
const remaining = TOOL_PENDING_SHOW_DELAY_MS - Math.max(0, Date.now() - started);
|
|
147
|
-
if (remaining <= 0) {
|
|
148
|
-
setPendingDelayElapsed(true);
|
|
149
|
-
return undefined;
|
|
150
|
-
}
|
|
151
|
-
setPendingDelayElapsed(false);
|
|
152
|
-
const timer = setTimeout(() => setPendingDelayElapsed(true), remaining);
|
|
153
|
-
return () => clearTimeout(timer);
|
|
154
|
-
}, [pending, startedAt]);
|
|
155
|
-
|
|
156
|
-
useEffect(() => {
|
|
157
|
-
if (!pending || !pendingDisplayReady || blinkExpired) {
|
|
158
|
-
setBlinkOn(true);
|
|
159
|
-
return undefined;
|
|
160
|
-
}
|
|
161
|
-
const timer = setInterval(() => setBlinkOn((on) => !on), TOOL_BLINK_MS);
|
|
162
|
-
return () => clearInterval(timer);
|
|
163
|
-
}, [pending, pendingDisplayReady, blinkExpired]);
|
|
164
|
-
|
|
165
|
-
useEffect(() => {
|
|
166
|
-
if (!pending || !pendingDisplayReady) {
|
|
167
|
-
setBlinkExpired(false);
|
|
168
|
-
return undefined;
|
|
169
|
-
}
|
|
170
|
-
const started = Number(startedAt || 0);
|
|
171
|
-
const remaining = TOOL_BLINK_LIMIT_MS - (started ? Math.max(0, Date.now() - started) : 0);
|
|
172
|
-
if (remaining <= 0) {
|
|
173
|
-
setBlinkExpired(true);
|
|
174
|
-
return undefined;
|
|
175
|
-
}
|
|
176
|
-
setBlinkExpired(false);
|
|
177
|
-
const timer = setTimeout(() => setBlinkExpired(true), remaining);
|
|
178
|
-
return () => clearTimeout(timer);
|
|
179
|
-
}, [pending, pendingDisplayReady, startedAt]);
|
|
180
|
-
|
|
181
|
-
useEffect(() => {
|
|
182
|
-
if (!pending || !pendingDisplayReady || !startedAtMs) return undefined;
|
|
183
|
-
const timer = setInterval(() => setElapsedTick((tick) => (tick + 1) % 1000000), 1000);
|
|
184
|
-
return () => clearInterval(timer);
|
|
185
|
-
}, [pending, pendingDisplayReady, startedAtMs]);
|
|
186
|
-
|
|
187
153
|
// While a freshly-started tool is still inside its pending-show delay we used
|
|
188
154
|
// to `return null` (0 rendered rows). But estimateTranscriptItemRows() in
|
|
189
155
|
// App.jsx counts a collapsed tool item from the moment it is pushed (1 row for
|
|
@@ -466,7 +432,16 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
|
|
|
466
432
|
const markerGlyph = isAgentResponse
|
|
467
433
|
? AGENT_RESPONSE_MARKER
|
|
468
434
|
: (isAgentSurfaceCard ? AGENT_CALL_MARKER : TURN_MARKER);
|
|
469
|
-
|
|
435
|
+
// Directional arrow markers (`←` spawn/send out, `→` response back) render 2
|
|
436
|
+
// cells wide in some terminals (Windows Terminal / Cascadia) while our width
|
|
437
|
+
// math counts them as 1, so the `Box minWidth={2}` gutter padding gets
|
|
438
|
+
// overdrawn and the label glues to the arrow ("←Spawn"). Carry an explicit
|
|
439
|
+
// trailing space in the marker string so the gap is a real character that
|
|
440
|
+
// survives regardless of how wide the terminal actually draws the glyph. The
|
|
441
|
+
// `●` turn marker is a true 1-cell glyph and keeps the padding-only gutter.
|
|
442
|
+
const isDirectionalMarker = isAgentResponse || isAgentSurfaceCard;
|
|
443
|
+
const markerText = isDirectionalMarker ? `${markerGlyph} ` : markerGlyph;
|
|
444
|
+
const dotText = pending && !blinkExpired && !blinkOn ? ' ' : markerText;
|
|
470
445
|
let labelText;
|
|
471
446
|
if (isAgentResponse) labelText = agentResponseTitle(parsedArgs);
|
|
472
447
|
else if (isBackgroundResponse) labelText = backgroundTaskResultTitle(normalizedName, backgroundMeta || parsedArgs);
|
|
@@ -32,18 +32,22 @@ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme
|
|
|
32
32
|
/**
|
|
33
33
|
* True for code points we treat as wide (2 cells) when the policy is ON:
|
|
34
34
|
* - U+2460–U+24FF Enclosed Alphanumerics (① ② ③ …)
|
|
35
|
-
* - U+
|
|
36
|
-
* Deliberately excludes box-drawing / blocks / figures
|
|
35
|
+
* - U+2194–U+21FF Arrows (↔ ⇒ ⇧ …)
|
|
36
|
+
* Deliberately excludes box-drawing / blocks / figures — including the four
|
|
37
|
+
* Basic Arrows U+2190–U+2193 (← ↑ → ↓) that figures.mjs uses as 1-cell
|
|
38
|
+
* markers (agent card ←/→, history ↑/↓): WT draws them 1 cell in
|
|
39
|
+
* Cascadia, and widening them ate the marker's gutter padding space
|
|
40
|
+
* ("←Spawn" rendered glued / shifted vs the ● rows).
|
|
37
41
|
*/
|
|
38
42
|
export function isProblemCodePoint(cp) {
|
|
39
|
-
return (cp >= 0x2460 && cp <= 0x24ff) || (cp >=
|
|
43
|
+
return (cp >= 0x2460 && cp <= 0x24ff) || (cp >= 0x2194 && cp <= 0x21ff);
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
// Fast precheck for the problem ranges above. Lets the hot path bail before
|
|
43
47
|
// the per-grapheme segmenter loop when a string (the overwhelmingly common
|
|
44
48
|
// ASCII/status-text case) contains no widenable glyph. Kept identical to
|
|
45
49
|
// vendor/ink/build/display-width.js.
|
|
46
|
-
const PROBLEM_RE = /[\
|
|
50
|
+
const PROBLEM_RE = /[\u2194-\u21ff\u2460-\u24ff]/;
|
|
47
51
|
|
|
48
52
|
/**
|
|
49
53
|
* Resolve the wide policy from env. Override wins over the default; the
|