@parallel-cli/parallel 0.4.5 → 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/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  All notable changes to Parallel are documented here.
4
4
 
5
+ ## 0.4.6 - 2026-06-24
6
+
7
+ ### 0.4.6 Added
8
+
9
+ - Added an interactive npm update prompt on startup with daily cache, CI/headless/attach skips, `PARALLEL_SKIP_UPDATE_CHECK=1`, and `--no-update`.
10
+ - Added a Codex-like empty hub with a quieter framed header, cream-toned accents, and a full-width prompt block with distinct background.
11
+ - Added a complete paginated slash palette driven by one deterministic rendered order.
12
+ - Added selectable `/help` command navigation with visible highlight and Enter-to-run behavior.
13
+ - Added localized prompt placeholder copy and an i18n audit to keep all used UI keys translated.
14
+
15
+ ### 0.4.6 Changed
16
+
17
+ - Reduced default Hub noise by removing persistent startup toasts, duplicate task hints, and always-on command footer lines.
18
+ - Replaced blue/cyan UI accents with a softer cream theme across hub, palettes, wizard/settings lists, markdown summaries, and attached agent terminals.
19
+ - Made the prompt block start at three rows, grow when input wraps, and show the blinking cursor on the first placeholder character while empty.
20
+ - Reused the same minimal prompt treatment in dedicated agent terminals and toned down their footer.
21
+ - Simplified agent rows so secondary telemetry only appears when there is a useful latest signal or result.
22
+ - Moved command palette priority to shared command helpers so autocomplete, help, and rendering stay aligned.
23
+
24
+ ### 0.4.6 Fixed
25
+
26
+ - Fixed slash palette Up/Down navigation jumping to visually unrelated commands.
27
+ - Fixed `/help` being scrollable but not actually selectable.
28
+ - Fixed Wizard and Settings selection lists wrapping around unexpectedly by clamping navigation at list boundaries.
29
+
5
30
  ## 0.4.5 - 2026-06-23
6
31
 
7
32
  ### 0.4.5 Added
package/README.md CHANGED
@@ -19,13 +19,14 @@ Parallel lets you run several AI coding agents on the same repository at the sam
19
19
  - Use context-aware input in the hub, focus view, and attached agent terminals.
20
20
  - Steer one agent with `@a1 ...` or broadcast with `@all ...`.
21
21
  - Open dedicated agent terminals with native scrollback.
22
- - See a richer live hub with latest agent signals, mode badges, context usage, and responsive timelines.
22
+ - Use a cleaner Codex-like hub with a framed header, focused prompt bar, and quieter empty state.
23
23
  - Review agents, notes, file activity, diffs, cost, skills, specialists, and saved sessions from the TUI.
24
24
  - Track shell-created file mutations in the same live diff feed as agent edits.
25
25
  - Configure OpenAI-compatible providers through a guided wizard and settings panel.
26
26
  - Use 29 provider presets across Western, Chinese, Gateway, Inference, and Local categories.
27
27
  - Support local no-key endpoints such as Ollama and vLLM/SGLang.
28
28
  - Keep shell execution controlled with `ask`, `auto-safe`, or `yolo` approvals.
29
+ - Get prompted for npm updates at startup, with an explicit skip path.
29
30
  - Save and restore project sessions.
30
31
  - Run headless multi-agent jobs for CI or scripts.
31
32
 
@@ -117,7 +118,9 @@ Plain text is equivalent to `/task`.
117
118
 
118
119
  ## Control Room
119
120
 
120
- The main TUI is the Parallel hub. It is designed to answer:
121
+ The main TUI is the Parallel hub. The default view stays intentionally quiet: a Codex-like framed header, cream-toned accents, a focused prompt block, and detailed status moved into explicit views.
122
+
123
+ It is designed to answer:
121
124
 
122
125
  - what needs your input
123
126
  - which agents are working
@@ -129,7 +132,7 @@ Input has three explicit contexts:
129
132
 
130
133
  - Hub: plain text launches a new `/task` agent. Slash suggestions show hub commands and agent arguments autocomplete for `/focus`, `/send`, `/attach`, `/pause`, `/resume`, `/stop`, `/restore`, and `/commit`.
131
134
  - Focus: after `/focus a1`, plain text talks to the focused agent instead of spawning a new one. `/raw` affects this view only.
132
- - Attach: in `parallel attach a1`, plain text steers the attached agent. `/task`, `/ask`, and `/plan` spawn new agents from that terminal, while `@all ...`, `@a2 ...`, and `/send ...` route instructions through the main session.
135
+ - Attach: in `parallel attach a1`, the same minimal prompt UI steers the attached agent. `/task`, `/ask`, and `/plan` spawn new agents from that terminal, while `@all ...`, `@a2 ...`, and `/send ...` route instructions through the main session.
133
136
 
134
137
  Use `Name: task` when naming an agent:
135
138
 
@@ -157,9 +160,11 @@ Commands are typed in the control room input. When a long view is open, use Esca
157
160
  Keyboard behavior:
158
161
 
159
162
  - `/` opens slash command suggestions.
160
- - Up/Down selects suggestions when a suggestion menu is open.
163
+ - Up/Down selects suggestions in the same order they appear.
161
164
  - Enter accepts the selected suggestion.
162
165
  - Tab or Right accepts the best completion.
166
+ - `/help` is keyboard navigable: Up/Down moves the visible selection, PgUp/PgDn pages, and Enter runs the selected command.
167
+ - Wizard, settings, slash suggestions, and help views use clamped keyboard selection so the highlight does not jump, disappear, or wrap unexpectedly.
163
168
  - PgUp/PgDn scrolls the hub or focus view even while the input is active. Up/Down scrolls long views and navigates suggestions/history.
164
169
  - Escape returns to the agents view or clears the input.
165
170
 
@@ -239,6 +244,19 @@ Environment variables:
239
244
  - `PARALLEL_BASE_URL`: override the default provider base URL.
240
245
  - `PARALLEL_MODEL`: override the session model.
241
246
  - `PARALLEL_NO_ALT_SCREEN=1`: disable the alternate terminal screen.
247
+ - `PARALLEL_SKIP_UPDATE_CHECK=1`: disable npm update checks.
248
+
249
+ ## Updates
250
+
251
+ On interactive startup, Parallel checks npm at most once per day for a newer `@parallel-cli/parallel` version. The check is skipped in `attach`, `--headless`, `--first-run`, CI, non-TTY sessions, or when `PARALLEL_SKIP_UPDATE_CHECK=1` is set.
252
+
253
+ When an update is available, Parallel asks before running:
254
+
255
+ ```bash
256
+ npm install -g @parallel-cli/parallel
257
+ ```
258
+
259
+ If the update succeeds, restart Parallel to run the new version. Use `parallel --no-update` for a one-off launch without checking.
242
260
 
243
261
  ## Commands
244
262
 
