mixdog 0.9.0 → 0.9.1

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 (57) hide show
  1. package/package.json +3 -3
  2. package/scripts/session-ingest-smoke.mjs +2 -2
  3. package/src/headless-role.mjs +1 -1
  4. package/src/lib/mixdog-debug.cjs +0 -22
  5. package/src/lib/plugin-paths.cjs +1 -7
  6. package/src/lib/rules-builder.cjs +2 -2
  7. package/src/mixdog-session-runtime.mjs +0 -1
  8. package/src/repl.mjs +0 -2
  9. package/src/runtime/agent/orchestrator/internal-roles.mjs +1 -1
  10. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +27 -49
  11. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +5 -20
  12. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +8 -27
  13. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +52 -182
  14. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +8 -30
  15. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +258 -0
  16. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +0 -8
  18. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -44
  19. package/src/runtime/channels/index.mjs +0 -30
  20. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  21. package/src/runtime/channels/lib/config.mjs +0 -1
  22. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  23. package/src/runtime/channels/lib/executor.mjs +0 -3
  24. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  25. package/src/runtime/channels/lib/output-forwarder.mjs +1 -8
  26. package/src/runtime/channels/lib/runtime-paths.mjs +0 -6
  27. package/src/runtime/channels/lib/session-discovery.mjs +0 -4
  28. package/src/runtime/channels/lib/tool-format.mjs +0 -1
  29. package/src/runtime/channels/lib/transcript-discovery.mjs +1 -10
  30. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  31. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  32. package/src/runtime/memory/index.mjs +3 -24
  33. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  34. package/src/runtime/memory/lib/memory-ops-policy.mjs +0 -1
  35. package/src/runtime/memory/lib/runtime-fetcher.mjs +43 -18
  36. package/src/runtime/memory/lib/session-ingest.mjs +9 -7
  37. package/src/runtime/search/index.mjs +2 -7
  38. package/src/runtime/search/lib/config.mjs +0 -4
  39. package/src/runtime/search/lib/state.mjs +1 -15
  40. package/src/runtime/search/lib/web-tools.mjs +0 -1
  41. package/src/runtime/shared/child-spawn-gate.mjs +0 -6
  42. package/src/standalone/seeds.mjs +1 -11
  43. package/src/tui/App.jsx +35 -12
  44. package/src/tui/components/PromptInput.jsx +63 -2
  45. package/src/tui/components/ToolExecution.jsx +7 -2
  46. package/src/tui/components/tool-output-format.mjs +156 -22
  47. package/src/tui/components/tool-output-format.test.mjs +93 -1
  48. package/src/tui/dist/index.mjs +473 -116
  49. package/src/tui/markdown/format-token.mjs +267 -108
  50. package/src/tui/markdown/format-token.test.mjs +105 -9
  51. package/src/tui/theme.mjs +10 -0
  52. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -623
  53. package/vendor/ink/build/ink.js +54 -8
  54. package/src/hooks/lib/permission-rules.cjs +0 -170
  55. package/src/hooks/lib/settings-loader.cjs +0 -112
  56. package/src/lib/hook-pipe-path.cjs +0 -10
  57. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "generated_at": "2026-06-11T13:16:44.033Z",
3
+ "generated_at": "2026-06-30T06:23:20.385Z",
4
4
  "release_tag": "runtime-v0.4.0",
5
5
  "pg": {
6
6
  "major": 16,
@@ -20,6 +20,11 @@
20
20
  "sha256": "8e39bfc3f06d88e9854946d5d83f7d77d9530f99284d7c1c51872c12f389f7e7",
21
21
  "size": 23687666
22
22
  },
