mixdog 0.9.66 → 0.9.68

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 (45) hide show
  1. package/package.json +1 -1
  2. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +3 -0
  3. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +5 -0
  4. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +20 -2
  5. package/src/runtime/channels/lib/config.mjs +13 -5
  6. package/src/runtime/channels/lib/owned-runtime.mjs +65 -5
  7. package/src/runtime/channels/lib/scheduler.mjs +7 -15
  8. package/src/runtime/channels/lib/status-snapshot.mjs +6 -30
  9. package/src/runtime/channels/lib/tool-dispatch.mjs +6 -1
  10. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +203 -0
  11. package/src/runtime/channels/lib/webhook.mjs +46 -246
  12. package/src/runtime/channels/lib/worker-main.mjs +45 -92
  13. package/src/runtime/shared/automation-attachments.mjs +72 -0
  14. package/src/runtime/shared/automation-workflow.mjs +41 -0
  15. package/src/runtime/shared/config.mjs +0 -9
  16. package/src/runtime/shared/schedule-session-run.mjs +10 -1
  17. package/src/runtime/shared/schedules-db.mjs +18 -3
  18. package/src/runtime/shared/time-format.mjs +56 -0
  19. package/src/runtime/shared/tool-card-model.mjs +740 -0
  20. package/src/runtime/shared/webhook-session-run.mjs +57 -0
  21. package/src/runtime/shared/webhooks-db.mjs +19 -3
  22. package/src/session-runtime/channel-config-api.mjs +13 -1
  23. package/src/session-runtime/lifecycle-api.mjs +12 -0
  24. package/src/session-runtime/prewarm.mjs +13 -7
  25. package/src/session-runtime/runtime-core.mjs +127 -22
  26. package/src/session-runtime/session-text.mjs +6 -0
  27. package/src/session-runtime/settings-api.mjs +0 -12
  28. package/src/standalone/channel-admin.mjs +71 -22
  29. package/src/standalone/channel-daemon-client.mjs +5 -1
  30. package/src/standalone/channel-daemon-transport.mjs +112 -20
  31. package/src/standalone/channel-daemon.mjs +6 -4
  32. package/src/tui/App.jsx +2 -47
  33. package/src/tui/app/channel-pickers.mjs +8 -173
  34. package/src/tui/app/doctor.mjs +0 -1
  35. package/src/tui/app/slash-commands.mjs +1 -3
  36. package/src/tui/app/slash-dispatch.mjs +3 -3
  37. package/src/tui/components/ToolExecution.jsx +47 -196
  38. package/src/tui/components/tool-execution/surface-detail.mjs +33 -394
  39. package/src/tui/components/tool-execution/text-format.mjs +27 -104
  40. package/src/tui/dist/index.mjs +340 -346
  41. package/src/tui/engine/live-share.mjs +9 -0
  42. package/src/tui/engine/session-api-ext.mjs +32 -16
  43. package/src/tui/engine.mjs +14 -1
  44. package/src/tui/time-format.mjs +4 -51
  45. package/src/runtime/channels/lib/webhook/ngrok.mjs +0 -181