package/dist/commands.js CHANGED
@@ -60,12 +60,43 @@ export const COMMANDS = [
60
60
  export function visibleCommands() {
61
61
  return COMMANDS.filter((c) => !c.hidden);
62
62
  }
63
+ const COMMAND_GROUP_ORDER = ['modes', 'control', 'views', 'settings', 'git', 'other'];
64
+ const COMMAND_PALETTE_PRIORITY = [
65
+ '/ask',
66
+ '/task',
67
+ '/plan',
68
+ '/send',
69
+ '/focus',
70
+ '/attach',
71
+ '/agents',
72
+ '/board',
73
+ '/diff',
74
+ '/settings',
75
+ '/help',
76
+ '/quit',
77
+ ];
78
+ function commandRank(c) {
79
+ const priority = COMMAND_PALETTE_PRIORITY.indexOf(c.name);
80
+ if (priority !== -1)
81
+ return priority;
82
+ const group = COMMAND_GROUP_ORDER.indexOf(c.group ?? 'other');
83
+ return COMMAND_PALETTE_PRIORITY.length + group * 100 + COMMANDS.indexOf(c);
84
+ }
85
+ export function sortCommandsForPalette(commands) {
86
+ return [...commands].sort((a, b) => commandRank(a) - commandRank(b) || a.name.localeCompare(b.name));
87
+ }
63
88
  export function matchCommands(input, opts = {}) {
64
89
  if (!input.startsWith('/'))
65
90
  return [];
66
91
  const word = input.split(/\s+/)[0].toLowerCase();
67
92
  return COMMANDS.filter((c) => opts.includeHidden || !c.hidden).filter((c) => c.name.startsWith(word) || c.aliases?.some((a) => a.startsWith(word)));
68
93
  }
94
+ export function commandPalette(input, opts = {}) {
95
+ const allowed = opts.allowedNames
96
+ ? (c) => opts.allowedNames.includes(c.name) || c.aliases?.some((a) => opts.allowedNames.includes(a))
97
+ : () => true;
98
+ return sortCommandsForPalette(matchCommands(input, opts).filter(allowed));
99
+ }
69
100
  function agentList(ctl) {
70
101
  return [...ctl.board.agents.values()].map((a) => a.name).join(', ') || t('m.none');
71
102
  }
@@ -9,7 +9,7 @@ import { saveConfig, getProvider, upsertProvider } from './config.js';
9
9
  import { priceFor, fmtCost } from './pricing.js';
10
10
  import { loadSkills, loadSpecialists } from './skills.js';
11
11
  import { t } from './i18n.js';
12
- const AGENT_COLORS = ['cyan', 'magenta', 'yellow', 'green', 'blue', 'redBright', 'cyanBright', 'magentaBright'];
12
+ const AGENT_COLORS = ['#f3e7c7', 'magenta', 'yellow', 'green', 'whiteBright', 'redBright', '#c8bfa6', 'magentaBright'];
13
13
  export function normalizeShellApprovalMode(mode) {
14
14
  if (mode === 'ask' || mode === 'auto-safe' || mode === 'yolo')
15
15
  return mode;
package/dist/i18n.js CHANGED
@@ -76,6 +76,10 @@ const en = {
76
76
  'main.ready1': '⚡ Ready — folder: {folder}',
77
77
  'main.ready2': 'Type a task + Enter to launch your first agent. /help for help.',
78
78
  'main.empty': 'No agents yet. Type a task + Enter to launch your first agent — then launch more at any time, even while they work.',
79
+ 'main.prompt': 'Example: Redesign the UI',
80
+ 'main.emptyCard.tagline': 'Multi-agent coding from one terminal.',
81
+ 'main.emptyCard.cta': 'Describe work below to launch the first agent.',
82
+ 'main.emptyCard.hints': '/ for commands · @agent to steer · /help for shortcuts',
79
83
  'main.status': 'Enter = new agent N+1 (even while others work) · @Name = real-time instruction · /help · views: /agents /board /diff /notes',
80
84
  'main.placeholder': 'Type a task (= new agent N+1) · @Agent message · /command',
81
85
  'agent.summary': 'Summary',
@@ -454,6 +458,10 @@ const fr = {
454
458
  'main.ready1': '⚡ Prêt — dossier : {folder}',
455
459
  'main.ready2': "Tape une tâche + Entrée pour lancer ton premier agent. /help pour l'aide.",
456
460
  'main.empty': "Aucun agent pour le moment. Tape une tâche + Entrée pour lancer ton premier agent — puis relances-en d'autres à tout moment, même pendant qu'ils travaillent.",
461
+ 'main.prompt': 'Exemple : Fais moi une refonte UI',
462
+ 'main.emptyCard.tagline': 'Code multi-agent depuis un terminal.',
463
+ 'main.emptyCard.cta': 'Décris le travail ci-dessous pour lancer le premier agent.',
464
+ 'main.emptyCard.hints': '/ pour les commandes · @agent pour piloter · /help pour les raccourcis',
457
465
  'main.status': 'Entrée = nouvel agent N+1 (même pendant que les autres travaillent) · @Nom = instruction temps réel · /help · vues : /agents /board /diff /notes',
458
466
  'main.placeholder': 'Tape une tâche (= nouvel agent N+1) · @Agent message · /commande',
459
467
  'agent.summary': 'Récapitulatif',
@@ -820,6 +828,10 @@ const es = {
820
828
  'main.ready1': '⚡ Listo — carpeta: {folder}',
821
829
  'main.ready2': 'Escribe una tarea + Enter para lanzar tu primer agente. /help para ayuda.',
822
830
  'main.empty': 'Aún no hay agentes. Escribe una tarea + Enter para lanzar tu primer agente — luego lanza más en cualquier momento, incluso mientras trabajan.',
831
+ 'main.prompt': 'Ejemplo: hazme un rediseño de UI',
832
+ 'main.emptyCard.tagline': 'Código multiagente desde un terminal.',
833
+ 'main.emptyCard.cta': 'Describe el trabajo abajo para lanzar el primer agente.',
834
+ 'main.emptyCard.hints': '/ para comandos · @agente para dirigir · /help para atajos',
823
835
  'main.status': 'Enter = nuevo agente N+1 (incluso mientras otros trabajan) · @Nombre = instrucción en tiempo real · /help · vistas: /agents /board /diff /notes',
824
836
  'main.placeholder': 'Escribe una tarea (= nuevo agente N+1) · @Agente mensaje · /comando',
825
837
  'agent.summary': 'Resumen',
@@ -1186,6 +1198,10 @@ const zh = {
1186
1198
  'main.ready1': '⚡ 就绪 — 文件夹:{folder}',
1187
1199
  'main.ready2': '输入任务 + 回车即可启动第一个智能体。/help 查看帮助。',
1188
1200
  'main.empty': '尚无智能体。输入任务 + 回车启动第一个 — 之后可随时启动更多,即使它们正在工作。',
1201
+ 'main.prompt': '示例:帮我重做 UI',
1202
+ 'main.emptyCard.tagline': '在一个终端中进行多智能体编码。',
1203
+ 'main.emptyCard.cta': '在下方描述工作以启动第一个智能体。',
1204
+ 'main.emptyCard.hints': '/ 查看命令 · @智能体 可指挥 · /help 查看快捷键',
1189
1205
  'main.status': '回车 = 新智能体 N+1(即使其他智能体正在工作)· @名称 = 实时指令 · /help · 视图:/agents /board /diff /notes',
1190
1206
  'main.placeholder': '输入任务(= 新智能体 N+1)· @智能体 消息 · /命令',
1191
1207
  'agent.summary': '摘要',
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);
@@ -6,7 +6,7 @@ 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
10
  import { latestSignal, toUIEvents } from './events.js';
11
11
  export const KIND_COLOR = {
12
12
  tool: UI.accent,
@@ -52,7 +52,7 @@ function ResultBlock({ agent, compact = false }) {
52
52
  const SPINNER_STATES = new Set(['thinking', 'working', 'listening', 'waiting']);
53
53
  function spinnerColor(state) {
54
54
  if (state === 'working')
55
- return 'cyan';
55
+ return COLOR.cream;
56
56
  return 'yellow'; // thinking, listening, waiting
57
57
  }
58
58
  function modeChar(mode) {
@@ -87,18 +87,14 @@ export function AgentRow({ agent, logs, cols, }) {
87
87
  const telemetry = formatAgentTelemetry(agent);
88
88
  const signal = latestSignal(agent, toUIEvents(logs));
89
89
  const specialist = agent.specialist ? ` #${agent.specialist}` : '';
90
- // Line 2 content
91
90
  let line2 = null;
92
91
  if (agent.lastResult) {
93
92
  line2 = { text: `✓ ${compactResultSummary(agent.lastResult, line2Max)}`, color: UI.ok };
94
93
  }
95
- else if (signal) {
94
+ else if (signal && signal !== agent.task) {
96
95
  line2 = { text: `▸ ${truncate(signal, line2Max)}`, color: UI.accent };
97
96
  }
98
- else {
99
- line2 = { text: meta.label, color: meta.color };
100
- }
101
- 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)] })] }), _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] }));
102
98
  }