23
+ "linux-arm64": {
24
+ "url": "https://github.com/tribgames/mixdog/releases/download/runtime-v0.4.0/mixdog-runtime-linux-arm64-pg16.4-pgvector0.8.2.tar.gz",
25
+ "sha256": "64ff170013b9543ad8843c98cbfe14019e6be6ea3d142ec189b2bea0dd1dc797",
26
+ "size": 22885036
27
+ },
23
28
  "linux-x64": {
24
29
  "url": "https://github.com/tribgames/mixdog/releases/download/runtime-v0.4.0/mixdog-runtime-linux-x64-pg16.4-pgvector0.8.2.tar.gz",
25
30
  "sha256": "28ee64125a0125ff36c47560c08ce8f38e49aef034c010c7e4867e8cdd783385",
@@ -29,12 +34,6 @@
29
34
  "url": "https://github.com/tribgames/mixdog/releases/download/runtime-v0.4.0/mixdog-runtime-win32-x64-pg16.4-pgvector0.8.2.tar.gz",
30
35
  "sha256": "6faed8a49b3303b0adb1c5715b2b19c5cc47df2e738d586d0d0bd432f1ca035d",
31
36
  "size": 44036958
32
- },
33
- "linux-arm64": {
34
- "unsupported": true,
35
- "url": "TBD",
36
- "sha256": "TBD",
37
- "size": 0
38
37
  }
39
38
  }
40
39
  }
