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
@@ -64,7 +64,11 @@ export function resolveTuiRuntimeNotificationDelivery(event, text) {
64
64
  // UI-only notices (e.g. boot auto-update outcome): render as a transcript
65
65
  // notice, never enqueue anything model-visible.
66
66
  if (meta.kind === 'update-notice') {
67
- return { action: 'notice', displayText: trimmed, tone: meta.tone === 'warn' ? 'warn' : 'info' };
67
+ // Wording lives here (the notice surface), not in the emitting runtime:
68
+ // the emitter only supplies meta.version; the sentence is composed here.
69
+ const ver = String(meta.version || '').trim();
70
+ const displayText = ver ? `mixdog v${ver} ready — restart to apply.` : trimmed;
71
+ return { action: 'notice', displayText, tone: meta.tone === 'warn' ? 'warn' : 'info' };
68
72
  }
69
73
  if (!isExecutionNotification(event, trimmed, parsed)) {
70
74
  return { action: 'enqueue', displayText: trimmed, modelContent: trimmed };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * hooks/useSharedTick.mjs — one process-wide animation timer for the TUI.
3
+ *
4
+ * The transcript used to fan out timers: Spinner's useAnimation, a 500ms blink
5
+ * + 1s elapsed setInterval PER pending ToolExecution card, and StatusLine's own
6
+ * refresh interval. With N pending cards that is 1 + 2N + 1 OS timers all waking
7
+ * the event loop independently, which showed up in the stutter bench as +369
8
+ * CPU-ms/s and 143ms frame gaps while idle-animated.
9
+ *
10
+ * This collapses every animated component onto a SINGLE shared setInterval.
11
+ * Subscribers register the cadence they want; the shared timer runs at the
12
+ * finest requested interval and only notifies each subscriber once its own
13
+ * interval has elapsed (derived from Date.now()), so a 500ms blink and a 130ms
14
+ * spinner still animate at their own rates off one timer. When the last
15
+ * subscriber unmounts the timer is cleared, so nothing ticks when nothing
16
+ * animated is on screen. Components derive blink phase / elapsed seconds /
17
+ * spinner frame from Date.now() at render — the tick only forces the re-render.
18
+ */
19
+ import { useEffect, useRef, useState } from 'react';
20
+
21
+ // Boundary tolerance: fire a subscriber a hair early rather than skip a whole
22
+ // base-interval and drift its cadence by a full frame.
23
+ const TICK_SLOP_MS = 16;
24
+
25
+ const subscribers = new Set();
26
+ let timer = null;
27
+
28
+ // Delay until the soonest subscriber is due (clamped to >= 1ms). A single timer
29
+ // is always aimed at the next real boundary, so a subscriber's cadence is tied
30
+ // to ITS OWN lastFire + interval — never to a shared base that resets when the
31
+ // finest-interval subscriber leaves. Returns null when nothing is subscribed.
32
+ function nextDelayMs(now) {
33
+ let soonest = Infinity;
34
+ for (const sub of subscribers) {
35
+ const due = sub.lastFire + sub.interval - now;
36
+ if (due < soonest) soonest = due;
37
+ }
38
+ return Number.isFinite(soonest) ? Math.max(1, soonest) : null;
39
+ }
40
+
41
+ // Aim the single timer at the next due boundary. Called on every subscribe /
42
+ // unsubscribe and after each tick, so adding a faster subscriber pulls the next
43
+ // wake earlier and removing the finest one re-aims at the remaining subscribers'
44
+ // own due times (no full fresh-interval stall). Clears the timer when empty.
45
+ function reconcileTimer() {
46
+ if (timer) {
47
+ clearTimeout(timer);
48
+ timer = null;
49
+ }
50
+ const delay = nextDelayMs(Date.now());
51
+ if (delay == null) return;
52
+ timer = setTimeout(onTick, delay);
53
+ timer.unref?.();
54
+ }
55
+
56
+ function onTick() {
57
+ timer = null;
58
+ const now = Date.now();
59
+ // Snapshot so a subscriber unsubscribing during notify can't mutate the set
60
+ // mid-iteration.
61
+ for (const sub of Array.from(subscribers)) {
62
+ if (!subscribers.has(sub)) continue;
63
+ if (now - sub.lastFire >= sub.interval - TICK_SLOP_MS) {
64
+ sub.lastFire = now;
65
+ sub.notify(now);
66
+ }
67
+ }
68
+ reconcileTimer();
69
+ }
70
+
71
+ /**
72
+ * Subscribe the calling component to the shared tick.
73
+ *
74
+ * @param {number} intervalMs desired re-render cadence in ms.
75
+ * @param {boolean} [isActive=true] unsubscribe (and let the timer stop) when false.
76
+ * @param {(now:number)=>void} [onTick] optional callback fired on each tick
77
+ * instead of the default internal re-render (e.g. to bump an existing state).
78
+ */
79
+ export function useSharedTick(intervalMs, isActive = true, onTick = null) {
80
+ const [, setTick] = useState(0);
81
+ const cbRef = useRef(onTick);
82
+ cbRef.current = onTick;
83
+ useEffect(() => {
84
+ if (!isActive) return undefined;
85
+ const interval = Math.max(1, Number(intervalMs) || 0);
86
+ const sub = {
87
+ interval,
88
+ lastFire: Date.now(),
89
+ notify: (now) => {
90
+ if (cbRef.current) cbRef.current(now);
91
+ else setTick((t) => (t + 1) % 1_000_000);
92
+ },
93
+ };
94
+ subscribers.add(sub);
95
+ reconcileTimer();
96
+ return () => {
97
+ subscribers.delete(sub);
98
+ reconcileTimer();
99
+ };
100
+ }, [intervalMs, isActive]);
101
+ }
102
+
103
+ export default useSharedTick;
package/src/tui/index.jsx CHANGED
@@ -18,6 +18,7 @@ import { emitTerminalBackground, loadThemeSettingFromConfig, theme } from './the
18
18
  import { POP_KITTY, DISABLE_MODIFY_OTHER_KEYS } from './keyboard-protocol.mjs';
19
19
  import { displayWidth } from './display-width.mjs';
20
20
  import { rotateBoundedLog, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES } from '../lib/mixdog-debug.cjs';
21
+ import { localPackageVersion } from '../runtime/shared/update-checker.mjs';
21
22
 
22
23
  // Trailing `\x1b[>0s` restores XTSHIFTESCAPE (shift-to-select-extend) to its
23
24
  // terminal default; MOUSE_TRACKING_ON opts into `\x1b[>1s`, so every mouse/alt
@@ -290,7 +291,7 @@ function paintBootSplash() {
290
291
  out += `${bold}${fg}${center(logo[i])}${reset}\r\n`;
291
292
  }
292
293
  out += '\r\n';
293
- out += `${subtleFg}${center(`mixdog coding agent · ${process.cwd()}`)}${reset}`;
294
+ out += `${subtleFg}${center(`mixdog coding agent · v${localPackageVersion()} · ${process.cwd()}`)}${reset}`;
294
295
 
295
296
  // Park the cursor at home so ink's first frame paints top-down over the
296
297
  // splash instead of starting at the bottom row and scrolling the screen.
@@ -550,6 +550,36 @@ function collectFlatBodyLines(text, codeBlock, bandWidth) {
550
550
  return lines;
551
551
  }
552
552
 
553
+ // Streaming open-fence (`token.plain`) fast normalize: strip raw TAB / C0
554
+ // controls (so no stray byte survives to bypass the viewport clip) WITHOUT the
555
+ // per-character displayWidth loop of normalizeCodeText — column-accurate tab
556
+ // stops are cosmetic and are restored when the block settles and re-renders.
557
+ function normalizeCodePlain(text) {
558
+ return String(text ?? '')
559
+ .replace(/\r\n?/g, '\n')
560
+ .replace(/\t/g, ' ')
561
+ // C0 controls + DEL except LF → single space.
562
+ .replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, ' ');
563
+ }
564
+
565
+ // Flat body for the streaming open block: color each line, wrapping only lines
566
+ // that actually overflow (the common code line fits, so no wrapAnsi call). The
567
+ // displayWidth guard is skipped for over-length lines (they always wrap) and for
568
+ // blanks, keeping the per-frame pass close to O(n) with a small constant.
569
+ function collectPlainBodyLines(text, codeBlock, bandWidth) {
570
+ const max = Math.max(1, bandWidth);
571
+ const lines = [];
572
+ for (const line of String(text ?? '').split(EOL)) {
573
+ if (!line) { lines.push(''); continue; }
574
+ if (line.length <= max && displayWidth(line) <= max) {
575
+ lines.push(codeBlock(line));
576
+ } else {
577
+ for (const seg of wrapCodeLine(codeBlock(line), max)) lines.push(seg);
578
+ }
579
+ }
580
+ return lines;
581
+ }
582
+
553
583
  function collectHighlightedBodyLines(text, hljsLang, bandWidth) {
554
584
  const highlighted = highlightCodeText(text, hljsLang);
555
585
  const { codeBlock } = colorizers();
@@ -576,22 +606,30 @@ function renderCodeBlock(token, width = 0) {
576
606
  const { codeBlock } = colorizers();
577
607
  const c = extraColorizers();
578
608
  const lang = normalizeLang(token.lang);
579
- const text = normalizeCodeText(decodeEntities(token.text ?? ''));
580
609
  const renderWidth = Math.max(8, Number(width) || 80);
581
610
  // Wrap content to the render width minus the left gutter so `gutter + content`
582
611
  // never overruns the available width.
583
612
  const contentWidth = Math.max(1, renderWidth - CODE_GUTTER.length);
584
613
 
585
614
  let bodyLines;
586
- if (DIFF_LANGS.has(lang) || (!lang && looksLikeUnifiedDiff(text))) {
587
- bodyLines = collectDiffBodyLines(text, c, contentWidth);
615
+ if (token.plain) {
616
+ // Streaming open fence: flat, un-highlighted body via the cheap normalize +
617
+ // conditional wrap. Avoids marked's lexer AND highlight.js AND the per-char
618
+ // width normalize every delta; the settled block re-renders fully below.
619
+ const text = normalizeCodePlain(decodeEntities(token.text ?? ''));
620
+ bodyLines = collectPlainBodyLines(text, codeBlock, contentWidth);
588
621
  } else {
589
- const hljsLang = resolveHljsLanguage(lang);
590
- const family = LANG_FAMILY[lang];
591
- if (hljsLang && family !== 'md') {
592
- bodyLines = collectHighlightedBodyLines(text, hljsLang, contentWidth);
622
+ const text = normalizeCodeText(decodeEntities(token.text ?? ''));
623
+ if (DIFF_LANGS.has(lang) || (!lang && looksLikeUnifiedDiff(text))) {
624
+ bodyLines = collectDiffBodyLines(text, c, contentWidth);
593
625
  } else {
594
- bodyLines = collectFlatBodyLines(text, codeBlock, contentWidth);
626
+ const hljsLang = resolveHljsLanguage(lang);
627
+ const family = LANG_FAMILY[lang];
628
+ if (hljsLang && family !== 'md') {
629
+ bodyLines = collectHighlightedBodyLines(text, hljsLang, contentWidth);
630
+ } else {
631
+ bodyLines = collectFlatBodyLines(text, codeBlock, contentWidth);
632
+ }
595
633
  }
596
634
  }
597
635
  // Indent every body row by the gutter (no bg band).
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import { marked } from 'marked';
17
17
  import { formatToken } from './format-token.mjs';
18
- import { trimPartialClosingFences } from './stream-fence.mjs';
18
+ import { trimPartialClosingFences, findOpenFenceStart } from './stream-fence.mjs';
19
19
 
20
20
  const TOKEN_CACHE_MAX = 500;
21
21
  const tokenCache = new Map();
@@ -50,6 +50,29 @@ export function lexMarkdown(content, { trimPartialFences = false } = {}) {
50
50
  // are transient per-delta and trimming mutates them in place, so they are NOT
51
51
  // cached — caching would corrupt the shared cache for stable text.
52
52
  if (trimPartialFences) {
53
+ // Open-fence fast path: an unclosed fenced code block would make marked
54
+ // re-scan the whole growing block every delta (its closing-fence regex
55
+ // never matches and backtracks over the entire body — ~O(n²) per frame).
56
+ // Build the `code` token directly from the raw fence text instead, and mark
57
+ // it `plain` so it renders flat (no per-frame highlight.js pass) until the
58
+ // closing fence arrives; the settled (closed) frame lexes normally.
59
+ const open = findOpenFenceStart(text);
60
+ if (open) {
61
+ const post = text.slice(open.index);
62
+ const nl = post.indexOf('\n');
63
+ const codeToken = {
64
+ type: 'code',
65
+ raw: post,
66
+ lang: open.lang,
67
+ text: nl === -1 ? '' : post.slice(nl + 1),
68
+ plain: true,
69
+ };
70
+ const pre = text.slice(0, open.index);
71
+ const tokens = pre ? marked.lexer(pre) : [];
72
+ tokens.push(codeToken);
73
+ trimPartialClosingFences(tokens);
74
+ return tokens;
75
+ }
53
76
  const tokens = marked.lexer(text);
54
77
  trimPartialClosingFences(tokens);
55
78
  return tokens;
@@ -32,3 +32,63 @@ export function trimPartialClosingFences(tokens) {
32
32
  if (lastLine.length >= marker.length || lastLine !== marker[0].repeat(lastLine.length)) return;
33
33
  token.text = String(token.text ?? '').slice(0, -lastLine.length).replace(/\n$/, '');
34
34
  }
35
+
36
+ /**
37
+ * Locate the currently-open (unclosed) fenced code block that is safe to split
38
+ * off cheaply, or null. Returns { index, lang } where `index` is the byte offset
39
+ * of the opening fence line's first char. Pure line scan (no marked.lexer), so a
40
+ * growing open code block avoids marked's catastrophic never-matched-closer
41
+ * backtracking every delta.
42
+ *
43
+ * Only a TOP-LEVEL fence (opening line at column 0) is reported: a col-0 fence
44
+ * is always a fresh top-level block in CommonMark (list/blockquote content is
45
+ * never at column 0), so everything before it is complete blocks and the split
46
+ * is render-invariant. An indented (possibly list/blockquote-nested) open fence
47
+ * returns null so the caller falls back to the correct full marked.lexer path.
48
+ *
49
+ * Fence rules are marked/CommonMark-aligned:
50
+ * - opening: 0–3 spaces indent, run of ≥3 same fence char; a backtick fence's
51
+ * info string may not contain a backtick;
52
+ * - closing: 0–3 spaces indent, a run of fence chars that STARTS with the exact
53
+ * opening marker (same char, ≥ opening length) and may continue with any mix
54
+ * of backticks/tildes (marked's `\1[~`]*`), followed only by spaces/tabs.
55
+ */
56
+ const OPEN_FENCE_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/;
57
+ const CLOSE_FENCE_RE = /^ {0,3}([`~]+)[ \t]*$/;
58
+
59
+ function isClosingFence(line, char, openLen) {
60
+ const m = CLOSE_FENCE_RE.exec(line);
61
+ if (!m) return false;
62
+ const run = m[1];
63
+ // marked closes on ` {0,3}\1[~`]* *`: the run must begin with the exact opening
64
+ // marker (openLen × openChar); trailing chars may be the OPPOSITE fence char
65
+ // (e.g. ``` closed by ```~~), so only the prefix is constrained.
66
+ return run.startsWith(char.repeat(openLen));
67
+ }
68
+
69
+ export function findOpenFenceStart(text) {
70
+ const value = String(text ?? '');
71
+ let open = null;
72
+ let start = 0;
73
+ for (let i = 0; i <= value.length; i++) {
74
+ if (i !== value.length && value[i] !== '\n') continue;
75
+ const line = value.slice(start, i);
76
+ if (!open) {
77
+ const m = OPEN_FENCE_RE.exec(line);
78
+ if (m) {
79
+ const char = m[2][0];
80
+ const info = m[3];
81
+ // A backtick fence's info string may not contain a backtick.
82
+ if (!(char === '`' && info.indexOf('`') !== -1)) {
83
+ open = { index: start, indent: m[1].length, char, len: m[2].length, lang: info.trim() };
84
+ }
85
+ }
86
+ } else if (isClosingFence(line, open.char, open.len)) {
87
+ open = null;
88
+ }
89
+ start = i + 1;
90
+ }
91
+ // Only fast-path unambiguously top-level (column-0) fences.
92
+ if (!open || open.indent !== 0) return null;
93
+ return { index: open.index, lang: open.lang };
94
+ }
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { marked } from 'marked';
6
6
  import { configureMarked, hasMarkdownSyntax } from './render-ansi.mjs';
7
- import { trimPartialClosingFences } from './stream-fence.mjs';
7
+ import { trimPartialClosingFences, findOpenFenceStart } from './stream-fence.mjs';
8
8
 
9
9
  const stablePrefixByStreamKey = new Map();
10
10
  const STABLE_PREFIX_LRU_MAX = 32;
@@ -129,6 +129,27 @@ export function resolveStreamingMarkdownParts(text, streamKey) {
129
129
  stablePrefix = '';
130
130
  }
131
131
 
132
+ // Open-fence fast path: never run marked.lexer on a growing unclosed code
133
+ // block (its closing-fence regex never matches and backtracks over the whole
134
+ // body every delta — the ~56ms/frame cost). Split cheaply at the fence line:
135
+ // everything before it is settled markdown (lexed + cached once by the stable
136
+ // <Markdown>), the open block is rendered flat until the closing fence lands.
137
+ const open = findOpenFenceStart(t);
138
+ if (open) {
139
+ let openPrefix = t.substring(0, open.index);
140
+ if (isWhitespaceOnlyText(openPrefix)) openPrefix = '';
141
+ if (key && openPrefix) touchStablePrefixKey(key, openPrefix);
142
+ else if (key) stablePrefixByStreamKey.delete(key);
143
+ const unstableSuffix = t.substring(openPrefix.length);
144
+ return {
145
+ plain: false,
146
+ openFence: true,
147
+ stablePrefix: openPrefix,
148
+ unstableSuffix,
149
+ unstableForRender: unstableSuffix,
150
+ };
151
+ }
152
+
132
153
  try {
133
154
  configureMarked();
134
155
  const boundary = stablePrefix.length;
@@ -13,19 +13,23 @@ exploration only. Diagnosis agreement or problem-pointing is NOT approval.
13
13
 
14
14
  Lead supervises: delegates, coordinates, judges, decides. Route by complexity
15
15
  (after approval):
16
- - Lead directly: simple 1–2 step work, coordination, config, git deployment.
17
- - Worker: multi-step implementation. Heavy Worker: high-complexity scopes.
16
+ - Lead directly: only one-step fixes doable in a single turn, plus
17
+ coordination, config, and git deployment. Everything else delegates.
18
+ - Worker: any multi-step or multi-file implementation. Heavy Worker:
19
+ high-complexity scopes.
18
20
  - Reviewer: verify implementation scopes. Debugger: very high complexity, or
19
21
  root-causing already failed once.
20
22
 
21
23
  1. Plan — present a draft plan before ANY implementation; if not approved,
22
24
  revise and re-present (ping-pong) until an explicit go-ahead.
23
- 2. Delegate — split into the maximum independent scopes; spawn all in the
24
- SAME turn (parallel by default; sequential steps only inside one complex
25
- scope, gated build/test-green). Shared/cross-cutting code does NOT justify
26
- merging scopes split per path, verify shared parts yourself; a genuinely
27
- inseparable single scope must be stated. Briefs per the Lead brief
28
- contract. After spawning async agents, END THE TURN.
25
+ 2. Delegate — maximize distribution: split the work into as many independent
26
+ scopes as possible and hand each to its own agent, all spawned in the SAME
27
+ turn (parallel by default; sequential steps only inside one complex scope,
28
+ gated build/test-green). This applies to every role alike worker,
29
+ heavy-worker, reviewer, debugger: fan them out across agents, never one at
30
+ a time. Only a genuinely inseparable single scope stays whole, and say so.
31
+ Briefs per the Lead brief contract. After spawning async agents, END THE
32
+ TURN.
29
33
  3. Review — pair one reviewer 1:1 per implementation scope, same turn.
30
34
  Cross-check agent results yourself; send fixes back to the original scope
31
35
  and loop fix -> re-verify until clean. Skip only for simple low-risk work.
@@ -10,8 +10,9 @@
10
10
  * overlap gets worse, not better.
11
11
  *
12
12
  * Policy: widen ONLY U+2460–U+24FF (enclosed alphanumerics / circled digits)
13
- * and U+2190–U+21FF (arrows) to 2 cells when ON. NEVER widen box-drawing
14
- * (U+2500–U+257F), block elements, or other ambiguous glyphs. ON by default
13
+ * and U+2194–U+21FF (arrows) to 2 cells when ON. NEVER widen box-drawing
14
+ * (U+2500–U+257F), block elements, the four Basic Arrows U+2190–U+2193
15
+ * (← ↑ → ↓ — figures.mjs 1-cell markers), or other ambiguous glyphs. ON by default
15
16
  * on Windows (win32) or under Windows Terminal (WT_SESSION);
16
17
  * MIXDOG_TUI_AMBIGUOUS_WIDE='1'/'0' overrides and wins. OFF ⇒ identical to
17
18
  * plain string-width.
@@ -23,14 +24,14 @@ import stringWidth from 'string-width';
23
24
  const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
24
25
 
25
26
  function isProblemCodePoint(cp) {
26
- return (cp >= 0x2460 && cp <= 0x24ff) || (cp >= 0x2190 && cp <= 0x21ff);
27
+ return (cp >= 0x2460 && cp <= 0x24ff) || (cp >= 0x2194 && cp <= 0x21ff);
27
28
  }
28
29
 
29
30
  // [mixdog fork] Fast precheck for the problem ranges above. Lets the hot path
30
31
  // bail before the per-grapheme segmenter loop when a string (the common
31
32
  // ASCII/status-text case) contains no widenable glyph. Kept in sync with
32
33
  // src/tui/display-width.mjs.
33
- const PROBLEM_RE = /[\u2190-\u21ff\u2460-\u24ff]/;
34
+ const PROBLEM_RE = /[\u2194-\u21ff\u2460-\u24ff]/;
34
35
 
35
36
  function resolveAmbiguousWidePolicy(env = process.env, platform = process.platform) {
36
37
  const override = env?.MIXDOG_TUI_AMBIGUOUS_WIDE;