@@ -215,165 +215,8 @@ export function createChannelPickers({
215
215
  setChannelPrompt(prompt);
216
216
  };
217
217
 
218
- const channelRemoteEnabled = store.isRemoteEnabled?.() === true;
219
-
220
- if (focus === 'schedules') {
221
- const schedules = setup.schedules || [];
222
- const items = [
223
- ...(schedules.length ? schedules.map((schedule) => {
224
- const enabled = schedule.enabled !== false;
225
- return {
226
- value: `schedule:${schedule.name}`,
227
- label: schedule.name,
228
- marker: enabled ? '●' : '○',
229
- markerColor: channelRemoteEnabled ? (enabled ? theme.success : theme.inactive) : theme.inactive,
230
- description: `${schedule.time || '(no cron)'} · ${schedule.route}${schedule.model ? ` · ${schedule.model}` : ''}${channelRemoteEnabled ? '' : ' · channel off'}`,
231
- _action: 'schedule-toggle',
232
- _name: schedule.name,
233
- _enabled: enabled,
234
- };
235
- }) : [{
236
- value: 'empty',
237
- label: 'No schedules',
238
- description: 'no schedules configured',
239
- _action: 'noop',
240
- }]),
241
- ];
242
- const toggleSchedule = async (item) => {
243
- if (item._action !== 'schedule-toggle') return;
244
- if (!channelRemoteEnabled) {
245
- store.pushNotice('enable channel first', 'warn');
246
- return;
247
- }
248
- try {
249
- await store.setScheduleEnabled?.(item._name, !item._enabled);
250
- void openChannelSetupPicker('schedules', { highlightValue: `schedule:${item._name}` });
251
- } catch (e) {
252
- store.pushNotice(`schedule toggle failed: ${e?.message || e}`, 'error');
253
- }
254
- };
255
- setPicker({
256
- title: 'Schedules',
257
- description: channelRemoteEnabled ? 'Enable or disable cron schedules.' : 'Enable channel to toggle schedules.',
258
- initialIndex: Math.max(0, items.findIndex((entry) => entry.value === options.highlightValue)),
259
- items,
260
- onSelect: (_value, item) => toggleSchedule(item),
261
- onLeft: (item) => toggleSchedule(item),
262
- onRight: (item) => toggleSchedule(item),
263
- onCancel: () => {
264
- setPicker(null);
265
- },
266
- });
267
- return;
268
- }
269
-
270
- if (focus === 'webhook-endpoint') {
271
- const returnTo = typeof options.returnTo === 'function'
272
- ? options.returnTo
273
- : () => setPicker(null);
274
- const domain = setup.webhook?.ngrokDomain || setup.webhook?.domain || '';
275
- const items = [
276
- {
277
- value: 'endpoint-domain',
278
- label: 'ngrok domain',
279
- description: domain ? domain : 'Not set · Enter ngrok domain',
280
- _action: 'endpoint-domain',
281
- },
282
- {
283
- value: 'endpoint-authtoken',
284
- label: 'authtoken',
285
- description: setup.webhook?.authenticated === true ? 'Set' : 'Not set · Enter authtoken',
286
- _action: 'endpoint-authtoken',
287
- },
288
- ];
289
- setPicker({
290
- title: 'Webhook endpoint',
291
- description: 'ngrok domain and authtoken. Toggle individual webhooks in /webhooks.',
292
- help: '↑/↓ Select · Enter Edit · Esc Back',
293
- indexMode: 'always',
294
- labelWidth: 18,
295
- items,
296
- onSelect: (_value, item) => {
297
- try {
298
- if (item._action === 'endpoint-domain') {
299
- openChannelPrompt({
300
- kind: 'webhook-domain',
301
- label: 'ngrok domain',
302
- hint: 'Paste the reserved ngrok domain (e.g. my-app.ngrok-free.app).',
303
- afterSave: () => void openChannelSetupPicker('webhook-endpoint', options),
304
- });
305
- return;
306
- }
307
- if (item._action === 'endpoint-authtoken') {
308
- openChannelPrompt({
309
- kind: 'webhook-token',
310
- label: 'Webhook/ngrok authtoken',
311
- hint: 'Paste the webhook/ngrok authtoken. It is stored in the OS keychain.',
312
- afterSave: () => void openChannelSetupPicker('webhook-endpoint', options),
313
- });
314
- }
315
- } catch (e) {
316
- store.pushNotice(`webhook endpoint failed: ${e?.message || e}`, 'error');
317
- }
318
- },
319
- onCancel: () => {
320
- setPicker(null);
321
- returnTo();
322
- },
323
- });
324
- return;
325
- }
326
-
327
- if (focus === 'webhooks') {
328
- const hooks = setup.webhooks || [];
329
- const items = [
330
- ...(hooks.length ? hooks.map((hook) => {
331
- const enabled = hook.enabled !== false;
332
- return {
333
- value: `webhook:${hook.name}`,
334
- label: hook.name,
335
- marker: enabled ? '●' : '○',
336
- markerColor: channelRemoteEnabled ? (enabled ? theme.success : theme.inactive) : theme.inactive,
337
- description: `${hook.parser || 'github'} · ${hook.route} · secret:${hook.secretSet ? 'set' : 'missing'}${channelRemoteEnabled ? '' : ' · channel off'}`,
338
- _action: 'webhook-toggle',
339
- _name: hook.name,
340
- _enabled: enabled,
341
- };
342
- }) : [{
343
- value: 'empty',
344
- label: 'No webhooks',
345
- description: 'no webhook endpoints configured',
346
- _action: 'noop',
347
- }]),
348
- ];
349
- const toggleWebhook = async (item) => {
350
- if (item._action !== 'webhook-toggle') return;
351
- if (!channelRemoteEnabled) {
352
- store.pushNotice('enable channel first', 'warn');
353
- return;
354
- }
355
- try {
356
- await store.setWebhookEnabled?.(item._name, !item._enabled);
357
- void openChannelSetupPicker('webhooks', { highlightValue: `webhook:${item._name}` });
358
- } catch (e) {
359
- store.pushNotice(`webhook toggle failed: ${e?.message || e}`, 'error');
360
- }
361
- };
362
- setPicker({
363
- title: 'Webhooks',
364
- description: channelRemoteEnabled ? 'Enable or disable inbound webhook endpoints.' : 'Enable channel to toggle webhooks.',
365
- initialIndex: Math.max(0, items.findIndex((entry) => entry.value === options.highlightValue)),
366
- items,
367
- onSelect: (_value, item) => toggleWebhook(item),
368
- onLeft: (item) => toggleWebhook(item),
369
- onRight: (item) => toggleWebhook(item),
370
- onCancel: () => {
371
- setPicker(null);
372
- },
373
- });
374
- return;
375
- }
376
-
218
+ // Schedules/webhooks management is desktop-only (user decision): the TUI
219
+ // no longer carries their pickers or the webhook-endpoint info page.
377
220
  const worker = store.getChannelWorkerStatus?.();
378
221
  const activeBackend = setup.backend === 'telegram' ? 'telegram' : 'discord';
379
222
  const backendLabel = activeBackend === 'telegram' ? 'Telegram' : 'Discord';
@@ -465,20 +308,12 @@ export function createChannelPickers({
465
308
  {
466
309
  value: 'webhook-endpoint',
467
310
  label: 'Webhook endpoint',
468
- meta: (() => {
469
- const hasDomain = Boolean(setup.webhook?.ngrokDomain || setup.webhook?.domain);
470
- const hasAuth = setup.webhook?.authenticated === true;
471
- return (hasDomain && hasAuth) ? 'On' : 'Off';
472
- })(),
473
- description: (() => {
474
- const hasDomain = Boolean(setup.webhook?.ngrokDomain || setup.webhook?.domain);
475
- const hasAuth = setup.webhook?.authenticated === true;
476
- const needs = [
477
- ...(hasDomain ? [] : ['domain']),
478
- ...(hasAuth ? [] : ['authtoken']),
479
- ];
480
- return needs.length ? `Needs ${needs.join(' + ')}` : 'ngrok domain and authtoken set';
481
- })(),
311
+ // Relay tunnel: public exposure is automatic, so the endpoint is
312
+ // "On" whenever the webhook server itself is enabled.
313
+ meta: setup.webhook?.enabled === false ? 'Off' : 'On',
314
+ description: setup.webhook?.publicUrl
315
+ ? setup.webhook.publicUrl
316
+ : 'Mixdog relay tunnel — URL assigned on first run',
482
317
  _action: 'webhook-endpoint',
483
318
  },
484
319
  ];
@@ -165,7 +165,6 @@ export async function buildDoctorReport(runtime = {}, getState = () => ({})) {
165
165
  const tokens = [];
166
166
  if (setup.discord?.authenticated) tokens.push('discord');
167
167
  if (setup.telegram?.authenticated) tokens.push('telegram');
168
- if (setup.webhook?.authenticated) tokens.push('webhook');
169
168
  const running = worker.running === true;
170
169
  const detail = `enabled · worker ${running ? 'running' : 'stopped'} · tokens: ${tokens.length ? tokens.join(', ') : 'none'}`;
171
170
  row(running ? 'ok' : 'warn', detail);
@@ -25,10 +25,8 @@ export const SLASH_COMMANDS = [
25
25
  { name: 'plugins', usage: '/plugins', description: 'Manage local plugin integrations' },
26
26
  { name: 'hooks', usage: '/hooks', description: 'Manage before-tool hook rules and events' },
27
27
  { name: 'providers', usage: '/providers', description: 'Manage auth, API keys, OAuth, and local endpoints' },
28
- { name: 'channels', usage: '/channels', description: 'Manage Discord, channels, schedules, webhooks' },
28
+ { name: 'channels', usage: '/channels', description: 'Manage Discord, Telegram, and voice' },
29
29
  { name: 'remote', usage: '/remote', description: 'Claim remote for this session (takes over from any other session)' },
30
- { name: 'schedules', usage: '/schedules', description: 'Manage schedules' },
31
- { name: 'webhooks', usage: '/webhooks', description: 'Manage inbound webhooks' },
32
30
  { name: 'settings', usage: '/setting', aliases: ['setting', 'config'], aliasUsage: ['settings', 'config'], showAliasUsage: false, description: 'Open runtime settings' },
33
31
  { name: 'profile', usage: '/profile', description: 'Set your title and response language' },
34
32
  { name: 'update', usage: '/update', description: 'Check version and update mixdog' },
@@ -265,10 +265,10 @@ export function createSlashDispatch({
265
265
  void openChannelSetupPicker('all');
266
266
  return true;
267
267
  case 'schedules':
268
- void openChannelSetupPicker('schedules');
269
- return true;
270
268
  case 'webhooks':
271
- void openChannelSetupPicker('webhooks');
269
+ // Management surface is desktop-only (user decision): hidden from the
270
+ // palette, and a typed command answers instead of opening a picker.
271
+ store.pushNotice('Schedules and webhooks are managed in the Mixdog desktop app', 'info');
272
272
  return true;
273
273
  case 'auth':
274
274
  store.pushNotice('/auth moved to /providers', 'info');
@@ -18,13 +18,9 @@ import { BULLET_OPERATOR } from '../figures.mjs';
18
18
  import {
19
19
  displayToolName as surfaceDisplayToolName,
20
20
  formatToolSurface,
21
- summarizeToolResult as surfaceSummarizeToolResult,
22
21
  formatAggregateHeader,
23
- formatToolActionHeader,
24
- summarizeAgentSurfaceBrief,
25
- AGENT_SURFACE_BRIEF_MAX,
26
22
  } from '../../runtime/shared/tool-surface.mjs';
27
- import { isBackgroundErrorOnlyBody, backgroundTaskFailureStatusLabel } from '../../runtime/shared/err-text.mjs';
23
+ import { deriveToolCardModel } from '../../runtime/shared/tool-card-model.mjs';
28
24
  import {
29
25
  MIN_RESULT_LINE_CHARS,
30
26
  RESULT_LINE_HARD_MAX,
@@ -33,36 +29,13 @@ import {
33
29
  safeInlineText,
34
30
  normalizeCountMap,
35
31
  truncateToWidth,
36
- shellResultElapsed,
37
- normalizeTerminalStatus,
38
32
  resultTerminalStatus,
39
33
  stripLeadingStatusMarkerLines,
40
34
  stripLeadingStatusMarkerFromText,
41
35
  } from './tool-execution/text-format.mjs';
42
36
  import {
43
37
  SKILL_SURFACE_NAMES,
44
- isShellTool,
45
- shellDisplayStatus,
46
- shellHeader,
47
38
  isAgentTool,
48
- isBackgroundTaskTool,
49
- agentResponseTitle,
50
- agentActionTitle,
51
- agentActionSummary,
52
- hasAgentResponseResult,
53
- resolveBackgroundTaskMeta,
54
- backgroundTaskElapsed,
55
- prefixElapsed,
56
- mergeTerminalDetail,
57
- shouldPrefixSyncElapsed,
58
- backgroundTaskResultTitle,
59
- backgroundTaskActionTitle,
60
- backgroundTaskFailureDetail,
61
- backgroundTaskDetail,
62
- isBackgroundTaskResponseArgs,
63
- genericCompletedDetail,
64
- toolSearchLoadedSummary,
65
- agentTerminalDetail,
66
39
  clampFailureCount,
67
40
  toolStatusColor,
68
41
  } from './tool-execution/surface-detail.mjs';
@@ -77,15 +50,6 @@ const TOOL_PENDING_SHOW_DELAY_MS = 1000;
77
50
  // One shared-tick cadence covers both the 500ms blink and per-second elapsed;
78
51
  // finer than either boundary so both stay crisp off a single timer.
79
52
  const TOOL_ANIM_TICK_MS = TOOL_BLINK_MS;
80
- function statusCopy(name, label, count, doneCount, pending, isError, args = {}) {
81
- // No stableVerbWidth padding: it padded the done verb to the active ("-ing")
82
- // width, which Ink trims at the line END (vendor output trimEnd) so it never
83
- // stabilized the pending→done flip — it only left an UGLY mid-header gap
84
- // ("Searched 1 pattern", "Read 1 file"). The header is wrap="truncate"
85
- // behind a fixed gutter and the fullscreen full-clear repaints the row, so
86
- // dropping the pad just normalizes the spacing.
87
- return formatToolActionHeader(name, args, { pending, count });
88
- }
89
53
  export function ToolExecution({ name, args, result, rawResult, isError, errorCount, callErrorCount, exitErrorCount, expanded, columns = 80, attached = false, count = 1, completedCount = 0, startedAt = 0, completedAt = 0, aggregate = false, categories = {}, doneCategories = null, headerFinalized = true, deferredDisplayReady = false, agentResponseAggregate = false }) {
90
54
  const rowWidth = Math.max(1, Number(columns || 80));
91
55
  const groupCount = Math.max(1, Number(count || 1));
@@ -284,104 +248,52 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
284
248
  }
285
249
 
286
250
  // ── Normal (non-aggregate) tool card ────────────────────────────
287
- const { label, summary, normalizedName, args: parsedArgs } = formatToolSurface(name, args);
288
- const isShellSurface = isShellTool(normalizedName, label);
289
- const isSkillSurface = SKILL_SURFACE_NAMES.has(String(normalizedName || '').toLowerCase());
290
- const backgroundMeta = !pending && isBackgroundTaskTool(normalizedName)
291
- ? resolveBackgroundTaskMeta(parsedArgs, rt || '')
292
- : null;
293
- const backgroundError = backgroundMeta?.error || parsedArgs?.error || '';
294
- const errorOnlyResult = Boolean(rt) && isBackgroundErrorOnlyBody(rt, backgroundError);
295
- const backgroundResultText = backgroundMeta?.hasResponse ? backgroundMeta.body : '';
296
- const displayedResultText = backgroundResultText || (errorOnlyResult ? '' : (rt || ''));
297
- const hasDisplayResult = Boolean(String(displayedResultText || '').trim());
298
- const displayedResultBodyText = stripLeadingStatusMarkerFromText(displayedResultText);
299
- const hasDisplayBody = Boolean(String(displayedResultBodyText || '').trim());
300
- const lines = displayedResultBodyText ? displayedResultBodyText.split('\n') : [];
301
- const totalLines = lines.length;
302
- // Semantic one-line summary derived purely from name/args/result text.
303
- // Shown in the collapsed, non-error view in place of the raw result block.
304
- // Grouped cards ("Searched N files" / "Read N files") get the same treatment
305
- // as single calls: a one-line semantic summary stands in for the raw block.
306
- const resultSummary = !pending && hasDisplayBody
307
- ? surfaceSummarizeToolResult(name, args, displayedResultBodyText, isError)
308
- : null;
309
- // Same fit budget fitResultLine() uses, to detect a line that will be clipped.
251
+ // Single source: the shared collapsed-card derivation (labels, casing,
252
+ // status merging, detail row) consumed by BOTH the TUI and the desktop
253
+ // renderer (apps/desktop TranscriptView ToolCard). Width fitting, theme
254
+ // colors, blink, and expansion handling stay TUI-side below.
310
255
  const maxResultChars = Math.min(RESULT_LINE_HARD_MAX, Math.max(MIN_RESULT_LINE_CHARS, Number(columns || 80) - 7));
256
+ const model = deriveToolCardModel({
257
+ name,
258
+ args,
259
+ result,
260
+ rawResult,
261
+ isError,
262
+ errorCount,
263
+ callErrorCount,
264
+ exitErrorCount,
265
+ count: displayGroupCount,
266
+ completedCount: doneCount,
267
+ startedAt,
268
+ completedAt,
269
+ headerFinalized,
270
+ nowMs,
271
+ }, { truncate: truncateToWidth, maxResultChars });
272
+ const {
273
+ labelText,
274
+ summaryText,
275
+ headerFailureText: headerFailureStatus,
276
+ detailLine: collapsedDetailLine,
277
+ detailIsPlaceholder,
278
+ terminalStatus,
279
+ normalizedName,
280
+ isShellSurface,
281
+ isAgentSurfaceCard,
282
+ isAgentResponse,
283
+ isBackgroundMetadataResult,
284
+ hasDisplayResult,
285
+ hasDisplayBody,
286
+ displayedResultBodyText,
287
+ firstResultLine,
288
+ totalLines,
289
+ resultSummary,
290
+ shellCollapsedSummary,
291
+ toolArgPath,
292
+ } = model;
293
+ const lines = displayedResultBodyText ? displayedResultBodyText.split('\n') : [];
311
294
  const resultColor = theme.text;
312
- const firstResultLine = hasDisplayResult ? String(lines[0] ?? '') : '';
313
295
  const firstResultLineClipped = hasDisplayBody && stringWidth(firstResultLine) > maxResultChars;
314
296
  const hasHiddenDetail = !pending && hasDisplayBody && (totalLines > 1 || firstResultLineClipped || Boolean(resultSummary));
315
- const shellStatus = isShellSurface ? shellDisplayStatus({ pending, failedCount, exitFailedCount, isError, result: displayedResultText }) : '';
316
- const shellElapsed = isShellSurface ? (shellResultElapsed(displayedResultText) || elapsed) : '';
317
- const backgroundElapsed = backgroundMeta
318
- ? backgroundTaskElapsed(backgroundMeta, elapsed)
319
- : (isBackgroundTaskTool(normalizedName) ? backgroundTaskElapsed(parsedArgs, elapsed) : '');
320
-
321
- const toolArgPath = parsedArgs?.path ?? parsedArgs?.file_path ?? parsedArgs?.file ?? '';
322
- // Audit HIGH: on a FAILED view_image the path detail used to win over the
323
- // error cause (nonShellDetail order puts imageDetail before genericDetail),
324
- // so the card showed the filename instead of why it failed. Suppress the
325
- // path detail on error; the error-cause summary/first line takes the row.
326
- const imageDetail = normalizedName === 'view_image' && toolArgPath && !isError ? String(toolArgPath) : '';
327
- const isBackgroundResult = !pending && isBackgroundTaskTool(normalizedName) && Boolean(backgroundMeta);
328
- const isBackgroundResponse = isBackgroundResult && (backgroundMeta?.hasResponse || isBackgroundTaskResponseArgs(normalizedName, parsedArgs));
329
- const isBackgroundMetadataResult = isBackgroundResult && !isBackgroundResponse && Boolean(backgroundMeta);
330
- const backgroundMetadataFailureLabel = isBackgroundMetadataResult
331
- ? backgroundTaskFailureDetail(backgroundMeta, parsedArgs)
332
- : '';
333
- const backgroundMetadataHeaderFailure = Boolean(backgroundMetadataFailureLabel) && !hasDisplayResult
334
- ? backgroundMetadataFailureLabel
335
- : '';
336
- const agentHeaderFailure = !pending && isAgentTool(normalizedName) && isError && parsedArgs?.error && !hasDisplayResult
337
- ? backgroundTaskFailureStatusLabel(parsedArgs?.status, parsedArgs?.error, { surface: 'agent' })
338
- : '';
339
- const headerFailureStatus = backgroundMetadataHeaderFailure || agentHeaderFailure || '';
340
- const agentCompletionDetail = !pending && isAgentTool(normalizedName) && !agentHeaderFailure
341
- ? agentTerminalDetail(parsedArgs?.status, isError, elapsed, parsedArgs?.error)
342
- : '';
343
- const agentDetail = !pending && isAgentTool(normalizedName) && !hasDisplayResult
344
- ? agentCompletionDetail
345
- : '';
346
- const genericDetail = !pending && !isShellSurface && !agentDetail && !imageDetail && !resultSummary
347
- ? genericCompletedDetail({ normalizedName, label, hasResult, firstResultLine, isError })
348
- : '';
349
- const terminalStatus = pending
350
- ? 'running'
351
- : (shellStatus || normalizeTerminalStatus(backgroundMeta?.status) || normalizeTerminalStatus(parsedArgs?.status) || resultTerminalStatus(displayedResultText) || (isError || failedCount > 0 ? 'failed' : 'completed'));
352
- const backgroundMetadataDetail = isBackgroundMetadataResult && !backgroundMetadataHeaderFailure
353
- ? backgroundTaskDetail(backgroundMeta, backgroundElapsed, parsedArgs)
354
- : '';
355
- const backgroundResponseDetail = isBackgroundResponse && resultSummary
356
- ? prefixElapsed(resultSummary, backgroundElapsed)
357
- : resultSummary;
358
- const syncElapsedDetail = !isBackgroundResponse && shouldPrefixSyncElapsed(normalizedName, label)
359
- ? prefixElapsed(backgroundResponseDetail, elapsed)
360
- : backgroundResponseDetail;
361
- const nonShellDetail = backgroundMetadataDetail || (/^(Cancelled|Failed|Finished)$/i.test(resultSummary || '') && agentCompletionDetail
362
- ? agentCompletionDetail
363
- : syncElapsedDetail) || agentDetail || imageDetail || genericDetail;
364
- // A pending non-aggregate tool used to drop its detail row entirely
365
- // (collapsedDetail = ''), so the card rendered as a single header row. But
366
- // estimateTranscriptItemRows() in App.jsx reserves 2 rows for a collapsed
367
- // non-aggregate tool (1 only for skill surfaces). That left a 1-row gap that
368
- // closed the instant the result landed — the surviving "line-jump". Reserve the same
369
- // dim placeholder detail row the aggregate card uses (`Running`) for the whole
370
- // pending lifecycle so the height stays fixed at header + one detail row and
371
- // the final summary just fills in place. Skill surfaces collapse to a single
372
- // row in BOTH the estimate and the render (visibleDetailLines drops the row
373
- // for isSkillSurface below), so they get no placeholder.
374
- const pendingDetailPlaceholder = pending && !isSkillSurface
375
- ? (elapsed ? `Running · ${elapsed}` : 'Running')
376
- : '';
377
- const shellCollapsedSummary = isShellSurface && !pending && hasDisplayResult
378
- ? (resultSummary || truncateToWidth(firstResultLine, Math.min(120, maxResultChars)))
379
- : resultSummary;
380
- const collapsedDetail = pending
381
- ? pendingDetailPlaceholder
382
- : isShellSurface
383
- ? prefixElapsed(mergeTerminalDetail(shellStatus, shellCollapsedSummary), shellElapsed)
384
- : mergeTerminalDetail(terminalStatus, nonShellDetail);
385
297
  const backgroundMetadataExpandable = isBackgroundMetadataResult && hasRawResult && !pending;
386
298
  const showRawResult = expanded && (hasDisplayBody || hasRawResult)
387
299
  && (!isBackgroundMetadataResult || hasRawResult);
@@ -389,47 +301,11 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
389
301
  ? (agentResponseAggregate && hasRawResult
390
302
  ? stripLeadingStatusMarkerLines(rawRt.split('\n'))
391
303
  : (hasDisplayBody ? lines : (rawRt ? stripLeadingStatusMarkerLines(rawRt.split('\n')) : [])))
392
- : (collapsedDetail ? [collapsedDetail] : []);
393
- const isPendingPlaceholderDetail = !showRawResult && Boolean(pendingDetailPlaceholder);
304
+ : (collapsedDetailLine ? [collapsedDetailLine] : []);
305
+ const isPendingPlaceholderDetail = !showRawResult && detailIsPlaceholder;
394
306
  const detailColor = isPendingPlaceholderDetail ? theme.subtle : theme.text;
395
-
396
- const isAgentResult = !isBackgroundResult && !pending && isAgentTool(normalizedName) && hasDisplayResult;
397
- const isAgentResponse = isAgentResult && hasAgentResponseResult(rt);
398
- const isAgentSurfaceCard = isAgentTool(normalizedName);
399
- const agentSurfaceBriefRaw = isAgentSurfaceCard && !showRawResult
400
- ? summarizeAgentSurfaceBrief(name, parsedArgs, displayedResultText || '', { isError, isResponse: isAgentResponse })
401
- : '';
402
- const agentSurfaceBrief = agentSurfaceBriefRaw
403
- ? truncateToWidth(agentSurfaceBriefRaw, Math.min(AGENT_SURFACE_BRIEF_MAX, maxResultChars))
404
- : '';
405
- // Skill loads carry the skill name in the header already
406
- // ("Loaded 1 skill (name)"); the collapsed detail row just repeats it, so
407
- // drop it and keep the card a single line. Expanding (ctrl+o) still shows the
408
- // full skill body via the raw-result path.
409
- // Agent spawn/send/response cards show a tight brief under the ⎿ gutter when
410
- // collapsed; ctrl+o expand still surfaces the full body.
411
- let visibleDetailLines = detailLines;
412
- if (isSkillSurface && !showRawResult) {
413
- // Audit HIGH: a FAILED skill load used to drop its detail row with the
414
- // success path, hiding the one-line cause entirely. Keep the detail row
415
- // when the call errored; only the redundant success repeat is dropped.
416
- visibleDetailLines = isError && collapsedDetail ? [collapsedDetail] : [];
417
- } else if (isBackgroundMetadataResult && backgroundMetadataHeaderFailure && !showRawResult) {
418
- visibleDetailLines = [];
419
- } else if (isAgentSurfaceCard && !showRawResult) {
420
- // Agent cards collapse to a SINGLE header row like skill surfaces. Keep the
421
- // ⎿ detail row ONLY when it carries failure info: the call errored, or the
422
- // brief/summary reads as a failure/cancel. A header-failure-only card (no
423
- // brief) shows the cause in the header, so it stays a single row too.
424
- // Expanded (ctrl+o raw) still flows through the showRawResult path above.
425
- const agentDetailFallback = collapsedDetail
426
- || (pending ? (pendingDetailPlaceholder || 'Running') : 'Finished');
427
- const agentDetailLine = agentSurfaceBrief
428
- || truncateToWidth(String(agentDetailFallback), Math.min(AGENT_SURFACE_BRIEF_MAX, maxResultChars));
429
- const agentFailureText = /\b(Cancelled|Canceled|Failed)\b/i.test(agentSurfaceBrief || collapsedDetail || '');
430
- const keepAgentDetail = (isError || agentFailureText) && !(agentHeaderFailure && !agentSurfaceBrief);
431
- visibleDetailLines = keepAgentDetail ? [agentDetailLine] : [];
432
- }
307
+ // Skill/agent collapsed gating lives in the shared model (detailLine).
308
+ const visibleDetailLines = detailLines;
433
309
  const finalStatusColor = toolStatusColor({ pending, groupCount, callFailedCount, exitFailedCount, terminalStatus });
434
310
  const dotColor = finalStatusColor;
435
311
  // Agent surface cards use directional markers: `←` for requests going OUT
@@ -449,31 +325,6 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
449
325
  const isDirectionalMarker = isAgentResponse || isAgentSurfaceCard;
450
326
  const markerText = isDirectionalMarker ? `${markerGlyph} ` : markerGlyph;
451
327
  const dotText = pending && !blinkOn ? ' ' : markerText;
452
- let labelText;
453
- if (isAgentResponse) labelText = agentResponseTitle(parsedArgs, displayGroupCount);
454
- else if (isBackgroundResponse) labelText = backgroundTaskResultTitle(normalizedName, backgroundMeta || parsedArgs);
455
- else if (isBackgroundMetadataResult) labelText = backgroundTaskActionTitle(normalizedName, backgroundMeta);
456
- else if (isShellSurface) labelText = shellHeader(shellStatus, displayGroupCount);
457
- else labelText = (isAgentTool(normalizedName) ? agentActionTitle(parsedArgs) : '') || statusCopy(name, label, displayGroupCount, doneCount, headerPending, isError, parsedArgs);
458
- labelText = safeInlineText(labelText);
459
- // Show the parenthesized arg summary for grouped cards too, matching single
460
- // calls so the header carries the same context.
461
- const toolSearchSummary = !pending && normalizedName === 'load_tool' && hasResult
462
- ? toolSearchLoadedSummary(displayedResultText)
463
- : '';
464
- const rawSummaryText = safeInlineText(isAgentResponse || isBackgroundResponse
465
- ? ''
466
- : toolSearchSummary || (isAgentTool(normalizedName) ? agentActionSummary(parsedArgs, summary) : summary));
467
- // Drop the parenthesized arg summary when it is a bare "<n> <unit>" count
468
- // that the header verb already spells out (e.g. header "Searching 6 patterns"
469
- // + summary "6 patterns"). Multi-arg array calls hit this; single calls keep
470
- // their descriptive summary ("pattern: \"foo\"") since it never matches the
471
- // header tail. Channel surfaces are unaffected — they build the summary from
472
- // summarizeToolArgs directly and never render this header verb.
473
- const summaryIsHeaderCount = rawSummaryText
474
- && /^\d+\s+\S+$/.test(rawSummaryText)
475
- && labelText.endsWith(rawSummaryText);
476
- const summaryText = summaryIsHeaderCount ? '' : rawSummaryText;
477
328
  // Agent cards hide their collapsed body but still expose ctrl+o expand only
478
329
  // when expanding would actually reveal something: an agent response body, or a
479
330
  // multiline / clipped raw result (e.g. the "agents: N …" worker list). A