@parallel-cli/parallel 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ui/App.js ADDED
@@ -0,0 +1,400 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { Box, Text, useApp, useInput, useStdout } from 'ink';
6
+ import { Controller } from '../controller.js';
7
+ import { startSessionServer } from '../server.js';
8
+ import { executeInput } from '../commands.js';
9
+ import { PROVIDER_PRESETS, getProvider, rememberFolder, saveConfig } from '../config.js';
10
+ import { fmtCost } from '../pricing.js';
11
+ import { LANGS, setLang, t } from '../i18n.js';
12
+ import { AgentPanel } from './AgentPanel.js';
13
+ import { ApprovalPrompt } from './ApprovalPrompt.js';
14
+ import { QuestionPrompt } from './QuestionPrompt.js';
15
+ import { CommandInput } from './CommandInput.js';
16
+ import { SettingsPanel } from './SettingsPanel.js';
17
+ import { BoardView, CostView, DiffView, HelpView, NotesView, SessionsView, SkillsView, SpecialistsView } from './views.js';
18
+ import { SelectList, WizardStep } from './Wizard.js';
19
+ const LOGO = '⚡ P A R A L L E L';
20
+ function usableProvider(config) {
21
+ const p = getProvider(config);
22
+ return p && p.apiKey && (p.defaultModel || p.models[0]) ? p : undefined;
23
+ }
24
+ function normalizeFolder(p) {
25
+ return path.resolve(p.replace(/^~(?=$|\/)/, process.env.HOME ?? '~'));
26
+ }
27
+ function validFolder(p) {
28
+ const abs = normalizeFolder(p);
29
+ try {
30
+ return fs.existsSync(abs) && fs.statSync(abs).isDirectory() ? abs : null;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ function startupFolder(config, initialFolder) {
37
+ if (initialFolder)
38
+ return validFolder(initialFolder);
39
+ for (const recent of config.recentFolders ?? []) {
40
+ const found = validFolder(recent);
41
+ if (found)
42
+ return found;
43
+ }
44
+ return validFolder(process.cwd());
45
+ }
46
+ export function App({ config, initialFolder }) {
47
+ const { exit } = useApp();
48
+ const initialUsableProvider = usableProvider(config);
49
+ const directFolder = config.language && initialUsableProvider ? startupFolder(config, initialFolder) : null;
50
+ // ---------- wizard state ----------
51
+ const [phase, setPhase] = useState(directFolder ? 'main' : config.language ? 'folder' : 'lang');
52
+ const [folder, setFolder] = useState(directFolder ?? '');
53
+ const [wizardError, setWizardError] = useState('');
54
+ const [sessions, setSessions] = useState([]);
55
+ const [providerStep, setProviderStep] = useState({ id: 'pick' });
56
+ const [modelCustom, setModelCustom] = useState(false);
57
+ const ctlRef = useRef(directFolder ? new Controller(config, directFolder) : null);
58
+ // ---------- main state ----------
59
+ const [, setTick] = useState(0);
60
+ const [view, setView] = useState('agents');
61
+ // Focus mode (/focus <agent>): plain input is routed to that agent.
62
+ const [focus, setFocus] = useState(null);
63
+ const [systemLines, setSystemLines] = useState(directFolder ? [t('main.ready1', { folder: directFolder }), t('main.ready2')] : []);
64
+ const [inputReady, setInputReady] = useState(Boolean(directFolder));
65
+ const ctl = ctlRef.current;
66
+ // Re-render (throttled) on every blackboard/controller update.
67
+ useEffect(() => {
68
+ if (!ctl)
69
+ return;
70
+ let pending = false;
71
+ const onUpdate = () => {
72
+ if (pending)
73
+ return;
74
+ pending = true;
75
+ setTimeout(() => {
76
+ pending = false;
77
+ setTick((x) => x + 1);
78
+ }, 80);
79
+ };
80
+ ctl.on('update', onUpdate);
81
+ const timer = setInterval(onUpdate, 1000); // refresh elapsed timers
82
+ return () => {
83
+ ctl.off('update', onUpdate);
84
+ clearInterval(timer);
85
+ };
86
+ }, [ctl]);
87
+ // Session server: lets `parallel attach <agent>` open per-agent terminals.
88
+ useEffect(() => {
89
+ if (!ctl)
90
+ return;
91
+ const stop = startSessionServer(ctl);
92
+ ctl.attachEnabled = Boolean(stop);
93
+ return () => {
94
+ ctl.attachEnabled = false;
95
+ stop?.();
96
+ };
97
+ }, [ctl]);
98
+ const ui = useMemo(() => ({
99
+ setView,
100
+ system: (line) => setSystemLines((ls) => [...ls.slice(-5), line]),
101
+ exit: () => {
102
+ setTimeout(() => exit(), 50);
103
+ },
104
+ setFocus,
105
+ }), [exit]);
106
+ // ---------- wizard transitions ----------
107
+ // In normal launches, a complete config goes straight to the main TUI.
108
+ // This effect only supports partially-configured installs with a folder arg.
109
+ useEffect(() => {
110
+ if (config.language && initialFolder && phase === 'folder' && !ctlRef.current) {
111
+ chooseFolder(initialFolder);
112
+ }
113
+ // eslint-disable-next-line react-hooks/exhaustive-deps
114
+ }, []);
115
+ useEffect(() => {
116
+ if (directFolder)
117
+ rememberFolder(config, directFolder);
118
+ // eslint-disable-next-line react-hooks/exhaustive-deps
119
+ }, []);
120
+ const chooseLang = (code) => {
121
+ setLang(code);
122
+ config.language = code;
123
+ saveConfig(config);
124
+ if (initialFolder)
125
+ chooseFolder(initialFolder);
126
+ else
127
+ setPhase('folder');
128
+ };
129
+ const chooseFolder = (p) => {
130
+ const abs = validFolder(p);
131
+ if (!abs) {
132
+ setWizardError(t('wiz.folder.notFound', { path: normalizeFolder(p) }));
133
+ return;
134
+ }
135
+ setWizardError('');
136
+ setFolder(abs);
137
+ rememberFolder(config, abs);
138
+ const controller = new Controller(config, abs);
139
+ ctlRef.current = controller;
140
+ const found = Controller.listSessions(abs);
141
+ setSessions(found);
142
+ setPhase(found.length > 0 ? 'session' : usableProvider(config) ? 'model' : 'provider');
143
+ };
144
+ const wizardBack = () => {
145
+ if (phase === 'folder') {
146
+ if (!config.language)
147
+ setPhase('lang');
148
+ return;
149
+ }
150
+ if (phase === 'session') {
151
+ setPhase('folder');
152
+ return;
153
+ }
154
+ if (phase === 'model') {
155
+ if (modelCustom) {
156
+ setModelCustom(false);
157
+ return;
158
+ }
159
+ setPhase(sessions.length > 0 ? 'session' : 'folder');
160
+ return;
161
+ }
162
+ if (phase === 'provider') {
163
+ if (providerStep.id === 'pick') {
164
+ setPhase(sessions.length > 0 ? 'session' : 'folder');
165
+ }
166
+ else if (providerStep.id === 'key') {
167
+ setProviderStep({ id: 'pick' });
168
+ }
169
+ else if (providerStep.id === 'name') {
170
+ setProviderStep({ id: 'pick' });
171
+ }
172
+ else if (providerStep.id === 'url') {
173
+ setProviderStep({ id: 'name' });
174
+ }
175
+ else if (providerStep.id === 'model') {
176
+ setProviderStep({ id: 'url', name: providerStep.name });
177
+ }
178
+ else if (providerStep.id === 'newKey') {
179
+ setProviderStep({ id: 'model', name: providerStep.name, url: providerStep.url });
180
+ }
181
+ }
182
+ };
183
+ const chooseSession = (value) => {
184
+ if (value !== '__new__') {
185
+ const s = sessions.find((x) => x.file === value);
186
+ if (s && ctlRef.current)
187
+ ctlRef.current.loadSession(s.data);
188
+ }
189
+ setPhase(usableProvider(config) ? 'model' : 'provider');
190
+ };
191
+ const finishProvider = (p) => {
192
+ ctlRef.current?.saveProvider(p);
193
+ ctlRef.current?.setDefaultProvider(p.name);
194
+ setProviderStep({ id: 'pick' });
195
+ enterMain();
196
+ };
197
+ const enterMain = () => {
198
+ setSystemLines([t('main.ready1', { folder }), t('main.ready2')]);
199
+ setPhase('main');
200
+ setInputReady(false);
201
+ setTimeout(() => setInputReady(true), 350);
202
+ };
203
+ const chooseModel = (value) => {
204
+ if (value === '__custom__')
205
+ return setModelCustom(true);
206
+ if (value === '__provider__') {
207
+ setProviderStep({ id: 'pick' });
208
+ setPhase('provider');
209
+ return;
210
+ }
211
+ const ctl = ctlRef.current;
212
+ if (ctl) {
213
+ const resolved = ctl.resolveModel(value);
214
+ if (resolved) {
215
+ resolved.provider.defaultModel = resolved.model;
216
+ if (!resolved.provider.models.includes(resolved.model))
217
+ resolved.provider.models.push(resolved.model);
218
+ ctl.saveProvider(resolved.provider);
219
+ ctl.setDefaultProvider(resolved.provider.name);
220
+ ctl.setSessionModel(`${resolved.provider.name}:${resolved.model}`);
221
+ }
222
+ }
223
+ enterMain();
224
+ };
225
+ // ---------- wizard rendering ----------
226
+ if (phase !== 'main') {
227
+ const totalSteps = 5;
228
+ const sessionProvider = ctl ? ctl.sessionProvider() : getProvider(config);
229
+ 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 })), 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: [
230
+ { label: process.cwd(), value: process.cwd(), hint: t('wiz.folder.current') },
231
+ ...config.recentFolders
232
+ .filter((f) => f !== process.cwd())
233
+ .map((f) => ({ label: f, value: f, hint: t('wiz.folder.recent') })),
234
+ ], allowInput: true, inputPlaceholder: t('wiz.folder.input'), onBack: wizardBack, onSelect: chooseFolder, onInput: chooseFolder })] })), phase === 'session' && (_jsx(WizardStep, { step: 3, total: totalSteps, title: t('wiz.session.title'), children: _jsx(SelectList, { items: [
235
+ { label: t('wiz.session.new'), value: '__new__', hint: t('wiz.session.newHint') },
236
+ ...sessions.map((s) => ({
237
+ label: t('wiz.session.item', { date: new Date(s.data.savedAt).toLocaleString() }),
238
+ value: s.file,
239
+ hint: `(${s.data.agents.length}: ${s.data.agents
240
+ .map((a) => a.name)
241
+ .join(', ')
242
+ .slice(0, 60)})`,
243
+ })),
244
+ ], onBack: wizardBack, onSelect: chooseSession }) })), phase === 'provider' && providerStep.id === 'pick' && (_jsx(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.title'), children: _jsx(SelectList, { items: [
245
+ ...config.providers.map((p) => ({
246
+ label: p.name,
247
+ value: `existing:${p.name}`,
248
+ hint: `(${p.baseUrl}${p.apiKey ? '' : ' — ' + t('wiz.provider.needsKey')})`,
249
+ })),
250
+ ...PROVIDER_PRESETS.filter((preset) => !config.providers.some((p) => p.name.toLowerCase() === preset.name.toLowerCase())).map((preset) => ({
251
+ label: preset.name,
252
+ value: `preset:${preset.name}`,
253
+ hint: t('wiz.provider.presetHint', { url: preset.baseUrl, model: preset.defaultModel }),
254
+ })),
255
+ { label: t('wiz.provider.custom'), value: '__custom__', hint: t('wiz.provider.customHint') },
256
+ ], onBack: wizardBack, onSelect: (v) => {
257
+ if (v === '__custom__')
258
+ return setProviderStep({ id: 'name' });
259
+ if (v.startsWith('preset:')) {
260
+ const preset = PROVIDER_PRESETS.find((p) => p.name === v.slice('preset:'.length));
261
+ if (preset)
262
+ return setProviderStep({ id: 'key', preset: { ...preset, models: [...preset.models] } });
263
+ }
264
+ const p = config.providers.find((x) => x.name === v.slice('existing:'.length));
265
+ if (!p)
266
+ return;
267
+ if (p.apiKey) {
268
+ ctlRef.current?.setDefaultProvider(p.name);
269
+ ctlRef.current?.setSessionProvider(p.name);
270
+ enterMain();
271
+ }
272
+ else {
273
+ setProviderStep({ id: 'key', preset: p });
274
+ }
275
+ } }) })), phase === 'provider' && providerStep.id === 'key' && (_jsxs(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.key.title', { name: providerStep.preset.name }), footer: t('wiz.provider.key.footer'), children: [_jsx(Text, { color: "gray", children: providerStep.preset.baseUrl }), _jsx(SelectList, { items: [], allowInput: true, mask: true, inputPlaceholder: "sk-\u2026", onBack: wizardBack, onInput: (k) => finishProvider({ ...providerStep.preset, apiKey: k.trim() }) })] })), phase === 'provider' && providerStep.id === 'name' && (_jsx(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.name.title'), footer: t('wiz.footer.type'), children: _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: t('wiz.provider.name.ph'), onBack: wizardBack, onInput: (name) => setProviderStep({ id: 'url', name }) }) })), phase === 'provider' && providerStep.id === 'url' && (_jsx(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.url.title'), footer: t('wiz.footer.type'), children: _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: t('wiz.provider.url.ph'), onBack: wizardBack, onInput: (url) => setProviderStep({ id: 'model', name: providerStep.name, url }) }) })), phase === 'provider' && providerStep.id === 'model' && (_jsx(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.model.title'), footer: t('wiz.footer.type'), children: _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: t('wiz.provider.model.ph'), onBack: wizardBack, onInput: (model) => setProviderStep({ id: 'newKey', name: providerStep.name, url: providerStep.url, model }) }) })), phase === 'provider' && providerStep.id === 'newKey' && (_jsx(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.key.title', { name: providerStep.name }), footer: t('wiz.provider.key.footer'), children: _jsx(SelectList, { items: [], allowInput: true, mask: true, inputPlaceholder: "sk-\u2026", onBack: wizardBack, onInput: (key) => finishProvider({
276
+ name: providerStep.name,
277
+ baseUrl: providerStep.url,
278
+ apiKey: key.trim(),
279
+ models: [providerStep.model],
280
+ defaultModel: providerStep.model,
281
+ }) }) })), phase === 'model' && !modelCustom && sessionProvider && (_jsxs(WizardStep, { step: 5, total: totalSteps, title: t('wiz.model.title'), children: [_jsx(Text, { color: "gray", children: t('wiz.model.provider', { name: sessionProvider.name, url: sessionProvider.baseUrl }) }), _jsx(SelectList, { items: [
282
+ ...sessionProvider.models.map((m) => ({
283
+ label: m,
284
+ value: `${sessionProvider.name}:${m}`,
285
+ hint: m === sessionProvider.defaultModel ? t('wiz.model.default') : undefined,
286
+ })),
287
+ { label: t('wiz.model.custom'), value: '__custom__', hint: t('wiz.model.customHint') },
288
+ { label: t('wiz.model.addProvider'), value: '__provider__' },
289
+ ], onBack: wizardBack, onSelect: chooseModel })] })), phase === 'model' && modelCustom && (_jsx(WizardStep, { step: 5, total: totalSteps, title: t('wiz.model.customTitle'), footer: t('wiz.footer.type'), children: _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: t('wiz.provider.model.ph'), onBack: wizardBack, onInput: (m) => {
290
+ setModelCustom(false);
291
+ chooseModel(m);
292
+ } }) }))] })] }));
293
+ }
294
+ // ---------- main UI ----------
295
+ if (!ctl)
296
+ return null;
297
+ const agents = [...ctl.board.agents.values()];
298
+ // Names + short aliases (a1, a2, …) — both usable after @ and in commands.
299
+ const agentNames = [...new Set(agents.flatMap((a) => (a.alias && a.alias !== a.name ? [a.alias, a.name] : [a.name])))];
300
+ const approval = ctl.approvals[0];
301
+ const question = approval ? undefined : ctl.questions[0]; // approvals take priority
302
+ const settingsOpen = view === 'settings' || view === 'settings-session';
303
+ const inputActive = inputReady && !approval && !question && !settingsOpen;
304
+ return (_jsx(MainScreen, { ctl: ctl, folder: folder, view: view, focus: focus, systemLines: systemLines, agentNames: agentNames, approval: approval, question: question, inputActive: inputActive, onInput: (value, images) => {
305
+ const v = value.trim();
306
+ // Focus mode: plain text goes straight to the focused agent.
307
+ if (focus && v && !v.startsWith('/') && !v.startsWith('@')) {
308
+ executeInput(`@${focus} ${v}`, ctl, ui, images);
309
+ }
310
+ else {
311
+ executeInput(value, ctl, ui, images);
312
+ }
313
+ }, onEscape: () => {
314
+ if (view !== 'agents')
315
+ setView('agents');
316
+ else if (focus) {
317
+ setFocus(null);
318
+ ui.system(t('m.focusOff'));
319
+ }
320
+ }, notify: ui.system }));
321
+ }
322
+ function MainScreen({ ctl, folder, view, focus, systemLines, agentNames, approval, question, inputActive, onInput, onEscape, notify, }) {
323
+ const agents = [...ctl.board.agents.values()];
324
+ // Adapt the layout to the REAL terminal size (never resize the user's terminal).
325
+ const { stdout } = useStdout();
326
+ const cols = stdout?.columns ?? 100;
327
+ const narrow = cols < 110;
328
+ const logsPerAgent = agents.length <= 1 ? 10 : agents.length <= 2 ? 7 : 5;
329
+ const width = agents.length === 1 || narrow ? '100%' : '50%';
330
+ const settingsOpen = view === 'settings' || view === 'settings-session';
331
+ // Focus mode: one agent rendered alone, with scrollback (PgUp/PgDn).
332
+ const focused = focus
333
+ ? agents.find((a) => a.name.toLowerCase() === focus.toLowerCase())
334
+ : undefined;
335
+ const [scroll, setScroll] = useState(0);
336
+ useEffect(() => setScroll(0), [focus]);
337
+ const FOCUS_LOGS = 20;
338
+ const focusedLogs = focused ? ctl.board.logs.filter((l) => l.agentId === focused.id) : [];
339
+ const maxScroll = Math.max(0, focusedLogs.length - FOCUS_LOGS);
340
+ const clampedScroll = Math.min(scroll, maxScroll);
341
+ const visibleLogs = focused
342
+ ? focusedLogs.slice(Math.max(0, focusedLogs.length - FOCUS_LOGS - clampedScroll), focusedLogs.length - clampedScroll)
343
+ : [];
344
+ // Grid scroll: when more agents than fit on screen, PgUp/PgDn slides a
345
+ // window over the agent panels (with ▲/▼ indicators so you know where you are).
346
+ const GRID_CAP = narrow ? 2 : 4;
347
+ const [gridScroll, setGridScroll] = useState(0);
348
+ const maxGridScroll = Math.max(0, agents.length - GRID_CAP);
349
+ const clampedGrid = Math.min(gridScroll, maxGridScroll);
350
+ const visibleAgents = agents.length > GRID_CAP ? agents.slice(clampedGrid, clampedGrid + GRID_CAP) : agents;
351
+ // Solo scroll: with a SINGLE agent on the agents view, PgUp/PgDn scrolls
352
+ // its log history (same behaviour as /focus, without needing to focus).
353
+ const solo = agents.length === 1 ? agents[0] : null;
354
+ const [soloScroll, setSoloScroll] = useState(0);
355
+ const soloLogs = solo ? ctl.board.logs.filter((l) => l.agentId === solo.id) : [];
356
+ const maxSoloScroll = Math.max(0, soloLogs.length - logsPerAgent);
357
+ const clampedSolo = Math.min(soloScroll, maxSoloScroll);
358
+ // Esc always returns to the agents view, even while approval is shown.
359
+ useInput((_input, key) => {
360
+ if (key.escape)
361
+ onEscape();
362
+ if (focused) {
363
+ if (key.pageUp)
364
+ setScroll((s) => Math.min(s + 10, maxScroll));
365
+ if (key.pageDown)
366
+ setScroll((s) => Math.max(0, s - 10));
367
+ }
368
+ else if (view === 'agents') {
369
+ // Scroll only on the agents view — every other long view
370
+ // (/help, /notes, /diff…) owns PgUp/PgDn for its own scrolling.
371
+ if (solo) {
372
+ if (key.pageUp)
373
+ setSoloScroll((s) => Math.min(Math.min(s, maxSoloScroll) + 5, maxSoloScroll));
374
+ if (key.pageDown)
375
+ setSoloScroll((s) => Math.max(0, Math.min(s, maxSoloScroll) - 5));
376
+ }
377
+ else {
378
+ if (key.pageUp)
379
+ setGridScroll((s) => Math.max(0, Math.min(s, maxGridScroll) - 1));
380
+ if (key.pageDown)
381
+ setGridScroll((s) => Math.min(Math.min(s, maxGridScroll) + 1, maxGridScroll));
382
+ }
383
+ }
384
+ });
385
+ const p = ctl.sessionProvider();
386
+ const activeCount = agents.filter((a) => ['working', 'thinking', 'listening', 'waiting'].includes(a.state)).length;
387
+ const totalCost = agents.reduce((s, a) => s + (a.cost ?? 0), 0);
388
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsxs(Text, { backgroundColor: "cyan", color: "black", bold: true, children: [' ', "\u2302 HUB", ' '] }), _jsxs(Text, { bold: true, color: "cyanBright", children: [' ', LOGO] })] }), _jsxs(Text, { color: "gray", children: [p ? `${p.name}:${ctl.session.model}` : '—', " \u00B7 ", p ? p.baseUrl.replace(/^https?:\/\//, '') : '', " \u00B7", ' ', ctl.session.approvalMode, " \u00B7 ", ctl.session.soundEnabled ? '🔔' : '🔕'] })] }), _jsxs(Text, { color: "gray", wrap: "truncate-end", children: ["\uD83D\uDCC1 ", folder] }), view === 'settings' ? (_jsx(SettingsPanel, { ctl: ctl, scope: "global", onClose: onEscape })) : view === 'settings-session' ? (_jsx(SettingsPanel, { ctl: ctl, scope: "session", onClose: onEscape })) : view === 'board' ? (_jsx(BoardView, { board: ctl.board })) : view === 'notes' ? (_jsx(NotesView, { board: ctl.board })) : view === 'sessions' ? (_jsx(SessionsView, { projectRoot: ctl.projectRoot })) : view === 'diff' ? (_jsx(DiffView, { board: ctl.board })) : view === 'cost' ? (_jsx(CostView, { board: ctl.board })) : view === 'skills' ? (_jsx(SkillsView, { skills: ctl.getSkills() })) : view === 'specialists' ? (_jsx(SpecialistsView, { specialists: ctl.getSpecialists() })) : view === 'help' ? (_jsx(HelpView, {})) : agents.length === 0 ? (_jsx(Box, { borderStyle: "round", borderColor: "gray", flexDirection: "column", paddingX: 1, children: _jsx(Text, { color: "gray", children: t('main.empty') }) })) : focused ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(AgentPanel, { agent: focused, logs: visibleLogs, width: "100%", expanded: true }), _jsxs(Text, { color: "gray", wrap: "truncate-end", children: [t('m.focusHint', { name: focused.name }), clampedScroll > 0 ? ` · ↑${clampedScroll}` : ''] })] })) : (_jsxs(Box, { flexDirection: "column", children: [clampedGrid > 0 ? _jsx(Text, { color: "gray", children: t('grid.above', { n: clampedGrid }) }) : null, _jsx(Box, { flexWrap: "wrap", children: visibleAgents.map((a) => (_jsx(AgentPanel, { agent: a, logs: solo
389
+ ? soloLogs.slice(Math.max(0, soloLogs.length - logsPerAgent - clampedSolo), soloLogs.length - clampedSolo)
390
+ : ctl.board.logsFor(a.id, logsPerAgent), width: width }, a.id))) }), solo && clampedSolo > 0 ? (_jsxs(Text, { color: "gray", wrap: "truncate-end", children: ["\u2191", clampedSolo, " \u00B7 PgDn \u21E3"] })) : null, agents.length - clampedGrid - visibleAgents.length > 0 ? (_jsx(Text, { color: "gray", children: t('grid.below', { n: agents.length - clampedGrid - visibleAgents.length }) })) : null] })), systemLines.length > 0 && !settingsOpen && (_jsx(Box, { flexDirection: "column", children: systemLines.map((l, i) => (_jsx(Text, { color: "gray", wrap: "truncate-end", children: l }, i))) })), approval && (_jsx(ApprovalPrompt, { request: approval, pendingCount: ctl.approvals.length, onAnswer: (id, ok, always) => ctl.answerApproval(id, ok, always) })), question && (_jsx(QuestionPrompt, { question: question, pendingCount: ctl.questions.length, onAnswer: (id, answer, auto) => ctl.answerQuestion(id, answer, auto) }, question.id)), _jsx(CommandInput, { active: inputActive, placeholder: t('main.placeholder'), agentNames: agentNames, onSubmit: onInput, onEscape: onEscape, notify: notify }), _jsx(Text, { color: "gray", wrap: "truncate-end", children: agents.length === 0
391
+ ? t('main.status')
392
+ : t('status.bar', {
393
+ agents: agents.length,
394
+ active: activeCount,
395
+ cost: fmtCost(totalCost),
396
+ }) +
397
+ (ctl.questions.length > 0 ? ` · ❓${ctl.questions.length}` : '') +
398
+ (ctl.approvals.length > 0 ? ` · ⏳${ctl.approvals.length}` : '') +
399
+ (focused ? ` · 🎯 ${focused.name}` : '') })] }));
400
+ }
@@ -0,0 +1,18 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from 'ink';
3
+ import { t } from '../i18n.js';
4
+ const SENTINEL = '<<NAME>>';
5
+ export function ApprovalPrompt({ request, pendingCount, onAnswer }) {
6
+ useInput((input) => {
7
+ const c = input.toLowerCase();
8
+ if (c === 'y' || c === 'o')
9
+ onAnswer(request.id, true);
10
+ else if (c === 'n')
11
+ onAnswer(request.id, false);
12
+ else if (c === 'a')
13
+ onAnswer(request.id, true, true);
14
+ });
15
+ // appr.wants = "Agent {name} wants to run:" — render the name bold by splitting on the placeholder.
16
+ const [before, after = ''] = t('appr.wants', { name: SENTINEL }).split(SENTINEL);
17
+ return (_jsxs(Box, { borderStyle: "double", borderColor: "magenta", flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "magenta", children: [t('appr.title'), pendingCount > 1 ? t('appr.pending', { n: pendingCount }) : ''] }), _jsxs(Text, { children: [before, _jsx(Text, { bold: true, children: request.agentName }), after] }), _jsxs(Text, { color: "yellowBright", bold: true, children: [' $ ', request.command] }), _jsx(Text, { color: "gray", children: t('appr.keys') })] }));
18
+ }
@@ -0,0 +1,126 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import net from 'node:net';
4
+ import { Box, Static, Text, useApp } from 'ink';
5
+ import { ApprovalPrompt } from './ApprovalPrompt.js';
6
+ import { CommandInput } from './CommandInput.js';
7
+ import { KIND_COLOR, KIND_DIM } from './AgentPanel.js';
8
+ import { Md } from './Md.js';
9
+ import { QuestionPrompt } from './QuestionPrompt.js';
10
+ import { Spinner } from './Spinner.js';
11
+ import { STATE_LABEL, stateLabel, elapsed, truncate } from './theme.js';
12
+ import { fmtCost } from '../pricing.js';
13
+ import { t } from '../i18n.js';
14
+ const noop = () => { };
15
+ export function AttachApp({ agentRef, sock }) {
16
+ const { exit } = useApp();
17
+ const [info, setInfo] = useState(null);
18
+ const [others, setOthers] = useState([]);
19
+ const [lines, setLines] = useState([]);
20
+ const [approval, setApproval] = useState(null);
21
+ const [question, setQuestion] = useState(null);
22
+ const [gone, setGone] = useState(false);
23
+ const socketRef = useRef(null);
24
+ const keySeq = useRef(0);
25
+ const lastBellId = useRef('');
26
+ useEffect(() => {
27
+ const socket = net.connect(sock);
28
+ socketRef.current = socket;
29
+ let buffer = '';
30
+ socket.setEncoding('utf8');
31
+ socket.on('connect', () => {
32
+ socket.write(JSON.stringify({ type: 'hello', agent: agentRef }) + '\n');
33
+ });
34
+ socket.on('data', (chunk) => {
35
+ buffer += chunk;
36
+ let nl;
37
+ while ((nl = buffer.indexOf('\n')) !== -1) {
38
+ const line = buffer.slice(0, nl).trim();
39
+ buffer = buffer.slice(nl + 1);
40
+ if (!line)
41
+ continue;
42
+ let msg;
43
+ try {
44
+ msg = JSON.parse(line);
45
+ }
46
+ catch {
47
+ continue;
48
+ }
49
+ if (msg.type === 'state') {
50
+ setInfo(msg.info ?? null);
51
+ setOthers(Array.isArray(msg.others) ? msg.others : []);
52
+ setApproval(msg.approval ?? null);
53
+ setQuestion(msg.question ?? null);
54
+ if (Array.isArray(msg.logs) && msg.logs.length > 0) {
55
+ setLines((prev) => [...prev, ...msg.logs.map((l) => ({ key: ++keySeq.current, log: l }))]);
56
+ }
57
+ }
58
+ else if (msg.type === 'bye') {
59
+ setGone(true);
60
+ }
61
+ }
62
+ });
63
+ const drop = () => setGone(true);
64
+ socket.on('close', drop);
65
+ socket.on('error', drop);
66
+ return () => {
67
+ socket.destroy();
68
+ };
69
+ }, [agentRef, sock]);
70
+ // Audible alert in THIS terminal when a new interaction arrives — the hub
71
+ // also rings, but the user may well be looking at the agent's terminal.
72
+ useEffect(() => {
73
+ const id = approval ? `a${approval.id}` : question ? `q${question.id}` : '';
74
+ if (id && id !== lastBellId.current) {
75
+ lastBellId.current = id;
76
+ process.stdout.write('\x07');
77
+ setTimeout(() => process.stdout.write('\x07'), 300);
78
+ }
79
+ if (!id)
80
+ lastBellId.current = '';
81
+ }, [approval?.id, question?.id]);
82
+ const wire = (msg) => {
83
+ socketRef.current?.write(JSON.stringify(msg) + '\n');
84
+ };
85
+ const send = (text) => {
86
+ const v = text.trim();
87
+ if (!v)
88
+ return;
89
+ if (v === '/quit' || v === '/exit' || v === '/detach') {
90
+ exit();
91
+ return;
92
+ }
93
+ // /spawn <task> — launch agent N+1 from THIS terminal; its own dedicated
94
+ // terminal opens automatically (the main TUI stays the session hub).
95
+ const spawn = v.match(/^\/spawn\s+(.+)$/s);
96
+ if (spawn) {
97
+ wire({ type: 'spawn', text: spawn[1] });
98
+ return;
99
+ }
100
+ wire({ type: 'input', agent: agentRef, text: v });
101
+ };
102
+ const st = info ? STATE_LABEL[info.state] : null;
103
+ const busy = info ? ['thinking', 'working', 'listening'].includes(info.state) : false;
104
+ const interacting = Boolean(approval || question);
105
+ const banner = (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { backgroundColor: info?.color ?? 'gray', color: "black", bold: true, children: [' ', "\u26D3 ", t('attach.banner'), ' '] }), info ? (_jsxs(Text, { color: info.color, bold: true, children: [' ', "\u25C6 ", info.name, info.alias && info.alias !== info.name ? _jsxs(Text, { color: "gray", children: [" @", info.alias] }) : null] })) : null] }));
106
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: lines, children: (item) => (_jsx(Text, { color: KIND_COLOR[item.log.kind] ?? 'white', italic: KIND_DIM[item.log.kind] ?? false, wrap: "wrap", children: item.log.text }, item.key)) }), busy && info && st && !interacting ? (
107
+ /* COMPACT region while the agent runs: small + borderless, so Ink's
108
+ * constant repaints (spinner ticks) never erase tall zones — this is
109
+ * what used to leave stray blank lines in the native scrollback. */
110
+ _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { backgroundColor: info.color, color: "black", bold: true, children: [' ', "\u26D3 ", info.name, ' '] }), ' ', _jsxs(Text, { backgroundColor: st.color, color: "black", bold: true, children: [' ', st.icon, " ", stateLabel(info.state), ' '] }), ' ', _jsx(Spinner, { color: info.color }), _jsxs(Text, { color: "gray", children: [' ', "\u00B7 ", elapsed(info.startedAt), " \u00B7 ", info.steps, " st \u00B7", ' ', Math.round((info.tokensIn + info.tokensOut) / 1000), "k \u00B7", ' '] }), _jsx(Text, { color: "greenBright", children: info.cost === null ? '$—' : fmtCost(info.cost) })] }), info.currentAction ? (_jsxs(Text, { color: info.color, wrap: "truncate-end", children: ["\u25B8 ", truncate(info.currentAction, 120)] })) : null, others.length > 0 ? (_jsxs(Text, { color: "gray", wrap: "truncate-end", children: ["\u21C4", ' ', others
111
+ .map((o) => `${o.name} [${stateLabel(o.state)}] ${truncate(o.currentAction || o.task, 40)}`)
112
+ .join(' · ')] })) : null] })) : (
113
+ /* FULL panel when idle / waiting / done — repaints are rare here. */
114
+ _jsxs(Box, { borderStyle: "round", borderColor: info?.color ?? 'gray', flexDirection: "column", paddingX: 1, marginTop: 1, children: [info && st ? (_jsxs(_Fragment, { children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { children: [banner, _jsxs(Text, { backgroundColor: st.color, color: "black", bold: true, children: [' ', st.icon, " ", stateLabel(info.state), ' '] })] }), _jsxs(Text, { color: "gray", wrap: "truncate-end", children: [truncate(info.model, 18), " \u00B7 ", elapsed(info.startedAt), " \u00B7 ", info.steps, " st \u00B7", ' ', Math.round((info.tokensIn + info.tokensOut) / 1000), "k \u00B7", ' ', info.ctxPct !== undefined ? (_jsxs(Text, { color: info.ctxPct >= 90 ? 'redBright' : info.ctxPct >= 70 ? 'yellowBright' : 'gray', children: ["\u25D4", info.ctxPct, "% \u00B7", ' '] })) : null, _jsx(Text, { color: "greenBright", children: info.cost === null ? '$—' : fmtCost(info.cost) })] })] }), _jsxs(Text, { color: "gray", wrap: "wrap", children: ["\u25E6 ", info.task] }), info.currentAction ? (_jsxs(Text, { color: info.color, wrap: "truncate-end", children: ["\u25B8 ", truncate(info.currentAction, 160)] })) : null, others.length > 0 ? (
115
+ // The session's shared awareness, visible here too: what the
116
+ // OTHER agents are doing right now (live, same feed the agents get).
117
+ _jsxs(Text, { color: "gray", wrap: "truncate-end", children: ["\u21C4", ' ', others
118
+ .map((o) => `${o.name} [${stateLabel(o.state)}] ${truncate(o.currentAction || o.task, 40)}`)
119
+ .join(' · ')] })) : null, info.lastResult && (info.state === 'done' || info.state === 'error' || info.state === 'stopped') ? (_jsxs(Box, { borderStyle: "single", borderColor: "gray", flexDirection: "column", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "greenBright", bold: true, children: t('agent.summary') }), _jsx(Md, { text: info.lastResult })] })) : null] })) : (_jsx(Text, { color: "gray", children: gone ? t('attach.gone') : t('attach.waiting', { agent: agentRef }) })), gone && info ? _jsx(Text, { color: "redBright", children: t('attach.gone') }) : null] })), approval ? (_jsx(ApprovalPrompt, { request: { ...approval, agentId: info?.id ?? '', resolve: noop }, pendingCount: 1, onAnswer: (id, ok, always) => {
120
+ wire({ type: 'approve', id, approved: ok, always: !!always });
121
+ setApproval(null);
122
+ } })) : question ? (_jsx(QuestionPrompt, { question: { ...question, agentId: info?.id ?? '', resolve: noop }, pendingCount: 1, onAnswer: (id, answer) => {
123
+ wire({ type: 'answer', id, text: answer });
124
+ setQuestion(null);
125
+ } }, question.id)) : null, _jsx(CommandInput, { active: !gone && !interacting, placeholder: t('attach.placeholder', { agent: info?.name ?? agentRef }), onSubmit: send, onEscape: () => exit() }), _jsx(Text, { color: "gray", wrap: "truncate-end", children: t('attach.hint') })] }));
126
+ }