mixdog 0.9.140 → 0.9.142

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 (42) hide show
  1. package/LICENSES/MIT.txt +4 -0
  2. package/NOTICE.md +12 -46
  3. package/package.json +1 -1
  4. package/scripts/manifest-cache-key.mjs +44 -0
  5. package/scripts/native-binary-arch.mjs +93 -0
  6. package/scripts/native-binary-arch.test.mjs +75 -0
  7. package/scripts/native-tool-download.mjs +118 -0
  8. package/src/runtime/agent/orchestrator/providers/gemini.mjs +1 -1
  9. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +2 -2
  10. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +2 -2
  11. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +1 -1
  12. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
  13. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +12 -12
  14. package/src/runtime/agent/orchestrator/session/compact/file-reattach.mjs +2 -2
  15. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +1 -1
  16. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -1
  18. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -3
  19. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +6 -7
  20. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +2 -3
  21. package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +1 -1
  22. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +3 -3
  23. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +2 -2
  24. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  25. package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +1 -1
  26. package/src/runtime/channels/lib/parent-bridge.mjs +2 -2
  27. package/src/runtime/channels/lib/runtime-paths.mjs +1 -1
  28. package/src/standalone/agent-tool/shard-spread.mjs +5 -3
  29. package/src/standalone/agent-tool/shard-spread.test.mjs +16 -1
  30. package/src/tui/App.jsx +3 -3
  31. package/src/tui/app/transcript-window.mjs +4 -5
  32. package/src/tui/app/use-mouse-input.mjs +2 -2
  33. package/src/tui/app/use-transcript-scroll.mjs +37 -29
  34. package/src/tui/components/tool-output-format.mjs +2 -2
  35. package/src/tui/dist/index.mjs +21 -31
  36. package/src/tui/themes/teal.mjs +1 -1
  37. package/vendor/ink/build/ink.js +7 -85
  38. package/vendor/ink/build/output.js +7 -9
  39. package/vendor/ink/build/selection-rect.js +80 -0
  40. package/vendor/ink/build/termio-keypress.js +110 -79
  41. package/vendor/ink/build/_baseline-termio-keypress.js +0 -240
  42. package/vendor/ink/build/_baseline-termio-tokenize.js +0 -253
@@ -20,9 +20,8 @@ function snapshotBodyWasReturnedByRead(snapshot) {
20
20
  || source.startsWith('apply_patch_');
21
21
  }
22
22
 
23
- // BOM-only read-encoding detection. Mirrors CC fileRead.ts:34
24
- // (buffer[0]===0xff && buffer[1]===0xfe -> 'utf16le') / file.ts
25
- // detectFileEncoding. STRICTLY a leading-BOM rule — no content sniffing
23
+ // BOM-only read-encoding detection: buffer[0]===0xff && buffer[1]===0xfe
24
+ // means 'utf16le', and so on. STRICTLY a leading-BOM rule — no content sniffing
26
25
  // and no heuristic fallback.
27
26
  // Returns the decoder name plus the BOM byte length to strip before
28
27
  // decoding. utf8-with-BOM (EF BB BF) keeps the utf-8 decoder; its leading
