@parallel-cli/parallel 0.3.3 → 0.4.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.
package/dist/ui/App.js CHANGED
@@ -7,16 +7,18 @@ import { Controller } from '../controller.js';
7
7
  import { startSessionServer } from '../server.js';
8
8
  import { executeInput } from '../commands.js';
9
9
  import { PROVIDER_PRESETS, getProvider, rememberFolder, saveConfig } from '../config.js';
10
- import { fmtCost } from '../pricing.js';
11
10
  import { LANGS, setLang, t } from '../i18n.js';
12
- import { AgentPanel } from './AgentPanel.js';
11
+ import { AgentRow, AgentTranscript } from './AgentPanel.js';
13
12
  import { ApprovalPrompt } from './ApprovalPrompt.js';
14
13
  import { QuestionPrompt } from './QuestionPrompt.js';
15
14
  import { CommandInput } from './CommandInput.js';
16
15
  import { SettingsPanel } from './SettingsPanel.js';
17
16
  import { BoardView, CostView, DiffView, HelpView, NotesView, SessionsView, SkillsView, SpecialistsView } from './views.js';
18
17
  import { SelectList, WizardStep } from './Wizard.js';
19
- const LOGO = '⚡ P A R A L L E L';
18
+ import { BRAND, CHROME, STATE, STATE_META, UI, middleTruncate } from './tokens.js';
19
+ const LOGO = 'Parallel';
20
+ // Version from package.json (v0.3.3). Hardcoded — rootDir: "src" prevents importing ../../package.json.
21
+ const VERSION = '0.3.3';
20
22
  function usableProvider(config) {
21
23
  const p = getProvider(config);
22
24
  return p && p.apiKey && (p.defaultModel || p.models[0]) ? p : undefined;
@@ -60,7 +62,13 @@ export function App({ config, initialFolder }) {
60
62
  const [view, setView] = useState('agents');
61
63
  // Focus mode (/focus <agent>): plain input is routed to that agent.
62
64
  const [focus, setFocus] = useState(null);
63
- const [systemLines, setSystemLines] = useState(directFolder ? [t('main.ready1', { folder: directFolder }), t('main.ready2')] : []);
65
+ const [rawLogs, setRawLogs] = useState(false);
66
+ const [systemLines, setSystemLines] = useState(directFolder
67
+ ? [
68
+ { text: t('main.ready1', { folder: directFolder }), level: 'ok' },
69
+ { text: t('main.ready2'), level: 'info' },
70
+ ]
71
+ : []);
64
72
  const [inputReady, setInputReady] = useState(Boolean(directFolder));
65
73
  const ctl = ctlRef.current;
66
74
  // Re-render (throttled) on every blackboard/controller update.
@@ -97,11 +105,33 @@ export function App({ config, initialFolder }) {
97
105
  }, [ctl]);
98
106
  const ui = useMemo(() => ({
99
107
  setView,
100
- system: (line) => setSystemLines((ls) => [...ls.slice(-5), line]),
108
+ system: (line, level) => setSystemLines((ls) => [...ls.slice(-5), { text: line, level }]),
101
109
  exit: () => {
102
110
  setTimeout(() => exit(), 50);
103
111
  },
104
112
  setFocus,
113
+ toggleRaw: () => setRawLogs((v) => {
114
+ const next = !v;
115
+ setSystemLines((ls) => [
116
+ ...ls.slice(-5),
117
+ { text: t(next ? 'm.rawOn' : 'm.rawOff'), level: 'info' },
118
+ ]);
119
+ return next;
120
+ }),
121
+ copyLatest: () => {
122
+ const agents = [...(ctlRef.current?.board.agents.values() ?? [])].filter((a) => a.lastResult);
123
+ const latest = agents.sort((a, b) => b.startedAt - a.startedAt)[0];
124
+ if (!latest?.lastResult) {
125
+ setSystemLines((ls) => [...ls.slice(-5), { text: t('m.copyNone'), level: 'warn' }]);
126
+ return;
127
+ }
128
+ const encoded = Buffer.from(latest.lastResult).toString('base64');
129
+ process.stdout.write(`\x1b]52;c;${encoded}\x07`);
130
+ setSystemLines((ls) => [
131
+ ...ls.slice(-5),
132
+ { text: t('m.copyDone', { name: latest.name }), level: 'ok' },
133
+ ]);
134
+ },
105
135
  }), [exit]);
106
136
  // ---------- wizard transitions ----------
107
137
  // In normal launches, a complete config goes straight to the main TUI.
@@ -195,7 +225,10 @@ export function App({ config, initialFolder }) {
195
225
  enterMain();
196
226
  };
197
227
  const enterMain = () => {
198
- setSystemLines([t('main.ready1', { folder }), t('main.ready2')]);
228
+ setSystemLines([
229
+ { text: t('main.ready1', { folder }), level: 'ok' },
230
+ { text: t('main.ready2'), level: 'info' },
231
+ ]);
199
232
  setPhase('main');
200
233
  setInputReady(false);
201
234
  setTimeout(() => setInputReady(true), 350);
@@ -241,37 +274,106 @@ export function App({ config, initialFolder }) {
241
274
  .join(', ')
242
275
  .slice(0, 60)})`,
243
276
  })),
244
- ], onBack: wizardBack, onSelect: chooseSession }) })), phase === 'provider' && providerStep.id === 'pick' && (_jsx(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.title'), children: _jsx(SelectList, { items: [
245
- ...config.providers.map((p) => ({
277
+ ], onBack: wizardBack, onSelect: chooseSession }) })), phase === 'provider' && providerStep.id === 'pick' && (_jsx(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.title'), children: _jsx(SelectList, { items: (() => {
278
+ const items = [];
279
+ // Section: Configured
280
+ const configured = config.providers.map((p) => ({
246
281
  label: p.name,
247
- value: `existing:${p.name}`,
248
- hint: `(${p.baseUrl}${p.apiKey ? '' : ' — ' + t('wiz.provider.needsKey')})`,
249
- })),
250
- ...PROVIDER_PRESETS.filter((preset) => !config.providers.some((p) => p.name.toLowerCase() === preset.name.toLowerCase())).map((preset) => ({
251
- label: preset.name,
252
- value: `preset:${preset.name}`,
253
- hint: t('wiz.provider.presetHint', { url: preset.baseUrl, model: preset.defaultModel }),
254
- })),
255
- { label: t('wiz.provider.custom'), value: '__custom__', hint: t('wiz.provider.customHint') },
256
- ], onBack: wizardBack, onSelect: (v) => {
282
+ value: p.name,
283
+ detail: p.apiKey ? undefined : t('wiz.provider.needsKey'),
284
+ }));
285
+ if (configured.length > 0) {
286
+ items.push({ label: t('wiz.provider.section.configured'), value: '', section: true });
287
+ items.push(...configured);
288
+ }
289
+ // Section: Cloud Providers (presets except Ollama, not already configured)
290
+ const cloudPresets = PROVIDER_PRESETS.filter((preset) => preset.name !== 'Ollama' &&
291
+ !config.providers.some((p) => p.name.toLowerCase() === preset.name.toLowerCase()));
292
+ if (cloudPresets.length > 0) {
293
+ items.push({ label: t('wiz.provider.section.cloud'), value: '', section: true });
294
+ items.push(...cloudPresets.map((preset) => ({
295
+ label: preset.name,
296
+ value: preset.name,
297
+ detail: preset.defaultModel,
298
+ })));
299
+ }
300
+ // Section: Local (Ollama only, if not already configured)
301
+ const ollamaPreset = PROVIDER_PRESETS.find((p) => p.name === 'Ollama');
302
+ const ollamaConfigured = config.providers.some((p) => p.name.toLowerCase() === 'ollama');
303
+ if (ollamaPreset && !ollamaConfigured) {
304
+ items.push({ label: t('wiz.provider.section.local'), value: '', section: true });
305
+ items.push({
306
+ label: ollamaPreset.name,
307
+ value: ollamaPreset.name,
308
+ detail: t('wiz.provider.ollamaDetail'),
309
+ });
310
+ }
311
+ // Custom always last
312
+ items.push({
313
+ label: t('wiz.provider.custom'),
314
+ value: '__custom__',
315
+ detail: t('wiz.provider.customDetail'),
316
+ });
317
+ return items;
318
+ })(), onBack: wizardBack, onSelect: async (v) => {
257
319
  if (v === '__custom__')
258
320
  return setProviderStep({ id: 'name' });
259
- if (v.startsWith('preset:')) {
260
- const preset = PROVIDER_PRESETS.find((p) => p.name === v.slice('preset:'.length));
261
- if (preset)
262
- return setProviderStep({ id: 'key', preset: { ...preset, models: [...preset.models] } });
263
- }
264
- const p = config.providers.find((x) => x.name === v.slice('existing:'.length));
265
- if (!p)
321
+ // Already-configured provider?
322
+ const existing = config.providers.find((x) => x.name === v);
323
+ if (existing) {
324
+ if (existing.apiKey) {
325
+ ctlRef.current?.setDefaultProvider(v);
326
+ ctlRef.current?.setSessionProvider(v);
327
+ enterMain();
328
+ }
329
+ else {
330
+ setProviderStep({ id: 'key', preset: existing });
331
+ }
266
332
  return;
267
- if (p.apiKey) {
268
- ctlRef.current?.setDefaultProvider(p.name);
269
- ctlRef.current?.setSessionProvider(p.name);
270
- enterMain();
271
333
  }
272
- else {
273
- setProviderStep({ id: 'key', preset: p });
334
+ // Must be a preset
335
+ const preset = PROVIDER_PRESETS.find((p) => p.name === v);
336
+ if (!preset)
337
+ return;
338
+ // Ollama: connectivity check with 2s timeout
339
+ if (preset.name.toLowerCase() === 'ollama') {
340
+ setSystemLines((ls) => [
341
+ ...ls.slice(-5),
342
+ { text: t('wiz.provider.ollama.checking', { url: preset.baseUrl }), level: 'info' },
343
+ ]);
344
+ let models = [...preset.models];
345
+ let defaultModel = preset.defaultModel;
346
+ try {
347
+ const controller = new AbortController();
348
+ const timeout = setTimeout(() => controller.abort(), 2000);
349
+ const resp = await fetch(preset.baseUrl + '/models', { signal: controller.signal });
350
+ clearTimeout(timeout);
351
+ if (resp.ok) {
352
+ const data = (await resp.json());
353
+ const detected = data?.data?.map((m) => m.id).filter(Boolean) ?? [];
354
+ if (detected.length > 0) {
355
+ models = detected;
356
+ defaultModel = detected[0];
357
+ setSystemLines((ls) => [
358
+ ...ls.slice(-5),
359
+ { text: t('wiz.provider.ollama.found', { n: detected.length }), level: 'ok' },
360
+ ]);
361
+ }
362
+ }
363
+ }
364
+ catch {
365
+ setSystemLines((ls) => [
366
+ ...ls.slice(-5),
367
+ { text: t('wiz.provider.ollama.notFound', { url: preset.baseUrl }), level: 'warn' },
368
+ ]);
369
+ }
370
+ const ollamaProvider = { ...preset, apiKey: 'ollama-local', models, defaultModel };
371
+ ctlRef.current?.saveProvider(ollamaProvider);
372
+ ctlRef.current?.setSessionProvider(ollamaProvider.name);
373
+ setPhase('model');
374
+ return;
274
375
  }
376
+ setProviderStep({ id: 'key', preset: { ...preset, models: [...preset.models] } });
275
377
  } }) })), phase === 'provider' && providerStep.id === 'key' && (_jsxs(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.key.title', { name: providerStep.preset.name }), footer: t('wiz.provider.key.footer'), children: [_jsx(Text, { color: "gray", children: providerStep.preset.baseUrl }), _jsx(SelectList, { items: [], allowInput: true, mask: true, inputPlaceholder: "sk-\u2026", onBack: wizardBack, onInput: (k) => finishProvider({ ...providerStep.preset, apiKey: k.trim() }) })] })), phase === 'provider' && providerStep.id === 'name' && (_jsx(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.name.title'), footer: t('wiz.footer.type'), children: _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: t('wiz.provider.name.ph'), onBack: wizardBack, onInput: (name) => setProviderStep({ id: 'url', name }) }) })), phase === 'provider' && providerStep.id === 'url' && (_jsx(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.url.title'), footer: t('wiz.footer.type'), children: _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: t('wiz.provider.url.ph'), onBack: wizardBack, onInput: (url) => setProviderStep({ id: 'model', name: providerStep.name, url }) }) })), phase === 'provider' && providerStep.id === 'model' && (_jsx(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.model.title'), footer: t('wiz.footer.type'), children: _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: t('wiz.provider.model.ph'), onBack: wizardBack, onInput: (model) => setProviderStep({ id: 'newKey', name: providerStep.name, url: providerStep.url, model }) }) })), phase === 'provider' && providerStep.id === 'newKey' && (_jsx(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.key.title', { name: providerStep.name }), footer: t('wiz.provider.key.footer'), children: _jsx(SelectList, { items: [], allowInput: true, mask: true, inputPlaceholder: "sk-\u2026", onBack: wizardBack, onInput: (key) => finishProvider({
276
378
  name: providerStep.name,
277
379
  baseUrl: providerStep.url,
@@ -301,7 +403,7 @@ export function App({ config, initialFolder }) {
301
403
  const question = approval ? undefined : ctl.questions[0]; // approvals take priority
302
404
  const settingsOpen = view === 'settings' || view === 'settings-session';
303
405
  const inputActive = inputReady && !approval && !question && !settingsOpen;
304
- return (_jsx(MainScreen, { ctl: ctl, folder: folder, view: view, focus: focus, systemLines: systemLines, agentNames: agentNames, approval: approval, question: question, inputActive: inputActive, onInput: (value, images) => {
406
+ return (_jsx(MainScreen, { ctl: ctl, folder: folder, view: view, focus: focus, rawLogs: rawLogs, systemLines: systemLines, agentNames: agentNames, approval: approval, question: question, inputActive: inputActive, onInput: (value, images) => {
305
407
  const v = value.trim();
306
408
  // Focus mode: plain text goes straight to the focused agent.
307
409
  if (focus && v && !v.startsWith('/') && !v.startsWith('@')) {
@@ -315,86 +417,183 @@ export function App({ config, initialFolder }) {
315
417
  setView('agents');
316
418
  else if (focus) {
317
419
  setFocus(null);
318
- ui.system(t('m.focusOff'));
420
+ ui.system(t('m.focusOff'), 'info');
319
421
  }
320
422
  }, notify: ui.system }));
321
423
  }
322
- function MainScreen({ ctl, folder, view, focus, systemLines, agentNames, approval, question, inputActive, onInput, onEscape, notify, }) {
424
+ function MainScreen({ ctl, folder, view, focus, rawLogs, systemLines, agentNames, approval, question, inputActive, onInput, onEscape, notify, }) {
323
425
  const agents = [...ctl.board.agents.values()];
324
426
  // Adapt the layout to the REAL terminal size (never resize the user's terminal).
325
427
  const { stdout } = useStdout();
326
428
  const cols = stdout?.columns ?? 100;
327
- const narrow = cols < 110;
328
- const logsPerAgent = agents.length <= 1 ? 10 : agents.length <= 2 ? 7 : 5;
329
- const width = agents.length === 1 || narrow ? '100%' : '50%';
429
+ const rows = stdout?.rows ?? 30;
330
430
  const settingsOpen = view === 'settings' || view === 'settings-session';
431
+ // Height budget: fixed sections → body gets the remainder.
432
+ const headerLines = 4; // border-box header (top border + 2 content lines + bottom border)
433
+ const footerLine2 = 1; // always shown
434
+ const footerLine1 = agents.length === 0 ? 1 : 0;
435
+ const footerLines = footerLine1 + footerLine2;
436
+ // System messages: count actual rendered lines (including \n splits + "Session" label).
437
+ const systemMsgLines = systemLines.length > 0 && !settingsOpen
438
+ ? (agents.length > 0 ? 1 : 0) + // "Session" label
439
+ (agents.length > 0
440
+ ? systemLines
441
+ .filter((l) => !/^Ready|^Type a task|^⚡ Ready|^Default \/task|^Agent .* launched/.test(l.text))
442
+ .slice(-2)
443
+ : systemLines).reduce((sum, l) => sum + l.text.split('\n').length, 0)
444
+ : 0;
445
+ const inputLines = 4; // modeHint (1) + input border box (3)
446
+ const spacerLines = 2; // after header + before footer
447
+ const approvalHeight = approval ? 6 : 0;
448
+ const questionHeight = question ? 7 : 0;
449
+ const bodyHeight = Math.max(1, rows - headerLines - footerLines - systemMsgLines - inputLines - spacerLines - approvalHeight - questionHeight);
331
450
  // Focus mode: one agent rendered alone, with scrollback (PgUp/PgDn).
332
451
  const focused = focus
333
452
  ? agents.find((a) => a.name.toLowerCase() === focus.toLowerCase())
334
453
  : undefined;
335
454
  const [scroll, setScroll] = useState(0);
455
+ const [focusFollowTail, setFocusFollowTail] = useState(true);
336
456
  useEffect(() => setScroll(0), [focus]);
337
- const FOCUS_LOGS = 20;
457
+ const FOCUS_LOGS = Math.max(8, bodyHeight - 1);
338
458
  const focusedLogs = focused ? ctl.board.logs.filter((l) => l.agentId === focused.id) : [];
339
459
  const maxScroll = Math.max(0, focusedLogs.length - FOCUS_LOGS);
340
460
  const clampedScroll = Math.min(scroll, maxScroll);
341
461
  const visibleLogs = focused
342
462
  ? focusedLogs.slice(Math.max(0, focusedLogs.length - FOCUS_LOGS - clampedScroll), focusedLogs.length - clampedScroll)
343
463
  : [];
344
- // Grid scroll: when more agents than fit on screen, PgUp/PgDn slides a
345
- // window over the agent panels (with ▲/▼ indicators so you know where you are).
346
- const GRID_CAP = narrow ? 2 : 4;
347
- const [gridScroll, setGridScroll] = useState(0);
348
- const maxGridScroll = Math.max(0, agents.length - GRID_CAP);
349
- const clampedGrid = Math.min(gridScroll, maxGridScroll);
350
- const visibleAgents = agents.length > GRID_CAP ? agents.slice(clampedGrid, clampedGrid + GRID_CAP) : agents;
351
- // Solo scroll: with a SINGLE agent on the agents view, PgUp/PgDn scrolls
352
- // its log history (same behaviour as /focus, without needing to focus).
353
- const solo = agents.length === 1 ? agents[0] : null;
354
- const [soloScroll, setSoloScroll] = useState(0);
355
- const soloLogs = solo ? ctl.board.logs.filter((l) => l.agentId === solo.id) : [];
356
- const maxSoloScroll = Math.max(0, soloLogs.length - logsPerAgent);
357
- const clampedSolo = Math.min(soloScroll, maxSoloScroll);
358
- // Esc always returns to the agents view, even while approval is shown.
464
+ const [hubScroll, setHubScroll] = useState(0);
465
+ const [hubFollowTail, setHubFollowTail] = useState(true);
466
+ const hubRows = Math.max(6, bodyHeight - 2);
467
+ const maxHubScroll = Math.max(0, agents.length - hubRows);
468
+ const clampedHub = Math.min(hubScroll, maxHubScroll);
469
+ const logSeq = ctl.board.logs.length > 0 ? ctl.board.logs[ctl.board.logs.length - 1].seq ?? ctl.board.logs.length : 0;
470
+ useEffect(() => {
471
+ if (focusFollowTail)
472
+ setScroll(0);
473
+ }, [logSeq, focused?.state, focusFollowTail]);
474
+ useEffect(() => {
475
+ if (hubFollowTail)
476
+ setHubScroll(0);
477
+ }, [logSeq, agents.length, hubFollowTail]);
478
+ // Scroll helpers (also used by mouse wheel handler below).
479
+ const scrollFocusUp = () => {
480
+ setFocusFollowTail(false);
481
+ setScroll((s) => Math.min(s + 1, maxScroll));
482
+ };
483
+ const scrollFocusDown = () => {
484
+ setScroll((s) => {
485
+ const next = Math.max(0, s - 1);
486
+ if (next === 0)
487
+ setFocusFollowTail(true);
488
+ return next;
489
+ });
490
+ };
491
+ const scrollHubUp = () => {
492
+ setHubFollowTail(false);
493
+ setHubScroll((s) => Math.min(Math.min(s, maxHubScroll) + 1, maxHubScroll));
494
+ };
495
+ const scrollHubDown = () => {
496
+ setHubScroll((s) => {
497
+ const next = Math.max(0, Math.min(s, maxHubScroll) - 1);
498
+ if (next === 0)
499
+ setHubFollowTail(true);
500
+ return next;
501
+ });
502
+ };
503
+ // Keyboard: Esc / PgUp-PgDn / Up-Down arrows.
504
+ // When CommandInput is NOT focused, Up/Down scroll the hub or focused agent;
505
+ // when it IS focused, CommandInput's own useInput sees them first (history nav).
359
506
  useInput((_input, key) => {
360
507
  if (key.escape)
361
508
  onEscape();
362
509
  if (focused) {
363
- if (key.pageUp)
364
- setScroll((s) => Math.min(s + 10, maxScroll));
365
- if (key.pageDown)
366
- setScroll((s) => Math.max(0, s - 10));
510
+ if (key.pageUp || key.upArrow)
511
+ scrollFocusUp();
512
+ if (key.pageDown || key.downArrow)
513
+ scrollFocusDown();
367
514
  }
368
515
  else if (view === 'agents') {
369
- // Scroll only on the agents view — every other long view
370
- // (/help, /notes, /diff…) owns PgUp/PgDn for its own scrolling.
371
- if (solo) {
372
- if (key.pageUp)
373
- setSoloScroll((s) => Math.min(Math.min(s, maxSoloScroll) + 5, maxSoloScroll));
374
- if (key.pageDown)
375
- setSoloScroll((s) => Math.max(0, Math.min(s, maxSoloScroll) - 5));
376
- }
377
- else {
378
- if (key.pageUp)
379
- setGridScroll((s) => Math.max(0, Math.min(s, maxGridScroll) - 1));
380
- if (key.pageDown)
381
- setGridScroll((s) => Math.min(Math.min(s, maxGridScroll) + 1, maxGridScroll));
516
+ if (key.pageUp || key.upArrow)
517
+ scrollHubUp();
518
+ if (key.pageDown || key.downArrow)
519
+ scrollHubDown();
520
+ }
521
+ }, { isActive: !inputActive });
522
+ const idleCount = agents.filter((a) => a.state === 'idle').length;
523
+ const workingCount = agents.filter((a) => ['working', 'thinking', 'listening'].includes(a.state)).length;
524
+ const doneCount = agents.filter((a) => a.state === 'done').length;
525
+ const errorCount = agents.filter((a) => ['error', 'stopped'].includes(a.state)).length;
526
+ const globalDotColor = workingCount > 0 ? 'green'
527
+ : agents.some((a) => ['waiting', 'paused'].includes(a.state)) ? 'yellow'
528
+ : 'gray';
529
+ const folderMax = Math.max(10, cols - 40);
530
+ // View breadcrumb: when not in agents view, show the view name instead of "control room".
531
+ const VIEW_LABEL = {
532
+ agents: 'control room',
533
+ board: 'coordination',
534
+ diff: 'diffs',
535
+ notes: 'notes',
536
+ help: 'help',
537
+ settings: 'settings',
538
+ 'settings-session': 'session settings',
539
+ sessions: 'sessions',
540
+ cost: 'cost',
541
+ skills: 'skills',
542
+ specialists: 'specialists',
543
+ };
544
+ const viewLabel = VIEW_LABEL[view] ?? 'control room';
545
+ return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: CHROME.muted, children: ["\u256D", '─'.repeat(cols - 2), "\u256E"] }), _jsxs(Box, { flexDirection: "row", width: cols, children: [_jsx(Text, { color: CHROME.muted, children: "\u2502 " }), _jsxs(Box, { flexDirection: "row", width: cols - 4, justifyContent: "space-between", children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { bold: true, color: BRAND.primary, children: "PARALLEL" }), _jsx(Text, { color: globalDotColor, children: " \u25CF" }), _jsxs(Text, { color: view === 'agents' ? CHROME.muted : BRAND.muted, children: [" ", viewLabel] }), rawLogs ? _jsx(Text, { color: UI.warn, children: " [RAW]" }) : null] }), _jsx(Text, { color: CHROME.muted, children: middleTruncate(folder, folderMax) })] }), _jsx(Text, { color: CHROME.muted, children: " \u2502" })] }), _jsxs(Box, { flexDirection: "row", width: cols, children: [_jsx(Text, { color: CHROME.muted, children: "\u2502 " }), _jsxs(Box, { flexDirection: "row", width: cols - 4, justifyContent: agents.length > 0 ? 'space-between' : 'flex-end', children: [agents.length > 0 ? (_jsx(Box, { flexDirection: "row", children: _jsxs(Text, { children: [_jsxs(Text, { color: CHROME.muted, children: ["\u25C7 ", idleCount, " idle"] }), ' · ', _jsxs(Text, { color: workingCount > 0 ? STATE.working : CHROME.muted, children: ["\u25CF ", workingCount, " active"] }), ' · ', _jsxs(Text, { color: doneCount > 0 ? STATE.done : CHROME.muted, children: ["\u2713 ", doneCount, " done"] }), ' · ', _jsxs(Text, { color: errorCount > 0 ? STATE.error : CHROME.muted, children: ["\u2717 ", errorCount, " err"] })] }) })) : null, _jsxs(Text, { color: CHROME.muted, children: ["v", VERSION] })] }), _jsx(Text, { color: CHROME.muted, children: " \u2502" })] }), _jsxs(Text, { color: CHROME.muted, children: ["\u2570", '─'.repeat(cols - 2), "\u256F"] })] }), _jsx(Text, { children: " " }), _jsx(Box, { height: bodyHeight, overflow: "hidden", flexDirection: "column", children: view === 'settings' ? (_jsx(SettingsPanel, { ctl: ctl, scope: "global", onClose: onEscape })) : view === 'settings-session' ? (_jsx(SettingsPanel, { ctl: ctl, scope: "session", onClose: onEscape })) : view === 'board' ? (_jsx(BoardView, { board: ctl.board })) : view === 'notes' ? (_jsx(NotesView, { board: ctl.board })) : view === 'sessions' ? (_jsx(SessionsView, { projectRoot: ctl.projectRoot })) : view === 'diff' ? (_jsx(DiffView, { board: ctl.board })) : view === 'cost' ? (_jsx(CostView, { board: ctl.board })) : view === 'skills' ? (_jsx(SkillsView, { skills: ctl.getSkills() })) : view === 'specialists' ? (_jsx(SpecialistsView, { specialists: ctl.getSpecialists() })) : view === 'help' ? (_jsx(HelpView, {})) : agents.length === 0 ? (_jsx(Box, { borderStyle: "single", borderColor: "gray", flexDirection: "column", paddingX: 1, children: _jsx(Text, { color: "gray", children: t('main.empty') }) })) : focused ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(AgentTranscript, { agent: focused, logs: visibleLogs, raw: rawLogs, scrolled: clampedScroll }), !focusFollowTail ? _jsx(Text, { color: UI.warn, children: "Viewing older \u00B7 PgDn to latest" }) : null] })) : (_jsx(AgentHub, { agents: agents, ctl: ctl, cols: cols, scroll: clampedHub, visibleRows: hubRows })) }), systemLines.length > 0 && !settingsOpen && (_jsxs(Box, { flexDirection: "column", children: [agents.length > 0 ? _jsx(Text, { color: UI.muted, bold: true, children: "Session" }) : null, (agents.length > 0
546
+ ? systemLines
547
+ .filter((l) => !/^Ready|^Type a task|^⚡ Ready|^Default \/task|^Agent .* launched/.test(l.text))
548
+ .slice(-2)
549
+ : systemLines).flatMap((l, i) => {
550
+ const levelColor = l.level === 'ok' ? UI.ok :
551
+ l.level === 'warn' ? UI.warn :
552
+ l.level === 'error' ? UI.danger :
553
+ 'gray';
554
+ // Split on \n so multiline i18n messages render correctly (Ink <Text> doesn't interpret \n).
555
+ const lines = l.text.split('\n');
556
+ return lines.map((line, j) => (_jsx(Text, { color: levelColor, wrap: "truncate-end", children: line }, `${i}-${j}`)));
557
+ })] })), approval && (_jsx(ApprovalPrompt, { request: approval, pendingCount: ctl.approvals.length, onAnswer: (id, ok, always) => ctl.answerApproval(id, ok, always) })), question && (_jsx(QuestionPrompt, { question: question, pendingCount: ctl.questions.length, onAnswer: (id, answer, auto) => ctl.answerQuestion(id, answer, auto) }, question.id)), _jsx(CommandInput, { active: inputActive, placeholder: focus ? `Message ${focus} or /command` : 'Task mode: describe work to run · /ask question · /plan proposal · / for commands', agentNames: agentNames, agents: agents, onSubmit: onInput, onEscape: onEscape, notify: notify }), _jsx(Text, { children: " " }), _jsxs(Box, { flexDirection: "column", children: [agents.length === 0 ? (_jsxs(Text, { children: [_jsx(Text, { color: BRAND.muted, children: "/ask /task /plan" }), _jsx(Text, { color: CHROME.muted, children: " \u00B7 Tab autocompletes \u00B7 Esc clears" })] })) : null, _jsxs(Text, { children: [_jsx(Text, { color: CHROME.muted, children: "\u2318 Parallel" }), _jsx(Text, { color: CHROME.muted, children: " \u00B7 Shell " }), _jsx(Text, { color: ctl.session.approvalMode === 'ask' ? UI.warn :
558
+ ctl.session.approvalMode === 'yolo' ? UI.danger :
559
+ UI.ok, children: ctl.session.approvalMode === 'auto-safe' ? 'auto' : ctl.session.approvalMode }), _jsxs(Text, { color: CHROME.muted, children: [" \u00B7 Sessions: ", Controller.listSessions(ctl.projectRoot).length] }), ctl.questions.length > 0 ? (_jsxs(Text, { color: UI.warn, children: [" \u00B7 \u2753", ctl.questions.length] })) : null, ctl.approvals.length > 0 ? (_jsxs(Text, { color: UI.warn, children: [" \u00B7 \u23F3", ctl.approvals.length] })) : null, focused ? (_jsxs(Text, { color: BRAND.muted, children: [" \u00B7 \uD83C\uDFAF ", focused.name] })) : null] })] })] }));
560
+ }
561
+ function groupAgents(agents) {
562
+ const needs = agents.filter((a) => ['waiting', 'paused'].includes(a.state));
563
+ const working = agents.filter((a) => ['working', 'thinking', 'listening', 'idle'].includes(a.state));
564
+ const errors = agents.filter((a) => ['error', 'stopped'].includes(a.state));
565
+ const completed = agents.filter((a) => a.state === 'done');
566
+ return [
567
+ { title: 'Needs input', color: UI.warn, agents: needs },
568
+ { title: 'Working', color: UI.accent, agents: working },
569
+ { title: 'Errors', color: UI.danger, agents: errors },
570
+ { title: 'Completed', color: UI.ok, agents: completed },
571
+ ].filter((g) => g.agents.length > 0);
572
+ }
573
+ function AgentHub({ agents, ctl, cols, scroll, visibleRows, }) {
574
+ const groups = groupAgents([...agents].sort((a, b) => STATE_META[a.state].rank - STATE_META[b.state].rank || a.startedAt - b.startedAt));
575
+ let skipped = scroll;
576
+ let rendered = 0;
577
+ const rows = [];
578
+ for (const group of groups) {
579
+ const groupRows = [];
580
+ for (const agent of group.agents) {
581
+ if (skipped > 0) {
582
+ skipped--;
583
+ continue;
382
584
  }
585
+ if (rendered >= visibleRows)
586
+ continue;
587
+ rendered++;
588
+ groupRows.push(_jsx(AgentRow, { agent: agent, logs: ctl.board.logsFor(agent.id, 8), cols: cols }, agent.id));
589
+ }
590
+ if (groupRows.length === 0)
591
+ continue;
592
+ if (rows.length > 0) {
593
+ rows.push(_jsx(Text, { color: CHROME.separator, children: '─'.repeat(cols - 2) }, `sep-${group.title}`));
383
594
  }
384
- });
385
- const p = ctl.sessionProvider();
386
- const activeCount = agents.filter((a) => ['working', 'thinking', 'listening', 'waiting'].includes(a.state)).length;
387
- const totalCost = agents.reduce((s, a) => s + (a.cost ?? 0), 0);
388
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsxs(Text, { backgroundColor: "cyan", color: "black", bold: true, children: [' ', "\u2302 HUB", ' '] }), _jsxs(Text, { bold: true, color: "cyanBright", children: [' ', LOGO] })] }), _jsxs(Text, { color: "gray", children: [p ? `${p.name}:${ctl.session.model}` : '—', " \u00B7 ", p ? p.baseUrl.replace(/^https?:\/\//, '') : '', " \u00B7", ' ', ctl.session.approvalMode, " \u00B7 ", ctl.session.soundEnabled ? '🔔' : '🔕'] })] }), _jsxs(Text, { color: "gray", wrap: "truncate-end", children: ["\uD83D\uDCC1 ", folder] }), view === 'settings' ? (_jsx(SettingsPanel, { ctl: ctl, scope: "global", onClose: onEscape })) : view === 'settings-session' ? (_jsx(SettingsPanel, { ctl: ctl, scope: "session", onClose: onEscape })) : view === 'board' ? (_jsx(BoardView, { board: ctl.board })) : view === 'notes' ? (_jsx(NotesView, { board: ctl.board })) : view === 'sessions' ? (_jsx(SessionsView, { projectRoot: ctl.projectRoot })) : view === 'diff' ? (_jsx(DiffView, { board: ctl.board })) : view === 'cost' ? (_jsx(CostView, { board: ctl.board })) : view === 'skills' ? (_jsx(SkillsView, { skills: ctl.getSkills() })) : view === 'specialists' ? (_jsx(SpecialistsView, { specialists: ctl.getSpecialists() })) : view === 'help' ? (_jsx(HelpView, {})) : agents.length === 0 ? (_jsx(Box, { borderStyle: "round", borderColor: "gray", flexDirection: "column", paddingX: 1, children: _jsx(Text, { color: "gray", children: t('main.empty') }) })) : focused ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(AgentPanel, { agent: focused, logs: visibleLogs, width: "100%", expanded: true }), _jsxs(Text, { color: "gray", wrap: "truncate-end", children: [t('m.focusHint', { name: focused.name }), clampedScroll > 0 ? ` · ↑${clampedScroll}` : ''] })] })) : (_jsxs(Box, { flexDirection: "column", children: [clampedGrid > 0 ? _jsx(Text, { color: "gray", children: t('grid.above', { n: clampedGrid }) }) : null, _jsx(Box, { flexWrap: "wrap", children: visibleAgents.map((a) => (_jsx(AgentPanel, { agent: a, logs: solo
389
- ? soloLogs.slice(Math.max(0, soloLogs.length - logsPerAgent - clampedSolo), soloLogs.length - clampedSolo)
390
- : ctl.board.logsFor(a.id, logsPerAgent), width: width }, a.id))) }), solo && clampedSolo > 0 ? (_jsxs(Text, { color: "gray", wrap: "truncate-end", children: ["\u2191", clampedSolo, " \u00B7 PgDn \u21E3"] })) : null, agents.length - clampedGrid - visibleAgents.length > 0 ? (_jsx(Text, { color: "gray", children: t('grid.below', { n: agents.length - clampedGrid - visibleAgents.length }) })) : null] })), systemLines.length > 0 && !settingsOpen && (_jsx(Box, { flexDirection: "column", children: systemLines.map((l, i) => (_jsx(Text, { color: "gray", wrap: "truncate-end", children: l }, i))) })), approval && (_jsx(ApprovalPrompt, { request: approval, pendingCount: ctl.approvals.length, onAnswer: (id, ok, always) => ctl.answerApproval(id, ok, always) })), question && (_jsx(QuestionPrompt, { question: question, pendingCount: ctl.questions.length, onAnswer: (id, answer, auto) => ctl.answerQuestion(id, answer, auto) }, question.id)), _jsx(CommandInput, { active: inputActive, placeholder: t('main.placeholder'), agentNames: agentNames, onSubmit: onInput, onEscape: onEscape, notify: notify }), _jsx(Text, { color: "gray", wrap: "truncate-end", children: agents.length === 0
391
- ? t('main.status')
392
- : t('status.bar', {
393
- agents: agents.length,
394
- active: activeCount,
395
- cost: fmtCost(totalCost),
396
- }) +
397
- (ctl.questions.length > 0 ? ` · ❓${ctl.questions.length}` : '') +
398
- (ctl.approvals.length > 0 ? ` · ⏳${ctl.approvals.length}` : '') +
399
- (focused ? ` · 🎯 ${focused.name}` : '') })] }));
595
+ rows.push(_jsx(Box, { flexDirection: "column", children: groupRows }, group.title));
596
+ }
597
+ const below = Math.max(0, agents.length - scroll - rendered);
598
+ return (_jsxs(Box, { flexDirection: "column", children: [scroll > 0 ? _jsxs(Text, { color: CHROME.muted, children: ["\u25B2 ", scroll, " older \u00B7 PgDn to latest"] }) : null, rows, below > 0 ? _jsxs(Text, { color: CHROME.muted, children: ["\u25BC ", below, " more \u00B7 PgUp"] }) : null] }));
400
599
  }
@@ -1,17 +1,39 @@
1
- import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from 'react';
3
3
  import net from 'node:net';
4
4
  import { Box, Static, Text, useApp } from 'ink';
5
5
  import { ApprovalPrompt } from './ApprovalPrompt.js';
6
6
  import { CommandInput } from './CommandInput.js';
7
- import { KIND_COLOR, KIND_DIM } from './AgentPanel.js';
7
+ import { formatAgentTelemetry, KIND_COLOR, KIND_DIM } from './AgentPanel.js';
8
8
  import { Md } from './Md.js';
9
9
  import { QuestionPrompt } from './QuestionPrompt.js';
10
10
  import { Spinner } from './Spinner.js';
11
- import { STATE_LABEL, stateLabel, elapsed, truncate } from './theme.js';
11
+ import { Timeline } from './Timeline.js';
12
+ import { stateLabel, elapsed, truncate } from './theme.js';
12
13
  import { fmtCost } from '../pricing.js';
13
14
  import { t } from '../i18n.js';
15
+ import { STATE_META, UI, middleTruncate } from './tokens.js';
14
16
  const noop = () => { };
17
+ export function parseAttachCommand(text) {
18
+ const v = text.trim();
19
+ if (!v)
20
+ return null;
21
+ if (v === '/quit' || v === '/exit' || v === '/detach')
22
+ return { type: 'detach' };
23
+ if (v === '/raw')
24
+ return { type: 'raw' };
25
+ const m = v.match(/^\/(ask|a|task|t|plan|p)\s+(.+)$/s);
26
+ if (m) {
27
+ const mode = m[1] === 'ask' || m[1] === 'a' ? 'ask' : m[1] === 'plan' || m[1] === 'p' ? 'plan' : 'task';
28
+ return { type: 'spawn', text: m[2].trim(), mode };
29
+ }
30
+ return { type: 'input', text: v };
31
+ }
32
+ export function formatAttachFooter(info) {
33
+ if (!info)
34
+ return 'Waiting for agent · /quit';
35
+ return `${middleTruncate(info.model, 28)} · ${formatAgentTelemetry(info)} · plain text steers · /task new · /quit`;
36
+ }
15
37
  export function AttachApp({ agentRef, sock }) {
16
38
  const { exit } = useApp();
17
39
  const [info, setInfo] = useState(null);
@@ -20,6 +42,7 @@ export function AttachApp({ agentRef, sock }) {
20
42
  const [approval, setApproval] = useState(null);
21
43
  const [question, setQuestion] = useState(null);
22
44
  const [gone, setGone] = useState(false);
45
+ const [raw, setRaw] = useState(false);
23
46
  const socketRef = useRef(null);
24
47
  const keySeq = useRef(0);
25
48
  const lastBellId = useRef('');
@@ -83,44 +106,46 @@ export function AttachApp({ agentRef, sock }) {
83
106
  socketRef.current?.write(JSON.stringify(msg) + '\n');
84
107
  };
85
108
  const send = (text) => {
86
- const v = text.trim();
87
- if (!v)
109
+ const cmd = parseAttachCommand(text);
110
+ if (!cmd)
88
111
  return;
89
- if (v === '/quit' || v === '/exit' || v === '/detach') {
112
+ if (cmd.type === 'detach') {
90
113
  exit();
91
114
  return;
92
115
  }
93
- // /spawn <task> launch agent N+1 from THIS terminal; its own dedicated
94
- // terminal opens automatically (the main TUI stays the session hub).
95
- const spawn = v.match(/^\/spawn\s+(.+)$/s);
96
- if (spawn) {
97
- wire({ type: 'spawn', text: spawn[1] });
116
+ if (cmd.type === 'raw') {
117
+ setRaw((r) => !r);
118
+ return;
119
+ }
120
+ // /task|/ask|/plan <text> — launch agent N+1 from this terminal.
121
+ if (cmd.type === 'spawn') {
122
+ wire({ type: 'spawn', text: cmd.text, mode: cmd.mode });
98
123
  return;
99
124
  }
100
- wire({ type: 'input', agent: agentRef, text: v });
125
+ wire({ type: 'input', agent: agentRef, text: cmd.text });
101
126
  };
102
- const st = info ? STATE_LABEL[info.state] : null;
127
+ const st = info ? STATE_META[info.state] : null;
103
128
  const busy = info ? ['thinking', 'working', 'listening'].includes(info.state) : false;
104
129
  const interacting = Boolean(approval || question);
105
- const banner = (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { backgroundColor: info?.color ?? 'gray', color: "black", bold: true, children: [' ', "\u26D3 ", t('attach.banner'), ' '] }), info ? (_jsxs(Text, { color: info.color, bold: true, children: [' ', "\u25C6 ", info.name, info.alias && info.alias !== info.name ? _jsxs(Text, { color: "gray", children: [" @", info.alias] }) : null] })) : null] }));
106
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: lines, children: (item) => (_jsx(Text, { color: KIND_COLOR[item.log.kind] ?? 'white', italic: KIND_DIM[item.log.kind] ?? false, wrap: "wrap", children: item.log.text }, item.key)) }), busy && info && st && !interacting ? (
130
+ const banner = (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: UI.brand, bold: true, children: t('attach.banner') }), info ? (_jsxs(Text, { color: info.color, bold: true, children: [' ', info.name, info.alias && info.alias !== info.name ? _jsxs(Text, { color: UI.muted, children: [" @", info.alias] }) : null] })) : null] }));
131
+ return (_jsxs(Box, { flexDirection: "column", children: [raw ? (_jsx(Static, { items: lines, children: (item) => (_jsx(Text, { color: KIND_COLOR[item.log.kind] ?? 'white', italic: KIND_DIM[item.log.kind] ?? false, wrap: "wrap", children: item.log.text }, item.key)) })) : null, busy && info && st && !interacting ? (
107
132
  /* COMPACT region while the agent runs: small + borderless, so Ink's
108
133
  * constant repaints (spinner ticks) never erase tall zones — this is
109
134
  * what used to leave stray blank lines in the native scrollback. */
110
- _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { backgroundColor: info.color, color: "black", bold: true, children: [' ', "\u26D3 ", info.name, ' '] }), ' ', _jsxs(Text, { backgroundColor: st.color, color: "black", bold: true, children: [' ', st.icon, " ", stateLabel(info.state), ' '] }), ' ', _jsx(Spinner, { color: info.color }), _jsxs(Text, { color: "gray", children: [' ', "\u00B7 ", elapsed(info.startedAt), " \u00B7 ", info.steps, " st \u00B7", ' ', Math.round((info.tokensIn + info.tokensOut) / 1000), "k \u00B7", ' '] }), _jsx(Text, { color: "greenBright", children: info.cost === null ? '$—' : fmtCost(info.cost) })] }), info.currentAction ? (_jsxs(Text, { color: info.color, wrap: "truncate-end", children: ["\u25B8 ", truncate(info.currentAction, 120)] })) : null, others.length > 0 ? (_jsxs(Text, { color: "gray", wrap: "truncate-end", children: ["\u21C4", ' ', others
135
+ _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: info.color, bold: true, children: info.alias || info.name }), ' ', _jsxs(Text, { color: st.color, bold: true, children: [st.mark, " ", st.label] }), ' ', _jsx(Spinner, { color: info.color }), _jsxs(Text, { color: UI.muted, children: [' ', "\u00B7 ", elapsed(info.startedAt), " \u00B7 ", info.steps, " st \u00B7", ' ', Math.round((info.tokensIn + info.tokensOut) / 1000), "k \u00B7", ' '] }), _jsx(Text, { color: UI.ok, children: info.cost === null ? '$-' : fmtCost(info.cost) })] }), info.currentAction ? (_jsxs(Text, { color: info.color, wrap: "truncate-end", children: ["Current ", truncate(info.currentAction, 120)] })) : null, others.length > 0 ? (_jsxs(Text, { color: UI.muted, wrap: "truncate-end", children: ["Others ", ' ', others
111
136
  .map((o) => `${o.name} [${stateLabel(o.state)}] ${truncate(o.currentAction || o.task, 40)}`)
112
- .join(' · ')] })) : null] })) : (
137
+ .join(' · ')] })) : null, !raw ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: UI.muted, bold: true, children: t('timeline.activity') }), _jsx(Timeline, { logs: lines.map((l) => l.log) })] })) : null] })) : (
113
138
  /* FULL panel when idle / waiting / done — repaints are rare here. */
114
- _jsxs(Box, { borderStyle: "round", borderColor: info?.color ?? 'gray', flexDirection: "column", paddingX: 1, marginTop: 1, children: [info && st ? (_jsxs(_Fragment, { children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { children: [banner, _jsxs(Text, { backgroundColor: st.color, color: "black", bold: true, children: [' ', st.icon, " ", stateLabel(info.state), ' '] })] }), _jsxs(Text, { color: "gray", wrap: "truncate-end", children: [truncate(info.model, 18), " \u00B7 ", elapsed(info.startedAt), " \u00B7 ", info.steps, " st \u00B7", ' ', Math.round((info.tokensIn + info.tokensOut) / 1000), "k \u00B7", ' ', info.ctxPct !== undefined ? (_jsxs(Text, { color: info.ctxPct >= 90 ? 'redBright' : info.ctxPct >= 70 ? 'yellowBright' : 'gray', children: ["\u25D4", info.ctxPct, "% \u00B7", ' '] })) : null, _jsx(Text, { color: "greenBright", children: info.cost === null ? '$—' : fmtCost(info.cost) })] })] }), _jsxs(Text, { color: "gray", wrap: "wrap", children: ["\u25E6 ", info.task] }), info.currentAction ? (_jsxs(Text, { color: info.color, wrap: "truncate-end", children: ["\u25B8 ", truncate(info.currentAction, 160)] })) : null, others.length > 0 ? (
139
+ _jsxs(Box, { borderStyle: "single", borderColor: info?.color ?? 'gray', flexDirection: "column", paddingX: 1, marginTop: 1, children: [info && st ? (_jsxs(_Fragment, { children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { children: [banner, _jsxs(Text, { color: st.color, bold: true, children: [' ', st.mark, " ", st.label] })] }), _jsxs(Text, { color: UI.muted, wrap: "truncate-end", children: [middleTruncate(info.model, 18), " \u00B7 ", elapsed(info.startedAt), " \u00B7 ", info.steps, " st \u00B7", ' ', Math.round((info.tokensIn + info.tokensOut) / 1000), "k \u00B7", ' ', info.ctxPct !== undefined ? (_jsxs(Text, { color: info.ctxPct >= 90 ? UI.danger : info.ctxPct >= 70 ? UI.warn : UI.muted, children: [info.ctxPct, "% \u00B7", ' '] })) : null, _jsx(Text, { color: UI.ok, children: info.cost === null ? '$-' : fmtCost(info.cost) })] })] }), _jsxs(Text, { color: UI.muted, wrap: "wrap", children: ["Task ", _jsx(Text, { color: UI.text, children: info.task })] }), info.currentAction ? (_jsxs(Text, { color: info.color, wrap: "truncate-end", children: ["Current ", truncate(info.currentAction, 160)] })) : null, others.length > 0 ? (
115
140
  // The session's shared awareness, visible here too: what the
116
141
  // OTHER agents are doing right now (live, same feed the agents get).
117
- _jsxs(Text, { color: "gray", wrap: "truncate-end", children: ["\u21C4", ' ', others
142
+ _jsxs(Text, { color: UI.muted, wrap: "truncate-end", children: ["Others ", ' ', others
118
143
  .map((o) => `${o.name} [${stateLabel(o.state)}] ${truncate(o.currentAction || o.task, 40)}`)
119
- .join(' · ')] })) : null, info.lastResult && (info.state === 'done' || info.state === 'error' || info.state === 'stopped') ? (_jsxs(Box, { borderStyle: "single", borderColor: "gray", flexDirection: "column", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "greenBright", bold: true, children: t('agent.summary') }), _jsx(Md, { text: info.lastResult })] })) : null] })) : (_jsx(Text, { color: "gray", children: gone ? t('attach.gone') : t('attach.waiting', { agent: agentRef }) })), gone && info ? _jsx(Text, { color: "redBright", children: t('attach.gone') }) : null] })), approval ? (_jsx(ApprovalPrompt, { request: { ...approval, agentId: info?.id ?? '', resolve: noop }, pendingCount: 1, onAnswer: (id, ok, always) => {
144
+ .join(' · ')] })) : null, !raw ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: UI.muted, bold: true, children: t('timeline.activity') }), _jsx(Timeline, { logs: lines.map((l) => l.log) })] })) : null, info.lastResult && (info.state === 'done' || info.state === 'error' || info.state === 'stopped') ? (_jsxs(Box, { borderStyle: "single", borderColor: "gray", flexDirection: "column", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: UI.ok, bold: true, children: "Result" }), _jsx(Md, { text: info.lastResult })] })) : null] })) : (_jsx(Text, { color: "gray", children: gone ? t('attach.gone') : t('attach.waiting', { agent: agentRef }) })), gone && info ? _jsx(Text, { color: UI.danger, children: t('attach.gone') }) : null] })), approval ? (_jsx(ApprovalPrompt, { request: { ...approval, agentId: info?.id ?? '', resolve: noop }, pendingCount: 1, onAnswer: (id, ok, always) => {
120
145
  wire({ type: 'approve', id, approved: ok, always: !!always });
121
146
  setApproval(null);
122
147
  } })) : question ? (_jsx(QuestionPrompt, { question: { ...question, agentId: info?.id ?? '', resolve: noop }, pendingCount: 1, onAnswer: (id, answer) => {
123
148
  wire({ type: 'answer', id, text: answer });
124
149
  setQuestion(null);
125
- } }, question.id)) : null, _jsx(CommandInput, { active: !gone && !interacting, placeholder: t('attach.placeholder', { agent: info?.name ?? agentRef }), onSubmit: send, onEscape: () => exit() }), _jsx(Text, { color: "gray", wrap: "truncate-end", children: t('attach.hint') })] }));
150
+ } }, question.id)) : null, _jsx(CommandInput, { active: !gone && !interacting, placeholder: t('attach.placeholder', { agent: info?.name ?? agentRef }), onSubmit: send, onEscape: () => exit() }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { color: "yellowBright", wrap: "truncate-end", children: formatAttachFooter(info) }) })] }));
126
151
  }