@parallel-cli/parallel 0.4.5 → 0.4.7

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
@@ -16,9 +16,8 @@ import { SettingsPanel } from './SettingsPanel.js';
16
16
  import { BoardView, CostView, DiffView, HelpView, NotesView, SessionsView, SkillsView, SpecialistsView } from './views.js';
17
17
  import { SelectList, WizardStep } from './Wizard.js';
18
18
  import { BRAND, CHROME, STATE, STATE_META, UI, middleTruncate } from './tokens.js';
19
+ import { VERSION } from '../version.js';
19
20
  const LOGO = 'Parallel';
20
- // Version from package.json. Hardcoded — rootDir: "src" prevents importing ../../package.json.
21
- const VERSION = '0.4.5';
22
21
  function usableProvider(config) {
23
22
  const p = getProvider(config);
24
23
  return p && providerReady(p) && (p.defaultModel || p.models[0]) ? p : undefined;
@@ -65,12 +64,7 @@ export function App({ config, initialFolder }) {
65
64
  // Focus mode (/focus <agent>): plain input is routed to that agent.
66
65
  const [focus, setFocus] = useState(null);
67
66
  const [rawLogs, setRawLogs] = useState(false);
68
- const [systemLines, setSystemLines] = useState(directFolder
69
- ? [
70
- { text: t('main.ready1', { folder: directFolder }), level: 'ok' },
71
- { text: t('main.ready2'), level: 'info' },
72
- ]
73
- : []);
67
+ const [systemLines, setSystemLines] = useState([]);
74
68
  const [inputReady, setInputReady] = useState(Boolean(directFolder));
75
69
  const ctl = ctlRef.current;
76
70
  const leaveCurrentProject = () => {
@@ -277,10 +271,7 @@ export function App({ config, initialFolder }) {
277
271
  enterMain();
278
272
  };
279
273
  const enterMain = () => {
280
- setSystemLines([
281
- { text: t('main.ready1', { folder }), level: 'ok' },
282
- { text: t('main.ready2'), level: 'info' },
283
- ]);
274
+ setSystemLines([]);
284
275
  setPhase('main');
285
276
  setInputReady(false);
286
277
  setTimeout(() => setInputReady(true), 350);
@@ -311,7 +302,7 @@ export function App({ config, initialFolder }) {
311
302
  if (phase !== 'main') {
312
303
  const totalSteps = 5;
313
304
  const sessionProvider = ctl ? ctl.sessionProvider() : getProvider(config);
314
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyanBright", children: LOGO }), _jsx(Text, { color: "gray", children: t('tagline') }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [phase === 'lang' && (_jsx(WizardStep, { step: 1, total: totalSteps, title: t('wiz.lang.title'), children: _jsx(SelectList, { items: LANGS.map((l) => ({ label: l.label, value: l.code })), height: wizardListHeight, onSelect: chooseLang }) })), phase === 'folder' && (_jsxs(WizardStep, { step: 2, total: totalSteps, title: t('wiz.folder.title'), footer: t('wiz.folder.footer'), children: [wizardError ? _jsx(Text, { color: "red", children: wizardError }) : null, _jsx(SelectList, { items: [
305
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: BRAND.primary, children: LOGO }), _jsx(Text, { color: "gray", children: t('tagline') }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [phase === 'lang' && (_jsx(WizardStep, { step: 1, total: totalSteps, title: t('wiz.lang.title'), children: _jsx(SelectList, { items: LANGS.map((l) => ({ label: l.label, value: l.code })), height: wizardListHeight, onSelect: chooseLang }) })), phase === 'folder' && (_jsxs(WizardStep, { step: 2, total: totalSteps, title: t('wiz.folder.title'), footer: t('wiz.folder.footer'), children: [wizardError ? _jsx(Text, { color: "red", children: wizardError }) : null, _jsx(SelectList, { items: [
315
306
  { label: process.cwd(), value: process.cwd(), hint: t('wiz.folder.current') },
316
307
  ...config.recentFolders
317
308
  .filter((f) => f !== process.cwd())
@@ -523,6 +514,10 @@ export function App({ config, initialFolder }) {
523
514
  }
524
515
  }, notify: ui.system }));
525
516
  }
517
+ function EmptyHub({ bodyHeight }) {
518
+ const topPad = Math.max(0, Math.floor((bodyHeight - 3) / 2));
519
+ return (_jsxs(Box, { flexDirection: "column", children: [topPad > 0 ? _jsx(Box, { height: topPad }) : null, _jsx(Text, { color: UI.text, children: t('main.emptyCard.cta') }), _jsx(Text, { color: CHROME.muted, children: t('main.emptyCard.hints') })] }));
520
+ }
526
521
  function MainScreen({ ctl, folder, view, focus, rawLogs, systemLines, agentNames, approval, question, inputActive, onInput, onEscape, notify, }) {
527
522
  const agents = [...ctl.board.agents.values()];
528
523
  // Adapt the layout to the REAL terminal size (never resize the user's terminal).
@@ -530,11 +525,10 @@ function MainScreen({ ctl, folder, view, focus, rawLogs, systemLines, agentNames
530
525
  const cols = Math.max(20, stdout?.columns ?? 100);
531
526
  const rows = Math.max(12, stdout?.rows ?? 30);
532
527
  const settingsOpen = view === 'settings' || view === 'settings-session';
528
+ const [inputRows, setInputRows] = useState(3);
533
529
  // Height budget: fixed sections → body gets the remainder.
534
- const headerLines = 4; // border-box header (top border + 2 content lines + bottom border)
535
- const footerLine2 = 1; // always shown
536
- const footerLine1 = agents.length === 0 ? 1 : 0;
537
- const footerLines = footerLine1 + footerLine2;
530
+ const headerLines = 4; // Codex-like framed header: top + 2 content lines + bottom
531
+ const footerLines = 1;
538
532
  // System messages: count actual rendered lines (including \n splits + "Session" label).
539
533
  const systemMsgLines = systemLines.length > 0 && !settingsOpen
540
534
  ? (agents.length > 0 ? 1 : 0) + // "Session" label
@@ -544,8 +538,8 @@ function MainScreen({ ctl, folder, view, focus, rawLogs, systemLines, agentNames
544
538
  .slice(-2)
545
539
  : systemLines).reduce((sum, l) => sum + l.text.split('\n').length, 0)
546
540
  : 0;
547
- const inputLines = 4; // modeHint (1) + input border box (3)
548
- const spacerLines = 2; // after header + before footer
541
+ const inputLines = inputRows;
542
+ const spacerLines = 2;
549
543
  const approvalHeight = approval ? 6 : 0;
550
544
  const questionHeight = question ? 7 : 0;
551
545
  const bodyHeight = Math.max(1, rows - headerLines - footerLines - systemMsgLines - inputLines - spacerLines - approvalHeight - questionHeight);
@@ -630,6 +624,11 @@ function MainScreen({ ctl, folder, view, focus, rawLogs, systemLines, agentNames
630
624
  : agents.some((a) => ['waiting', 'paused'].includes(a.state)) ? 'yellow'
631
625
  : 'gray';
632
626
  const folderMax = Math.max(10, cols - 40);
627
+ const provider = ctl.sessionProvider();
628
+ const providerModel = provider ? `${provider.name}:${ctl.session.model}` : 'no model';
629
+ const headerColor = view === 'agents' && agents.length === 0 ? BRAND.muted : CHROME.muted;
630
+ const workMapAlerts = ctl.board.workMapWarnings.filter((w) => w.level !== 'info');
631
+ const conflictAlerts = workMapAlerts.filter((w) => w.level === 'conflict');
633
632
  // View breadcrumb: when not in agents view, show the view name instead of "control room".
634
633
  const VIEW_LABEL = {
635
634
  agents: 'control room',
@@ -645,7 +644,7 @@ function MainScreen({ ctl, folder, view, focus, rawLogs, systemLines, agentNames
645
644
  specialists: 'specialists',
646
645
  };
647
646
  const viewLabel = VIEW_LABEL[view] ?? 'control room';
648
- 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 && focused ? _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", height: bodyHeight, onClose: onEscape })) : view === 'settings-session' ? (_jsx(SettingsPanel, { ctl: ctl, scope: "session", height: bodyHeight, onClose: onEscape })) : view === 'board' ? (_jsx(BoardView, { board: ctl.board, bodyHeight: bodyHeight })) : view === 'notes' ? (_jsx(NotesView, { board: ctl.board, bodyHeight: bodyHeight })) : view === 'sessions' ? (_jsx(SessionsView, { projectRoot: ctl.projectRoot, bodyHeight: bodyHeight })) : view === 'diff' ? (_jsx(DiffView, { board: ctl.board, bodyHeight: bodyHeight })) : view === 'cost' ? (_jsx(CostView, { board: ctl.board, bodyHeight: bodyHeight })) : view === 'skills' ? (_jsx(SkillsView, { skills: ctl.getSkills(), bodyHeight: bodyHeight })) : view === 'specialists' ? (_jsx(SpecialistsView, { specialists: ctl.getSpecialists(), bodyHeight: bodyHeight })) : view === 'help' ? (_jsx(HelpView, { bodyHeight: bodyHeight })) : 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, cols: cols }), !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
647
+ return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: headerColor, children: ["\u256D", '─'.repeat(cols - 2), "\u256E"] }), _jsxs(Box, { flexDirection: "row", width: cols, children: [_jsx(Text, { color: headerColor, 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: [" ", agents.length === 0 ? 'ready' : viewLabel] }), rawLogs && focused ? _jsx(Text, { color: UI.warn, children: " [RAW]" }) : null] }), _jsx(Text, { color: CHROME.muted, children: middleTruncate(folder, folderMax) })] }), _jsx(Text, { color: headerColor, children: " \u2502" })] }), _jsxs(Box, { flexDirection: "row", width: cols, children: [_jsx(Text, { color: headerColor, children: "\u2502 " }), _jsxs(Box, { flexDirection: "row", width: cols - 4, justifyContent: "space-between", 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"] }), workMapAlerts.length > 0 ? (_jsxs(Text, { color: conflictAlerts.length > 0 ? UI.danger : UI.warn, children: [" \u00B7 \u26A0 work-map ", workMapAlerts.length] })) : null] }) })) : (_jsx(Text, { color: CHROME.muted, children: providerModel })), _jsxs(Text, { color: CHROME.muted, children: ["v", VERSION] })] }), _jsx(Text, { color: headerColor, children: " \u2502" })] }), _jsxs(Text, { color: headerColor, children: ["\u2570", '─'.repeat(cols - 2), "\u256F"] })] }), _jsx(Box, { height: bodyHeight, overflow: "hidden", flexDirection: "column", children: view === 'settings' ? (_jsx(SettingsPanel, { ctl: ctl, scope: "global", height: bodyHeight, onClose: onEscape })) : view === 'settings-session' ? (_jsx(SettingsPanel, { ctl: ctl, scope: "session", height: bodyHeight, onClose: onEscape })) : view === 'board' ? (_jsx(BoardView, { board: ctl.board, bodyHeight: bodyHeight })) : view === 'notes' ? (_jsx(NotesView, { board: ctl.board, bodyHeight: bodyHeight })) : view === 'sessions' ? (_jsx(SessionsView, { projectRoot: ctl.projectRoot, bodyHeight: bodyHeight })) : view === 'diff' ? (_jsx(DiffView, { board: ctl.board, bodyHeight: bodyHeight })) : view === 'cost' ? (_jsx(CostView, { board: ctl.board, bodyHeight: bodyHeight })) : view === 'skills' ? (_jsx(SkillsView, { skills: ctl.getSkills(), bodyHeight: bodyHeight })) : view === 'specialists' ? (_jsx(SpecialistsView, { specialists: ctl.getSpecialists(), bodyHeight: bodyHeight })) : view === 'help' ? (_jsx(HelpView, { bodyHeight: bodyHeight, onSelect: (cmd) => onInput(cmd) })) : agents.length === 0 ? (_jsx(EmptyHub, { bodyHeight: bodyHeight })) : focused ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(AgentTranscript, { agent: focused, logs: visibleLogs, raw: rawLogs, scrolled: clampedScroll, cols: cols }), !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
649
648
  ? systemLines
650
649
  .filter((l) => !/^Ready|^Type a task|^⚡ Ready|^Default \/task|^Agent .* launched/.test(l.text))
651
650
  .slice(-2)
@@ -657,9 +656,9 @@ function MainScreen({ ctl, folder, view, focus, rawLogs, systemLines, agentNames
657
656
  // Split on \n so multiline i18n messages render correctly (Ink <Text> doesn't interpret \n).
658
657
  const lines = l.text.split('\n');
659
658
  return lines.map((line, j) => (_jsx(Text, { color: levelColor, wrap: "truncate-end", children: line }, `${i}-${j}`)));
660
- })] })), 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', context: focus ? 'focus' : 'hub', targetAgent: focused?.name, modelLabel: ctl.sessionProvider() ? `${ctl.sessionProvider()?.name}:${ctl.session.model}` : undefined, 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 :
661
- ctl.session.approvalMode === 'yolo' ? UI.danger :
662
- 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] })] })] }));
659
+ })] })), 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(Text, { children: " " }), _jsx(CommandInput, { active: inputActive, placeholder: focus ? `Message ${focus} or /command` : t('main.prompt'), context: focus ? 'focus' : 'hub', targetAgent: focused?.name, modelLabel: ctl.sessionProvider() ? `${ctl.sessionProvider()?.name}:${ctl.session.model}` : undefined, agentNames: agentNames, agents: agents, width: cols, onHeightChange: setInputRows, onSubmit: onInput, onEscape: onEscape, notify: notify }), _jsx(Text, { children: " " }), _jsx(Box, { flexDirection: "column", children: _jsxs(Text, { children: [_jsx(Text, { color: CHROME.muted, children: "/ for commands" }), _jsx(Text, { color: CHROME.muted, children: " \u00B7 Shell " }), _jsx(Text, { color: ctl.session.approvalMode === 'ask' ? UI.warn :
660
+ ctl.session.approvalMode === 'yolo' ? UI.danger :
661
+ UI.ok, children: ctl.session.approvalMode === 'auto-safe' ? 'auto' : ctl.session.approvalMode }), agents.length > 0 ? _jsxs(Text, { color: CHROME.muted, children: [" \u00B7 Sessions ", Controller.listSessions(ctl.projectRoot).length] }) : null, 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, workMapAlerts.length > 0 ? (_jsx(Text, { color: conflictAlerts.length > 0 ? UI.danger : UI.warn, children: " \u00B7 \u26A0 /board" })) : null, conflictAlerts.length > 0 ? (_jsx(Text, { color: UI.danger, children: " \u00B7 run /review all" })) : null, focused ? (_jsxs(Text, { color: BRAND.muted, children: [" \u00B7 \uD83C\uDFAF ", focused.name] })) : null] }) })] }));
663
662
  }
