mixdog 0.9.140 → 0.9.141

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/LICENSES/MIT.txt CHANGED
@@ -12,6 +12,10 @@ component listed here. Each component keeps its own copyright notice.
12
12
  https://github.com/microsoft/vscode-codicons
13
13
  Copyright (c) Microsoft Corporation
14
14
 
15
+ Octicons
16
+ https://github.com/primer/octicons
17
+ Copyright (c) GitHub, Inc.
18
+
15
19
  GitHub Pull Requests and Issues (VS Code extension)
16
20
  https://github.com/microsoft/vscode-pull-request-github
17
21
  Copyright (c) Microsoft Corporation. All rights reserved.
package/NOTICE.md CHANGED
@@ -1,10 +1,8 @@
1
1
  # NOTICE
2
2
 
3
- Mixdog itself is MIT-licensed (see `LICENSE`). The license sections below
4
- cover third-party code and data that Mixdog actually carries. The closing
5
- "Behavioral references" section records projects that only informed the
6
- implementation, with no code taken. Everything is kept here in one place so
7
- the individual source files stay free of scattered attribution comments.
3
+ Mixdog itself is MIT-licensed (see `LICENSE`). The sections below cover the
4
+ third-party code and data that Mixdog actually carries, kept here in one place
5
+ so the individual source files stay free of scattered attribution comments.
8
6
 
9
7
  Full license texts live in `LICENSES/`:
10
8
 
@@ -47,6 +45,15 @@ The chrome-level UI glyph font used by the desktop and web renderer. The code
47
45
  is MIT; the icons themselves are licensed under Creative Commons Attribution
48
46
  4.0 International (CC BY 4.0).
49
47
 
48
+ ### Octicons — Copyright (c) GitHub, Inc.
49
+
50
+ <https://github.com/primer/octicons>
51
+
52
+ The five changed-file status glyphs in
53
+ `apps/desktop/src/renderer/ScmStatusIcon.tsx` carry Octicons 16px path data
54
+ unmodified: `diffAdded`, `diffModified`, `diffRemoved`, `diffRenamed` and
55
+ `alert`.
56
+
50
57
  ### GitHub Pull Requests extension — Copyright (c) Microsoft Corporation
51
58
 
52
59
  <https://github.com/microsoft/vscode-pull-request-github>
@@ -98,44 +105,3 @@ upstream NOTICE file is preserved as `LICENSES/codex-NOTICE.txt`.
98
105
  The Apache-2.0 terms require this notice to travel with any redistribution of
99
106
  the derived files. Full license text: `LICENSES/Apache-2.0.txt`
