@parallel-cli/parallel 0.4.4 → 0.4.6

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/index.js CHANGED
@@ -8,6 +8,7 @@ import { App } from './ui/App.js';
8
8
  import { Controller } from './controller.js';
9
9
  import { loadConfig, providerReady, setConfigHome } from './config.js';
10
10
  import { setLang } from './i18n.js';
11
+ import { maybeRunStartupUpdate } from './update.js';
11
12
  const argv = process.argv.slice(2);
12
13
  function takeFlagValue(flag) {
13
14
  const i = argv.indexOf(flag);
@@ -26,6 +27,9 @@ if (headless)
26
27
  const jsonOut = argv.includes('--json');
27
28
  if (jsonOut)
28
29
  argv.splice(argv.indexOf('--json'), 1);
30
+ const noUpdate = argv.includes('--no-update');
31
+ if (noUpdate)
32
+ argv.splice(argv.indexOf('--no-update'), 1);
29
33
  const configHome = takeFlagValue('--config-home');
30
34
  if (argv.includes('--help') || argv.includes('-h')) {
31
35
  console.log(`⚡ Parallel — real-time parallel coding agents.
@@ -39,6 +43,8 @@ Usage:
39
43
  parallel --first-run Test the first-run wizard with a temporary config home
40
44
  parallel --config-home <dir> [folder]
41
45
  Use <dir>/config.json instead of ~/.parallel/config.json
46
+ parallel --no-update [folder]
47
+ Start without checking npm for a newer Parallel version
42
48
  parallel --headless "task1" ["task2"…] [--json]
43
49
  No TUI: one agent per task in the current folder,
44
50
  auto-approved commands, summary (or JSON) on stdout — for CI
@@ -48,6 +54,7 @@ Environment variables:
48
54
  PARALLEL_MODEL Default model
49
55
  PARALLEL_BASE_URL OpenAI-compatible endpoint
50
56
  PARALLEL_NO_ALT_SCREEN=1 Disable the alternate terminal screen.
57
+ PARALLEL_SKIP_UPDATE_CHECK=1 Disable npm update checks.
51
58
 
52
59
  Inside the TUI:
53
60
  <task> + Enter Launch agent N+1 — even while the others are working
@@ -172,6 +179,8 @@ if (!process.stdout.isTTY) {
172
179
  console.error('Parallel requires an interactive terminal (TTY).');
173
180
  process.exit(1);
174
181
  }
182
+ if (await maybeRunStartupUpdate(firstRun || noUpdate))
183
+ process.exit(0);
175
184
  const config = loadConfig();
176
185
  if (config.language)
177
186
  setLang(config.language);
package/dist/server.js CHANGED
@@ -108,6 +108,16 @@ export function startSessionServer(ctl) {
108
108
  else if (msg.type === 'answer' && typeof msg.id === 'number' && typeof msg.text === 'string') {
109
109
  ctl.answerQuestion(msg.id, msg.text);
110
110
  }
111
+ else if (msg.type === 'send' && typeof msg.target === 'string' && typeof msg.text === 'string') {
112
+ const text = msg.text.trim();
113
+ const target = msg.target.trim();
114
+ if (!text || !target)
115
+ continue;
116
+ if (target.toLowerCase() === 'all')
117
+ ctl.broadcast(text);
118
+ else
119
+ ctl.sendToAgent(target, text);
120
+ }
111
121
  else if (msg.type === 'spawn' && typeof msg.text === 'string') {
112
122
  // Agent N+1 can be launched from ANY terminal of the session —
113
123
  // its own dedicated terminal then opens automatically.
@@ -6,7 +6,8 @@ import { elapsed, truncate } from './theme.js';
6
6
  import { Md } from './Md.js';
7
7
  import { Spinner } from './Spinner.js';
8
8
  import { Timeline } from './Timeline.js';
9
- import { MARK, MODE, STATE_META, UI, ANIM } from './tokens.js';
9
+ import { MARK, MODE, STATE_META, UI, ANIM, COLOR } from './tokens.js';
10
+ import { latestSignal, toUIEvents } from './events.js';
10
11
  export const KIND_COLOR = {
11
12
  tool: UI.accent,
12
13
  llm: UI.muted,
@@ -26,7 +27,19 @@ export function cleanHubSummary(text) {
26
27
  .trim();
27
28
  }
28
29
  export function formatAgentTelemetry(agent) {
29
- return `${elapsed(agent.startedAt)} · ${agent.cost === null ? '$-' : fmtCost(agent.cost)}`;
30
+ const ctx = agent.ctxPct !== undefined ? ` · ${agent.ctxPct}% ctx` : '';
31
+ return `${elapsed(agent.startedAt)}${ctx} · ${agent.cost === null ? '$-' : fmtCost(agent.cost)}`;
32
+ }
33
+ function compactResultSummary(text, max) {
34
+ const clean = cleanHubSummary(text);
35
+ const validation = text.match(/validation[^:\n]*[:\n]\s*([^\n]+)/i)?.[1]?.trim();
36
+ const risk = text.match(/risks?[^:\n]*[:\n]\s*([^\n]+)/i)?.[1]?.trim() ?? text.match(/risques?[^:\n]*[:\n]\s*([^\n]+)/i)?.[1]?.trim();
37
+ const parts = [clean.slice(0, Math.max(40, Math.floor(max * 0.55)))];
38
+ if (validation)
39
+ parts.push(`V: ${validation}`);
40
+ if (risk)
41
+ parts.push(`R: ${risk}`);
42
+ return truncate(parts.join(' · '), max);
30
43
  }
31
44
  function ResultBlock({ agent, compact = false }) {
32
45
  if (!agent.lastResult)
@@ -39,7 +52,7 @@ function ResultBlock({ agent, compact = false }) {
39
52
  const SPINNER_STATES = new Set(['thinking', 'working', 'listening', 'waiting']);
40
53
  function spinnerColor(state) {
41
54
  if (state === 'working')
42
- return 'cyan';
55
+ return COLOR.cream;
43
56
  return 'yellow'; // thinking, listening, waiting
44
57
  }
45
58
  function modeChar(mode) {
@@ -72,22 +85,20 @@ export function AgentRow({ agent, logs, cols, }) {
72
85
  const taskMax = Math.max(10, cols - 18);
73
86
  const line2Max = Math.max(10, cols - 2);
74
87
  const telemetry = formatAgentTelemetry(agent);
75
- // Line 2 content
88
+ const signal = latestSignal(agent, toUIEvents(logs));
89
+ const specialist = agent.specialist ? ` #${agent.specialist}` : '';
76
90
  let line2 = null;
77
91
  if (agent.lastResult) {
78
- line2 = { text: `✓ ${truncate(cleanHubSummary(agent.lastResult), line2Max)}`, color: UI.ok };
79
- }
80
- else if (agent.currentAction) {
81
- line2 = { text: `▸ ${truncate(agent.currentAction, line2Max)}`, color: UI.accent };
92
+ line2 = { text: `✓ ${compactResultSummary(agent.lastResult, line2Max)}`, color: UI.ok };
82
93
  }
83
- else {
84
- line2 = { text: meta.label, color: meta.color };
94
+ else if (signal && signal !== agent.task) {
95
+ line2 = { text: `▸ ${truncate(signal, line2Max)}`, color: UI.accent };
85
96
  }
86
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 0, paddingLeft: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [SPINNER_STATES.has(agent.state) ? (_jsx(Spinner, { color: pulseColor ?? spinnerColor(agent.state) })) : (_jsx(Text, { color: pulseColor ?? meta.color, bold: true, children: meta.mark })), _jsx(Text, { children: " " }), _jsx(Text, { color: agent.color, bold: true, children: name }), mode ? (_jsxs(Text, { color: mode.color, children: [" ", mode.char] })) : null, _jsxs(Text, { color: UI.text, children: [" ", truncate(agent.task, taskMax)] })] }), _jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(Text, { color: line2.color, wrap: "truncate-end", children: line2.text }), _jsx(Text, { color: UI.muted, children: telemetry })] })] }));
97
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 0, paddingLeft: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [SPINNER_STATES.has(agent.state) ? (_jsx(Spinner, { color: pulseColor ?? spinnerColor(agent.state) })) : (_jsx(Text, { color: pulseColor ?? meta.color, bold: true, children: meta.mark })), _jsx(Text, { children: " " }), _jsx(Text, { color: agent.color, bold: true, children: name }), mode ? (_jsxs(Text, { color: mode.color, children: [" ", mode.char] })) : null, specialist ? _jsx(Text, { color: UI.note, children: specialist }) : null, _jsxs(Text, { color: UI.text, children: [" ", truncate(agent.task, taskMax)] })] }), line2 ? (_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(Text, { color: line2.color, wrap: "truncate-end", children: line2.text }), _jsx(Text, { color: UI.muted, children: telemetry })] })) : null] }));
87
98
  }