664
663
  function groupAgents(agents) {
665
664
  const needs = agents.filter((a) => ['waiting', 'paused'].includes(a.state));
@@ -687,7 +686,10 @@ function AgentHub({ agents, ctl, cols, scroll, visibleRows, }) {
687
686
  continue;
688
687
  }
689
688
  const needsSeparator = rows.length > 0;
690
- const neededLines = 2 + (needsSeparator ? 1 : 0);
689
+ const summaryLines = agent.lastResult ? Math.min(4, Math.max(1, agent.lastResult.split('\n').filter((l) => l.trim()).length)) : 0;
690
+ const stepLines = !agent.lastResult && agent.progressSteps && agent.progressSteps.length > 0 ? Math.min(3, agent.progressSteps.length) : 0;
691
+ const agentLines = 1 + Math.max(summaryLines, agent.currentAction || agent.claims?.length ? 1 : 0) + stepLines;
692
+ const neededLines = agentLines + (needsSeparator ? 1 : 0);
691
693
  if (renderedLines + neededLines > visibleRows) {
692
694
  full = true;
693
695
  break;
@@ -697,7 +699,7 @@ function AgentHub({ agents, ctl, cols, scroll, visibleRows, }) {
697
699
  renderedLines++;
698
700
  }
699
701
  renderedAgents++;
700
- renderedLines += 2;
702
+ renderedLines += agentLines;
701
703
  rows.push(_jsx(AgentRow, { agent: agent, logs: ctl.board.logsFor(agent.id, 8), cols: cols }, agent.id));
702
704
  }
703
705
  if (full)
@@ -1,19 +1,20 @@
1
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
- import { Box, Static, Text, useApp } from 'ink';
4
+ import { Box, Static, Text, useApp, useInput, useStdout } from 'ink';
5
5
  import { ApprovalPrompt } from './ApprovalPrompt.js';
6
6
  import { CommandInput } from './CommandInput.js';
7
- import { formatAgentTelemetry, KIND_COLOR, KIND_DIM } from './AgentPanel.js';
7
+ import { formatAgentTelemetry, KIND_COLOR, KIND_DIM, modeBadge, ProgressSteps } from './AgentPanel.js';
8
8
  import { Md } from './Md.js';
9
9
  import { QuestionPrompt } from './QuestionPrompt.js';
10
- import { Spinner } from './Spinner.js';
11
10
  import { Timeline } from './Timeline.js';
11
+ import { toUIEvents } from './events.js';
12
12
  import { stateLabel, elapsed, truncate } from './theme.js';
13
13
  import { fmtCost } from '../pricing.js';
14
14
  import { t } from '../i18n.js';
15
- import { STATE_META, UI, middleTruncate } from './tokens.js';
15
+ import { COLOR, STATE_META, UI, middleTruncate } from './tokens.js';
16
16
  const noop = () => { };
17
+ const TERMINAL_STATES = new Set(['done', 'error', 'stopped']);
17
18
  export function parseAttachCommand(text) {
18
19
  const v = text.trim();
19
20
  if (!v)
@@ -28,6 +29,14 @@ export function parseAttachCommand(text) {
28
29
  const send = v.match(/^\/send\s+(\S+)\s+(.+)$/s);
29
30
  if (send)
30
31
  return { type: 'send', target: send[1], text: send[2].trim() };
32
+ const review = v.match(/^\/review\s+(.+)$/s);
33
+ if (review) {
34
+ return {
35
+ type: 'spawn',
36
+ text: `Review current shared-tree work: ${review[1].trim()}. Return Verdict: APPROVE | REVISE | BLOCK, Risks, Tests to run, Files to inspect, and Notes.`,
37
+ mode: 'ask',
38
+ };
39
+ }
31
40
  const m = v.match(/^\/(ask|a|task|t|plan|p)\s+(.+)$/s);
32
41
  if (m) {
33
42
  const mode = m[1] === 'ask' || m[1] === 'a' ? 'ask' : m[1] === 'plan' || m[1] === 'p' ? 'plan' : 'task';
@@ -40,11 +49,39 @@ export function formatAttachFooter(info) {
40
49
  return 'Waiting for agent · /quit';
41
50
  return `${middleTruncate(info.model, 28)} · ${formatAgentTelemetry(info)} · plain text steers · /task new · /quit`;
42
51
  }
52
+ function AttachStaticLine({ item, raw }) {
53
+ if (raw) {
54
+ return (_jsx(Text, { color: KIND_COLOR[item.log.kind] ?? 'white', italic: KIND_DIM[item.log.kind] ?? false, wrap: "wrap", children: item.log.text }));
55
+ }
56
+ const event = toUIEvents([item.log])[0];
57
+ if (!event || event.kind === 'thought')
58
+ return _jsx(Text, { color: UI.muted, children: " " });
59
+ const color = event.kind === 'error' ? UI.danger : event.kind === 'note' ? UI.note : event.kind === 'command' ? UI.accent : UI.muted;
60
+ const detail = event.detail.replace(/\r/g, '').split('\n').filter(Boolean).slice(0, 3).join(' ↳ ');
61
+ return (_jsxs(Text, { color: color, wrap: "truncate-end", children: [_jsx(Text, { color: UI.muted, children: "\u2022 " }), _jsx(Text, { bold: true, children: event.label }), detail ? _jsxs(Text, { color: event.kind === 'command_output' ? UI.muted : color, children: [" ", truncate(detail, process.stdout.columns ? process.stdout.columns - 8 : 120)] }) : null] }));
62
+ }
63
+ export function isLaunchSystemLog(log) {
64
+ return log.kind === 'system' && /\bAgent\s+.+\slaunched\b|Terminal dédié ouvert|Dedicated terminal/i.test(log.text);
65
+ }
66
+ function AttachLaunchHeader({ item }) {
67
+ const { info } = item;
68
+ const mode = modeBadge(info.mode);
69
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: info.color, paddingX: 1, marginBottom: 1, children: [_jsx(Text, { color: UI.brand, bold: true, children: "Parallel agent terminal" }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: info.color, bold: true, children: info.name }), info.alias && info.alias !== info.name ? _jsxs(Text, { color: UI.muted, children: [" @", info.alias] }) : null, _jsxs(Text, { color: mode.color, children: [" [", mode.label, "]"] }), _jsx(Text, { color: UI.muted, children: " \u00B7 " }), _jsx(Text, { color: UI.text, children: middleTruncate(info.model, 36) })] }), _jsxs(Text, { color: UI.muted, wrap: "wrap", children: ["Task ", _jsx(Text, { color: UI.text, children: info.task })] }), _jsx(Text, { color: COLOR.creamMuted, children: "Dedicated terminal is ready." }), _jsx(Text, { children: " " }), _jsx(Text, { children: " " })] }));
70
+ }
71
+ function AttachResultCard({ item }) {
72
+ const st = STATE_META[item.info.state];
73
+ return (_jsxs(Box, { borderStyle: "single", borderColor: st.color, flexDirection: "column", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: COLOR.cream, bold: true, children: ["Result \u00B7 ", item.info.name, " [", st.label, "]"] }), _jsx(Md, { text: item.result })] }));
74
+ }
43
75
  export function AttachApp({ agentRef, sock }) {
44
76
  const { exit } = useApp();
77
+ const { stdout } = useStdout();
45
78
  const [info, setInfo] = useState(null);
46
79
  const [others, setOthers] = useState([]);
80
+ const [launchCards, setLaunchCards] = useState([]);
81
+ const [resultCards, setResultCards] = useState([]);
47
82
  const [lines, setLines] = useState([]);
83
+ const [timelineScroll, setTimelineScroll] = useState(0);
84
+ const [timelineFollowTail, setTimelineFollowTail] = useState(true);
48
85
  const [approval, setApproval] = useState(null);
49
86
  const [question, setQuestion] = useState(null);
50
87
  const [gone, setGone] = useState(false);
@@ -52,6 +89,8 @@ export function AttachApp({ agentRef, sock }) {
52
89
  const socketRef = useRef(null);
53
90
  const keySeq = useRef(0);
54
91
  const lastBellId = useRef('');
92
+ const launchRendered = useRef(false);
93
+ const renderedResultKey = useRef('');
55
94
  useEffect(() => {
56
95
  const socket = net.connect(sock);
57
96
  socketRef.current = socket;
@@ -96,6 +135,21 @@ export function AttachApp({ agentRef, sock }) {
96
135
  socket.destroy();
97
136
  };
98
137
  }, [agentRef, sock]);
138
+ useEffect(() => {
139
+ if (!info || launchRendered.current)
140
+ return;
141
+ launchRendered.current = true;
142
+ setLaunchCards([{ key: ++keySeq.current, info }]);
143
+ }, [info?.id]);
144
+ useEffect(() => {
145
+ if (!info || !TERMINAL_STATES.has(info.state) || !info.lastResult)
146
+ return;
147
+ const key = `${info.id}:${info.state}:${info.lastResult.length}:${info.lastResult.slice(0, 24)}`;
148
+ if (renderedResultKey.current === key)
149
+ return;
150
+ renderedResultKey.current = key;
151
+ setResultCards((prev) => [...prev, { key: ++keySeq.current, info: { ...info }, result: info.lastResult ?? '' }]);
152
+ }, [info?.id, info?.state, info?.lastResult]);
99
153
  // Audible alert in THIS terminal when a new interaction arrives — the hub
100
154
  // also rings, but the user may well be looking at the agent's terminal.
101
155
  useEffect(() => {
@@ -123,7 +177,7 @@ export function AttachApp({ agentRef, sock }) {
123
177
  setRaw((r) => !r);
124
178
  return;
125
179
  }
126
- // /task|/ask|/plan <text> — launch agent N+1 from this terminal.
180
+ // /task|/ask|/plan|/review <text> — launch agent N+1 from this terminal.
127
181
  if (cmd.type === 'spawn') {
128
182
  wire({ type: 'spawn', text: cmd.text, mode: cmd.mode });
129
183
  return;
@@ -136,26 +190,57 @@ export function AttachApp({ agentRef, sock }) {
136
190
  };
137
191
  const st = info ? STATE_META[info.state] : null;
138
192
  const busy = info ? ['thinking', 'working', 'listening'].includes(info.state) : false;
193
+ const terminal = info ? TERMINAL_STATES.has(info.state) : false;
139
194
  const interacting = Boolean(approval || question);
195
+ const logs = lines.map((l) => l.log);
196
+ const staticLines = raw ? lines : lines.filter((l) => l.log.kind !== 'llm' && !isLaunchSystemLog(l.log));
197
+ const timelineVisibleLogs = Math.max(8, (stdout?.rows ?? 30) - 14);
198
+ const maxTimelineScroll = Math.max(0, logs.length - timelineVisibleLogs);
199
+ const clampedTimelineScroll = Math.min(timelineScroll, maxTimelineScroll);
200
+ const timelineWindow = logs.slice(Math.max(0, logs.length - timelineVisibleLogs - clampedTimelineScroll), logs.length - clampedTimelineScroll);
201
+ useEffect(() => {
202
+ if (timelineFollowTail)
203
+ setTimelineScroll(0);
204
+ }, [logs.length, timelineFollowTail]);
205
+ const scrollTimeline = (direction) => {
206
+ if (direction === 'up') {
207
+ setTimelineFollowTail(false);
208
+ setTimelineScroll((s) => Math.min(Math.min(s, maxTimelineScroll) + Math.max(1, Math.floor(timelineVisibleLogs / 2)), maxTimelineScroll));
209
+ return;
210
+ }
211
+ setTimelineScroll((s) => {
212
+ const next = Math.max(0, Math.min(s, maxTimelineScroll) - Math.max(1, Math.floor(timelineVisibleLogs / 2)));
213
+ if (next === 0)
214
+ setTimelineFollowTail(true);
215
+ return next;
216
+ });
217
+ };
218
+ useInput((_input, key) => {
219
+ if (!busy || interacting || raw)
220
+ return;
221
+ if (key.pageUp)
222
+ scrollTimeline('up');
223
+ if (key.pageDown)
224
+ scrollTimeline('down');
225
+ }, { isActive: Boolean(busy && !interacting && !raw) });
140
226
  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] }));
141
- 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 ? (
142
- /* COMPACT region while the agent runs: small + borderless, so Ink's
143
- * constant repaints (spinner ticks) never erase tall zones this is
144
- * what used to leave stray blank lines in the native scrollback. */
145
- _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
227
+ return (_jsxs(Box, { flexDirection: "column", children: [!raw ? (_jsx(Static, { items: launchCards, children: (item) => _jsx(AttachLaunchHeader, { item: item }, item.key) })) : null, _jsx(Static, { items: staticLines, children: (item) => (_jsx(AttachStaticLine, { item: item, raw: raw }, item.key)) }), !raw ? (_jsx(Static, { items: resultCards, children: (item) => _jsx(AttachResultCard, { item: item }, item.key) })) : null, !raw && staticLines.length > 0 ? _jsx(Text, { color: UI.muted, children: '─'.repeat(Math.min(Math.max(20, (stdout?.columns ?? 100) - 4), 80)) }) : null, (busy || terminal) && info && st && !interacting ? (
228
+ /* While running, keep the native terminal scrollback stable: activity is
229
+ * appended once above via <Static>, and this live region stays tiny. */
230
+ _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: modeBadge(info.mode).color, children: ["[", modeBadge(info.mode).label, "]"] }), ' ', _jsxs(Text, { color: st.color, bold: true, children: [st.mark, " ", st.label] }), _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, _jsx(ProgressSteps, { agent: info, max: 6, cols: process.stdout.columns || 100 }), terminal && info.lastResult ? (_jsx(Text, { color: COLOR.creamMuted, children: "Result was appended above; native mouse scroll stays available." })) : null, others.length > 0 ? (_jsxs(Text, { color: UI.muted, wrap: "truncate-end", children: ["Others ", ' ', others
146
231
  .map((o) => `${o.name} [${stateLabel(o.state)}] ${truncate(o.currentAction || o.task, 40)}`)
147
- .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), cols: process.stdout.columns || 100 })] })) : null] })) : (
148
- /* FULL panel when idle / waiting / done repaints are rare here. */
149
- _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 ? (
232
+ .join(' · ')] })) : null, !timelineFollowTail ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: UI.warn, children: "Viewing older activity \u00B7 \u2193/PgDn to latest" }), _jsx(Timeline, { logs: timelineWindow, cols: process.stdout.columns || 100 })] })) : null] })) : (
233
+ /* FULL panel for idle/waiting/interactionsterminal states stay compact. */
234
+ _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: modeBadge(info.mode).color, children: [" [", modeBadge(info.mode).label, "]"] }), _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, _jsx(ProgressSteps, { agent: info, max: 6, cols: process.stdout.columns || 100 }), others.length > 0 ? (
150
235
  // The session's shared awareness, visible here too: what the
151
236
  // OTHER agents are doing right now (live, same feed the agents get).
152
237
  _jsxs(Text, { color: UI.muted, wrap: "truncate-end", children: ["Others ", ' ', others
153
238
  .map((o) => `${o.name} [${stateLabel(o.state)}] ${truncate(o.currentAction || o.task, 40)}`)
154
- .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), cols: process.stdout.columns || 100 })] })) : 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) => {
239
+ .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: 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) => {
155
240
  wire({ type: 'approve', id, approved: ok, always: !!always });
156
241
  setApproval(null);
157
242
  } })) : question ? (_jsx(QuestionPrompt, { question: { ...question, agentId: info?.id ?? '', resolve: noop }, pendingCount: 1, onAnswer: (id, answer) => {
158
243
  wire({ type: 'answer', id, text: answer });
159
244
  setQuestion(null);
160
- } }, question.id)) : null, _jsx(CommandInput, { active: !gone && !interacting, placeholder: t('attach.placeholder', { agent: info?.name ?? agentRef }), context: "attach", targetAgent: info?.name ?? agentRef, modelLabel: info?.model, agentNames: [info?.alias, info?.name, ...others.flatMap((o) => [o.alias, o.name])].filter((n) => Boolean(n)), agents: info ? [info] : [], onSubmit: send, onEscape: () => exit() }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { color: "yellowBright", wrap: "truncate-end", children: formatAttachFooter(info) }) })] }));
245
+ } }, question.id)) : null, _jsx(Text, { children: " " }), _jsx(CommandInput, { active: !gone && !interacting, placeholder: t('attach.placeholder', { agent: info?.name ?? agentRef }), context: "attach", targetAgent: info?.name ?? agentRef, modelLabel: info?.model, agentNames: [info?.alias, info?.name, ...others.flatMap((o) => [o.alias, o.name])].filter((n) => Boolean(n)), agents: info ? [info] : [], width: process.stdout.columns || 100, onIdleNavigation: busy && !raw ? scrollTimeline : undefined, onSubmit: send, onEscape: () => exit() }), _jsx(Text, { children: " " }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: COLOR.creamMuted, wrap: "truncate-end", children: formatAttachFooter(info) }) })] }));
161
246
  }
