mixdog 0.9.49 → 0.9.51

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 (39) hide show
  1. package/README.md +6 -6
  2. package/package.json +7 -7
  3. package/scripts/embedding-worker-exit-test.mjs +76 -0
  4. package/scripts/hook-bus-test.mjs +23 -0
  5. package/scripts/internal-tools-normalization-test.mjs +10 -0
  6. package/scripts/openai-oauth-ws-1006-retry-test.mjs +67 -1
  7. package/scripts/provider-toolcall-test.mjs +200 -2
  8. package/scripts/session-bench-cache-break-test.mjs +102 -0
  9. package/scripts/session-bench.mjs +101 -42
  10. package/scripts/shell-failure-diagnostics-test.mjs +73 -4
  11. package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
  12. package/scripts/smoke-loop-failure-summary.mjs +16 -0
  13. package/scripts/smoke-loop.mjs +4 -3
  14. package/scripts/smoke.mjs +5 -106
  15. package/scripts/tool-failures.mjs +15 -1
  16. package/scripts/tui-transcript-perf-test.mjs +38 -0
  17. package/scripts/verify-release-assets-test.mjs +15 -381
  18. package/scripts/web-fetch-routing-test.mjs +158 -0
  19. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -0
  20. package/src/runtime/agent/orchestrator/internal-tools.mjs +2 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +14 -4
  22. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
  23. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +75 -3
  24. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -0
  25. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +38 -0
  26. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -0
  27. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
  28. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +4 -0
  29. package/src/runtime/memory/lib/embedding-provider.mjs +3 -2
  30. package/src/runtime/search/index.mjs +41 -0
  31. package/src/runtime/search/lib/http-fetch.mjs +154 -0
  32. package/src/runtime/search/tool-defs.mjs +23 -0
  33. package/src/session-runtime/runtime-core.mjs +37 -15
  34. package/src/standalone/hook-bus/handlers.mjs +4 -0
  35. package/src/standalone/hook-bus/rules.mjs +7 -1
  36. package/src/standalone/hook-bus.mjs +10 -2
  37. package/src/tui/App.jsx +17 -11
  38. package/src/tui/app/live-spinner-visibility.mjs +20 -0
  39. package/src/tui/dist/index.mjs +28 -3
@@ -1,4 +1,6 @@
1
1
  import { readFileSync } from 'fs'
2
+ import dns from 'node:dns'
3
+ import { Agent, fetch as undiciFetch } from 'undici'
2
4
 
