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.
Files changed (36) hide show
  1. package/package.json +7 -2
  2. package/scripts/arg-guard-test.mjs +68 -0
  3. package/scripts/compacted-placeholder-scrub-test.mjs +77 -0
  4. package/src/app.mjs +14 -0
  5. package/src/cli.mjs +40 -4
  6. package/src/rules/shared/01-tool.md +3 -0
  7. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +3 -1
  8. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +30 -0
  9. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +5 -0
  10. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +14 -1
  11. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  12. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  13. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  14. package/src/runtime/memory/tool-defs.mjs +3 -3
  15. package/src/runtime/shared/staged-install-worker.mjs +14 -0
  16. package/src/runtime/shared/staged-update.mjs +530 -0
  17. package/src/runtime/shared/update-checker.mjs +1 -1
  18. package/src/session-runtime/lifecycle-api.mjs +9 -11
  19. package/src/session-runtime/runtime-core.mjs +61 -40
  20. package/src/standalone/agent-tool/tool-def.mjs +2 -2
  21. package/src/tui/App.jsx +2 -1
  22. package/src/tui/app/transcript-window.mjs +26 -6
  23. package/src/tui/components/Spinner.jsx +5 -2
  24. package/src/tui/components/StatusLine.jsx +9 -9
  25. package/src/tui/components/ToolExecution.jsx +34 -59
  26. package/src/tui/display-width.mjs +8 -4
  27. package/src/tui/dist/index.mjs +545 -425
  28. package/src/tui/engine/notification-plan.mjs +5 -1
  29. package/src/tui/hooks/useSharedTick.mjs +103 -0
  30. package/src/tui/index.jsx +2 -1
  31. package/src/tui/markdown/format-token.mjs +46 -8
  32. package/src/tui/markdown/render-ansi.mjs +24 -1
  33. package/src/tui/markdown/stream-fence.mjs +60 -0
  34. package/src/tui/markdown/streaming-markdown.mjs +22 -1
  35. package/src/workflows/default/WORKFLOW.md +12 -8
  36. 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, runGlobalUpdate, localPackageVersion, isDevInstall } from '../runtime/shared/update-checker.mjs';
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 but DEFERS the actual npm install to the
638
- // graceful shutdown path (flushPendingUpdate, invoked from close()). Running
639
- // `npm install -g` while the process is live overwrites the very .mjs files
640
- // node has loaded Windows TAR_ENTRY_ERROR file-locks. Installing on quit
641
- // lets the parent release those handles first.
642
- let pendingUpdateInstall = false;
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
- // A manual install consumes the boot-armed deferred install so quitting
672
- // afterwards can't spawn a second redundant `npm install -g`.
673
- pendingUpdateInstall = false;
674
- updateProcessState = { phase: 'installing', version: null, error: null };
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 runGlobalUpdate();
688
+ const result = await runStagedInstall(ver);
677
689
  if (result?.ok) {
678
- updateProcessState = { phase: 'installed', version: result.version || null, error: null };
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
- // the boot hook only DETECTS + arms a deferred install — the actual
693
- // runGlobalUpdate now happens on graceful shutdown (flushPendingUpdate via
694
- // close()) so npm never overwrites files the live process holds.
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 — `npm install -g` would fight the working tree.
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
- pendingUpdateInstall = true;
705
- const v = updateCheckState.latestVersion ? `v${updateCheckState.latestVersion}` : 'latest';
706
- // UI-only notice (TUI maps meta.kind 'update-notice' to a transcript
707
- // notice, never a model-visible message). tone 'info': non-urgent,
708
- // the install fires quietly on quit.
709
- emitRuntimeNotification(`mixdog ${v} available installs when you quit.`, { kind: 'update-notice', tone: 'info' });
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 scopes in parallel with distinct tags; 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.',
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
- const maxItems = Math.max(minItems, TRANSCRIPT_WINDOW_MAX_ITEMS);
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 - TRANSCRIPT_WINDOW_OVERSCAN_ROWS);
912
- const desiredBottom = Math.min(totalRows, visibleBottom + TRANSCRIPT_WINDOW_OVERSCAN_ROWS);
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
- startIndex = Math.max(0, Math.min(visibleStartIndex, itemCount - maxItems));
924
- endIndex = Math.min(itemCount, Math.max(visibleEndIndex, startIndex + maxItems));
925
- if (endIndex - startIndex > maxItems) startIndex = Math.max(0, endIndex - maxItems);
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, useAnimation } from 'ink';
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
- useAnimation({ interval: FRAME_MS });
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
- useEffect(() => {
518
- const timer = setInterval(() => {
519
- setRefreshTick((tick) => (tick + 1) % 1_000_000);
520
- }, refreshMs);
521
- timer.unref?.();
522
- return () => clearInterval(timer);
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, { useEffect, useState } from '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 pendingAgeMs = pending && startedAtMs ? Math.max(0, Date.now() - startedAtMs) : 0;
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 ? Date.now() : (completedAtMs || Date.now())) - startedAtMs) : 0;
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
- const dotText = pending && !blinkExpired && !blinkOn ? ' ' : markerGlyph;
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+2190–U+21FF Arrows ( …)
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 >= 0x2190 && cp <= 0x21ff);
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 = /[\u2190-\u21ff\u2460-\u24ff]/;
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