@@ -1,28 +1,23 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
- import { matchCommands } from '../commands.js';
4
+ import { commandPalette } from '../commands.js';
5
5
  import { t } from '../i18n.js';
6
6
  import { readClipboardImage } from './clipboard.js';
7
+ import { BRAND, COLOR } from './tokens.js';
7
8
  /** A paste is "long" when it spans multiple lines — it then collapses into a chip. */
8
9
  const PASTE_MIN_LINES = 2;
9
- const GROUP_LABEL = {
10
- modes: 'Agent modes',
11
- control: 'Control',
12
- views: 'Views',
13
- settings: 'Settings',
14
- git: 'Git/session',
15
- other: 'Other',
16
- };
17
10
  const AGENT_ARG_COMMANDS = new Set(['/focus', '/send', '/attach', '/pause', '/resume', '/stop', '/restore', '/commit']);
11
+ const COMMAND_PAGE_SIZE = 9;
12
+ const PROMPT_GUTTER = '› ';
18
13
  function modeHint(value, context, targetAgent) {
19
14
  const v = value.trimStart().toLowerCase();
20
15
  if (!v) {
21
16
  if (context === 'focus')
22
- return `Message ${targetAgent ?? 'focused agent'} · / for hub commands · PgUp/PgDn scroll`;
17
+ return `Message ${targetAgent ?? 'focused agent'} · / commands`;
23
18
  if (context === 'attach')
24
- return `Steer ${targetAgent ?? 'agent'} · /task spawns · @all broadcasts · /quit detaches`;
25
- return 'Default /task · Tab/→ autocomplete · / for commands';
19
+ return `Steer ${targetAgent ?? 'agent'} · @all broadcasts · /quit detaches`;
20
+ return 'Type a task or / for commands';
26
21
  }
27
22
  if (!v.startsWith('/')) {
28
23
  if (context === 'focus')
@@ -37,22 +32,18 @@ function modeHint(value, context, targetAgent) {
37
32
  return 'Task mode · execute, edit, validate';
38
33
  if (v.startsWith('/plan') || v === '/p')
39
34
  return 'Plan mode · asks before editing';
40
- return 'Tab/→ accepts the best suggestion';
41
- }
42
- function groupedCommands(commands) {
43
- const order = ['modes', 'control', 'views', 'settings', 'git', 'other'];
44
- return order
45
- .map((g) => [g, commands.filter((c) => (c.group ?? 'other') === g)])
46
- .filter(([, items]) => items.length > 0);
35
+ if (v.startsWith('/review'))
36
+ return 'Review mode · verdict, risks, tests';
37
+ return '↑/↓ select · Enter accept';
47
38
  }
48
39
  export function bestCommandCompletion(value) {
49
- const cmd = matchCommands(value)[0];
40
+ const cmd = commandPalette(value)[0];
50
41
  return cmd ? `${cmd.name} ` : null;
51
42
  }
52
43
  export function commandNamesForContext(context) {
53
44
  if (context !== 'attach')
54
45
  return undefined;
55
- return ['/ask', '/a', '/task', '/t', '/plan', '/p', '/send', '/raw', '/quit', '/exit', '/detach'];
46
+ return ['/ask', '/a', '/task', '/t', '/plan', '/p', '/review', '/send', '/raw', '/quit', '/exit', '/detach'];
56
47
  }
57
48
  export function agentArgCommand(value) {
58
49
  const m = value.match(/^(\/\S+)\s+([^\s]*)$/);
@@ -67,12 +58,36 @@ export function completeAgentArgument(value, agent) {
67
58
  return value;
68
59
  return `${cmd} ${agent} `;
69
60
  }
70
- export function CommandInput({ active, placeholder, mask, context = 'hub', targetAgent, modelLabel, commandNames, agentNames = [], agents = [], onSubmit, onEscape, notify, }) {
61
+ export function clampSuggestionIndex(index, count) {
62
+ if (count <= 0)
63
+ return 0;
64
+ return Math.max(0, Math.min(index, count - 1));
65
+ }
66
+ export function wrappedPromptLines(text, width) {
67
+ const usable = Math.max(8, width - PROMPT_GUTTER.length);
68
+ if (!text)
69
+ return [''];
70
+ const lines = [];
71
+ for (const logical of text.split('\n')) {
72
+ if (!logical) {
73
+ lines.push('');
74
+ continue;
75
+ }
76
+ for (let i = 0; i < logical.length; i += usable)
77
+ lines.push(logical.slice(i, i + usable));
78
+ }
79
+ return lines.length > 0 ? lines : [''];
80
+ }
81
+ function paintLine(text, width) {
82
+ return text.length >= width ? text.slice(0, width) : text.padEnd(width, ' ');
83
+ }
84
+ export function CommandInput({ active, placeholder, mask, context = 'hub', targetAgent, modelLabel, commandNames, agentNames = [], agents = [], width, onHeightChange, onIdleNavigation, onSubmit, onEscape, notify, }) {
71
85
  const [value, setValue] = useState('');
72
86
  const [attachments, setAttachments] = useState([]);
73
87
  const [history, setHistory] = useState([]);
74
88
  const [histIdx, setHistIdx] = useState(-1);
75
89
  const [selectedSuggestion, setSelectedSuggestion] = useState(0);
90
+ const [cursorOn, setCursorOn] = useState(true);
76
91
  const attSeq = useRef(0);
77
92
  const reset = () => {
78
93
  setValue('');
@@ -118,8 +133,7 @@ export function CommandInput({ active, placeholder, mask, context = 'hub', targe
118
133
  };
119
134
  const uniqueAgentNames = [...new Set(agentNames.filter(Boolean))];
120
135
  const allowedCommands = commandNames ?? commandNamesForContext(context);
121
- const commandAllowed = (c) => !allowedCommands || allowedCommands.includes(c.name) || c.aliases?.some((a) => allowedCommands.includes(a));
122
- const cmdSuggestions = value.startsWith('/') && !value.includes(' ') ? matchCommands(value).filter(commandAllowed).slice(0, 10) : [];
136
+ const cmdSuggestions = value.startsWith('/') && !value.includes(' ') ? commandPalette(value, { allowedNames: allowedCommands }) : [];
123
137
  const agentSuggestions = value.startsWith('@') && !value.includes(' ')
124
138
  ? ['all', ...uniqueAgentNames].filter((n) => n.toLowerCase().startsWith(value.slice(1).toLowerCase())).slice(0, 8)
125
139
  : [];
@@ -141,23 +155,51 @@ export function CommandInput({ active, placeholder, mask, context = 'hub', targe
141
155
  useEffect(() => {
142
156
  setSelectedSuggestion(0);
143
157
  }, [value]);
158
+ const safeSelectedSuggestion = clampSuggestionIndex(selectedSuggestion, suggestionCount);
144
159
  useEffect(() => {
145
- if (selectedSuggestion >= suggestionCount)
146
- setSelectedSuggestion(Math.max(0, suggestionCount - 1));
147
- }, [selectedSuggestion, suggestionCount]);
160
+ if (selectedSuggestion !== safeSelectedSuggestion)
161
+ setSelectedSuggestion(safeSelectedSuggestion);
162
+ }, [selectedSuggestion, safeSelectedSuggestion]);
163
+ useEffect(() => {
164
+ if (!active)
165
+ return;
166
+ const timer = setInterval(() => setCursorOn((on) => !on), 450);
167
+ return () => clearInterval(timer);
168
+ }, [active]);
169
+ const commandWindowStart = cmdSuggestions.length > COMMAND_PAGE_SIZE
170
+ ? Math.min(Math.max(0, safeSelectedSuggestion - Math.floor(COMMAND_PAGE_SIZE / 2)), Math.max(0, cmdSuggestions.length - COMMAND_PAGE_SIZE))
171
+ : 0;
172
+ const shownCommandSuggestions = cmdSuggestions.slice(commandWindowStart, commandWindowStart + COMMAND_PAGE_SIZE);
173
+ const shown = mask ? '•'.repeat(value.length) : value;
174
+ const promptWidth = Math.max(20, width ?? process.stdout.columns ?? 100);
175
+ const inputText = shown || placeholder;
176
+ const promptLines = wrappedPromptLines(inputText, promptWidth);
177
+ const suggestionRows = cmdSuggestions.length > 0
178
+ ? shownCommandSuggestions.length + 1
179
+ : agentSuggestions.length > 0
180
+ ? agentSuggestions.length
181
+ : argSuggestions.length > 0
182
+ ? argSuggestions.length + 1
183
+ : 0;
184
+ const attachmentRows = attachments.length > 0 ? 1 : 0;
185
+ const hintRows = value || context !== 'hub' ? 1 : 0;
186
+ const renderedRows = suggestionRows + attachmentRows + promptLines.length + 2 + hintRows;
187
+ useEffect(() => {
188
+ onHeightChange?.(renderedRows);
189
+ }, [onHeightChange, renderedRows]);
148
190
  const completeBest = () => {
149
191
  if (cmdSuggestions.length > 0) {
150
- const cmd = cmdSuggestions[Math.min(selectedSuggestion, cmdSuggestions.length - 1)];
192
+ const cmd = cmdSuggestions[clampSuggestionIndex(safeSelectedSuggestion, cmdSuggestions.length)];
151
193
  setValue(`${cmd.name} `);
152
194
  return true;
153
195
  }
154
196
  if (agentSuggestions.length > 0) {
155
- const agent = agentSuggestions[Math.min(selectedSuggestion, agentSuggestions.length - 1)];
197
+ const agent = agentSuggestions[clampSuggestionIndex(safeSelectedSuggestion, agentSuggestions.length)];
156
198
  setValue('@' + agent + ' ');
157
199
  return true;
158
200
  }
159
201
  if (argSuggestions.length > 0) {
160
- const agent = argSuggestions[Math.min(selectedSuggestion, argSuggestions.length - 1)];
202
+ const agent = argSuggestions[clampSuggestionIndex(safeSelectedSuggestion, argSuggestions.length)];
161
203
  setValue(completeAgentArgument(value, agent));
162
204
  return true;
163
205
  }
@@ -193,7 +235,11 @@ export function CommandInput({ active, placeholder, mask, context = 'hub', targe
193
235
  }
194
236
  if (key.upArrow) {
195
237
  if (hasSuggestions) {
196
- setSelectedSuggestion((i) => (i - 1 + suggestionCount) % suggestionCount);
238
+ setSelectedSuggestion((i) => clampSuggestionIndex(i - 1, suggestionCount));
239
+ return;
240
+ }
241
+ if (!value && attachments.length === 0 && onIdleNavigation) {
242
+ onIdleNavigation('up');
197
243
  return;
198
244
  }
199
245
  setHistIdx((i) => {
@@ -206,7 +252,11 @@ export function CommandInput({ active, placeholder, mask, context = 'hub', targe
206
252
  }
207
253
  if (key.downArrow) {
208
254
  if (hasSuggestions) {
209
- setSelectedSuggestion((i) => (i + 1) % suggestionCount);
255
+ setSelectedSuggestion((i) => clampSuggestionIndex(i + 1, suggestionCount));
256
+ return;
257
+ }
258
+ if (!value && attachments.length === 0 && onIdleNavigation) {
259
+ onIdleNavigation('down');
210
260
  return;
211
261
  }
212
262
  setHistIdx((i) => {
@@ -257,15 +307,21 @@ export function CommandInput({ active, placeholder, mask, context = 'hub', targe
257
307
  }
258
308
  setValue((v) => v + input);
259
309
  }, { isActive: active });
260
- const shown = mask ? '•'.repeat(value.length) : value;
261
310
  const byName = new Map(agents.flatMap((a) => [[a.name, a], [a.alias, a]]));
262
- const commandIndexes = new Map(cmdSuggestions.map((c, i) => [c.name, i]));
263
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Text, { color: "gray", wrap: "truncate-end", children: modeHint(value, context, targetAgent) }), _jsxs(Text, { color: "cyan", children: ["[", context, "]"] }), targetAgent ? _jsxs(Text, { color: "magenta", children: ["[", targetAgent, "]"] }) : null, modelLabel ? _jsxs(Text, { color: "yellow", children: ["[", modelLabel, "]"] }) : null] }), cmdSuggestions.length > 0 && (_jsx(Box, { borderStyle: "single", borderColor: "gray", flexDirection: "column", paddingX: 1, children: groupedCommands(cmdSuggestions).map(([group, commands]) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "gray", bold: true, children: GROUP_LABEL[group] ?? group }), commands.map((c) => ((() => {
264
- const selected = commandIndexes.get(c.name) === selectedSuggestion;
265
- return (_jsxs(Text, { children: [_jsxs(Text, { color: selected ? 'cyanBright' : 'cyan', bold: true, children: [selected ? '› ' : ' ', c.name.padEnd(14)] }), _jsx(Text, { color: "yellow", children: c.args.padEnd(22) }), _jsx(Text, { color: "gray", children: t(c.descKey) })] }, c.name));
266
- })()))] }, group))) })), agentSuggestions.length > 0 && (_jsx(Box, { borderStyle: "single", borderColor: "gray", flexDirection: "column", paddingX: 1, children: agentSuggestions.map((n, i) => (_jsxs(Text, { children: [_jsxs(Text, { color: i === selectedSuggestion ? 'cyanBright' : 'cyan', bold: true, children: [i === selectedSuggestion ? '› ' : ' ', "@", n.padEnd(10)] }), _jsx(Text, { color: "gray", children: n === 'all'
311
+ return (_jsxs(Box, { flexDirection: "column", children: [cmdSuggestions.length > 0 && (_jsxs(Box, { borderStyle: "single", borderColor: "gray", flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { color: "gray", children: ["Commands ", Math.min(safeSelectedSuggestion + 1, cmdSuggestions.length), "/", cmdSuggestions.length, " \u00B7 \u2191/\u2193"] }), shownCommandSuggestions.map((c, i) => {
312
+ const absolute = commandWindowStart + i;
313
+ const selected = absolute === safeSelectedSuggestion;
314
+ return (_jsxs(Text, { children: [_jsxs(Text, { color: selected ? COLOR.cream : COLOR.creamMuted, bold: true, children: [selected ? '› ' : ' ', c.name.padEnd(13)] }), _jsx(Text, { color: "gray", children: t(c.descKey) })] }, c.name));
315
+ })] })), agentSuggestions.length > 0 && (_jsx(Box, { borderStyle: "single", borderColor: "gray", flexDirection: "column", paddingX: 1, children: agentSuggestions.map((n, i) => (_jsxs(Text, { children: [_jsxs(Text, { color: i === safeSelectedSuggestion ? COLOR.cream : COLOR.creamMuted, bold: true, children: [i === safeSelectedSuggestion ? '› ' : ' ', "@", n.padEnd(10)] }), _jsx(Text, { color: "gray", children: n === 'all'
267
316
  ? t('input.atAll')
268
- : `${byName.get(n)?.state ?? ''} ${byName.get(n)?.mode ? `/${byName.get(n)?.mode}` : ''}` })] }, n))) })), argSuggestions.length > 0 && (_jsxs(Box, { borderStyle: "single", borderColor: "gray", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: "gray", bold: true, children: "Agents" }), argSuggestions.map((n, i) => (_jsxs(Text, { children: [_jsxs(Text, { color: i === selectedSuggestion ? 'cyanBright' : 'cyan', bold: true, children: [i === selectedSuggestion ? '› ' : ' ', n.padEnd(12)] }), _jsx(Text, { color: "gray", children: n === 'all'
317
+ : `${byName.get(n)?.state ?? ''} ${byName.get(n)?.mode ? `/${byName.get(n)?.mode}` : ''}` })] }, n))) })), argSuggestions.length > 0 && (_jsxs(Box, { borderStyle: "single", borderColor: "gray", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: "gray", bold: true, children: "Agents" }), argSuggestions.map((n, i) => (_jsxs(Text, { children: [_jsxs(Text, { color: i === safeSelectedSuggestion ? COLOR.cream : COLOR.creamMuted, bold: true, children: [i === safeSelectedSuggestion ? '› ' : ' ', n.padEnd(12)] }), _jsx(Text, { color: "gray", children: n === 'all'
269
318
  ? t('input.atAll')
270
- : `${byName.get(n)?.state ?? ''} ${byName.get(n)?.mode ? `/${byName.get(n)?.mode}` : ''}` })] }, n)))] })), attachments.length > 0 && (_jsx(Box, { flexDirection: "row", gap: 1, paddingX: 1, children: attachments.map((a) => (_jsxs(Text, { color: "cyan", backgroundColor: "gray", children: [' ', a.kind === 'paste' ? t('input.attPaste', { n: a.n, lines: a.lines }) : t('input.attImage', { n: a.n, file: a.label }), ' '] }, a.n))) })), _jsxs(Box, { borderStyle: "single", borderColor: active ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { color: "cyanBright", bold: true, children: ["\u203A", ' '] }), shown ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: shown }), active && _jsx(Text, { color: "cyanBright", children: "\u2588" })] })) : (_jsxs(_Fragment, { children: [active && _jsx(Text, { color: "cyanBright", children: "\u2588" }), _jsx(Text, { color: "gray", children: placeholder })] }))] })] }));
319
+ : `${byName.get(n)?.state ?? ''} ${byName.get(n)?.mode ? `/${byName.get(n)?.mode}` : ''}` })] }, n)))] })), attachments.length > 0 && (_jsx(Box, { flexDirection: "row", gap: 1, paddingX: 1, children: attachments.map((a) => (_jsxs(Text, { color: COLOR.cream, backgroundColor: COLOR.promptBackground, children: [' ', a.kind === 'paste' ? t('input.attPaste', { n: a.n, lines: a.lines }) : t('input.attImage', { n: a.n, file: a.label }), ' '] }, a.n))) })), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { backgroundColor: COLOR.promptBackground, children: paintLine('', promptWidth) }), promptLines.map((line, i) => {
320
+ const last = i === promptLines.length - 1;
321
+ const placeholderCursor = !shown && active && cursorOn && i === 0;
322
+ const cursor = shown && active && last && cursorOn ? '█' : '';
323
+ const prefix = i === 0 ? PROMPT_GUTTER : ' ';
324
+ const content = placeholderCursor ? `█${line.slice(1)}` : `${line}${cursor}`;
325
+ return (_jsxs(Text, { backgroundColor: COLOR.promptBackground, children: [_jsx(Text, { color: active ? BRAND.primary : BRAND.muted, backgroundColor: COLOR.promptBackground, bold: true, children: prefix }), _jsx(Text, { color: shown ? 'white' : COLOR.creamMuted, backgroundColor: COLOR.promptBackground, children: paintLine(content, promptWidth - prefix.length) })] }, i));
326
+ }), _jsx(Text, { backgroundColor: COLOR.promptBackground, children: paintLine('', promptWidth) })] }), (value || context !== 'hub') && (_jsxs(Text, { color: "gray", wrap: "truncate-end", children: [modeHint(value, context, targetAgent), targetAgent ? ` · ${targetAgent}` : '', modelLabel && value ? ` · ${modelLabel}` : ''] }))] }));
271
327
  }