3
5
  import {
4
6
  assertPublicUrl,
@@ -128,6 +130,158 @@ export async function readBodyBytesWithCap(response, maxBytes) {
128
130
  return Buffer.concat(chunks.map((c) => Buffer.from(c)))
129
131
  }
130
132
 
133
+ const SAFE_IMAGE_MIMES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])
134
+
135
+ function loopbackHost(hostname) {
136
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, '')
137
+ if (host === 'localhost' || host === '::1') return true
138
+ const match = host.match(/^(\d{1,3})(?:\.(\d{1,3})){3}$/)
139
+ return Boolean(match && Number(match[1]) === 127)
140
+ }
141
+
142
+ function abortRace(promise, signal) {
143
+ if (!signal) return promise
144
+ if (signal.aborted) return Promise.reject(signal.reason || new Error('local_fetch aborted'))
145
+ return new Promise((resolve, reject) => {
146
+ const onAbort = () => reject(signal.reason || new Error('local_fetch aborted'))
147
+ signal.addEventListener('abort', onAbort, { once: true })
148
+ promise.then(
149
+ (value) => { signal.removeEventListener('abort', onAbort); resolve(value) },
150
+ (error) => { signal.removeEventListener('abort', onAbort); reject(error) },
151
+ )
152
+ })
153
+ }
154
+
155
+ async function pinnedLoopbackFetch(url, options = {}) {
156
+ const parsed = assertLoopbackUrl(url)
157
+ const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '')
158
+ const addresses = host === 'localhost'
159
+ ? await abortRace(dns.promises.lookup(host, { all: true }), options.signal)
160
+ : [{ address: host, family: host.includes(':') ? 6 : 4 }]
161
+ if (!addresses.length || addresses.some((entry) => !loopbackHost(entry.address))) {
162
+ throw new Error(`Blocked non-loopback local_fetch resolution: ${host}`)
163
+ }
164
+ const pinned = addresses[0]
165
+ const dispatcher = new Agent({
166
+ connect: {
167
+ lookup: (_hostname, opts, cb) => opts?.all
168
+ ? cb(null, [{ address: pinned.address, family: pinned.family }])
169
+ : cb(null, pinned.address, pinned.family),
170
+ },
171
+ })
172
+ let response
173
+ try {
174
+ response = await undiciFetch(url, { ...options, dispatcher })
175
+ } catch (error) {
176
+ dispatcher.destroy().catch(() => {})
177
+ throw error
178
+ }
179
+ if (!response.body) {
180
+ dispatcher.destroy().catch(() => {})
181
+ return response
182
+ }
183
+ const reader = response.body.getReader()
184
+ let cleaned = false
185
+ const cleanup = () => {
186
+ if (cleaned) return
187
+ cleaned = true
188
+ dispatcher.destroy().catch(() => {})
189
+ }
190
+ const monitored = new ReadableStream({
191
+ async pull(controller) {
192
+ try {
193
+ const { done, value } = await reader.read()
194
+ if (done) {
195
+ controller.close()
196
+ cleanup()
197
+ } else {
198
+ controller.enqueue(value)
199
+ }
200
+ } catch (error) {
201
+ controller.error(error)
202
+ cleanup()
203
+ }
204
+ },
205
+ cancel(reason) {
206
+ reader.cancel(reason).catch(() => {})
207
+ cleanup()
208
+ },
209
+ })
210
+ return new Response(monitored, {
211
+ status: response.status,
212
+ statusText: response.statusText,
213
+ headers: response.headers,
214
+ })
215
+ }
216
+
217
+ export function assertLoopbackUrl(url) {
218
+ const parsed = new URL(url)
219
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
220
+ throw new Error(`Blocked non-HTTP protocol: ${parsed.protocol}`)
221
+ }
222
+ if (parsed.username || parsed.password) throw new Error('Blocked loopback URL with userinfo credentials')
223
+ if (!loopbackHost(parsed.hostname)) throw new Error(`Blocked non-loopback local_fetch target: ${parsed.hostname}`)
224
+ return parsed
225
+ }
226
+
227
+ async function boundedManualFetch(url, { signal, fetchImpl, validateUrl, sameHost = false } = {}) {
228
+ const original = validateUrl(url)
229
+ let currentUrl = original.toString()
230
+ for (let hops = 0; ; hops++) {
231
+ const current = validateUrl(currentUrl)
232
+ if (sameHost && current.hostname.toLowerCase() !== original.hostname.toLowerCase()) {
233
+ throw new Error(`cross-host redirect blocked (redirected_to: ${currentUrl})`)
234
+ }
235
+ const response = await fetchImpl(currentUrl, {
236
+ signal,
237
+ headers: buildHeaders(),
238
+ redirect: 'manual',
239
+ })
240
+ if (!REDIRECT_STATUSES.has(response.status)) return response
241
+ try { await response.body?.cancel() } catch {}
242
+ if (hops >= MAX_REDIRECTS) throw new Error(`Too many redirects (max ${MAX_REDIRECTS})`)
243
+ const location = response.headers.get('location')
244
+ if (!location) throw new Error(`Redirect ${response.status} without Location header`)
245
+ currentUrl = new URL(location, currentUrl).toString()
246
+ }
247
+ }
248
+
249
+ export async function fetchLoopbackText(url, { signal, fetchImpl = pinnedLoopbackFetch } = {}) {
250
+ const response = await boundedManualFetch(url, {
251
+ signal,
252
+ fetchImpl,
253
+ validateUrl: assertLoopbackUrl,
254
+ })
255
+ if (!response.ok) {
256
+ try { await response.body?.cancel() } catch {}
257
+ throw new Error(`HTTP ${response.status}`)
258
+ }
259
+ return readBodyWithCap(response, MAX_BODY_BYTES)
260
+ }
261
+
262
+ export async function fetchPublicImage(url, { signal, fetchImpl = pinnedFetch } = {}) {
263
+ const response = await boundedManualFetch(url, {
264
+ signal,
265
+ fetchImpl,
266
+ validateUrl: (value) => {
267
+ assertPublicUrl(value)
268
+ return new URL(value)
269
+ },
270
+ sameHost: true,
271
+ })
272
+ if (!response.ok) {
273
+ try { await response.body?.cancel() } catch {}
274
+ throw new Error(`HTTP ${response.status}`)
275
+ }
276
+ const mimeType = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
277
+ if (!SAFE_IMAGE_MIMES.has(mimeType)) {
278
+ try { await response.body?.cancel() } catch {}
279
+ throw new Error(`Blocked unsupported image content-type: ${mimeType || '(missing)'}`)
280
+ }
281
+ const bytes = await readBodyBytesWithCap(response, MAX_BODY_BYTES)
282
+ return { mimeType, data: bytes.toString('base64'), bytes: bytes.byteLength }
283
+ }
284
+
131
285
  const CDP_FORBIDDEN_RESPONSE_HEADERS = new Set([
132
286
  'content-length',
133
287
  'transfer-encoding',
@@ -2,6 +2,8 @@ import {
2
2
  TOOL_SYNC_EXECUTION_CONTRACT,
3
3
  } from '../shared/tool-execution-contract.mjs';
4
4
 
5
+ const TOOL_DEFS_PLACEHOLDER = Symbol('web-fetch-schema')
6
+
5
7
  export const TOOL_DEFS = [
6
8
  {
7
9
  name: 'search',
@@ -54,4 +56,25 @@ export const TOOL_DEFS = [
54
56
  },
55
57
  annotations: { title: 'Mixdog Web Fetch', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
56
58
  },
59
+ {
60
+ name: 'local_fetch',
61
+ title: 'Mixdog Loopback Fetch',
62
+ public: false,
63
+ description: 'Runtime-only loopback HTTP(S) fetch target.',
64
+ inputSchema: TOOL_DEFS_PLACEHOLDER,
65
+ annotations: { title: 'Mixdog Loopback Fetch', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
66
+ },
67
+ {
68
+ name: 'image_fetch',
69
+ title: 'Mixdog Image Fetch',
70
+ public: false,
71
+ description: 'Runtime-only bounded public image fetch target.',
72
+ inputSchema: TOOL_DEFS_PLACEHOLDER,
73
+ annotations: { title: 'Mixdog Image Fetch', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
74
+ },
57
75
  ]
76
+
77
+ const webFetchSchema = TOOL_DEFS.find((tool) => tool.name === 'web_fetch').inputSchema
78
+ for (const tool of TOOL_DEFS) {
79
+ if (tool.inputSchema === TOOL_DEFS_PLACEHOLDER) tool.inputSchema = webFetchSchema
80
+ }
@@ -331,6 +331,32 @@ export function shouldMirrorCompletionToPendingQueue({
331
331
  return shouldPersistModelVisibleToolCompletion(text, meta);
332
332
  }
333
333
 
334
+ export async function dispatchSearchRuntimeTool(name, args, callerCtx = {}, {
335
+ getSearchModule,
336
+ getCurrentCwd,
337
+ getSession,
338
+ notifyFnForSession,
339
+ runNativeWebSearch,
340
+ } = {}) {
341
+ const currentSession = typeof getSession === 'function' ? getSession() : null;
342
+ const callerCwd = callerCtx?.callerCwd || (typeof getCurrentCwd === 'function' ? getCurrentCwd() : process.cwd());
343
+ const callerSessionId = callerCtx?.callerSessionId || currentSession?.id || null;
344
+ const callerSignal = callerCtx?.signal || currentSession?.controller?.signal;
345
+ const searchMod = await getSearchModule();
346
+ if (!searchMod?.handleToolCall) throw new Error('search runtime is not available');
347
+ return await searchMod.handleToolCall(name, args || {}, {
348
+ callerCwd,
349
+ callerSessionId,
350
+ routingSessionId: callerSessionId,
351
+ clientHostPid: callerCtx?.clientHostPid || currentSession?.clientHostPid || process.pid,
352
+ notifyFn: notifyFnForSession(callerSessionId),
353
+ signal: callerSignal,
354
+ nativeSearch: name === 'search'
355
+ ? async (searchArgs) => runNativeWebSearch(searchArgs, { signal: callerSignal })
356
+ : undefined,
357
+ });
358
+ }
359
+
334
360
  export async function createMixdogSessionRuntime({
335
361
  provider,
336
362
  model,
@@ -1049,13 +1075,15 @@ export async function createMixdogSessionRuntime({
1049
1075
  });
1050
1076
  bootProfile('channels:worker-ready', { ms: (performance.now() - channelsStartedAt).toFixed(1) });
1051
1077
  const toolsStartedAt = performance.now();
1078
+ const searchRuntimeTools = (searchToolDefs?.TOOL_DEFS || [])
1079
+ .filter((tool) => ['search', 'web_fetch', 'local_fetch', 'image_fetch'].includes(tool?.name));
1052
1080
  const standaloneTools = [
1053
1081
  TOOL_SEARCH_TOOL,
1054
1082
  ...(envFlag('MIXDOG_DISABLE_SKILLS') ? [] : [SKILL_TOOL]),
1055
1083
  CWD_TOOL,
1056
1084
  SESSION_MANAGE_TOOL,
1057
1085
  EXPLORE_TOOL,
1058
- ...(searchToolDefs?.TOOL_DEFS || []).filter((tool) => tool?.name === 'search' || tool?.name === 'web_fetch'),
1086
+ ...searchRuntimeTools.filter((tool) => tool?.public !== false),
1059
1087
  ...(memoryToolDefs?.TOOL_DEFS || []).filter((tool) => tool?.name === 'recall' || tool?.name === 'memory'),
1060
1088
  ...(channelToolDefs?.TOOL_DEFS || []).filter((tool) => channels.isChannelTool(tool?.name)),
1061
1089
  ...(codeGraphToolDefs?.CODE_GRAPH_TOOL_DEFS || []).filter((tool) => tool?.name === 'code_graph'),
@@ -1102,22 +1130,16 @@ export async function createMixdogSessionRuntime({
1102
1130
  if (replay.length) selectDeferredTools(session, replay, deferredSurfaceModeForLead(mode));
1103
1131
  }
1104
1132
  internalTools.setInternalToolsProvider({
1105
- tools: standaloneTools,
1133
+ tools: [...standaloneTools, ...searchRuntimeTools.filter((tool) => tool?.public === false)],
1106
1134
  executor: async (name, args, callerCtx = {}) => {
1107
1135
  const callerCwd = callerCtx?.callerCwd || currentCwd;
1108
- if (name === 'search' || name === 'web_fetch') {
1109
- const callerSessionId = callerCtx?.callerSessionId || session?.id || null;
1110
- const searchMod = await getSearchModule();
1111
- if (!searchMod?.handleToolCall) throw new Error('search runtime is not available');
1112
- return await searchMod.handleToolCall(name, args || {}, {
1113
- callerCwd,
1114
- callerSessionId,
1115
- routingSessionId: callerSessionId,
1116
- clientHostPid: callerCtx?.clientHostPid || session?.clientHostPid || process.pid,
1117
- notifyFn: notifyFnForSession(callerSessionId),
1118
- nativeSearch: name === 'search'
1119
- ? async (searchArgs) => runNativeWebSearch(searchArgs, { signal: callerCtx?.signal || session?.controller?.signal })
1120
- : undefined,
1136
+ if (name === 'search' || name === 'web_fetch' || name === 'local_fetch' || name === 'image_fetch') {
1137
+ return dispatchSearchRuntimeTool(name, args, callerCtx, {
1138
+ getSearchModule,
1139
+ getCurrentCwd: () => currentCwd,
1140
+ getSession: () => session,
1141
+ notifyFnForSession,
1142
+ runNativeWebSearch,
1121
1143
  });
1122
1144
  }
1123
1145
  if (name === 'recall' || name === 'memory' || name === 'search_memories') {
@@ -498,6 +498,7 @@ export function parseHandlerOutput(run, eventName) {
498
498
  reason: null,
499
499
  permissionDecision: null,
500
500
  updatedInput: null,
501
+ updatedToolName: null,
501
502
  updatedToolOutput: null,
502
503
  additionalContext: null,
503
504
  systemMessage: null,
@@ -545,6 +546,9 @@ export function parseHandlerOutput(run, eventName) {
545
546
  out.updatedInput = hso.updatedInput;
546
547
  }
547
548
  if (hso.updatedToolOutput != null) out.updatedToolOutput = hso.updatedToolOutput;
549
+ if (typeof hso.updatedToolName === 'string' && hso.updatedToolName.trim()) {
550
+ out.updatedToolName = hso.updatedToolName.trim();
551
+ }
548
552
  if (hso.permissionDecision) out.permissionDecision = String(hso.permissionDecision).toLowerCase();
549
553
  if (hso.permissionDecisionReason) out.reason = out.reason || limitText(hso.permissionDecisionReason);
550
554
  if (eventName === 'PermissionRequest' && hso.decision && typeof hso.decision === 'object') {
@@ -35,7 +35,13 @@ export function decisionFromRule(rule, input) {
35
35
  const nextArgs = rule.args && typeof rule.args === 'object'
36
36
  ? rule.args
37
37
  : { ...(input.args || {}), ...(rule.patch || {}) };
38
- return { action: 'modify', args: nextArgs, reason: rule.reason || rule.message || `modified by hook rule for ${input.name}` };
38
+ const nextName = rule.updatedToolName ?? rule.replaceTool ?? rule.targetTool;
39
+ return {
40
+ action: 'modify',
41
+ args: nextArgs,
42
+ ...(typeof nextName === 'string' && nextName.trim() ? { name: nextName.trim() } : {}),
43
+ reason: rule.reason || rule.message || `modified by hook rule for ${input.name}`,
44
+ };
39
45
  }
40
46
  if (action === 'ask') {
41
47
  return { action: 'ask', reason: rule.reason || rule.message || `approval requested by hook rule for ${input.name}` };
@@ -268,6 +268,7 @@ export function createStandaloneHookBus({ maxEvents = 80, dataDir = null, prompt
268
268
  blocked: false,
269
269
  reason: null,
270
270
  updatedInput: null,
271
+ updatedToolName: null,
271
272
  updatedToolOutput: null,
272
273
  additionalContext: [],
273
274
  systemMessage: null,
@@ -304,6 +305,7 @@ export function createStandaloneHookBus({ maxEvents = 80, dataDir = null, prompt
304
305
  if (parsed.additionalContext) agg.additionalContext.push(parsed.additionalContext);
305
306
  if (parsed.systemMessage && !agg.systemMessage) agg.systemMessage = parsed.systemMessage;
306
307
  if (parsed.updatedInput && !agg.updatedInput) agg.updatedInput = parsed.updatedInput;
308
+ if (parsed.updatedToolName && !agg.updatedToolName) agg.updatedToolName = parsed.updatedToolName;
307
309
  if (parsed.updatedToolOutput != null && agg.updatedToolOutput == null) agg.updatedToolOutput = parsed.updatedToolOutput;
308
310
  if (parsed.askReason && !agg.ask && !agg.blocked) {
309
311
  agg.ask = true;
@@ -452,9 +454,14 @@ export function createStandaloneHookBus({ maxEvents = 80, dataDir = null, prompt
452
454
  emit('tool:deny', { sessionId: input.sessionId || input.session_id || null, name: input.name || input.tool_name || 'tool', reason: agg.reason });
453
455
  return { action: 'deny', reason: agg.reason };
454
456
  }
455
- if (agg.updatedInput) {
457
+ if (agg.updatedInput || agg.updatedToolName) {
456
458
  emit('tool:modify', { sessionId: input.sessionId || input.session_id || null, name: input.name || input.tool_name || 'tool', reason: agg.reason });
457
- return { action: 'modify', args: agg.updatedInput, reason: agg.reason };
459
+ return {
460
+ action: 'modify',
461
+ ...(agg.updatedInput ? { args: agg.updatedInput } : {}),
462
+ ...(agg.updatedToolName ? { name: agg.updatedToolName } : {}),
463
+ reason: agg.reason,
464
+ };
458
465
  }
459
466
  if (agg.ask) {
460
467
  emit('tool:ask', { sessionId: input.sessionId || input.session_id || null, name: input.name || input.tool_name || 'tool', reason: agg.askReason });
@@ -504,6 +511,7 @@ export function createStandaloneHookBus({ maxEvents = 80, dataDir = null, prompt
504
511
  additionalContext: agg.additionalContext.length ? agg.additionalContext : undefined,
505
512
  systemMessage: agg.systemMessage || undefined,
506
513
  updatedInput: agg.updatedInput || undefined,
514
+ updatedToolName: agg.updatedToolName || undefined,
507
515
  handlersRun: agg.handlersRun || undefined,
508
516
  };
509
517
  if (agg.updatedToolOutput != null) out.updatedToolOutput = agg.updatedToolOutput;
package/src/tui/App.jsx CHANGED
@@ -77,6 +77,7 @@ import {
77
77
  slashCommandForName,
78
78
  slashArgumentHint,
79
79
  } from './app/slash-commands.mjs';
80
+ import { isCompletedTranscriptTailAppendedThisCommit, isLiveSpinnerMetaVisible } from './app/live-spinner-visibility.mjs';
80
81
  import {
81
82
  parseHookRuleInput,
82
83
  parseMcpServerInput,
@@ -2564,7 +2565,6 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2564
2565
  ? promptHintTone
2565
2566
  : (latestToast?.tone || progressHint?.tone || 'info');
2566
2567
  const latestTranscriptItem = state.items[state.items.length - 1] || null;
2567
- const latestDoneAtTail = latestTranscriptItem?.kind === 'turndone' || latestTranscriptItem?.kind === 'statusdone';
2568
2568
  // Bottom meta band ownership is LIVE-SPINNER ONLY. A finished turn's done row
2569
2569
  // (turndone/statusdone) is a normal transcript item and flows into scrollback
2570
2570
  // like anything else, so the area directly above the prompt is CLEAR when the
@@ -2621,13 +2621,17 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2621
2621
  // While the slash palette is open it owns the area above the prompt, so the
2622
2622
  // live spinner/meta row is suppressed entirely — no reservation and no render.
2623
2623
  // Normalize the spinner → TurnDone handoff by making them occupy the SAME
2624
- // two-row slot. Engine appends turndone/statusdone before clearing spinner, so
2625
- // a transient frame can otherwise contain BOTH: transcript grows by two rows
2626
- // while the bottom spinner still reserves two rows, making the viewport visibly
2627
- // jump. As soon as the done row is the transcript tail, drop the spinner slot;
2628
- // the new done row replaces that height in the same frame, with no ms timer.
2629
- const promptMetaVisible = !inputBoxHidden && !slashPaletteOpen && !!liveSpinner
2630
- && (liveSpinnerIsCommand || !latestDoneAtTail);
2624
+ // two-row slot. Completion appends turndone and clears spinner in one commit,
2625
+ // so a completed row replaces the spinner slot with no transient jump. A
2626
+ // statusdone can be emitted mid-turn (for example after compaction), so it
2627
+ // must not suppress the still-active spinner.
2628
+ const promptMetaVisible = isLiveSpinnerMetaVisible({
2629
+ inputBoxHidden,
2630
+ slashPaletteOpen,
2631
+ liveSpinner,
2632
+ liveSpinnerIsCommand,
2633
+ latestTranscriptItem,
2634
+ });
2631
2635
  const promptMetaRows = promptMetaVisible ? 2 : 0;
2632
2636
  // Toast/error text without a live spinner uses the existing transcript guard
2633
2637
  // row directly above the prompt. Do NOT reserve another row here: that made a
@@ -2718,7 +2722,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2718
2722
  // transcript bounce. Exempt exactly the meta-collapse rows from the ink mask
2719
2723
  // when the done row is the transcript tail. Every other prompt-row-only
2720
2724
  // shrink (typing newline removal, queued-row churn) AND the reclaimed/no-op
2721
- // path (engine.mjs skips turndone, so latestDoneAtTail stays false and no
2725
+ // path (engine.mjs skips turndone, so the completed-tail check stays false and no
2722
2726
  // row replaces the height) keep the mask so they still reclaim smoothly.
2723
2727
  const prevMetaRows = Number(String(panelTransition.signature).split('|')[PANEL_LAYOUT_SIG.PROMPT_META]) || 0;
2724
2728
  const nextMetaRows = Number(String(panelLayoutSignature).split('|')[PANEL_LAYOUT_SIG.PROMPT_META]) || 0;
@@ -2727,8 +2731,10 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2727
2731
  // tail and then clear without appending statusdone (e.g. /recall — see
2728
2732
  // engine.mjs), collapsing the meta band with NO same-commit backfill; masking
2729
2733
  // must stay on for that path or the vacated rows overpaint the stale row.
2730
- const doneTailAppendedThisCommit = latestDoneAtTail
2731
- && (latestTranscriptItem?.id ?? null) !== panelTransition.tailId;
2734
+ const doneTailAppendedThisCommit = isCompletedTranscriptTailAppendedThisCommit(
2735
+ latestTranscriptItem,
2736
+ panelTransition.tailId,
2737
+ );
2732
2738
  const spinnerMetaCollapseRows = doneTailAppendedThisCommit
2733
2739
  ? Math.max(0, prevMetaRows - nextMetaRows)
2734
2740
  : 0;
@@ -0,0 +1,20 @@
1
+ export function isCompletedTranscriptTail(latestTranscriptItem) {
2
+ return latestTranscriptItem?.kind === 'turndone'
3
+ || latestTranscriptItem?.kind === 'statusdone';
4
+ }
5
+
6
+ export function isCompletedTranscriptTailAppendedThisCommit(latestTranscriptItem, previousTailId) {
7
+ return isCompletedTranscriptTail(latestTranscriptItem)
8
+ && (latestTranscriptItem?.id ?? null) !== previousTailId;
9
+ }
10
+
11
+ export function isLiveSpinnerMetaVisible({
12
+ inputBoxHidden,
13
+ slashPaletteOpen,
14
+ liveSpinner,
15
+ liveSpinnerIsCommand,
16
+ latestTranscriptItem,
17
+ }) {
18
+ return !inputBoxHidden && !slashPaletteOpen && !!liveSpinner
19
+ && (liveSpinnerIsCommand || latestTranscriptItem?.kind !== 'turndone');
20
+ }
@@ -8083,6 +8083,23 @@ function slashArgumentHint(value) {
8083
8083
  return command?.params ? `${command.usage} ${command.params}` : "";
8084
8084
  }
8085
8085
 
8086
+ // src/tui/app/live-spinner-visibility.mjs
8087
+ function isCompletedTranscriptTail(latestTranscriptItem) {
8088
+ return latestTranscriptItem?.kind === "turndone" || latestTranscriptItem?.kind === "statusdone";
8089
+ }
8090
+ function isCompletedTranscriptTailAppendedThisCommit(latestTranscriptItem, previousTailId) {
8091
+ return isCompletedTranscriptTail(latestTranscriptItem) && (latestTranscriptItem?.id ?? null) !== previousTailId;
8092
+ }
8093
+ function isLiveSpinnerMetaVisible({
8094
+ inputBoxHidden,
8095
+ slashPaletteOpen,
8096
+ liveSpinner,
8097
+ liveSpinnerIsCommand,
8098
+ latestTranscriptItem
8099
+ }) {
8100
+ return !inputBoxHidden && !slashPaletteOpen && !!liveSpinner && (liveSpinnerIsCommand || latestTranscriptItem?.kind !== "turndone");
8101
+ }
8102
+
8086
8103
  // src/tui/app/input-parsers.mjs
8087
8104
  function parseHookRuleInput(text) {
8088
8105
  const parts = String(text || "").split("|").map((part) => part.trim());
@@ -21453,7 +21470,6 @@ function App({ store, initialStatusLine = "", forceOnboarding = false }) {
21453
21470
  const inputHint = promptHint || toastHint || (progressHint?.text || "");
21454
21471
  const inputHintTone = promptHint ? promptHintTone : latestToast?.tone || progressHint?.tone || "info";
21455
21472
  const latestTranscriptItem = state.items[state.items.length - 1] || null;
21456
- const latestDoneAtTail = latestTranscriptItem?.kind === "turndone" || latestTranscriptItem?.kind === "statusdone";
21457
21473
  const latestDoneItem = null;
21458
21474
  const SCROLL_HINT_ROWS = 0;
21459
21475
  const LIVE_STATUS_ROWS = 0;
@@ -21473,7 +21489,13 @@ function App({ store, initialStatusLine = "", forceOnboarding = false }) {
21473
21489
  const TEXT_ENTRY_ROWS = PANEL_CHROME_ROWS + textEntryContentRows + textEntryDetailRows;
21474
21490
  const OPTION_PANEL_EXTRA_ROWS = expandedOptionPanel ? 3 : 0;
21475
21491
  const queuedVisible = !hasFloatingPanel && !inputBoxHidden && state.queued?.length > 0;
21476
- const promptMetaVisible = !inputBoxHidden && !slashPaletteOpen && !!liveSpinner && (liveSpinnerIsCommand || !latestDoneAtTail);
21492
+ const promptMetaVisible = isLiveSpinnerMetaVisible({
21493
+ inputBoxHidden,
21494
+ slashPaletteOpen,
21495
+ liveSpinner,
21496
+ liveSpinnerIsCommand,
21497
+ latestTranscriptItem
21498
+ });
21477
21499
  const promptMetaRows = promptMetaVisible ? 2 : 0;
21478
21500
  const overlayHintRequested = !inputBoxHidden && !hasFloatingPanel && !liveSpinner && !!inputHint && !queuedVisible;
21479
21501
  const overlayHintRows = 0;
@@ -21516,7 +21538,10 @@ function App({ store, initialStatusLine = "", forceOnboarding = false }) {
21516
21538
  const promptRowsOnlyChange = panelShrinkRows > 0 && panelKindSignature(panelTransition.signature) === panelKindSignature(panelLayoutSignature);
21517
21539
  const prevMetaRows = Number(String(panelTransition.signature).split("|")[PANEL_LAYOUT_SIG.PROMPT_META]) || 0;
21518
21540
  const nextMetaRows = Number(String(panelLayoutSignature).split("|")[PANEL_LAYOUT_SIG.PROMPT_META]) || 0;
21519
- const doneTailAppendedThisCommit = latestDoneAtTail && (latestTranscriptItem?.id ?? null) !== panelTransition.tailId;
21541
+ const doneTailAppendedThisCommit = isCompletedTranscriptTailAppendedThisCommit(
21542
+ latestTranscriptItem,
21543
+ panelTransition.tailId
21544
+ );
21520
21545
  const spinnerMetaCollapseRows = doneTailAppendedThisCommit ? Math.max(0, prevMetaRows - nextMetaRows) : 0;
21521
21546
  const prevQueuedSigRows = Number(String(panelTransition.signature).split("|")[PANEL_LAYOUT_SIG.QUEUED]) || 0;
21522
21547
  const nextQueuedSigRows = Number(String(panelLayoutSignature).split("|")[PANEL_LAYOUT_SIG.QUEUED]) || 0;