103
99
  export function AgentTranscript({ agent, logs, raw = false, scrolled = 0, cols = 100, }) {
104
100
  const meta = STATE_META[agent.state];
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 = 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);
@@ -630,6 +624,9 @@ 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;
633
630
  // View breadcrumb: when not in agents view, show the view name instead of "control room".
634
631
  const VIEW_LABEL = {
635
632
  agents: 'control room',
@@ -645,7 +642,7 @@ function MainScreen({ ctl, folder, view, focus, rawLogs, systemLines, agentNames
645
642
  specialists: 'specialists',
646
643
  };
647
644
  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
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
649
646
  ? systemLines
650
647
  .filter((l) => !/^Ready|^Type a task|^⚡ Ready|^Default \/task|^Agent .* launched/.test(l.text))
651
648
  .slice(-2)
@@ -657,9 +654,9 @@ function MainScreen({ ctl, folder, view, focus, rawLogs, systemLines, agentNames
657
654
  // Split on \n so multiline i18n messages render correctly (Ink <Text> doesn't interpret \n).
658
655
  const lines = l.text.split('\n');
659
656
  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] })] })] }));
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] }) })] }));
663
660
  }
664
661
  function groupAgents(agents) {
665
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();
@@ -157,5 +157,5 @@ export function AttachApp({ agentRef, sock }) {
157
157
  } })) : question ? (_jsx(QuestionPrompt, { question: { ...question, agentId: info?.id ?? '', resolve: noop }, pendingCount: 1, onAnswer: (id, answer) => {
158
158
  wire({ type: 'answer', id, text: answer });
159
159
  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) }) })] }));
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) }) })] }));
161
161
  }
@@ -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,16 +32,10 @@ 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
+ return '↑/↓ select · Enter accept';
47
36
  }
48
37
  export function bestCommandCompletion(value) {
49
- const cmd = matchCommands(value)[0];
38
+ const cmd = commandPalette(value)[0];
50
39
  return cmd ? `${cmd.name} ` : null;
51
40
  }