88
- export function AgentTranscript({ agent, logs, raw = false, scrolled = 0, }) {
99
+ export function AgentTranscript({ agent, logs, raw = false, scrolled = 0, cols = 100, }) {
89
100
  const meta = STATE_META[agent.state];
90
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { children: [_jsx(Text, { color: agent.color, bold: true, children: agent.name }), agent.alias && agent.alias !== agent.name ? _jsxs(Text, { color: UI.muted, children: [" @", agent.alias] }) : null, _jsx(Text, { color: UI.muted, children: " " }), _jsxs(Text, { color: meta.color, bold: true, children: [meta.mark, " ", meta.label] })] }), _jsxs(Text, { color: UI.muted, wrap: "truncate-end", children: [agent.model, " \u00B7 ", formatAgentTelemetry(agent)] })] }), _jsxs(Text, { color: UI.muted, wrap: "wrap", children: ["Task ", _jsx(Text, { color: UI.text, children: agent.task })] }), agent.claims && agent.claims.length > 0 ? (_jsxs(Text, { color: UI.warn, wrap: "truncate-end", children: ["Claims ", agent.claims.join(' ')] })) : null, agent.currentAction ? (_jsxs(Text, { color: UI.accent, wrap: "truncate-end", children: ["Current ", truncate(agent.currentAction, 140)] })) : null, agent.state === 'done' || agent.lastResult ? _jsx(ResultBlock, { agent: agent }) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: UI.muted, bold: true, children: ["Activity", raw ? ' raw' : ''] }), _jsx(Timeline, { logs: logs, raw: raw })] }), _jsxs(Text, { color: UI.muted, wrap: "truncate-end", children: ["PgUp/PgDn scroll \u00B7 /raw toggles detail \u00B7 Esc returns", scrolled > 0 ? ` · ${scrolled} older` : ''] })] }));
101
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { children: [_jsx(Text, { color: agent.color, bold: true, children: agent.name }), agent.alias && agent.alias !== agent.name ? _jsxs(Text, { color: UI.muted, children: [" @", agent.alias] }) : null, _jsx(Text, { color: UI.muted, children: " " }), _jsxs(Text, { color: meta.color, bold: true, children: [meta.mark, " ", meta.label] })] }), _jsxs(Text, { color: UI.muted, wrap: "truncate-end", children: [agent.model, " \u00B7 ", formatAgentTelemetry(agent)] })] }), _jsxs(Text, { color: UI.muted, wrap: "wrap", children: ["Task ", _jsx(Text, { color: UI.text, children: agent.task })] }), agent.claims && agent.claims.length > 0 ? (_jsxs(Text, { color: UI.warn, wrap: "truncate-end", children: ["Claims ", agent.claims.join(' ')] })) : null, agent.currentAction ? (_jsxs(Text, { color: UI.accent, wrap: "truncate-end", children: ["Current ", truncate(agent.currentAction, 140)] })) : null, agent.state === 'done' || agent.lastResult ? _jsx(ResultBlock, { agent: agent }) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: UI.muted, bold: true, children: ["Activity", raw ? ' raw' : ''] }), _jsx(Timeline, { logs: logs, raw: raw, cols: cols })] }), _jsxs(Text, { color: UI.muted, wrap: "truncate-end", children: ["PgUp/PgDn scroll \u00B7 /raw toggles detail \u00B7 Esc returns", scrolled > 0 ? ` · ${scrolled} older` : ''] })] }));
91
102
  }