@@ -788,10 +788,9 @@ async function ingestSessionMessages(args = {}) {
788
788
  const m = messages[i]
789
789
  if (!m || typeof m !== 'object') continue
790
790
  const role = normalizeIngestRole(m.role)
791
- // Persist the whole session conversation by role: user/assistant carry the
792
- // dialogue, tool carries tool_results, system/developer carry steering
793
- // context. Previously only user/assistant were kept, silently dropping
794
- // recent tool/system/developer state that recall fast-track must surface.
791
+ // ingest_session persists user/assistant only; system/developer/tool rows
792
+ // are dropped so recall-fasttrack summaries stay conversation-focused and
793
+ // do not re-inject content already in the protected system prefix.
795
794
  if (!role) continue
796
795
  const content = cleanMemoryText(sessionMessageContent(m))
797
796
  if (!content || !content.trim()) continue
@@ -3666,26 +3665,6 @@ const httpServer = http.createServer(async (req, res) => {
3666
3665
  return
3667
3666
  }
3668
3667
 
3669
- if (req.method === 'POST' && req.url === '/session-start/recap') {
3670
- try {
3671
- const body = await readBody(req)
3672
- const projectId = resolveProjectScope(typeof body.cwd === 'string' && body.cwd ? body.cwd : null)
3673
- const rows = projectId !== null
3674
- ? (await db.query(`
3675
- SELECT id, ts, summary FROM entries
3676
- WHERE is_root = 1 AND (project_id IS NULL OR project_id = $1)
3677
- ORDER BY ts DESC, id DESC LIMIT 20
3678
- `, [projectId])).rows
3679
- : (await db.query(`
3680
- SELECT id, ts, summary FROM entries
3681
- WHERE is_root = 1
3682
- ORDER BY ts DESC, id DESC LIMIT 20
3683
- `)).rows
3684
- sendJson(res, { ok: true, projectId, rows })
3685
- } catch (e) { sendError(res, e.message) }
3686
- return
3687
- }
3688
-
3689
3668
  if (req.method === 'POST' && req.url === '/api/tool') {
3690
3669
  if (!isLocalOrigin(req)) {
3691
3670
  sendJson(res, { content: [{ type: 'text', text: 'forbidden: cross-origin' }], isError: true }, 403)
@@ -6,12 +6,8 @@
6
6
  * Each task spawns a child process directly and communicates via stdio.
7
7
  */
8
8
 
9
- let active = false
10
-
11
9
  export function startLlmWorker() {
12
- active = true
13
10
  }
14
11
 
15
12
  export async function stopLlmWorker() {
16
- active = false
17
13
  }
@@ -5,7 +5,6 @@ function __mixdogMemoryLog(...args) {
5
5
  }
6
6
 
7
7
  import fs from 'node:fs'
8
- import os from 'node:os'
9
8
  import path from 'node:path'
10
9
  import { mixdogHome } from '../../shared/plugin-paths.mjs'
11
10
 
@@ -48,6 +48,15 @@ function platformKey() {
48
48
  return `${os}-${process.arch}`
49
49
  }
50
50
 
51
+ function platformKeyCandidates() {
52
+ const primary = platformKey()
53
+ const candidates = [primary]
54
+ if (process.platform === 'win32' && process.arch === 'arm64') {
55
+ candidates.push('win32-x64')
56
+ }
57
+ return candidates
58
+ }
59
+
51
60
  // Fail-closed asset validation. A selected manifest asset is usable only if it
52
61
  // is not explicitly marked unsupported AND carries a real downloadable payload:
53
62
  // non-empty url, a well-formed 64-hex sha256, and a positive integer size.
@@ -365,25 +374,35 @@ export async function ensureRuntime(dataDir) {
365
374
  gcRuntimeDir(runtimeBaseDir, readActiveVersion(runtimeBaseDir))
366
375
 
367
376
  const manifest = await loadManifest(key)
368
- const pkey = platformKey()
369
- const asset = manifest.assets?.[pkey]
370
- if (!asset) {
371
- // Platform/arch absent from the manifest entirely (e.g. win32-arm64).
372
- // The memory PG runtime cannot start here; fail with a single clear,
373
- // actionable message. The memory worker's init().catch reports this as
374
- // degraded and the rest of mixdog (agent, tools) keeps working without
375
- // memory.
376
- const supported = Object.keys(manifest.assets || {})
377
- .filter((k) => isUsableAsset(manifest.assets[k]))
378
- .join(', ') || '(none)'
379
- throw new Error(
380
- `[runtime-fetcher] memory runtime not available on ${pkey}: ` +
381
- `no runtime asset for this platform/arch in the manifest. ` +
382
- `Supported: ${supported}. ` +
383
- `Memory is disabled on this platform; the rest of mixdog continues to work.`
384
- )
377
+ const pkey = platformKey()
378
+ let selectedKey = null
379
+ let asset = null
380
+ for (const candidateKey of platformKeyCandidates()) {
381
+ const candidateAsset = manifest.assets?.[candidateKey]
382
+ if (isUsableAsset(candidateAsset)) {
383
+ selectedKey = candidateKey
384
+ asset = candidateAsset
385
+ break
386
+ }
385
387
  }
386
- if (!isUsableAsset(asset)) {
388
+ if (!asset) {
389
+ const primaryAsset = manifest.assets?.[pkey]
390
+ if (!primaryAsset) {
391
+ // Platform/arch absent from the manifest entirely (e.g. an exotic arch).
392
+ // The memory PG runtime cannot start here; fail with a single clear,
393
+ // actionable message. The memory worker's init().catch reports this as
394
+ // degraded and the rest of mixdog (agent, tools) keeps working without
395
+ // memory.
396
+ const supported = Object.keys(manifest.assets || {})
397
+ .filter((k) => isUsableAsset(manifest.assets[k]))
398
+ .join(', ') || '(none)'
399
+ throw new Error(
400
+ `[runtime-fetcher] memory runtime not available on ${pkey}: ` +
401
+ `no runtime asset for this platform/arch in the manifest. ` +
402
+ `Supported: ${supported}. ` +
403
+ `Memory is disabled on this platform; the rest of mixdog continues to work.`
404
+ )
405
+ }
387
406
  // Platform/arch present but explicitly marked unsupported or carrying a
388
407
  // placeholder/TBD payload (e.g. linux-arm64). Same graceful-degrade path.
389
408
  throw new Error(
@@ -393,6 +412,12 @@ export async function ensureRuntime(dataDir) {
393
412
  )
394
413
  }
395
414
 
415
+ if (selectedKey !== pkey) {
416
+ __mixdogMemoryLog(
417
+ '[runtime-fetcher] win32-arm64 has no native runtime; using win32-x64 under emulation\n'
418
+ )
419
+ }
420
+
396
421
  const { url, sha256, size } = asset
397
422
  const version = `pg${manifest.pg?.major}.${manifest.pg?.minor}+pgvector-${manifest.pgvector?.version}`
398
423
 
@@ -5,17 +5,19 @@ import crypto from 'node:crypto'
5
5
  // redaction, role/content shaping) can be unit-tested without importing the
6
6
  // MCP server entrypoint and its heavy boot-time side effects.
7
7
 
8
- // Roles we persist from an in-memory session transcript. Map provider/runtime
9
- // role spellings onto the canonical set; unknown roles are dropped.
10
- const INGEST_SESSION_ROLES = new Set(['user', 'assistant', 'tool', 'system', 'developer'])
8
+ // Roles we persist from an in-memory session transcript (conversation only).
9
+ // Map provider/runtime spellings onto canonical roles; only user/assistant are
10
+ // kept so recall-fasttrack memory does not duplicate protected system prefix.
11
+ const INGEST_SESSION_ROLES = new Set(['user', 'assistant'])
11
12
 
12
13
  export function normalizeIngestRole(role) {
13
14
  const raw = String(role || '').trim().toLowerCase()
14
15
  if (!raw) return null
15
- if (raw === 'tool_result' || raw === 'function' || raw === 'tool-result') return 'tool'
16
- if (raw === 'human') return 'user'
17
- if (raw === 'ai' || raw === 'model') return 'assistant'
18
- return INGEST_SESSION_ROLES.has(raw) ? raw : null
16
+ let canonical = raw
17
+ if (raw === 'tool_result' || raw === 'function' || raw === 'tool-result') canonical = 'tool'
18
+ else if (raw === 'human') canonical = 'user'
19
+ else if (raw === 'ai' || raw === 'model') canonical = 'assistant'
20
+ return INGEST_SESSION_ROLES.has(canonical) ? canonical : null
19
21
  }
20
22
 
21
23
  // Extract the first textual content block from a message content field.
@@ -4,7 +4,6 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
5
5
  import { z } from 'zod'
6
6
  import fs from 'fs'
7
- import path from 'path'
8
7
  import {
9
8
  ensureDataDir,
10
9
  getRequestTimeoutMs,
@@ -32,7 +31,7 @@ import {
32
31
  loadUsageState,
33
32
  updateProviderState,
34
33
  } from './lib/state.mjs'
35
- import { assertPublicUrl, crawlSite, getScrapeCapabilities, pinnedFetch, scrapeUrls } from './lib/web-tools.mjs'
34
+ import { getScrapeCapabilities, scrapeUrls } from './lib/web-tools.mjs'
36
35
  import { formatResponse } from './lib/formatter.mjs'
37
36
  ensureDataDir()
38
37
 
@@ -105,10 +104,6 @@ function formattedText(tool, payload) {
105
104
  }
106
105
  }
107
106
 
108
- function okText(text) {
109
- return { content: [{ type: 'text', text }], isError: false }
110
- }
111
-
112
107
  function isInvalidSearchResult(result) {
113
108
  const title = String(result?.title || '').trim()
114
109
  return /\bpage not found\b|\b404\b.*\bnot found\b/i.test(title)
@@ -529,7 +524,7 @@ async function handleToolCall(name, rawArgs, options = {}) {
529
524
  throw e
530
525
  }
531
526
  try {
532
- const result = await _fetchCore(urlArgs, { config, usageState, cacheState, timeoutMs, signal })
527
+ const result = await _fetchCore(urlArgs, { usageState, cacheState, timeoutMs, signal })
533
528
  flushCacheState()
534
529
  flushUsageState()
535
530
  return {
@@ -44,10 +44,6 @@ export function writeJson(filePath, value) {
44
44
  writeJsonAtomicSync(filePath, value, { lock: true, fsyncDir: true })
45
45
  }
46
46
 
47
- export function saveConfig(config) {
48
- return { ...DEFAULT_CONFIG, ...(config || {}) }
49
- }
50
-
51
47
  export function loadConfig() {
52
48
  ensureDataDir()
53
49
  if (Object.keys(readSection('search') || {}).length > 0) {
@@ -88,10 +88,6 @@ export function loadUsageState() {
88
88
  return state
89
89
  }
90
90
 
91
- export function saveUsageState(state) {
92
- scheduleUsageFlush(state)
93
- }
94
-
95
91
  export function updateProviderState(state, provider, patch) {
96
92
  let normalizedPatch = { ...patch }
97
93
  const remaining =
@@ -125,7 +121,7 @@ export function noteProviderSuccess(state, provider, extra = {}) {
125
121
  })
126
122
  }
127
123
 
128
- export const PROVIDER_ERROR_KIND = {
124
+ const PROVIDER_ERROR_KIND = {
129
125
  AUTH: 'auth',
130
126
  QUOTA: 'quota',
131
127
  PAYMENT: 'payment',
@@ -151,16 +147,6 @@ export function classifyProviderError(error) {
151
147
  return PROVIDER_ERROR_KIND.UNKNOWN
152
148
  }
153
149
 
154
- /** Structured HTTP error for search backends (enables cooldown via classifyProviderError). */
155
- export function providerHttpError(provider, status, detail = '') {
156
- const code = Number(status)
157
- const snippet = detail ? `: ${String(detail).slice(0, 200)}` : ''
158
- const err = new Error(`[search:${provider}] HTTP ${code}${snippet}`)
159
- err.status = code
160
- err.provider = provider
161
- return err
162
- }
163
-
164
150
  const PROVIDER_DISABLE_TTL_MS = {
165
151
  auth: 24 * 3600 * 1000,
166
152
  quota: 24 * 3600 * 1000,
@@ -24,7 +24,6 @@ async function loadPuppeteer() {
24
24
  return _puppeteer
25
25
  }
26
26
 
27
-
28
27
  const PKG_VERSION = (() => { try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version } catch { return '0.0.1' } })()
29
28
  import {
30
29
  noteProviderFailure,
@@ -137,9 +137,3 @@ export async function withGate(fn, signal = null) {
137
137
  release();
138
138
  }
139
139
  }
140
-
141
- export function gateStats() {
142
- return { inflight: _inflight, queued: _queue.length, maxInflight: MAX_INFLIGHT };
143
- }
144
-
145
- export const CHILD_SPAWN_MAX_INFLIGHT = MAX_INFLIGHT;
@@ -1,7 +1,4 @@
1
- import { copyFileSync, existsSync, mkdirSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
3
-
4
- const DEFAULT_DATA_FILES = [];
1
+ import { mkdirSync } from 'node:fs';
5
2
 
6
3
  export function ensureStandaloneEnvironment({ rootDir, dataDir }) {
7
4
  if (!rootDir) throw new Error('standalone rootDir is required');
@@ -17,11 +14,4 @@ export function ensureStandaloneEnvironment({ rootDir, dataDir }) {
17
14
  process.env.MIXDOG_PATCH_NATIVE_PREWARM ??= '0';
18
15
 
19
16
  mkdirSync(dataDir, { recursive: true });
20
- for (const file of DEFAULT_DATA_FILES) {
21
- const from = join(rootDir, 'defaults', file);
22
- const to = join(dataDir, file);
23
- if (!existsSync(from) || existsSync(to)) continue;
24
- mkdirSync(dirname(to), { recursive: true });
25
- copyFileSync(from, to);
26
- }
27
17
  }
package/src/tui/App.jsx CHANGED
@@ -844,6 +844,14 @@ function toolHasDisplayResultForRows(item) {
844
844
  return true;
845
845
  }
846
846
 
847
+ // Mirror ToolExecution expanded ResultBody rawText selection (aggregate always rawResult).
848
+ function toolExpandedRawTextForRows(item, rawRt) {
849
+ if (item?.aggregate) return rawRt;
850
+ const hasDisplayResult = toolHasDisplayResultForRows(item);
851
+ if (hasDisplayResult) return toolDisplayedResultTextForRows(item);
852
+ return rawRt;
853
+ }
854
+
847
855
  function toolHeaderFailureOnlyForRows(item, normalizedName, hasDisplayResult) {
848
856
  if (hasDisplayResult) return false;
849
857
  const bgArgs = backgroundArgsForRows(item.args);
@@ -876,10 +884,16 @@ function toolArgPathForRows(item) {
876
884
  return a?.path ?? a?.file_path ?? a?.file ?? '';
877
885
  }
878
886
 
879
- function isShellSurfaceForRows(normalizedName) {
887
+ function isShellSurfaceForRows(normalizedName, label = '') {
880
888
  const n = String(normalizedName || '').toLowerCase();
889
+ const l = String(label || '').toLowerCase();
881
890
  return n === 'shell' || n === 'bash' || n === 'bash_session'
882
- || n === 'shell_command' || n === 'job_wait';
891
+ || n === 'shell_command' || n === 'job_wait' || l === 'run';
892
+ }
893
+
894
+ function isShellSurfaceForToolItem(item, normalizedName) {
895
+ const label = formatToolSurface(item?.name, item?.args)?.label || '';
896
+ return isShellSurfaceForRows(normalizedName, label);
883
897
  }
884
898
 
885
899
  // EXPANDED tool bodies are post-processed by formatExpandedResult (JSON pretty,
@@ -892,7 +906,7 @@ function estimateToolRenderedResultRows(value, { pathArg = '', isShell = false,
892
906
  if (!text) return 1;
893
907
  try {
894
908
  const logical = formatExpandedResult(text, { pathArg, isShell });
895
- const rows = wrapExpandedResultLines(logical, columns).length;
909
+ const rows = wrapExpandedResultLines(logical, columns, { isShell }).length;
896
910
  return Math.max(1, rows);
897
911
  } catch {
898
912
  return Math.max(1, text.split('\n').length);
@@ -961,10 +975,11 @@ function estimateTranscriptItemRows(item, columns, toolOutputExpanded) {
961
975
  // Aggregate cards pass no pathArg/isShell to ResultBody; non-aggregate
962
976
  // cards do. Mirror that here so the formatExpandedResult row count matches
963
977
  // the rendered body exactly (JSON pretty / line-number split can shift it).
978
+ const estimateText = toolExpandedRawTextForRows(item, rawRt);
964
979
  const rawOpts = item.aggregate
965
980
  ? {}
966
- : { pathArg: toolArgPathForRows(item), isShell: isShellSurfaceForRows(normalizedName) };
967
- return TOOL_MARGIN_TOP + 1 + estimateToolRenderedResultRows(rawRt, { ...rawOpts, columns });
981
+ : { pathArg: toolArgPathForRows(item), isShell: isShellSurfaceForToolItem(item, normalizedName) };
982
+ return TOOL_MARGIN_TOP + 1 + estimateToolRenderedResultRows(estimateText, { ...rawOpts, columns });
968
983
  }
969
984
  // Expanded agent card with no raw body to reveal: ToolExecution still
970
985
  // shows one agent-brief detail line, so margin + header + one detail row.
@@ -999,7 +1014,7 @@ function estimateTranscriptItemRows(item, columns, toolOutputExpanded) {
999
1014
  const resultText = backgroundMeta?.hasResponse ? backgroundMeta.body : rt;
1000
1015
  const resultRows = estimateToolRenderedResultRows(resultText, {
1001
1016
  pathArg: toolArgPathForRows(item),
1002
- isShell: isShellSurfaceForRows(normalizedName),
1017
+ isShell: isShellSurfaceForToolItem(item, normalizedName),
1003
1018
  columns,
1004
1019
  });
1005
1020
  return TOOL_MARGIN_TOP + 1 + resultRows;
@@ -1970,13 +1985,21 @@ export function App({ store, initialStatusLine = '' }) {
1970
1985
  return clipped;
1971
1986
  }, [selectionClip]);
1972
1987
 
1973
- const paintSelectionRect = useCallback((clippedRect, { rememberText = true } = {}) => {
1988
+ const paintSelectionRect = useCallback((clippedRect, { rememberText = true, immediate = false } = {}) => {
1974
1989
  const nextRect = clippedRect || null;
1975
1990
  const state = selectionPaintRef.current;
1976
- if (selectionRectsEqual(state.rect, nextRect)) return false;
1991
+ if (selectionRectsEqual(state.rect, nextRect)) {
1992
+ const needsCapture = nextRect && rememberText && nextRect.captureText !== false;
1993
+ if (!immediate && !needsCapture) return false;
1994
+ if (immediate || needsCapture) {
1995
+ store.setRenderSelection?.(nextRect, { immediate: true });
1996
+ }
1997
+ if (needsCapture) rememberSelectionTextSoon();
1998
+ return true;
1999
+ }
1977
2000
  state.rect = nextRect;
1978
2001
  state.t = Date.now();
1979
- store.setRenderSelection?.(nextRect);
2002
+ store.setRenderSelection?.(nextRect, immediate ? { immediate: true } : undefined);
1980
2003
  if (nextRect && rememberText && nextRect.captureText !== false) rememberSelectionTextSoon();
1981
2004
  return true;
1982
2005
  }, [store, rememberSelectionTextSoon]);
@@ -1991,7 +2014,7 @@ export function App({ store, initialStatusLine = '' }) {
1991
2014
  state.timer = null;
1992
2015
  state.pending = null;
1993
2016
  }
1994
- paintSelectionRect(clippedRect, { rememberText: true });
2017
+ paintSelectionRect(clippedRect, { rememberText: true, immediate: true });
1995
2018
  }, [paintSelectionRect, withSelectionClip]);
1996
2019
 
1997
2020
  const applySelectionRectThrottled = useCallback((rect) => {
@@ -2348,7 +2371,7 @@ export function App({ store, initialStatusLine = '' }) {
2348
2371
  // Finalize the prompt selection; highlight persists (copy on Ctrl+C).
2349
2372
  const offset = promptOffsetAt(x, y);
2350
2373
  dragRef.current.active = false;
2351
- if (offset != null) promptMouseSelectionRef.current?.extendTo?.(offset);
2374
+ promptMouseSelectionRef.current?.extendTo?.(offset, true);
2352
2375
  continue;
2353
2376
  }
2354
2377
  // Button release while dragging: finalize with the release coordinate
@@ -7615,7 +7638,7 @@ export function App({ store, initialStatusLine = '' }) {
7615
7638
  if (deltaY === 0) return;
7616
7639
  const clippedRect = withSelectionClip(shiftSelectionRectY(dragRef.current.rect, deltaY));
7617
7640
  dragRef.current = { ...dragRef.current, rect: clippedRect };
7618
- paintSelectionRect(clippedRect, { rememberText: true });
7641
+ paintSelectionRect(clippedRect, { rememberText: true, immediate: true });
7619
7642
  }, [viewportHeight, transcriptWindow.totalRows, transcriptWindow.effectiveScrollOffset, withSelectionClip, paintSelectionRect]);
7620
7643
  useEffect(() => {
7621
7644
  const maxRows = Math.max(0, Number(transcriptWindow.maxScrollRows) || 0);
@@ -52,6 +52,9 @@ function hintStyle(tone) {
52
52
  // paddingX of 1, so the typing start can sit directly against that padding
53
53
  // without an extra guard column.
54
54
  const IME_LEFT_GUARD_COLUMNS = 0;
55
+ // Coalesce prompt mouse-drag extend commits (SGR motion can fire faster than ink
56
+ // needs to immediate-render). Matches transcript selection paint cadence.
57
+ const MOUSE_EXTEND_COALESCE_MS = 24;
55
58
 
56
59
  function insertText(draft, input) {
57
60
  if (!input) return draft;
@@ -146,6 +149,7 @@ export function PromptInput({
146
149
  const cursorEnabledRef = useRef(false); // latest enabled state, read by the anchor fn at render time
147
150
  const contentWidthRef = useRef(80);
148
151
  const preferredColumnRef = useRef(null);
152
+ const mouseExtendCoalesceRef = useRef({ pendingNext: null, timer: null, t: 0 });
149
153
  const { value, cursor } = draft;
150
154
  draftRef.current = draft;
151
155
  if (selectionRef) {
@@ -237,6 +241,49 @@ export function PromptInput({
237
241
  commitDraft(fn(draftRef.current), options);
238
242
  };
239
243
 
244
+ const cancelMouseExtendCoalesce = () => {
245
+ const state = mouseExtendCoalesceRef.current;
246
+ if (state.timer) {
247
+ clearTimeout(state.timer);
248
+ state.timer = null;
249
+ }
250
+ state.pendingNext = null;
251
+ };
252
+
253
+ const queueMouseExtendCommit = (next, immediate = false) => {
254
+ if (immediate) {
255
+ cancelMouseExtendCoalesce();
256
+ commitDraft(next);
257
+ mouseExtendCoalesceRef.current.t = Date.now();
258
+ return;
259
+ }
260
+ if (draftStateEqual(draftRef.current, next)) {
261
+ cancelMouseExtendCoalesce();
262
+ commitDraft(next);
263
+ return;
264
+ }
265
+ const state = mouseExtendCoalesceRef.current;
266
+ state.pendingNext = next;
267
+ const now = Date.now();
268
+ const elapsed = now - state.t;
269
+ if (elapsed >= MOUSE_EXTEND_COALESCE_MS) {
270
+ cancelMouseExtendCoalesce();
271
+ state.t = now;
272
+ commitDraft(next);
273
+ return;
274
+ }
275
+ if (state.timer) return;
276
+ state.timer = setTimeout(() => {
277
+ const current = mouseExtendCoalesceRef.current;
278
+ const pending = current.pendingNext;
279
+ current.timer = null;
280
+ current.pendingNext = null;
281
+ current.t = Date.now();
282
+ if (pending) commitDraft(pending);
283
+ }, Math.max(1, MOUSE_EXTEND_COALESCE_MS - elapsed));
284
+ state.timer.unref?.();
285
+ };
286
+
240
287
  // [mixdog] Mouse drag-selection driver. App's single mouse handler maps a
241
288
  // click/drag cell over the prompt box to an edit offset and calls these so the
242
289
  // SAME selectionAnchor/cursor engine that keyboard Shift-selection uses paints
@@ -246,22 +293,36 @@ export function PromptInput({
246
293
  mouseSelectionRef.current = {
247
294
  offsetAtCell: (row, col) => offsetAtCell(draftRef.current.value, row, col, contentWidthRef.current),
248
295
  anchorAt: (offset) => {
296
+ cancelMouseExtendCoalesce();
249
297
  const value = draftRef.current.value;
250
298
  const off = Math.max(0, Math.min(value.length, Math.floor(Number(offset) || 0)));
251
299
  commitDraft({ ...draftRef.current, cursor: off, selectionAnchor: off });
300
+ mouseExtendCoalesceRef.current.t = Date.now();
252
301
  },
253
- extendTo: (offset) => {
302
+ extendTo: (offset, immediate = false) => {
303
+ if (offset == null) {
304
+ if (immediate) cancelMouseExtendCoalesce();
305
+ return;
306
+ }
254
307
  const d = draftRef.current;
255
308
  const off = Math.max(0, Math.min(d.value.length, Math.floor(Number(offset) || 0)));
256
309
  const anchor = Number.isFinite(d.selectionAnchor) ? d.selectionAnchor : d.cursor;
257
- commitDraft({ ...d, cursor: off, selectionAnchor: anchor });
310
+ queueMouseExtendCommit({ ...d, cursor: off, selectionAnchor: anchor }, immediate);
258
311
  },
259
312
  clear: () => {
313
+ cancelMouseExtendCoalesce();
260
314
  if (selectionRange(draftRef.current)) commitDraft(clearSelection(draftRef.current));
261
315
  },
262
316
  };
263
317
  }
264
318
 
319
+ useEffect(() => () => {
320
+ const state = mouseExtendCoalesceRef.current;
321
+ if (state.timer) clearTimeout(state.timer);
322
+ state.timer = null;
323
+ state.pendingNext = null;
324
+ }, []);
325
+
265
326
  const moveDraftVertically = (direction, { extend = false } = {}) => {
266
327
  const current = draftRef.current;
267
328
  const moved = verticalOffset(
@@ -98,10 +98,15 @@ function renderDeltaText(text) {
98
98
  // renderDeltaText.
99
99
  // - EXPANDED (raw=true): formatExpandedResult then wrapExpandedResultLines so
100
100
  // each physical row fits the body width before render (rail rows stay 1:1;
101
- // ink does not re-wrap).
101
+ // ink does not re-wrap). Physical row mount cap: MIXDOG_TUI_TOOL_OUTPUT_MAX_RENDER_LINES
102
+ // (default 600; 0 disables). Shell/script bodies keep the newest tail when capped.
102
103
  function ResultBody({ lines, rawText, pathArg = '', isShell = false, columns, color, raw }) {
103
104
  const renderLines = raw
104
- ? wrapExpandedResultLines(formatExpandedResult(rawText, { pathArg, isShell }), columns)
105
+ ? wrapExpandedResultLines(
106
+ formatExpandedResult(rawText, { pathArg, isShell }),
107
+ columns,
108
+ { isShell },
109
+ )
105
110
  : (lines || []);
106
111
  if (!renderLines || renderLines.length === 0) return null;
107
112
  return (