52
41
  export function commandNamesForContext(context) {
@@ -67,12 +56,36 @@ export function completeAgentArgument(value, agent) {
67
56
  return value;
68
57
  return `${cmd} ${agent} `;
69
58
  }
70
- export function CommandInput({ active, placeholder, mask, context = 'hub', targetAgent, modelLabel, commandNames, agentNames = [], agents = [], onSubmit, onEscape, notify, }) {
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, }) {
71
83
  const [value, setValue] = useState('');
72
84
  const [attachments, setAttachments] = useState([]);
73
85
  const [history, setHistory] = useState([]);
74
86
  const [histIdx, setHistIdx] = useState(-1);
75
87
  const [selectedSuggestion, setSelectedSuggestion] = useState(0);
88
+ const [cursorOn, setCursorOn] = useState(true);
76
89
  const attSeq = useRef(0);
77
90
  const reset = () => {
78
91
  setValue('');
@@ -118,8 +131,7 @@ export function CommandInput({ active, placeholder, mask, context = 'hub', targe
118
131
  };
119
132
  const uniqueAgentNames = [...new Set(agentNames.filter(Boolean))];
120
133
  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) : [];
134
+ const cmdSuggestions = value.startsWith('/') && !value.includes(' ') ? commandPalette(value, { allowedNames: allowedCommands }) : [];
123
135
  const agentSuggestions = value.startsWith('@') && !value.includes(' ')
124
136
  ? ['all', ...uniqueAgentNames].filter((n) => n.toLowerCase().startsWith(value.slice(1).toLowerCase())).slice(0, 8)
125
137
  : [];
@@ -141,23 +153,51 @@ export function CommandInput({ active, placeholder, mask, context = 'hub', targe
141
153
  useEffect(() => {
142
154
  setSelectedSuggestion(0);
143
155
  }, [value]);
156
+ const safeSelectedSuggestion = clampSuggestionIndex(selectedSuggestion, suggestionCount);
144
157
  useEffect(() => {
145
- if (selectedSuggestion >= suggestionCount)
146
- setSelectedSuggestion(Math.max(0, suggestionCount - 1));
147
- }, [selectedSuggestion, suggestionCount]);
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;
185
+ useEffect(() => {
186
+ onHeightChange?.(renderedRows);
187
+ }, [onHeightChange, renderedRows]);
148
188
  const completeBest = () => {
149
189
  if (cmdSuggestions.length > 0) {
150
- const cmd = cmdSuggestions[Math.min(selectedSuggestion, cmdSuggestions.length - 1)];
190
+ const cmd = cmdSuggestions[clampSuggestionIndex(safeSelectedSuggestion, cmdSuggestions.length)];
151
191
  setValue(`${cmd.name} `);
152
192
  return true;
153
193
  }
154
194
  if (agentSuggestions.length > 0) {
155
- const agent = agentSuggestions[Math.min(selectedSuggestion, agentSuggestions.length - 1)];
195
+ const agent = agentSuggestions[clampSuggestionIndex(safeSelectedSuggestion, agentSuggestions.length)];
156
196
  setValue('@' + agent + ' ');
157
197
  return true;
158
198
  }
159
199
  if (argSuggestions.length > 0) {
160
- const agent = argSuggestions[Math.min(selectedSuggestion, argSuggestions.length - 1)];
200
+ const agent = argSuggestions[clampSuggestionIndex(safeSelectedSuggestion, argSuggestions.length)];
161
201
  setValue(completeAgentArgument(value, agent));
162
202
  return true;
163
203
  }
@@ -193,7 +233,7 @@ export function CommandInput({ active, placeholder, mask, context = 'hub', targe
193
233
  }
194
234
  if (key.upArrow) {
195
235
  if (hasSuggestions) {
196
- setSelectedSuggestion((i) => (i - 1 + suggestionCount) % suggestionCount);
236
+ setSelectedSuggestion((i) => clampSuggestionIndex(i - 1, suggestionCount));
197
237
  return;
198
238
  }
199
239
  setHistIdx((i) => {
@@ -206,7 +246,7 @@ export function CommandInput({ active, placeholder, mask, context = 'hub', targe
206
246
  }
207
247
  if (key.downArrow) {
208
248
  if (hasSuggestions) {
209
- setSelectedSuggestion((i) => (i + 1) % suggestionCount);
249
+ setSelectedSuggestion((i) => clampSuggestionIndex(i + 1, suggestionCount));
210
250
  return;
211
251
  }
212
252
  setHistIdx((i) => {
@@ -257,15 +297,21 @@ export function CommandInput({ active, placeholder, mask, context = 'hub', targe
257
297
  }
258
298
  setValue((v) => v + input);
259
299
  }, { isActive: active });
260
- const shown = mask ? '•'.repeat(value.length) : value;
261
300
  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'
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'
267
306
  ? 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'
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'
269
308
  ? 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 })] }))] })] }));
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}` : ''] }))] }));
271
317
  }
package/dist/ui/Md.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
+ import { BRAND } from './tokens.js';
3
4
  /**
4
5
  * Mini markdown renderer for agent summaries — line-based, zero deps.
5
6
  * Supports: ## headers, - / * bullets, numbered lists, **bold**, `code`.
@@ -25,15 +26,15 @@ export function Md({ text, dim }) {
25
26
  return _jsx(Text, { children: " " }, i);
26
27
  const header = line.match(/^#{1,3}\s+(.*)$/);
27
28
  if (header) {
28
- return (_jsx(Text, { bold: true, color: "cyanBright", children: header[1] }, i));
29
+ return (_jsx(Text, { bold: true, color: BRAND.primary, children: header[1] }, i));
29
30
  }
30
31
  const bullet = line.match(/^(\s*)[-*]\s+(.*)$/);
31
32
  if (bullet) {
32
- return (_jsxs(Text, { wrap: "wrap", dimColor: dim, children: [bullet[1], _jsx(Text, { color: "cyan", children: "\u2022 " }), inline(bullet[2], `b${i}`)] }, i));
33
+ return (_jsxs(Text, { wrap: "wrap", dimColor: dim, children: [bullet[1], _jsx(Text, { color: BRAND.primary, children: "\u2022 " }), inline(bullet[2], `b${i}`)] }, i));
33
34
  }
34
35
  const numbered = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/);
35
36
  if (numbered) {
36
- return (_jsxs(Text, { wrap: "wrap", dimColor: dim, children: [numbered[1], _jsxs(Text, { color: "cyan", children: [numbered[2], ". "] }), inline(numbered[3], `n${i}`)] }, i));
37
+ return (_jsxs(Text, { wrap: "wrap", dimColor: dim, children: [numbered[1], _jsxs(Text, { color: BRAND.primary, children: [numbered[2], ". "] }), inline(numbered[3], `n${i}`)] }, i));
37
38
  }
38
39
  return (_jsx(Text, { wrap: "wrap", dimColor: dim, children: inline(line, `l${i}`) }, i));
39
40
  }) }));
@@ -6,6 +6,7 @@ import { priceFor } from '../pricing.js';
6
6
  import { SelectList as BaseSelectList } from './Wizard.js';
7
7
  import { LANGS, getLang, setLang, t } from '../i18n.js';
8
8
  import { detectProviderModels, isLocalProvider, isPlaceholderModel, providerNeedsApiKey, PROVIDER_PRESETS } from '../config.js';
9
+ import { BRAND } from './tokens.js';
9
10
  function masked(key) {
10
11
  if (!key)
11
12
  return '—';
@@ -163,7 +164,7 @@ export function SettingsPanel({ ctl, scope, height, onClose, }) {
163
164
  setStep(next);
164
165
  };
165
166
  // ---- render ----
166
- return (_jsxs(Box, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: scope === 'global' ? t('set.title') : t('sset.title') }), flash ? _jsx(Text, { color: "green", children: flash }) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [step.id === 'root' && _jsx(SelectList, { items: rootItems, height: listHeight, onSelect: chooseRoot }), step.id === 'lang' && (_jsx(SelectList, { items: LANGS.map((l) => ({ label: l.label, value: l.code })), height: listHeight, onSelect: (code) => {
167
+ return (_jsxs(Box, { borderStyle: "round", borderColor: BRAND.muted, flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: BRAND.primary, children: scope === 'global' ? t('set.title') : t('sset.title') }), flash ? _jsx(Text, { color: "green", children: flash }) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [step.id === 'root' && _jsx(SelectList, { items: rootItems, height: listHeight, onSelect: chooseRoot }), step.id === 'lang' && (_jsx(SelectList, { items: LANGS.map((l) => ({ label: l.label, value: l.code })), height: listHeight, onSelect: (code) => {
167
168
  setLang(code);
168
169
  ctl.setLanguage(code);
169
170
  saved();
package/dist/ui/Wizard.js CHANGED
@@ -2,6 +2,12 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useState } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
4
  import { t } from '../i18n.js';
5
+ import { BRAND, COLOR } from './tokens.js';
6
+ function clampIndex(index, count) {
7
+ if (count <= 0)
8
+ return 0;
9
+ return Math.max(0, Math.min(index, count - 1));
10
+ }
5
11
  /**
6
12
  * Simple ↑/↓ + Entrée select list. If `allowInput` is set, the user can also
7
13
  * type a free value (e.g. a folder path or a custom model name) — typing
@@ -41,12 +47,12 @@ export function SelectList({ items, allowInput, inputPlaceholder, mask, height,
41
47
  }
42
48
  if (key.upArrow) {
43
49
  if (!typing)
44
- setIdx((i) => (i - 1 + selectable.length) % Math.max(1, selectable.length));
50
+ setIdx((i) => clampIndex(i - 1, selectable.length));
45
51
  return;
46
52
  }
47
53
  if (key.downArrow) {
48
54
  if (!typing)
49
- setIdx((i) => (i + 1) % Math.max(1, selectable.length));
55
+ setIdx((i) => clampIndex(i + 1, selectable.length));
50
56
  return;
51
57
  }
52
58
  if (key.pageUp) {
@@ -95,9 +101,9 @@ export function SelectList({ items, allowInput, inputPlaceholder, mask, height,
95
101
  const below = Math.max(0, items.length - start - visibleItems.length);
96
102
  return (_jsxs(Box, { flexDirection: "column", children: [above > 0 ? _jsxs(Text, { color: "gray", children: ["\u25B2 ", above] }) : null, visibleItems.map((it, localIdx) => {
97
103
  const i = start + localIdx;
98
- return (it.section ? (_jsx(Box, { marginTop: i > 0 ? 1 : 0, children: _jsx(Text, { bold: true, color: "white", children: it.label }) }, it.label)) : (_jsxs(Text, { children: [_jsxs(Text, { color: !typing && i === safeIdx ? 'cyanBright' : 'gray', bold: !typing && i === safeIdx, children: [!typing && i === safeIdx ? '❯ ' : ' ', it.label] }), it.hint ? _jsxs(Text, { color: "gray", children: [" ", it.hint] }) : null, it.detail ? _jsxs(Text, { color: "gray", children: [" \u2014 ", it.detail] }) : null] }, it.value + i)));
99
- }), below > 0 ? _jsxs(Text, { color: "gray", children: ["\u25BC ", below] }) : null, allowInput && (_jsx(Box, { marginTop: items.length > 0 ? 1 : 0, children: _jsxs(Text, { color: typing ? 'cyanBright' : 'gray', children: ["\u270E", ' ', typing ? (_jsx(Text, { color: "white", children: mask ? '•'.repeat(typed.length) : typed })) : (_jsx(Text, { color: "gray", children: inputPlaceholder ?? '…' })), typing ? _jsx(Text, { color: "cyanBright", children: "\u2588" }) : null] }) }))] }));
104
+ return (it.section ? (_jsx(Box, { marginTop: i > 0 ? 1 : 0, children: _jsx(Text, { bold: true, color: "white", children: it.label }) }, it.label)) : (_jsxs(Text, { children: [_jsxs(Text, { color: !typing && i === safeIdx ? COLOR.cream : 'gray', bold: !typing && i === safeIdx, children: [!typing && i === safeIdx ? '❯ ' : ' ', it.label] }), it.hint ? _jsxs(Text, { color: "gray", children: [" ", it.hint] }) : null, it.detail ? _jsxs(Text, { color: "gray", children: [" \u2014 ", it.detail] }) : null] }, it.value + i)));
105
+ }), below > 0 ? _jsxs(Text, { color: "gray", children: ["\u25BC ", below] }) : null, allowInput && (_jsx(Box, { marginTop: items.length > 0 ? 1 : 0, children: _jsxs(Text, { color: typing ? COLOR.cream : 'gray', children: ["\u270E", ' ', typing ? (_jsx(Text, { color: "white", children: mask ? '•'.repeat(typed.length) : typed })) : (_jsx(Text, { color: "gray", children: inputPlaceholder ?? '…' })), typing ? _jsx(Text, { color: COLOR.cream, children: "\u2588" }) : null] }) }))] }));
100
106
  }
101
107
  export function WizardStep({ step, total, title, children, footer, }) {
102
- return (_jsxs(Box, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["[", step, "/", total, "] ", title] }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: children }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: footer ?? t('wiz.footer.select') }) })] }));
108
+ return (_jsxs(Box, { borderStyle: "round", borderColor: BRAND.muted, flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { bold: true, color: BRAND.primary, children: ["[", step, "/", total, "] ", title] }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: children }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: footer ?? t('wiz.footer.select') }) })] }));
103
109
  }
package/dist/ui/theme.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import { t } from '../i18n.js';
2
- import { STATE } from './tokens.js';
2
+ import { COLOR, STATE } from './tokens.js';
3
3
  /** Strong visual cues: icon + label (i18n key) + color per state. */
4
4
  export const STATE_LABEL = {
5
5
  idle: { icon: '◇', labelKey: 'st.idle', color: STATE.idle },
6
6
  thinking: { icon: '🧠', labelKey: 'st.thinking', color: STATE.thinking },
7
- listening: { icon: '👂', labelKey: 'st.listening', color: 'cyanBright' },
7
+ listening: { icon: '👂', labelKey: 'st.listening', color: COLOR.cream },
8
8
  working: { icon: '🔨', labelKey: 'st.working', color: 'green' },
9
9
  waiting: { icon: '✋', labelKey: 'st.waiting', color: 'magenta' },
10
- paused: { icon: '⏸', labelKey: 'st.paused', color: 'blue' },
10
+ paused: { icon: '⏸', labelKey: 'st.paused', color: COLOR.creamMuted },
11
11
  done: { icon: '✅', labelKey: 'st.done', color: STATE.done },
12
12
  error: { icon: '✖', labelKey: 'st.error', color: 'red' },
13
13
  stopped: { icon: '⏹', labelKey: 'st.stopped', color: STATE.error },
package/dist/ui/tokens.js CHANGED
@@ -6,9 +6,14 @@ export const MARK = {
6
6
  waiting: '?',
7
7
  arrow: '▸',
8
8
  };
9
+ export const COLOR = {
10
+ cream: '#f3e7c7',
11
+ creamMuted: '#c8bfa6',
12
+ promptBackground: '#5f5963',
13
+ };
9
14
  export const UI = {
10
- brand: 'cyanBright',
11
- accent: 'cyan',
15
+ brand: COLOR.cream,
16
+ accent: COLOR.cream,
12
17
  muted: 'gray',
13
18
  text: 'white',
14
19
  ok: 'greenBright',
@@ -30,12 +35,12 @@ export const STATE_META = {
30
35
  // ─── Hub redesign tokens (Phase 0) ───────────────────────────────────────────
31
36
  /** Brand colors — logotype, borders, focus indicator. */
32
37
  export const BRAND = {
33
- primary: 'cyanBright',
34
- muted: 'cyan',
38
+ primary: COLOR.cream,
39
+ muted: COLOR.creamMuted,
35
40
  };
36
41
  /** Semantic state colors mapped to agent states. */
37
42
  export const STATE = {
38
- working: 'cyan',
43
+ working: COLOR.cream,
39
44
  thinking: 'yellow',
40
45
  listening: 'yellow',
41
46
  done: 'greenBright',
@@ -46,7 +51,7 @@ export const STATE = {
46
51
  /** Mode indicator colors. `task` is undefined — it renders no mark. */
47
52
  export const MODE = {
48
53
  ask: 'yellow',
49
- plan: 'blue',
54
+ plan: COLOR.creamMuted,
50
55
  task: undefined,
51
56
  };
52
57
  /** Chrome / UI element colors. */
package/dist/ui/views.js CHANGED
@@ -1,12 +1,38 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useState } from 'react';
2
+ import { useEffect, useState } from 'react';
3
3
  import { Box, Text, useInput, useStdout } from 'ink';
4
4
  import * as Diff from 'diff';
5
- import { visibleCommands } from '../commands.js';
5
+ import { sortCommandsForPalette, visibleCommands } from '../commands.js';
6
6
  import { Controller } from '../controller.js';
7
7
  import { fmtCost } from '../pricing.js';
8
8
  import { STATE_LABEL, stateLabel, truncate } from './theme.js';
9
9
  import { t } from '../i18n.js';
10
+ import { BRAND, COLOR } from './tokens.js';
11
+ function clampIndex(index, count) {
12
+ if (count <= 0)
13
+ return 0;
14
+ return Math.max(0, Math.min(index, count - 1));
15
+ }
16
+ function useSelectableIndex(count, pageSize) {
17
+ const [selected, setSelected] = useState(0);
18
+ const safeSelected = clampIndex(selected, count);
19
+ useEffect(() => {
20
+ if (safeSelected !== selected)
21
+ setSelected(safeSelected);
22
+ }, [safeSelected, selected]);
23
+ const step = Math.max(1, pageSize - 1);
24
+ useInput((_input, key) => {
25
+ if (key.downArrow)
26
+ setSelected((i) => clampIndex(i + 1, count));
27
+ if (key.upArrow)
28
+ setSelected((i) => clampIndex(i - 1, count));
29
+ if (key.pageDown)
30
+ setSelected((i) => clampIndex(i + step, count));
31
+ if (key.pageUp)
32
+ setSelected((i) => clampIndex(i - step, count));
33
+ });
34
+ return safeSelected;
35
+ }
10
36
  /**
11
37
  * PgUp/PgDn window over a list — the TUI runs in the alternate screen, so
12
38
  * every long view needs its own scrolling. `anchor` decides what you see
@@ -79,7 +105,7 @@ export function DiffView({ board, bodyHeight }) {
79
105
  return (_jsxs(Box, { borderStyle: "round", borderColor: "green", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "green", children: t('diff.title', { total: board.changes.length }) }), board.changes.length === 0 ? (_jsx(Text, { color: "gray", children: t('diff.empty') })) : (_jsxs(_Fragment, { children: [_jsx(Above, { n: above }), changes.map((c) => {
80
106
  const patch = Diff.createPatch(c.path, c.before, c.after, '', '', { context: 2 });
81
107
  const lines = patch.split('\n').slice(4, 34);
82
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { bold: true, children: [_jsx(Text, { color: "cyan", children: c.path }), _jsxs(Text, { color: "gray", children: [' ', t('diff.by', { agent: c.agentName, time: new Date(c.ts).toLocaleTimeString() })] })] }), lines.map((l, i) => (_jsx(Text, { color: l.startsWith('+') ? 'green' : l.startsWith('-') ? 'red' : l.startsWith('@') ? 'cyan' : 'gray', wrap: "truncate-end", children: l || ' ' }, i))), patch.split('\n').length > 38 ? _jsx(Text, { color: "gray", children: t('diff.trunc') }) : null] }, c.id));
108
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { bold: true, children: [_jsx(Text, { color: BRAND.primary, children: c.path }), _jsxs(Text, { color: "gray", children: [' ', t('diff.by', { agent: c.agentName, time: new Date(c.ts).toLocaleTimeString() })] })] }), lines.map((l, i) => (_jsx(Text, { color: l.startsWith('+') ? 'green' : l.startsWith('-') ? 'red' : l.startsWith('@') ? BRAND.primary : 'gray', wrap: "truncate-end", children: l || ' ' }, i))), patch.split('\n').length > 38 ? _jsx(Text, { color: "gray", children: t('diff.trunc') }) : null] }, c.id));
83
109
  }), _jsx(Below, { n: below })] }))] }));
84
110
  }
85
111
  /** Financial view: live cost / steps / tokens per agent + session total. */
@@ -90,21 +116,21 @@ export function CostView({ board, bodyHeight }) {
90
116
  const { slice, above, below } = useScrollWindow(agents, visible, 'top');
91
117
  const total = agents.reduce((s, a) => s + (a.cost ?? 0), 0);
92
118
  const unknown = agents.some((a) => a.cost === null);
93
- return (_jsxs(Box, { borderStyle: "round", borderColor: "greenBright", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "greenBright", children: t('cost.title') }), agents.length === 0 ? (_jsx(Text, { color: "gray", children: t('cost.empty') })) : (_jsxs(_Fragment, { children: [_jsx(Above, { n: above }), slice.map((a) => (_jsxs(Text, { wrap: "truncate-end", children: [' ', _jsx(Text, { color: a.color, bold: true, children: a.name.padEnd(12) }), _jsxs(Text, { color: "gray", children: [a.model.padEnd(24).slice(0, 24), " "] }), _jsxs(Text, { children: [String(a.steps).padStart(3), " steps "] }), _jsxs(Text, { color: "cyan", children: [String(Math.round(a.tokensIn / 1000)).padStart(5), "k in ", String(Math.round(a.tokensOut / 1000)).padStart(4), "k out", ' '] }), _jsx(Text, { color: "greenBright", bold: true, children: a.cost === null ? ' $—' : fmtCost(a.cost).padStart(8) }), a.cost === null ? _jsxs(Text, { color: "gray", children: [" ", t('cost.unknown')] }) : null] }, a.id))), _jsx(Below, { n: below }), _jsx(Text, { children: " " }), _jsxs(Text, { bold: true, children: [' ', t('cost.total'), " ", _jsx(Text, { color: "greenBright", children: fmtCost(total) }), unknown ? _jsxs(Text, { color: "gray", children: [" ", t('cost.partial')] }) : null] })] })), _jsx(Text, { color: "gray", children: t('cost.hint') })] }));
119
+ return (_jsxs(Box, { borderStyle: "round", borderColor: "greenBright", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "greenBright", children: t('cost.title') }), agents.length === 0 ? (_jsx(Text, { color: "gray", children: t('cost.empty') })) : (_jsxs(_Fragment, { children: [_jsx(Above, { n: above }), slice.map((a) => (_jsxs(Text, { wrap: "truncate-end", children: [' ', _jsx(Text, { color: a.color, bold: true, children: a.name.padEnd(12) }), _jsxs(Text, { color: "gray", children: [a.model.padEnd(24).slice(0, 24), " "] }), _jsxs(Text, { children: [String(a.steps).padStart(3), " steps "] }), _jsxs(Text, { color: BRAND.primary, children: [String(Math.round(a.tokensIn / 1000)).padStart(5), "k in ", String(Math.round(a.tokensOut / 1000)).padStart(4), "k out", ' '] }), _jsx(Text, { color: "greenBright", bold: true, children: a.cost === null ? ' $—' : fmtCost(a.cost).padStart(8) }), a.cost === null ? _jsxs(Text, { color: "gray", children: [" ", t('cost.unknown')] }) : null] }, a.id))), _jsx(Below, { n: below }), _jsx(Text, { children: " " }), _jsxs(Text, { bold: true, children: [' ', t('cost.total'), " ", _jsx(Text, { color: "greenBright", children: fmtCost(total) }), unknown ? _jsxs(Text, { color: "gray", children: [" ", t('cost.partial')] }) : null] })] })), _jsx(Text, { color: "gray", children: t('cost.hint') })] }));
94
120
  }
95
121
  /** Skills catalog: user-authored markdown instructions agents can load. */
96
122
  export function SkillsView({ skills, bodyHeight }) {
97
123
  const fallbackVisible = useVisibleRows(8);
98
124
  const visible = bodyHeight ? Math.max(3, bodyHeight - 6) : fallbackVisible;
99
125
  const { slice, above, below } = useScrollWindow(skills, visible, 'top');
100
- return (_jsxs(Box, { borderStyle: "round", borderColor: "blueBright", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "blueBright", children: t('skills.title') }), skills.length === 0 ? (_jsx(Text, { color: "gray", children: t('skills.empty') })) : (_jsxs(_Fragment, { children: [_jsx(Above, { n: above }), slice.map((s) => (_jsxs(Text, { wrap: "truncate-end", children: [' ', _jsxs(Text, { color: "blueBright", bold: true, children: ["#", s.name.padEnd(16)] }), _jsxs(Text, { color: s.scope === 'global' ? 'yellow' : 'green', children: ["[", s.scope, "] "] }), _jsx(Text, { color: "gray", children: truncate(s.description || s.file, 100) })] }, s.file))), _jsx(Below, { n: below })] })), _jsx(Text, { children: " " }), _jsx(Text, { color: "gray", children: t('skills.hint1') }), _jsx(Text, { color: "gray", children: t('skills.hint2') })] }));
126
+ return (_jsxs(Box, { borderStyle: "round", borderColor: BRAND.muted, flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: BRAND.primary, children: t('skills.title') }), skills.length === 0 ? (_jsx(Text, { color: "gray", children: t('skills.empty') })) : (_jsxs(_Fragment, { children: [_jsx(Above, { n: above }), slice.map((s) => (_jsxs(Text, { wrap: "truncate-end", children: [' ', _jsxs(Text, { color: BRAND.primary, bold: true, children: ["#", s.name.padEnd(16)] }), _jsxs(Text, { color: s.scope === 'global' ? 'yellow' : 'green', children: ["[", s.scope, "] "] }), _jsx(Text, { color: "gray", children: truncate(s.description || s.file, 100) })] }, s.file))), _jsx(Below, { n: below })] })), _jsx(Text, { children: " " }), _jsx(Text, { color: "gray", children: t('skills.hint1') }), _jsx(Text, { color: "gray", children: t('skills.hint2') })] }));
101
127
  }
102
128
  /** Specialists catalog: personas (role + optional pinned model). */
103
129
  export function SpecialistsView({ specialists, bodyHeight }) {
104
130
  const fallbackVisible = useVisibleRows(8);
105
131
  const visible = bodyHeight ? Math.max(3, bodyHeight - 6) : fallbackVisible;
106
132
  const { slice, above, below } = useScrollWindow(specialists, visible, 'top');
107
- return (_jsxs(Box, { borderStyle: "round", borderColor: "magentaBright", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "magentaBright", children: t('spec.title') }), specialists.length === 0 ? (_jsx(Text, { color: "gray", children: t('spec.empty') })) : (_jsxs(_Fragment, { children: [_jsx(Above, { n: above }), slice.map((s) => (_jsxs(Text, { wrap: "truncate-end", children: [' ', _jsxs(Text, { color: "magentaBright", bold: true, children: ["\uD83C\uDF93", s.name.padEnd(16)] }), _jsxs(Text, { color: s.scope === 'global' ? 'yellow' : 'green', children: ["[", s.scope, "] "] }), s.model ? _jsxs(Text, { color: "cyan", children: [s.model, " "] }) : null, _jsx(Text, { color: "gray", children: truncate(s.description || s.file, 90) })] }, s.file))), _jsx(Below, { n: below })] })), _jsx(Text, { children: " " }), _jsx(Text, { color: "gray", children: t('spec.hint1') }), _jsx(Text, { color: "gray", children: t('spec.hint2') })] }));
133
+ return (_jsxs(Box, { borderStyle: "round", borderColor: "magentaBright", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "magentaBright", children: t('spec.title') }), specialists.length === 0 ? (_jsx(Text, { color: "gray", children: t('spec.empty') })) : (_jsxs(_Fragment, { children: [_jsx(Above, { n: above }), slice.map((s) => (_jsxs(Text, { wrap: "truncate-end", children: [' ', _jsxs(Text, { color: "magentaBright", bold: true, children: ["\uD83C\uDF93", s.name.padEnd(16)] }), _jsxs(Text, { color: s.scope === 'global' ? 'yellow' : 'green', children: ["[", s.scope, "] "] }), s.model ? _jsxs(Text, { color: BRAND.primary, children: [s.model, " "] }) : null, _jsx(Text, { color: "gray", children: truncate(s.description || s.file, 90) })] }, s.file))), _jsx(Below, { n: below })] })), _jsx(Text, { children: " " }), _jsx(Text, { color: "gray", children: t('spec.hint1') }), _jsx(Text, { color: "gray", children: t('spec.hint2') })] }));
108
134
  }
109
135
  /** Saved sessions: inspect available restore points; restore via /session. */
110
136
  export function SessionsView({ projectRoot, bodyHeight }) {
@@ -118,16 +144,27 @@ export function SessionsView({ projectRoot, bodyHeight }) {
118
144
  agents: s.data.agents.length,
119
145
  }) }), _jsxs(Text, { color: "gray", children: [" ", s.data.agents.map((a) => a.name).join(', ').slice(0, 80)] })] }, s.file))), _jsx(Below, { n: below })] })), _jsx(Text, { children: " " }), _jsx(Text, { color: "gray", children: t('sessions.hint') })] }));
120
146
  }
121
- export function HelpView({ bodyHeight }) {
147
+ export function HelpView({ bodyHeight, onSelect }) {
122
148
  // Fixed intro/highlight/footer rows consume about 12 lines inside the already-sized body.
123
149
  const fallbackVisible = useVisibleRows(16);
124
150
  const visible = bodyHeight ? Math.max(3, bodyHeight - 12) : fallbackVisible;
125
- const commands = visibleCommands();
126
- const { slice, above, below } = useScrollWindow(commands, visible, 'top');
151
+ const commands = sortCommandsForPalette(visibleCommands());
152
+ const selected = useSelectableIndex(commands.length, visible);
153
+ useInput((_input, key) => {
154
+ if (key.return)
155
+ onSelect?.(commands[selected]?.name ?? '/help');
156
+ });
157
+ const start = Math.min(Math.max(0, selected - Math.floor(visible / 2)), Math.max(0, commands.length - visible));
158
+ const slice = commands.slice(start, start + visible);
159
+ const above = start;
160
+ const below = Math.max(0, commands.length - start - slice.length);
127
161
  const highlights = [
128
162
  ['Agent modes', ['/ask', '/task', '/plan']],
129
163
  ['Shell approvals', ['/approvals ask', '/approvals auto', '/approvals yolo']],
130
164
  ['Navigation', ['/focus', '/attach', '/raw', '/send']],
131
165
  ];
132
- return (_jsxs(Box, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: t('help.title') }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, children: t('help.l1a') }), t('help.l1b'), _jsx(Text, { bold: true, children: t('help.l1c') }), "."] }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, children: t('help.l2a') }), t('help.l2b'), _jsx(Text, { bold: true, children: t('help.l2c') }), t('help.l2d')] }), _jsx(Text, { wrap: "truncate-end", children: t('help.l3') }), _jsx(Text, { children: " " }), highlights.map(([label, names]) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: "cyan", bold: true, children: [label, ": "] }), _jsx(Text, { color: "gray", children: names.join(' ') })] }, label))), _jsx(Text, { color: "gray", wrap: "truncate-end", children: "Keyboard: \u2191/\u2193 or PgUp/PgDn scroll \u00B7 Tab/\u2192 autocomplete \u00B7 Esc back/clear \u00B7 Ctrl+U clear \u00B7 Ctrl+V image" }), _jsx(Text, { children: " " }), _jsx(Above, { n: above }), slice.map((c) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", bold: true, children: c.name.padEnd(18) }), _jsx(Text, { color: "yellow", children: c.args.padEnd(24) }), _jsxs(Text, { color: "gray", children: [t(c.descKey), c.aliases?.length ? ` (= ${c.aliases.join(', ')})` : ''] })] }, c.name))), _jsx(Below, { n: below }), _jsx(Text, { children: " " }), _jsx(Text, { color: "gray", wrap: "truncate-end", children: t('help.states') }), _jsx(Text, { color: "gray", wrap: "truncate-end", children: t('help.keys') })] }));
166
+ return (_jsxs(Box, { borderStyle: "round", borderColor: BRAND.muted, flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: BRAND.primary, children: t('help.title') }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, children: t('help.l1a') }), t('help.l1b'), _jsx(Text, { bold: true, children: t('help.l1c') }), "."] }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, children: t('help.l2a') }), t('help.l2b'), _jsx(Text, { bold: true, children: t('help.l2c') }), t('help.l2d')] }), _jsx(Text, { wrap: "truncate-end", children: t('help.l3') }), _jsx(Text, { children: " " }), highlights.map(([label, names]) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: BRAND.primary, bold: true, children: [label, ": "] }), _jsx(Text, { color: "gray", children: names.join(' ') })] }, label))), _jsx(Text, { color: "gray", wrap: "truncate-end", children: "Keyboard: \u2191/\u2193 select \u00B7 Enter run selected \u00B7 PgUp/PgDn page \u00B7 Esc back" }), _jsx(Text, { children: " " }), _jsx(Above, { n: above }), slice.map((c, i) => {
167
+ const isSelected = start + i === selected;
168
+ return (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: isSelected ? COLOR.cream : COLOR.creamMuted, bold: true, children: [isSelected ? '› ' : ' ', c.name.padEnd(16)] }), _jsx(Text, { color: "yellow", children: c.args.padEnd(24) }), _jsxs(Text, { color: "gray", children: [t(c.descKey), c.aliases?.length ? ` (= ${c.aliases.join(', ')})` : ''] })] }, c.name));
169
+ }), _jsx(Below, { n: below }), _jsx(Text, { children: " " }), _jsx(Text, { color: "gray", wrap: "truncate-end", children: t('help.states') }), _jsx(Text, { color: "gray", wrap: "truncate-end", children: t('help.keys') })] }));
133
170
  }
package/dist/update.js ADDED
@@ -0,0 +1,125 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline';
4
+ import { spawn } from 'node:child_process';
5
+ import { configDir } from './config.js';
6
+ import { PACKAGE_NAME, VERSION } from './version.js';
7
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
8
+ const REMIND_LATER_MS = 24 * 60 * 60 * 1000;
9
+ export function compareVersions(a, b) {
10
+ const pa = a.replace(/^v/, '').split(/[.-]/).map((n) => Number.parseInt(n, 10) || 0);
11
+ const pb = b.replace(/^v/, '').split(/[.-]/).map((n) => Number.parseInt(n, 10) || 0);
12
+ const len = Math.max(pa.length, pb.length);
13
+ for (let i = 0; i < len; i++) {
14
+ const left = pa[i] ?? 0;
15
+ const right = pb[i] ?? 0;
16
+ if (left !== right)
17
+ return left > right ? 1 : -1;
18
+ }
19
+ return 0;
20
+ }
21
+ function updateStateFile() {
22
+ return path.join(configDir(), 'update.json');
23
+ }
24
+ export function readUpdateState() {
25
+ try {
26
+ const file = updateStateFile();
27
+ return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {};
28
+ }
29
+ catch {
30
+ return {};
31
+ }
32
+ }
33
+ export function writeUpdateState(state) {
34
+ try {
35
+ fs.mkdirSync(configDir(), { recursive: true });
36
+ fs.writeFileSync(updateStateFile(), JSON.stringify(state, null, 2));
37
+ }
38
+ catch {
39
+ /* best effort */
40
+ }
41
+ }
42
+ export function shouldSkipUpdateCheck(env = process.env) {
43
+ return (env.PARALLEL_SKIP_UPDATE_CHECK === '1' ||
44
+ env.CI === 'true' ||
45
+ env.GITHUB_ACTIONS === 'true' ||
46
+ env.GITLAB_CI === 'true' ||
47
+ env.BUILDKITE === 'true');
48
+ }
49
+ export function shouldPromptForUpdate(info, state, now = Date.now()) {
50
+ if (compareVersions(info.latest, info.current) <= 0)
51
+ return false;
52
+ if (state.dismissedVersion === info.latest)
53
+ return false;
54
+ if (state.skipUntil && state.skipUntil > now)
55
+ return false;
56
+ return true;
57
+ }
58
+ export async function fetchLatestVersion(timeoutMs = 1500) {
59
+ let timeout;
60
+ try {
61
+ const controller = new AbortController();
62
+ timeout = setTimeout(() => controller.abort(), timeoutMs);
63
+ const resp = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}/latest`, {
64
+ signal: controller.signal,
65
+ });
66
+ if (!resp.ok)
67
+ return null;
68
+ const data = await resp.json();
69
+ return typeof data.version === 'string' ? data.version : null;
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ finally {
75
+ if (timeout)
76
+ clearTimeout(timeout);
77
+ }
78
+ }
79
+ function ask(question) {
80
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
81
+ return new Promise((resolve) => {
82
+ rl.question(question, (answer) => {
83
+ rl.close();
84
+ resolve(answer.trim());
85
+ });
86
+ });
87
+ }
88
+ export async function runGlobalUpdate() {
89
+ return new Promise((resolve) => {
90
+ const child = spawn('npm', ['install', '-g', PACKAGE_NAME], { stdio: 'inherit' });
91
+ child.on('close', (code) => resolve(code ?? 1));
92
+ child.on('error', () => resolve(1));
93
+ });
94
+ }
95
+ export async function maybeRunStartupUpdate(skip = false) {
96
+ if (skip || shouldSkipUpdateCheck() || !process.stdin.isTTY || !process.stdout.isTTY)
97
+ return false;
98
+ const now = Date.now();
99
+ const state = readUpdateState();
100
+ if (state.lastCheckAt && now - state.lastCheckAt < CHECK_INTERVAL_MS && (!state.skipUntil || state.skipUntil > now)) {
101
+ return false;
102
+ }
103
+ const latest = await fetchLatestVersion();
104
+ writeUpdateState({ ...state, lastCheckAt: now });
105
+ if (!latest)
106
+ return false;
107
+ const info = { current: VERSION, latest };
108
+ if (!shouldPromptForUpdate(info, state, now))
109
+ return false;
110
+ const answer = await ask(`Update Parallel ${VERSION} -> ${latest}? [y/N] `);
111
+ if (!/^y(es)?$/i.test(answer)) {
112
+ writeUpdateState({ ...state, lastCheckAt: now, skipUntil: now + REMIND_LATER_MS });
113
+ return false;
114
+ }
115
+ console.log(`Updating Parallel via \`npm install -g ${PACKAGE_NAME}\`...\n`);
116
+ const code = await runGlobalUpdate();
117
+ if (code === 0) {
118
+ writeUpdateState({ lastCheckAt: now, dismissedVersion: latest });
119
+ console.log('\nUpdate ran successfully! Please restart Parallel.');
120
+ }
121
+ else {
122
+ console.log('\nUpdate failed. Parallel will continue with the current version.');
123
+ }
124
+ return code === 0;
125
+ }
@@ -0,0 +1,2 @@
1
+ export const PACKAGE_NAME = '@parallel-cli/parallel';
2
+ export const VERSION = '0.4.6';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parallel-cli/parallel",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "Real-time multi-agent coding CLI with shared context, adaptive co-editing, dedicated agent terminals, and headless CI runs.",
5
5
  "keywords": [
6
6
  "cli",