92
103
  export function AgentPanel({ agent, logs, width, expanded = false, }) {
93
104
  return (_jsx(Box, { width: width, flexDirection: "column", children: expanded ? _jsx(AgentTranscript, { agent: agent, logs: logs }) : _jsx(AgentRow, { agent: agent, logs: logs, cols: 100 }) }));
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.4';
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 = 1;
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);
@@ -555,7 +549,10 @@ function MainScreen({ ctl, folder, view, focus, rawLogs, systemLines, agentNames
555
549
  : undefined;
556
550
  const [scroll, setScroll] = useState(0);
557
551
  const [focusFollowTail, setFocusFollowTail] = useState(true);
558
- useEffect(() => setScroll(0), [focus]);
552
+ useEffect(() => {
553
+ setScroll(0);
554
+ setFocusFollowTail(true);
555
+ }, [focus]);
559
556
  const FOCUS_LOGS = Math.max(8, bodyHeight - 1);
560
557
  const focusedLogs = focused ? ctl.board.logs.filter((l) => l.agentId === focused.id) : [];
561
558
  const maxScroll = Math.max(0, focusedLogs.length - FOCUS_LOGS);
@@ -627,6 +624,9 @@ function MainScreen({ ctl, folder, view, focus, rawLogs, systemLines, agentNames
627
624
  : agents.some((a) => ['waiting', 'paused'].includes(a.state)) ? 'yellow'
628
625
  : 'gray';
629
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
630
  // View breadcrumb: when not in agents view, show the view name instead of "control room".
631
631
  const VIEW_LABEL = {
632
632
  agents: 'control room',
@@ -642,7 +642,7 @@ function MainScreen({ ctl, folder, view, focus, rawLogs, systemLines, agentNames
642
642
  specialists: 'specialists',
643
643
  };
644
644
  const viewLabel = VIEW_LABEL[view] ?? 'control room';
645
- 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", 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 }), !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
645
+ 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"] })] }) })) : (_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
646
646
  ? systemLines
647
647
  .filter((l) => !/^Ready|^Type a task|^⚡ Ready|^Default \/task|^Agent .* launched/.test(l.text))
648
648
  .slice(-2)
@@ -654,9 +654,9 @@ function MainScreen({ ctl, folder, view, focus, rawLogs, systemLines, agentNames
654
654
  // Split on \n so multiline i18n messages render correctly (Ink <Text> doesn't interpret \n).
655
655
  const lines = l.text.split('\n');
656
656
  return lines.map((line, j) => (_jsx(Text, { color: levelColor, wrap: "truncate-end", children: line }, `${i}-${j}`)));
657
- })] })), 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 :
658
- ctl.session.approvalMode === 'yolo' ? UI.danger :
659
- 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] })] })] }));
657
+ })] })), 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` : 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(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 :
658
+ ctl.session.approvalMode === 'yolo' ? UI.danger :
659
+ 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, focused ? (_jsxs(Text, { color: BRAND.muted, children: [" \u00B7 \uD83C\uDFAF ", focused.name] })) : null] }) })] }));
660
660
  }
661
661
  function groupAgents(agents) {
662
662
  const needs = agents.filter((a) => ['waiting', 'paused'].includes(a.state));
@@ -12,7 +12,7 @@ import { Timeline } from './Timeline.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
17
  export function parseAttachCommand(text) {
18
18
  const v = text.trim();
@@ -22,6 +22,12 @@ export function parseAttachCommand(text) {
22
22
  return { type: 'detach' };
23
23
  if (v === '/raw')
24
24
  return { type: 'raw' };
25
+ const at = v.match(/^@(\S+)\s+(.+)$/s);
26
+ if (at)
27
+ return { type: 'send', target: at[1], text: at[2].trim() };
28
+ const send = v.match(/^\/send\s+(\S+)\s+(.+)$/s);
29
+ if (send)
30
+ return { type: 'send', target: send[1], text: send[2].trim() };
25
31
  const m = v.match(/^\/(ask|a|task|t|plan|p)\s+(.+)$/s);
26
32
  if (m) {
27
33
  const mode = m[1] === 'ask' || m[1] === 'a' ? 'ask' : m[1] === 'plan' || m[1] === 'p' ? 'plan' : 'task';
@@ -122,6 +128,10 @@ export function AttachApp({ agentRef, sock }) {
122
128
  wire({ type: 'spawn', text: cmd.text, mode: cmd.mode });
123
129
  return;
124
130
  }
131
+ if (cmd.type === 'send') {
132
+ wire({ type: 'send', target: cmd.target, text: cmd.text });
133
+ return;
134
+ }
125
135
  wire({ type: 'input', agent: agentRef, text: cmd.text });
126
136
  };
127
137
  const st = info ? STATE_META[info.state] : null;
@@ -134,18 +144,18 @@ export function AttachApp({ agentRef, sock }) {
134
144
  * what used to leave stray blank lines in the native scrollback. */
135
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
136
146
  .map((o) => `${o.name} [${stateLabel(o.state)}] ${truncate(o.currentAction || o.task, 40)}`)
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] })) : (
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] })) : (
138
148
  /* FULL panel when idle / waiting / done — repaints are rare here. */
139
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 ? (
140
150
  // The session's shared awareness, visible here too: what the
141
151
  // OTHER agents are doing right now (live, same feed the agents get).
142
152
  _jsxs(Text, { color: UI.muted, wrap: "truncate-end", children: ["Others ", ' ', others
143
153
  .map((o) => `${o.name} [${stateLabel(o.state)}] ${truncate(o.currentAction || o.task, 40)}`)
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) => {
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) => {
145
155
  wire({ type: 'approve', id, approved: ok, always: !!always });
146
156
  setApproval(null);
147
157
  } })) : question ? (_jsx(QuestionPrompt, { question: { ...question, agentId: info?.id ?? '', resolve: noop }, pendingCount: 1, onAnswer: (id, answer) => {
148
158
  wire({ type: 'answer', id, text: answer });
149
159
  setQuestion(null);
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) }) })] }));
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] : [], width: process.stdout.columns || 100, onSubmit: send, onEscape: () => exit() }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: COLOR.creamMuted, wrap: "truncate-end", children: formatAttachFooter(info) }) })] }));
151
161
  }
@@ -1,49 +1,91 @@
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
- function modeHint(value) {
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 = '';
13
+ function modeHint(value, context, targetAgent) {
18
14
  const v = value.trimStart().toLowerCase();
19
- if (!v)
20
- return 'Default /task · Tab/→ autocomplete · / for commands';
21
- if (!v.startsWith('/'))
15
+ if (!v) {
16
+ if (context === 'focus')
17
+ return `Message ${targetAgent ?? 'focused agent'} · / commands`;
18
+ if (context === 'attach')
19
+ return `Steer ${targetAgent ?? 'agent'} · @all broadcasts · /quit detaches`;
20
+ return 'Type a task or / for commands';
21
+ }
22
+ if (!v.startsWith('/')) {
23
+ if (context === 'focus')
24
+ return `Will message ${targetAgent ?? 'focused agent'}`;
25
+ if (context === 'attach')
26
+ return `Will steer ${targetAgent ?? 'attached agent'}`;
22
27
  return 'Will launch /task';
28
+ }
23
29
  if (v.startsWith('/ask') || v === '/a')
24
30
  return 'Ask mode · advice only · no edits';
25
31
  if (v.startsWith('/task') || v === '/t')
26
32
  return 'Task mode · execute, edit, validate';
27
33
  if (v.startsWith('/plan') || v === '/p')
28
34
  return 'Plan mode · asks before editing';
29
- return 'Tab/→ accepts the best suggestion';
30
- }
31
- function groupedCommands(commands) {
32
- const order = ['modes', 'control', 'views', 'settings', 'git', 'other'];
33
- return order
34
- .map((g) => [g, commands.filter((c) => (c.group ?? 'other') === g)])
35
- .filter(([, items]) => items.length > 0);
35
+ return '↑/↓ select · Enter accept';
36
36
  }
37
37
  export function bestCommandCompletion(value) {
38
- const cmd = matchCommands(value)[0];
38
+ const cmd = commandPalette(value)[0];
39
39
  return cmd ? `${cmd.name} ` : null;
40
40
  }
41
- export function CommandInput({ active, placeholder, mask, agentNames = [], agents = [], onSubmit, onEscape, notify }) {
41
+ export function commandNamesForContext(context) {
42
+ if (context !== 'attach')
43
+ return undefined;
44
+ return ['/ask', '/a', '/task', '/t', '/plan', '/p', '/send', '/raw', '/quit', '/exit', '/detach'];
45
+ }
46
+ export function agentArgCommand(value) {
47
+ const m = value.match(/^(\/\S+)\s+([^\s]*)$/);
48
+ if (!m)
49
+ return null;
50
+ const cmd = m[1].toLowerCase();
51
+ return AGENT_ARG_COMMANDS.has(cmd) ? cmd : null;
52
+ }
53
+ export function completeAgentArgument(value, agent) {
54
+ const cmd = agentArgCommand(value);
55
+ if (!cmd)
56
+ return value;
57
+ return `${cmd} ${agent} `;
58
+ }
59
+ export function clampSuggestionIndex(index, count) {
60
+ if (count <= 0)
61
+ return 0;
62
+ return Math.max(0, Math.min(index, count - 1));
63
+ }
64
+ export function wrappedPromptLines(text, width) {
65
+ const usable = Math.max(8, width - PROMPT_GUTTER.length);
66
+ if (!text)
67
+ return [''];
68
+ const lines = [];
69
+ for (const logical of text.split('\n')) {
70
+ if (!logical) {
71
+ lines.push('');
72
+ continue;
73
+ }
74
+ for (let i = 0; i < logical.length; i += usable)
75
+ lines.push(logical.slice(i, i + usable));
76
+ }
77
+ return lines.length > 0 ? lines : [''];
78
+ }
79
+ function paintLine(text, width) {
80
+ return text.length >= width ? text.slice(0, width) : text.padEnd(width, ' ');
81
+ }
82
+ export function CommandInput({ active, placeholder, mask, context = 'hub', targetAgent, modelLabel, commandNames, agentNames = [], agents = [], width, onHeightChange, onSubmit, onEscape, notify, }) {
42
83
  const [value, setValue] = useState('');
43
84
  const [attachments, setAttachments] = useState([]);
44
85
  const [history, setHistory] = useState([]);
45
86
  const [histIdx, setHistIdx] = useState(-1);
46
87
  const [selectedSuggestion, setSelectedSuggestion] = useState(0);
88
+ const [cursorOn, setCursorOn] = useState(true);
47
89
  const attSeq = useRef(0);
48
90
  const reset = () => {
49
91
  setValue('');
@@ -87,31 +129,78 @@ export function CommandInput({ active, placeholder, mask, agentNames = [], agent
87
129
  setAttachments((arr) => [...arr, { kind: 'image', n, dataUri: img.dataUri, label: img.label }]);
88
130
  notify?.(t('input.imageAdded'));
89
131
  };
90
- const cmdSuggestions = value.startsWith('/') && !value.includes(' ') ? matchCommands(value).slice(0, 10) : [];
132
+ const uniqueAgentNames = [...new Set(agentNames.filter(Boolean))];
133
+ const allowedCommands = commandNames ?? commandNamesForContext(context);
134
+ const cmdSuggestions = value.startsWith('/') && !value.includes(' ') ? commandPalette(value, { allowedNames: allowedCommands }) : [];
91
135
  const agentSuggestions = value.startsWith('@') && !value.includes(' ')
92
- ? ['all', ...agentNames].filter((n) => n.toLowerCase().startsWith(value.slice(1).toLowerCase())).slice(0, 8)
136
+ ? ['all', ...uniqueAgentNames].filter((n) => n.toLowerCase().startsWith(value.slice(1).toLowerCase())).slice(0, 8)
93
137
  : [];
94
- const suggestionCount = cmdSuggestions.length > 0 ? cmdSuggestions.length : agentSuggestions.length;
138
+ const argCommand = agentArgCommand(value);
139
+ const argPrefix = argCommand ? value.split(/\s+/)[1] ?? '' : '';
140
+ const argSuggestions = argCommand
141
+ ? [
142
+ ...(argCommand === '/send' || argCommand === '/pause' || argCommand === '/resume' || argCommand === '/stop' || argCommand === '/commit'
143
+ ? ['all']
144
+ : []),
145
+ ...uniqueAgentNames,
146
+ ]
147
+ .filter((n) => n.toLowerCase().startsWith(argPrefix.toLowerCase()))
148
+ .slice(0, 8)
149
+ : [];
150
+ const suggestionCount = cmdSuggestions.length > 0 ? cmdSuggestions.length : agentSuggestions.length > 0 ? agentSuggestions.length : argSuggestions.length;
95
151
  const hasSuggestions = suggestionCount > 0;
96
152
  const exactCommand = cmdSuggestions.some((c) => c.name === value.toLowerCase() || c.aliases?.some((a) => a === value.toLowerCase()));
97
153
  useEffect(() => {
98
154
  setSelectedSuggestion(0);
99
155
  }, [value]);
156
+ const safeSelectedSuggestion = clampSuggestionIndex(selectedSuggestion, suggestionCount);
157
+ useEffect(() => {
158
+ if (selectedSuggestion !== safeSelectedSuggestion)
159
+ setSelectedSuggestion(safeSelectedSuggestion);
160
+ }, [selectedSuggestion, safeSelectedSuggestion]);
161
+ useEffect(() => {
162
+ if (!active)
163
+ return;
164
+ const timer = setInterval(() => setCursorOn((on) => !on), 450);
165
+ return () => clearInterval(timer);
166
+ }, [active]);
167
+ const commandWindowStart = cmdSuggestions.length > COMMAND_PAGE_SIZE
168
+ ? Math.min(Math.max(0, safeSelectedSuggestion - Math.floor(COMMAND_PAGE_SIZE / 2)), Math.max(0, cmdSuggestions.length - COMMAND_PAGE_SIZE))
169
+ : 0;
170
+ const shownCommandSuggestions = cmdSuggestions.slice(commandWindowStart, commandWindowStart + COMMAND_PAGE_SIZE);
171
+ const shown = mask ? '•'.repeat(value.length) : value;
172
+ const promptWidth = Math.max(20, width ?? process.stdout.columns ?? 100);
173
+ const inputText = shown || placeholder;
174
+ const promptLines = wrappedPromptLines(inputText, promptWidth);
175
+ const suggestionRows = cmdSuggestions.length > 0
176
+ ? shownCommandSuggestions.length + 1
177
+ : agentSuggestions.length > 0
178
+ ? agentSuggestions.length
179
+ : argSuggestions.length > 0
180
+ ? argSuggestions.length + 1
181
+ : 0;
182
+ const attachmentRows = attachments.length > 0 ? 1 : 0;
183
+ const hintRows = value || context !== 'hub' ? 1 : 0;
184
+ const renderedRows = suggestionRows + attachmentRows + promptLines.length + 2 + hintRows;
100
185
  useEffect(() => {
101
- if (selectedSuggestion >= suggestionCount)
102
- setSelectedSuggestion(Math.max(0, suggestionCount - 1));
103
- }, [selectedSuggestion, suggestionCount]);
186
+ onHeightChange?.(renderedRows);
187
+ }, [onHeightChange, renderedRows]);
104
188
  const completeBest = () => {
105
189
  if (cmdSuggestions.length > 0) {
106
- const cmd = cmdSuggestions[Math.min(selectedSuggestion, cmdSuggestions.length - 1)];
190
+ const cmd = cmdSuggestions[clampSuggestionIndex(safeSelectedSuggestion, cmdSuggestions.length)];
107
191
  setValue(`${cmd.name} `);
108
192
  return true;
109
193
  }
110
194
  if (agentSuggestions.length > 0) {
111
- const agent = agentSuggestions[Math.min(selectedSuggestion, agentSuggestions.length - 1)];
195
+ const agent = agentSuggestions[clampSuggestionIndex(safeSelectedSuggestion, agentSuggestions.length)];
112
196
  setValue('@' + agent + ' ');
113
197
  return true;
114
198
  }
199
+ if (argSuggestions.length > 0) {
200
+ const agent = argSuggestions[clampSuggestionIndex(safeSelectedSuggestion, argSuggestions.length)];
201
+ setValue(completeAgentArgument(value, agent));
202
+ return true;
203
+ }
115
204
  return false;
116
205
  };
117
206
  useInput((input, key) => {
@@ -144,7 +233,7 @@ export function CommandInput({ active, placeholder, mask, agentNames = [], agent
144
233
  }
145
234
  if (key.upArrow) {
146
235
  if (hasSuggestions) {
147
- setSelectedSuggestion((i) => (i - 1 + suggestionCount) % suggestionCount);
236
+ setSelectedSuggestion((i) => clampSuggestionIndex(i - 1, suggestionCount));
148
237
  return;
149
238
  }
150
239
  setHistIdx((i) => {
@@ -157,7 +246,7 @@ export function CommandInput({ active, placeholder, mask, agentNames = [], agent
157
246
  }
158
247
  if (key.downArrow) {
159
248
  if (hasSuggestions) {
160
- setSelectedSuggestion((i) => (i + 1) % suggestionCount);
249
+ setSelectedSuggestion((i) => clampSuggestionIndex(i + 1, suggestionCount));
161
250
  return;
162
251
  }
163
252
  setHistIdx((i) => {
@@ -208,13 +297,21 @@ export function CommandInput({ active, placeholder, mask, agentNames = [], agent
208
297
  }
209
298
  setValue((v) => v + input);
210
299
  }, { isActive: active });
211
- const shown = mask ? '•'.repeat(value.length) : value;
212
300
  const byName = new Map(agents.flatMap((a) => [[a.name, a], [a.alias, a]]));
213
- const commandIndexes = new Map(cmdSuggestions.map((c, i) => [c.name, i]));
214
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "gray", wrap: "truncate-end", children: modeHint(value) }), 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) => ((() => {
215
- const selected = commandIndexes.get(c.name) === selectedSuggestion;
216
- 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));
217
- })()))] }, 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'
301
+ 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) => {
302
+ const absolute = commandWindowStart + i;
303
+ const selected = absolute === safeSelectedSuggestion;
304
+ 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));
305
+ })] })), 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'
218
306
  ? t('input.atAll')
219
- : `${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 })] }))] })] }));
307
+ : `${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'
308
+ ? t('input.atAll')
309
+ : `${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) => {
310
+ const last = i === promptLines.length - 1;
311
+ const placeholderCursor = !shown && active && cursorOn && i === 0;
312
+ const cursor = shown && active && last && cursorOn ? '█' : '';
313
+ const prefix = i === 0 ? PROMPT_GUTTER : ' ';
314
+ const content = placeholderCursor ? `█${line.slice(1)}` : `${line}${cursor}`;
315
+ 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));
316
+ }), _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}` : ''] }))] }));
220
317
  }