@@ -170,7 +170,7 @@ export async function extractIpynbText(fullPath, { maxOutputBytes = DEFAULT_READ
170
170
  const rawTxt = data['text/plain']
171
171
  ? (Array.isArray(data['text/plain']) ? data['text/plain'].join('') : data['text/plain'])
172
172
  : (Array.isArray(out.text) ? out.text.join('') : out.text);
173
- // Port CC behaviour: a single huge output is replaced
173
+ // A single huge output is replaced
174
174
  // with a jq hint rather than dumped inline.
175
175
  if (typeof rawTxt === 'string' && rawTxt.length > IPYNB_OUTPUT_MAX_CHARS) {
176
176
  block += `\n# Output: [large output omitted — ${rawTxt.length} chars; inspect with: cat "${fullPath}" | jq '.cells[${cellIndex}].outputs']`;
@@ -116,7 +116,7 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
116
116
  _recordReadSnapshot,
117
117
  READ_MAX_OUTPUT_BYTES,
118
118
  } = helpers;
119
- // CC `file_path` alias — official SDK schema uses `file_path`;
119
+ // `file_path` alias — the official SDK schema uses `file_path`;
120
120
  // mixdog has historically used `path`. Honor `file_path` so a
121
121
  // CC-trained agent's call shape works without translation.
122
122
  const usedFilePathAlias = typeof args.file_path === 'string' && !args.path;
@@ -232,8 +232,8 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
232
232
  // result is sliced back into the original per-entry windows
233
233
  // for response assembly. Non-same-path entries are untouched.
234
234
  const rawEntries = args.path.map((r) => {
235
- // CC `file_path` alias on a per-entry batch: file_path is
236
- // 1-based (CC schema), so decrement a positive offset to
235
+ // `file_path` alias on a per-entry batch: file_path is
236
+ // 1-based (SDK schema), so decrement a positive offset to
237
237
  // match the 0-based `path` form. Mirrors the scalar
238
238
  // alias adjustment at line 57.
239
239
  const entryUsesFilePathAlias = typeof r?.file_path === 'string' && !r?.path;
@@ -599,7 +599,7 @@ export function execShellCommand({
599
599
  if (grace.unref) grace.unref();
600
600
  };
601
601
  child.once('exit', _onChildExit);
602
- // Auto-background transition (CC startBackgrounding analogue). Two triggers
602
+ // Auto-background transition. Two triggers
603
603
  // resolve the call immediately with a 'backgrounded' result while the
604
604
  // child keeps running, adopted into the shell-jobs registry but still
605
605
  // owned by this CLI process:
@@ -904,7 +904,7 @@ export function execShellCommand({
904
904
  if (abortSignal) {
905
905
  abortHandler = () => {
906
906
  // Interrupt (the user typed a NEW message while this was running) is a
907
- // "also look at this" signal, not "stop that" — CC backgrounds instead
907
+ // "also look at this" signal, not "stop that" — it backgrounds instead
908
908
  // of killing there, and throwing away a long build the user never asked
909
909
  // to stop is the worse outcome. Explicit cancellation (ESC) keeps the
910
910
  // kill. The promotion reuses the timeout path's guards verbatim so a
@@ -337,7 +337,7 @@ export class TaskOutput {
337
337
  }
338
338
 
339
339
  // Direct mode has no JS write path, so byte counters must come from the
340
- // filesystem (CC "poll the file tail" analogue).
340
+ // filesystem (a "poll the file tail" approach).
341
341
  _refreshDirectSizes() {
342
342
  if (!this.direct) return;
343
343
  try { this.stdoutFileSize = statSync(this.stdoutPath).size; } catch {}
@@ -531,7 +531,7 @@ export class ExecResult {
531
531
  // renderer in builtin/bash-tool.mjs turns this into a model-visible marker.
532
532
  this.failurePhase = opts.failurePhase || null;
533
533
  this.failureReason = opts.failureReason || null;
534
- // Auto-background transition (CC startBackgrounding analogue). When a
534
+ // Auto-background transition. When a
535
535
  // foreground command outlives autoBackgroundMs the call settles with
536
536
  // backgrounded:true + the jobId for manual task control. The
537
537
  // child stays owned by the CLI process; stdout/stderr keep flowing to
@@ -55,7 +55,7 @@ const BLOCKED_PATTERNS = [
55
55
  // cmd `del /s` / `rd /s`) are NOT blocked outright — each is target-checked
56
56
  // by a dedicated guard below via _isDangerousDeleteTarget, blocking only
57
57
  // filesystem-root / home / top-level-system / whole-cwd targets (CC-level).
58
- // `git reset --hard` is likewise no longer hard-blocked (CC prompts; the
58
+ // `git reset --hard` is likewise no longer hard-blocked (a prompt suffices; the
59
59
  // agent workflow already gates destructive git ops).
60
60
  // Bare `git push --force` (and `--force=`) still blocks; the safer
61
61
  // `--force-with-lease` / `--force-if-includes` variants pass.
@@ -28,11 +28,11 @@ function setChannelNotifySink(fn) {
28
28
 
29
29
  function createParentBridge({ getInstanceId, ipcProcess = process }) {
30
30
  function sendNotifyToParent(method, params) {
31
- // CC channel schema requires meta: Record<string,string> (channelNotification.ts).
31
+ // The channel schema requires meta: Record<string,string>.
32
32
  // Coerce every meta value to string so a non-string (e.g. a Discord
33
33
  // interaction.type number) can't fail zod and silently drop the notify.
34
34
  // silent_to_agent stays boolean — an internal routing flag the daemon
35
- // router / agentNotify consume (=== true) before the CC zod boundary.
35
+ // router / agentNotify consume (=== true) before the zod boundary.
36
36
  const outParams = normalizeChannelNotifyParams(method, params);
37
37
  if (_notifySink) {
38
38
  try { _notifySink(method, outParams); }
@@ -289,7 +289,7 @@ function refreshActiveInstance(instanceId, meta, options) {
289
289
  }
290
290
  preservedExtra.gateway_server_pid = prevGatewayServerPid;
291
291
  // Clear session-scoped gateway metrics when the transcript changes —
292
- // a new CC session must not inherit the previous session's context
292
+ // a new session must not inherit the previous session's context
293
293
  // usage before the gateway re-advertises.
294
294
  const metricTranscript =
295
295
  typeof prevForPreserve?.gateway_transcript_path === 'string' && prevForPreserve.gateway_transcript_path
@@ -611,9 +611,11 @@ export function createAgentShardSpread({ mgr, log = null } = {}) {
611
611
  const createRemoteSession = createRemoteAgentSession;
612
612
  const remote = (id) => handles.get(String(id || '')) || null;
613
613
 
614
- // mgr facade: prototype delegation keeps every manager method reachable;
615
- // only session-addressed methods gain the remote-handle routing.
616
- const wrapped = Object.create(mgr);
614
+ // mgr is normally an ESM module namespace. Copy its enumerable exports onto
615
+ // a writable facade before overriding the session-addressed methods. Using
616
+ // the namespace as a prototype makes assignment hit its immutable exports
617
+ // under Node 24 (Electron 41), so even `wrapped.getSession = ...` throws.
618
+ const wrapped = Object.assign(Object.create(null), mgr);
617
619
  wrapped.getSession = (id) => {
618
620
  const handle = remote(id);
619
621
  return handle ? handle.facade : mgr.getSession(id);
@@ -1,7 +1,22 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
 
4
- import { remoteTurnHandoffReady } from './shard-spread.mjs';
4
+ import { createAgentShardSpread, remoteTurnHandoffReady } from './shard-spread.mjs';
5
+
6
+ test('manager wrapper owns writable overrides when the source manager is immutable', () => {
7
+ const localSession = { id: 'local-session' };
8
+ const createSession = () => localSession;
9
+ const mgr = Object.freeze({
10
+ createSession,
11
+ getSession: (id) => (id === localSession.id ? localSession : null),
12
+ });
13
+
14
+ const spread = createAgentShardSpread({ mgr });
15
+
16
+ assert.equal(Object.hasOwn(spread.mgr, 'getSession'), true);
17
+ assert.equal(spread.mgr.getSession(localSession.id), localSession);
18
+ assert.equal(spread.mgr.createSession, createSession);
19
+ });
5
20
 
6
21
  test('remote turn waits through the turndone-to-save gap for assistant content', () => {
7
22
  assert.equal(remoteTurnHandoffReady({
package/src/tui/App.jsx CHANGED
@@ -104,7 +104,7 @@ import {
104
104
  TRANSCRIPT_MEASURED_ROWS,
105
105
  selectionRectsEqual,
106
106
  shiftSelectionRectY,
107
- comparePoints,
107
+ compareCellOrder,
108
108
  upperBound,
109
109
  resolveAnchorScrollOffset,
110
110
  transcriptItemVariantKey,
@@ -641,8 +641,8 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false, on
641
641
  // | null. Press decides it; motion/release stay in that region.
642
642
  // anchorSpan: for word/line multi-click selections, the initial word/line
643
643
  // bounds ({ lo:{x,y}, hi:{x,y}, kind:'word'|'line' }) so a subsequent drag
644
- // extends the selection whole-word/whole-line from that span (see selection.ts
645
- // extendSelection). Null ⇔ ordinary char-drag selection.
644
+ // extends the selection whole-word/whole-line from that span. Null ⇔ an
645
+ // ordinary char-drag selection.
646
646
  const dragRef = useRef({ anchor: null, anchorScroll: 0, last: null, active: false, rect: null, region: null, anchorSpan: null });
647
647
  const transcriptViewportRef = useRef({ top: 0, bottom: 0 });
648
648
  const panelTransitionRef = useRef({ signature: '', reserve: 0, clearRows: 0, guardRows: 0, epoch: 0 });
@@ -135,11 +135,10 @@ export function shiftSelectionRectY(rect, deltaY) {
135
135
  return { ...rect, y1: rect.y1 + dy, y2: rect.y2 + dy };
136
136
  }
137
137
 
138
- // Reading-order compare (row then col): -1 if a<b, 1 if a>b, 0 equal.
139
- export function comparePoints(a, b) {
140
- if (a.y !== b.y) return a.y < b.y ? -1 : 1;
141
- if (a.x !== b.x) return a.x < b.x ? -1 : 1;
142
- return 0;
138
+ // Reading order over grid cells: the row decides, the column breaks ties.
139
+ // Returns the sign of the difference, so it drops straight into comparisons.
140
+ export function compareCellOrder(a, b) {
141
+ return a.y === b.y ? Math.sign(a.x - b.x) : Math.sign(a.y - b.y);
143
142
  }
144
143
 
145
144
 
@@ -590,7 +590,7 @@ export function useMouseInput({
590
590
  const now = Date.now();
591
591
  // Shift+click on an existing word/line (anchorSpan) selection extends
592
592
  // that selection by whole words/lines to the click point, preserving
593
- // the original anchor span (mirrors selection.ts extendSelection).
593
+ // the original anchor span.
594
594
  if (
595
595
  extendHeld
596
596
  && dragRef.current.region === region
@@ -660,7 +660,7 @@ export function useMouseInput({
660
660
  // words/lines from this span (see buildSpanRect). Leave the drag
661
661
  // ARMED (active:true): a release without motion keeps this highlight
662
662
  // (buildSpanRect returns the span for an in-span target), while
663
- // any motion extends it. Mirrors selectWordAt/selectLineAt setting
663
+ // any motion extends it. The word/line select sets
664
664
  // isDragging=true + anchorSpan; the mouse-up finalizes.
665
665
  const kind = clickCount === 2 ? 'word' : 'line';
666
666
  const wr = kind === 'word' ? store.getWordRectAt?.(x, y) : store.getLineRectAt?.(y);
@@ -16,7 +16,7 @@ import {
16
16
  accumulateDirectionalScrollDelta,
17
17
  selectionRectsEqual,
18
18
  shiftSelectionRectY,
19
- comparePoints,
19
+ compareCellOrder,
20
20
  upperBound,
21
21
  transcriptRowAt,
22
22
  } from './transcript-window.mjs';
@@ -101,7 +101,7 @@ export function useTranscriptScroll({
101
101
  // Synchronous sibling of harvestStitchRowsSoon: snapshot the rows CURRENTLY
102
102
  // under the selection into the stitch buffer immediately, keyed by the given
103
103
  // (pre-scroll) offset. Called right before a scroll shifts those rows out of
104
- // view — mirrors selection.ts captureScrolledRows, which grabs the outgoing
104
+ // view — the harvest has to grab the outgoing
105
105
  // rows BEFORE scrollBy overwrites them. The deferred harvest could never see
106
106
  // rows that a fast drag/wheel scrolled past between paint and its setTimeout.
107
107
  // selectionRows is harvested by the renderer UNCONDITIONALLY (even on the
@@ -373,35 +373,43 @@ export function useTranscriptScroll({
373
373
  };
374
374
  }, []);
375
375
 
376
- // Port of selection.ts extendSelection onto the linear-rect model, hoisted to
377
- // component scope so BOTH the mouse handler (motion/release) AND the
378
- // auto-scroll path (scrollTranscriptRows) can rebuild a span-aware rect. Grows
379
- // a word/line multi-click selection from its anchor span to the word/line under
380
- // the cursor: target ends before the span → extend backward (span.hi→targetLo);
381
- // target starts after → extend forward (span.lo→targetHi); overlapping → the
382
- // span. The moving end snaps to the word (getWordRectAt) or line (getLineRectAt)
383
- // at the cursor; a miss (blank/gutter) falls back to the raw cell. spanScroll
384
- // re-anchors the span to the current transcript scroll (status never scrolls) so
385
- // the original word/line tracks the content while dragging/auto-scrolling.
376
+ // Grow a word/line multi-click selection from its anchor span out to the
377
+ // word/line under the cursor. Hoisted to component scope so BOTH the mouse
378
+ // handler (motion/release) AND the auto-scroll path can rebuild a span-aware
379
+ // rect. The moving end snaps to the word or line under the cursor and falls
380
+ // back to the raw cell on a miss; spanScroll re-anchors the stored span to
381
+ // the current transcript scroll (the status band never scrolls) so the
382
+ // original word keeps tracking its content while dragging or auto-scrolling.
386
383
  const buildSpanRect = useCallback((span, x, y, region, spanScroll = 0) => {
387
- const conv = (pt) => (region === 'status' ? pt : selectionPointAtCurrentScroll(pt, spanScroll));
388
- const spanLo = conv(span.lo);
389
- const spanHi = conv(span.hi);
390
- let mLo;
391
- let mHi;
392
- if (span.kind === 'word') {
393
- const wr = store.getWordRectAt?.(x, y);
394
- if (wr) { mLo = { x: wr.x1, y: wr.y1 }; mHi = { x: wr.x2, y: wr.y2 }; }
395
- else { mLo = { x, y }; mHi = { x, y }; }
384
+ const atCurrentScroll = (point) => (
385
+ region === 'status' ? point : selectionPointAtCurrentScroll(point, spanScroll)
386
+ );
387
+ const anchorStart = atCurrentScroll(span.lo);
388
+ const anchorEnd = atCurrentScroll(span.hi);
389
+
390
+ const snapped = span.kind === 'word'
391
+ ? store.getWordRectAt?.(x, y)
392
+ : store.getLineRectAt?.(y);
393
+ let targetStart;
394
+ let targetEnd;
395
+ if (snapped) {
396
+ targetStart = { x: snapped.x1, y: snapped.y1 };
397
+ targetEnd = { x: snapped.x2, y: snapped.y2 };
398
+ } else if (span.kind === 'word') {
399
+ targetStart = { x, y };
400
+ targetEnd = { x, y };
396
401
  } else {
397
- const lr = store.getLineRectAt?.(y);
398
- if (lr) { mLo = { x: lr.x1, y: lr.y1 }; mHi = { x: lr.x2, y: lr.y2 }; }
399
- else { mLo = { x: 0, y }; mHi = { x: Math.max(0, frameColumns - 1), y }; }
402
+ targetStart = { x: 0, y };
403
+ targetEnd = { x: Math.max(0, frameColumns - 1), y };
400
404
  }
401
- const rect = (a, b) => ({ mode: 'linear', x1: a.x, y1: a.y, x2: b.x, y2: b.y });
402
- if (comparePoints(mHi, spanLo) < 0) return rect(spanHi, mLo);
403
- if (comparePoints(mLo, spanHi) > 0) return rect(spanLo, mHi);
404
- return rect(spanLo, spanHi);
405
+
406
+ // The anchor span always stays whole; the rect reaches from it toward the
407
+ // target when the target sits clear of it on either side, and collapses
408
+ // back to the anchor when the two overlap.
409
+ const linear = (from, to) => ({ mode: 'linear', x1: from.x, y1: from.y, x2: to.x, y2: to.y });
410
+ if (compareCellOrder(targetEnd, anchorStart) < 0) return linear(anchorEnd, targetStart);
411
+ if (compareCellOrder(targetStart, anchorEnd) > 0) return linear(anchorStart, targetEnd);
412
+ return linear(anchorStart, anchorEnd);
405
413
  }, [store, frameColumns, selectionPointAtCurrentScroll]);
406
414
 
407
415
  const transcriptViewportRows = useCallback(() => {
@@ -538,7 +546,7 @@ export function useTranscriptScroll({
538
546
  }
539
547
  // Before the scroll moves selected rows out of view, snapshot the rows
540
548
  // currently under the selection into the stitch buffer keyed by the
541
- // PRE-scroll offset (ref selection.ts captureScrolledRows). Runs for BOTH
549
+ // PRE-scroll offset. Runs for BOTH
542
550
  // an active drag and a wheel-shift of a released selection, so Ctrl+C
543
551
  // reconstructs the full text no matter how far it scrolled off-screen.
544
552
  if (appliedDelta !== 0 && dragRef.current.region === 'transcript' && dragRef.current.rect) {
@@ -35,11 +35,11 @@ import { buildTableRender } from '../markdown/table-layout.mjs';
35
35
  const DEFAULT_MARKDOWN_WIDTH = 80;
36
36
 
37
37
  // Hard ceilings so a pathological tool result can never lock the render loop.
38
- // CC uses ~MAX_LINES*width*4 for the collapsed fold; for the EXPANDED body we
38
+ // The collapsed fold budgets roughly MAX_LINES*width*4; for the EXPANDED body we
39
39
  // cap total characters processed and total lines kept, with an explicit marker.
40
40
  const MAX_EXPANDED_CHARS = 256 * 1024; // 256 KB of text gets per-line processing
41
41
  const MAX_EXPANDED_LINES = 4000; // logical-line safety ceiling; physical mount cap is separate
42
- const MAX_JSON_FORMAT_LENGTH = 10_000; // mirror CC's tryJsonFormatContent cap
42
+ const MAX_JSON_FORMAT_LENGTH = 10_000; // JSON pretty-format cap
43
43
  const MAX_HIGHLIGHT_LINE_CHARS = 2000; // skip token-scan on absurdly long lines
44
44
  // Lockstep with ToolExecution collapsed fit budget (MIN_RESULT_LINE_CHARS).
45
45
  const MIN_EXPANDED_BODY_COLS = 24;
@@ -2061,7 +2061,7 @@ var tealPalette = {
2061
2061
  error: "rgb(204,102,102)",
2062
2062
  // red #cc6666
2063
2063
  warning: "rgb(255,255,0)",
2064
- // yellow #ffff00 (pi uses pure yellow)
2064
+ // yellow #ffff00 (pure yellow by design)
2065
2065
  suggestion: "rgb(129,162,190)",
2066
2066
  // mdLink #81a2be
2067
2067
  panelTitle: "rgb(138,190,183)",
@@ -12937,10 +12937,8 @@ function shiftSelectionRectY(rect, deltaY) {
12937
12937
  if (!rect || dy === 0) return rect || null;
12938
12938
  return { ...rect, y1: rect.y1 + dy, y2: rect.y2 + dy };
12939
12939
  }
12940
- function comparePoints(a, b) {
12941
- if (a.y !== b.y) return a.y < b.y ? -1 : 1;
12942
- if (a.x !== b.x) return a.x < b.x ? -1 : 1;
12943
- return 0;
12940
+ function compareCellOrder(a, b) {
12941
+ return a.y === b.y ? Math.sign(a.x - b.x) : Math.sign(a.y - b.y);
12944
12942
  }
12945
12943
  function lowerBound(values, target) {
12946
12944
  let lo = 0;
@@ -14173,34 +14171,26 @@ function useTranscriptScroll({
14173
14171
  };
14174
14172
  }, []);
14175
14173
  const buildSpanRect = useCallback3((span, x, y, region, spanScroll = 0) => {
14176
- const conv = (pt) => region === "status" ? pt : selectionPointAtCurrentScroll(pt, spanScroll);
14177
- const spanLo = conv(span.lo);
14178
- const spanHi = conv(span.hi);
14179
- let mLo;
14180
- let mHi;
14181
- if (span.kind === "word") {
14182
- const wr = store.getWordRectAt?.(x, y);
14183
- if (wr) {
14184
- mLo = { x: wr.x1, y: wr.y1 };
14185
- mHi = { x: wr.x2, y: wr.y2 };
14186
- } else {
14187
- mLo = { x, y };
14188
- mHi = { x, y };
14189
- }
14174
+ const atCurrentScroll = (point) => region === "status" ? point : selectionPointAtCurrentScroll(point, spanScroll);
14175
+ const anchorStart = atCurrentScroll(span.lo);
14176
+ const anchorEnd = atCurrentScroll(span.hi);
14177
+ const snapped = span.kind === "word" ? store.getWordRectAt?.(x, y) : store.getLineRectAt?.(y);
14178
+ let targetStart;
14179
+ let targetEnd;
14180
+ if (snapped) {
14181
+ targetStart = { x: snapped.x1, y: snapped.y1 };
14182
+ targetEnd = { x: snapped.x2, y: snapped.y2 };
14183
+ } else if (span.kind === "word") {
14184
+ targetStart = { x, y };
14185
+ targetEnd = { x, y };
14190
14186
  } else {
14191
- const lr = store.getLineRectAt?.(y);
14192
- if (lr) {
14193
- mLo = { x: lr.x1, y: lr.y1 };
14194
- mHi = { x: lr.x2, y: lr.y2 };
14195
- } else {
14196
- mLo = { x: 0, y };
14197
- mHi = { x: Math.max(0, frameColumns - 1), y };
14198
- }
14187
+ targetStart = { x: 0, y };
14188
+ targetEnd = { x: Math.max(0, frameColumns - 1), y };
14199
14189
  }
14200
- const rect = (a, b) => ({ mode: "linear", x1: a.x, y1: a.y, x2: b.x, y2: b.y });
14201
- if (comparePoints(mHi, spanLo) < 0) return rect(spanHi, mLo);
14202
- if (comparePoints(mLo, spanHi) > 0) return rect(spanLo, mHi);
14203
- return rect(spanLo, spanHi);
14190
+ const linear = (from, to) => ({ mode: "linear", x1: from.x, y1: from.y, x2: to.x, y2: to.y });
14191
+ if (compareCellOrder(targetEnd, anchorStart) < 0) return linear(anchorEnd, targetStart);
14192
+ if (compareCellOrder(targetStart, anchorEnd) > 0) return linear(anchorStart, targetEnd);
14193
+ return linear(anchorStart, anchorEnd);
14204
14194
  }, [store, frameColumns, selectionPointAtCurrentScroll]);
14205
14195
  const transcriptViewportRows = useCallback3(() => {
14206
14196
  const top = Math.max(0, Number(transcriptViewportRef.current?.top) || 0);
@@ -38,7 +38,7 @@ export const tealPalette = {
38
38
  promptBorder: 'rgb(80,80,80)', // borderMuted/darkGray #505050
39
39
  success: 'rgb(181,189,104)', // green #b5bd68
40
40
  error: 'rgb(204,102,102)', // red #cc6666
41
- warning: 'rgb(255,255,0)', // yellow #ffff00 (pi uses pure yellow)
41
+ warning: 'rgb(255,255,0)', // yellow #ffff00 (pure yellow by design)
42
42
  suggestion: 'rgb(129,162,190)', // mdLink #81a2be
43
43
  panelTitle: 'rgb(138,190,183)', // accent
44
44
  permission: 'rgb(204,102,102)', // red
@@ -14,6 +14,7 @@ import reconciler from './reconciler.js';
14
14
  import render from './renderer.js';
15
15
  import * as dom from './dom.js';
16
16
  import { hideCursorEscape, showCursorEscape } from './cursor-helpers.js';
17
+ import { wordRectAt } from './selection-rect.js';
17
18
  import logUpdate, { frameLog } from './log-update.js';
18
19
  import { bsu, esu, shouldSynchronize } from './write-synchronized.js';
19
20
  import instances from './instances.js';
@@ -451,91 +452,12 @@ export default class Ink {
451
452
  this.rootNode.onRender();
452
453
  });
453
454
  };
454
- // [mixdog fork] Given a 0-based cell (x, y), return the inclusive rect of the
455
- // word at that cell on that single row, or null if the cell is whitespace/empty
456
- // or out of range. Reuses the cached cell-value rows from the last render so it
457
- // works without retaining the Output instance.
458
- //
459
- // Selection word model (3-class word
460
- // model) onto mixdog's rect(linear) infra. Instead of a naive "non-space run",
461
- // expansion stops at a CHARACTER-CLASS change:
462
- // class 1 = WORD_CHAR — letters (any script), digits, and the punctuation
463
- // iTerm2 treats as word-part by default (/-+~_.\), so a path like
464
- // `/usr/bin/bash` or `~/.claude/config.json` selects whole.
465
- // class 2 = other punctuation — so `->` selects just `->`, not the words
466
- // on either side.
467
- // class 0 = space/empty. A space run could be treated as selectable
468
- // (class 0), but mixdog intentionally returns null on empty/space
469
- // so a double-click on blank does nothing (safer for our transcript
470
- // where most alt-screen cells are padding).
471
- getWordRectAt = (x, y) => {
472
- const rows = this.lastPlainRows;
473
- if (!rows)
474
- return null;
475
- const cells = rows[y];
476
- if (!Array.isArray(cells))
477
- return null;
478
- // Unicode-aware word-char set (matches selection.ts WORD_CHAR).
479
- const WORD_CHAR = /[\p{L}\p{N}_/.\-+~\\]/u;
480
- const charClass = (v) => {
481
- if (!v || v === ' ')
482
- return 0;
483
- return WORD_CHAR.test(v) ? 1 : 2;
484
- };
485
- // [mixdog fork] Wide/CJK glyphs occupy 2+ grid cells: the HEAD cell
486
- // holds the glyph and each TRAILING cell is stored as '' (spacer tail)
487
- // carrying the glyph's styles — see output.js ~L237-243 for how wide
488
- // chars are laid into the grid. A '' cell is a wide-char TAIL only when
489
- // it directly follows a non-empty non-space glyph (class !== 0); a ''
490
- // after '' or after a space is genuine blank padding. This mirrors
491
- // selection.ts wordBoundsAt's SpacerTail step-back (L172-178) and
492
- // expansion step-over (L206-221) on mixdog's string-cell grid.
493
- const isWideTail = (i) => i > 0 && cells[i] === '' && charClass(cells[i - 1]) !== 0;
494
- // On entry: if the click landed on a spacer tail, step back to the head
495
- // so charClass sees the actual glyph. Genuine blank padding is left
496
- // alone, preserving the null-on-blank behavior below.
497
- let sx = x;
498
- if (isWideTail(sx))
499
- sx = sx - 1;
500
- const cls = charClass(cells[sx]);
501
- // Preserve mixdog's null-on-space/empty behavior (class 0).
502
- if (cls === 0)
503
- return null;
504
- let x1 = sx, x2 = sx;
505
- // Expand left: step OVER a spacer tail to the wide-char head and include
506
- // both columns when the head matches the class; otherwise stop at a
507
- // class change.
508
- while (x1 - 1 >= 0) {
509
- const p = x1 - 1;
510
- if (isWideTail(p)) {
511
- if (p - 1 >= 0 && charClass(cells[p - 1]) === cls) {
512
- x1 = p - 1;
513
- continue;
514
- }
515
- break;
516
- }
517
- if (charClass(cells[p]) === cls) {
518
- x1 = p;
519
- continue;
520
- }
521
- break;
522
- }
523
- // Expand right: INCLUDE a spacer tail that follows an in-run glyph so x2
524
- // covers the wide glyph's full width; otherwise stop at a class change.
525
- while (x2 + 1 < cells.length) {
526
- const n = x2 + 1;
527
- if (isWideTail(n)) {
528
- x2 = n;
529
- continue;
530
- }
531
- if (charClass(cells[n]) === cls) {
532
- x2 = n;
533
- continue;
534
- }
535
- break;
536
- }
537
- return { x1, y1: y, x2, y2: y };
538
- };
455
+ // [mixdog fork] Inclusive rect of the word at cell (x, y) on that row, or
456
+ // null when the cell is blank or out of range. Reuses the cached cell-value
457
+ // rows from the last render, so it works without retaining the Output
458
+ // instance; the class model and wide-glyph handling live in
459
+ // selection-rect.js.
460
+ getWordRectAt = (x, y) => wordRectAt(this.lastPlainRows, x, y);
539
461
  // [mixdog fork] Given a 0-based row y, return the inclusive rect of the whole
540
462
  // logical line at that row (select-line intent on
541
463
  // mixdog's rect infra). x1 is always 0; x2 is the last non-space content cell
@@ -302,15 +302,13 @@ export default class Output {
302
302
  // text is trailing-space-trimmed and partial first/last rows respect
303
303
  // x1/x2. Null when there is no selection (mirrors selectedText).
304
304
  let selectionRows = null;
305
- // [mixdog fork] noSelect exclusion (skip gutter / line-number
306
- // / diff-sigil cells from both highlight and copy via screen.noSelect):
307
- // mixdog's cell model has NO noSelect marker — the Output grid stores only
308
- // {value, styles} per cell, with no flag distinguishing gutter cells from
309
- // content. Inferring gutters from position/content would be a fragile
310
- // heuristic (line numbers, diff +/- sigils, and real content are
311
- // indistinguishable at the cell level), so this is deliberately NOT
312
- // implemented. It needs a noSelect bit threaded through the render
313
- // pipeline before it can be done cleanly.
305
+ // [mixdog fork] Excluding gutter / line-number / diff-sigil cells from
306
+ // the highlight and the copy is deliberately NOT implemented: the Output
307
+ // grid stores only {value, styles} per cell, with no flag distinguishing
308
+ // chrome from content. Inferring gutters from position or content would
309
+ // be a fragile heuristic (line numbers, diff +/- sigils and real content
310
+ // are indistinguishable at the cell level), so this needs an explicit
311
+ // no-select bit threaded through the render pipeline first.
314
312
  if (sel) {
315
313
  const captureSelectedText = sel.captureText !== false;
316
314
  if (!captureSelectedText) {
@@ -0,0 +1,80 @@
1
+ /**
2
+ * selection-rect.js — double-click word bounds over the rendered cell grid.
3
+ *
4
+ * The grid stores one string per column. A wide glyph (CJK, emoji) keeps its
5
+ * text in the HEAD column and leaves the column it overhangs as '' — a
6
+ * continuation cell that carries the head's styling. Everything here works in
7
+ * GLYPH units so a wide character is never split down the middle.
8
+ *
9
+ * Expansion stops at a character-class change rather than at whitespace, which
10
+ * is what terminals do: double-clicking `foo` takes `foo`, `->` takes `->`, and
11
+ * a path stays whole because terminals count a few punctuation marks as
12
+ * word-forming. iTerm2 publishes that default set as / - + ~ _ . and the
13
+ * backslash is added here so Windows paths behave the same way.
14
+ */
15
+
16
+ const CLASS_BLANK = 0;
17
+ const CLASS_WORD = 1;
18
+ const CLASS_SYMBOL = 2;
19
+
20
+ const WORD_PUNCTUATION = new Set(['_', '/', '.', '-', '+', '~', '\\']);
21
+ const LETTER_OR_DIGIT = /[\p{L}\p{N}]/u;
22
+
23
+ function carriesWordPunctuation(value) {
24
+ for (const character of value) {
25
+ if (WORD_PUNCTUATION.has(character)) return true;
26
+ }
27
+ return false;
28
+ }
29
+
30
+ /** Blank, word-forming, or plain symbol. Empty and space are both blank. */
31
+ export function cellClass(value) {
32
+ if (!value || value === ' ') return CLASS_BLANK;
33
+ return LETTER_OR_DIGIT.test(value) || carriesWordPunctuation(value)
34
+ ? CLASS_WORD
35
+ : CLASS_SYMBOL;
36
+ }
37
+
38
+ /**
39
+ * The glyph occupying `column`, as inclusive head/tail columns. A continuation
40
+ * cell is an empty string directly after a non-blank glyph; anything else that
41
+ * is empty — column 0, a run of empties, an empty after a space — is padding
42
+ * and stands alone.
43
+ */
44
+ function glyphAt(cells, column) {
45
+ const continues = (index) => index > 0
46
+ && cells[index] === ''
47
+ && cellClass(cells[index - 1]) !== CLASS_BLANK;
48
+ const head = continues(column) ? column - 1 : column;
49
+ return { head, tail: continues(head + 1) ? head + 1 : head };
50
+ }
51
+
52
+ /**
53
+ * Inclusive rect of the word at (x, y), or null when the cell is blank or out
54
+ * of range. Blank cells deliberately yield nothing so a double-click on the
55
+ * padding around transcript content does not select a run of spaces.
56
+ */
57
+ export function wordRectAt(rows, x, y) {
58
+ const cells = Array.isArray(rows?.[y]) ? rows[y] : null;
59
+ if (!cells) return null;
60
+
61
+ const origin = glyphAt(cells, x);
62
+ const wordClass = cellClass(cells[origin.head]);
63
+ if (wordClass === CLASS_BLANK) return null;
64
+
65
+ let first = origin.head;
66
+ while (first > 0) {
67
+ const previous = glyphAt(cells, first - 1);
68
+ if (cellClass(cells[previous.head]) !== wordClass) break;
69
+ first = previous.head;
70
+ }
71
+
72
+ let last = origin.tail;
73
+ while (last + 1 < cells.length) {
74
+ const next = glyphAt(cells, last + 1);
75
+ if (cellClass(cells[next.head]) !== wordClass) break;
76
+ last = next.tail;
77
+ }
78
+
79
+ return { x1: first, y1: y, x2: last, y2: y };
80
+ }