100
107
  (<https://www.apache.org/licenses/LICENSE-2.0>).
101
-
102
- ## Behavioral references
103
-
104
- The projects below informed Mixdog through publicly observable behavior, a
105
- published wire contract, or a documented algorithm. No source code from them
106
- is present in Mixdog, so no license obligation travels with the result; they
107
- are recorded here because the source files themselves carry no attribution
108
- comments.
109
-
110
- - **Chromium** — the workspace tab strip follows Chromium's tab-strip layout
111
- semantics: two layout domains around the crossover width, the active-tab
112
- floor, the inactive sliver floor, the left-to-right remainder grant and the
113
- icon visibility ladder. The implementation is independent TypeScript and CSS
114
- on Mixdog's own constants (`apps/desktop/src/renderer/WorkspaceTabStrip.tsx`,
115
- `apps/desktop/src/renderer/desktop/04-workspace-tabs.css`).
116
- - **GitHub Desktop** — the Source Control dock grammar: commit-form layout,
117
- changed-file status glyph semantics, single-sentence path colouring, history
118
- context-menu gating, and detached-checkout / tag rules. Painted entirely on
119
- Mixdog's own semantic tokens.
120
- - **OpenCode** — transcript auto-scroll gesture grammar, virtual-timeline
121
- anchoring and the streaming-markdown projection model.
122
- - **Orca** — editor-surface and tab-hierarchy structure, the terminal host
123
- portal, the keep-awake power-save blocker and the hosted-review link
124
- derivation.
125
- - **Files** — the folder pane's grouping keys, date-span labels, size buckets
126
- and discrete layout size ladder
127
- (`apps/desktop/src/renderer/FolderPane.lazy.tsx`).
128
- - **Claude Code** (Anthropic) — terminal input tokenizing and keypress
129
- parsing, the TUI selection word model in `vendor/ink`, and interactive turn
130
- semantics such as queued-message recall, message-selector rewind and
131
- background-task lifetime. The Anthropic OAuth route additionally sends the
132
- client identity that Anthropic's token edge validates; those values are a
133
- wire requirement, not a derivation.
134
- - **opencode-antigravity-auth** — MIT
135
- (<https://github.com/NoeFabris/opencode-antigravity-auth>). The Google Cloud
136
- Code Assist wire contract used by the Antigravity OAuth provider: OAuth
137
- client parameters, endpoint fallback order, client impersonation headers,
138
- request envelope and thinking-signature handling.
139
- - **assistant-ui, Chatbox, Cherry Studio, Jan, Zed** — the heading-size and
140
- list-density survey behind the desktop markdown ladder in
141
- `apps/desktop/src/renderer/desktop/22-markdown.css`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.140",
3
+ "version": "0.9.141",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Version-neutral digest for release cache keys.
5
+ *
6
+ * Deploy rewrites the version field of package.json / package-lock.json on
7
+ * every release, so a cache key built from hashFiles() over those manifests is
8
+ * guaranteed to miss on the one run that needs it: the desktop bundle and the
9
+ * five prepared platform runtimes were rebuilt from scratch each release even
10
+ * though their inputs were byte-identical apart from the version string.
11
+ * Hashing the same manifests with the package identity stripped keeps the key
12
+ * pinned to the dependency tree that actually decides the output.
13
+ */
14
+ import { createHash } from 'node:crypto'
15
+ import { readFile } from 'node:fs/promises'
16
+ import { resolve } from 'node:path'
17
+ import { pathToFileURL } from 'node:url'
18
+
19
+ import { normalizeRuntimeLockfile } from './runtime-dependency-cache-key.mjs'
20
+
21
+ export const MANIFEST_CACHE_SCHEMA = 1
22
+
23
+ export function manifestCacheKey(manifests) {
24
+ const digest = createHash('sha256')
25
+ digest.update(`manifest-v${MANIFEST_CACHE_SCHEMA}`)
26
+ for (const manifest of manifests) {
27
+ digest.update(JSON.stringify(normalizeRuntimeLockfile(manifest)))
28
+ }
29
+ return digest.digest('hex')
30
+ }
31
+
32
+ const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ''
33
+ if (invokedPath === import.meta.url) {
34
+ const manifestPaths = process.argv.slice(2)
35
+ if (!manifestPaths.length) {
36
+ throw new Error('Usage: manifest-cache-key.mjs <package.json|package-lock.json>...')
37
+ }
38
+ Promise.all(manifestPaths.map((path) => readFile(resolve(path))))
39
+ .then((manifests) => process.stdout.write(`${manifestCacheKey(manifests)}\n`))
40
+ .catch((error) => {
41
+ process.stderr.write(`Manifest cache key failed: ${error?.message || error}\n`)
42
+ process.exitCode = 1
43
+ })
44
+ }
@@ -110,7 +110,7 @@ export function resolveGrokOAuthResponsesTransport() {
110
110
  // Retired model aliases xAI no longer exposes by their old ids. The live
111
111
  // catalog surfaces the coding model as grok-build-0.1; map the legacy ids to
112
112
  // it so a stale config selection doesn't hit a model-not-found. Exact table,
113
- // not a heuristic. Mirrors openclaw extensions/xai/model-definitions.ts.
113
+ // not a heuristic.
114
114
  const RETIRED_MODEL_ALIASES = Object.freeze({
115
115
  'grok-code-fast-1': 'grok-build-0.1',
116
116
  'grok-code-fast': 'grok-build-0.1',
@@ -215,10 +215,10 @@ export function isNonTerminalStreamClose(err) {
215
215
  /**
216
216
  * Should this failed stream be re-issued as a NON-STREAMING request?
217
217
  *
218
- * cc's last safety net: when a stream dies it repeats the same request with
219
- * `stream:false` instead of failing the turn (claude.ts, gated off only when
218
+ * A last safety net: when a stream dies, repeat the same request with
219
+ * `stream:false` instead of failing the turn (gated off only when
220
220
  * streaming tool execution could double-run a tool). MixDog dispatches tools
221
- * eagerly, so this stays deliberately narrower than cc: only a stream that
221
+ * eagerly, so this stays deliberately narrow: only a stream that
222
222
  * exposed NOTHING qualifies. An exposed stream is already covered by the
223
223
  * loop-level retraction replay (send-with-recovery), which asks the owner to
224
224
  * withdraw the rendered characters first.
@@ -3,9 +3,8 @@ import { TOOL_OUTPUT_MAX_BYTES } from './tool-output-limit.mjs';
3
3
 
4
4
  // Read tool caps.
5
5
  //
6
- // READ_MAX_SIZE_BYTES (10 MB) — fast-path file-size threshold mirroring
7
- // Reference FAST_PATH_MAX_SIZE (readFileInRange.ts:44). Files at or below
8
- // this size use readFile + in-memory split by default, which CC measured at
6
+ // READ_MAX_SIZE_BYTES (10 MB) — fast-path file-size threshold. Files at or below
7
+ // this size use readFile + in-memory split by default, measured at
9
8
  // ~2x faster than createReadStream + readline for typical source. Explicit
10
9
  // offset/limit windows on files above READ_STREAM_RANGE_MIN_BYTES take the
11
10
  // streaming path too, so a targeted read avoids materialising a whole
@@ -1,7 +1,6 @@
1
- // Optional-sharp image resize / downsample helper. Mirrors reference
2
- // imageResizer.ts (maybeResizeAndDownsampleImageBuffer +
3
- // readImageWithTokenBudget) so a `read` on an image returns a viewable,
4
- // budget-bounded image block instead of refusing oversized originals.
1
+ // Optional-sharp image resize / downsample helper: a `read` on an image
2
+ // returns a viewable, budget-bounded image block instead of refusing
3
+ // oversized originals.
5
4
  //
6
5
  // sharp is a direct runtime dependency. Entry points still degrade to `null`
7
6
  // when a platform-native binding cannot load so a damaged install reports the
@@ -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
@@ -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); }
@@ -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
@@ -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) {
@@ -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);
@@ -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) {