@parallel-cli/parallel 0.4.1 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ui/App.js CHANGED
@@ -6,7 +6,7 @@ import { Box, Text, useApp, useInput, useStdout } from 'ink';
6
6
  import { Controller } from '../controller.js';
7
7
  import { startSessionServer } from '../server.js';
8
8
  import { executeInput } from '../commands.js';
9
- import { PROVIDER_PRESETS, getProvider, rememberFolder, saveConfig } from '../config.js';
9
+ import { PROVIDER_PRESETS, detectProviderModels, getProvider, isLocalProvider, isPlaceholderModel, providerNeedsApiKey, providerReady, rememberFolder, saveConfig, } from '../config.js';
10
10
  import { LANGS, setLang, t } from '../i18n.js';
11
11
  import { AgentRow, AgentTranscript } from './AgentPanel.js';
12
12
  import { ApprovalPrompt } from './ApprovalPrompt.js';
@@ -17,11 +17,11 @@ import { BoardView, CostView, DiffView, HelpView, NotesView, SessionsView, Skill
17
17
  import { SelectList, WizardStep } from './Wizard.js';
18
18
  import { BRAND, CHROME, STATE, STATE_META, UI, middleTruncate } from './tokens.js';
19
19
  const LOGO = 'Parallel';
20
- // Version from package.json (v0.3.3). Hardcoded — rootDir: "src" prevents importing ../../package.json.
21
- const VERSION = '0.3.3';
20
+ // Version from package.json. Hardcoded — rootDir: "src" prevents importing ../../package.json.
21
+ const VERSION = '0.4.4';
22
22
  function usableProvider(config) {
23
23
  const p = getProvider(config);
24
- return p && p.apiKey && (p.defaultModel || p.models[0]) ? p : undefined;
24
+ return p && providerReady(p) && (p.defaultModel || p.models[0]) ? p : undefined;
25
25
  }
26
26
  function normalizeFolder(p) {
27
27
  return path.resolve(p.replace(/^~(?=$|\/)/, process.env.HOME ?? '~'));
@@ -47,6 +47,8 @@ function startupFolder(config, initialFolder) {
47
47
  }
48
48
  export function App({ config, initialFolder }) {
49
49
  const { exit } = useApp();
50
+ const { stdout } = useStdout();
51
+ const wizardListHeight = Math.max(4, (stdout?.rows ?? 30) - 10);
50
52
  const initialUsableProvider = usableProvider(config);
51
53
  const directFolder = config.language && initialUsableProvider ? startupFolder(config, initialFolder) : null;
52
54
  // ---------- wizard state ----------
@@ -58,7 +60,7 @@ export function App({ config, initialFolder }) {
58
60
  const [modelCustom, setModelCustom] = useState(false);
59
61
  const ctlRef = useRef(directFolder ? new Controller(config, directFolder) : null);
60
62
  // ---------- main state ----------
61
- const [, setTick] = useState(0);
63
+ const [tick, setTick] = useState(0);
62
64
  const [view, setView] = useState('agents');
63
65
  // Focus mode (/focus <agent>): plain input is routed to that agent.
64
66
  const [focus, setFocus] = useState(null);
@@ -71,6 +73,19 @@ export function App({ config, initialFolder }) {
71
73
  : []);
72
74
  const [inputReady, setInputReady] = useState(Boolean(directFolder));
73
75
  const ctl = ctlRef.current;
76
+ const leaveCurrentProject = () => {
77
+ const current = ctlRef.current;
78
+ current?.saveSession();
79
+ current?.stopAll();
80
+ setFocus(null);
81
+ setView('agents');
82
+ setRawLogs(false);
83
+ setProviderStep({ id: 'pick' });
84
+ setModelCustom(false);
85
+ setWizardError('');
86
+ setSessions([]);
87
+ setInputReady(false);
88
+ };
74
89
  // Re-render (throttled) on every blackboard/controller update.
75
90
  useEffect(() => {
76
91
  if (!ctl)
@@ -92,6 +107,12 @@ export function App({ config, initialFolder }) {
92
107
  clearInterval(timer);
93
108
  };
94
109
  }, [ctl]);
110
+ useEffect(() => {
111
+ if (!ctl || !focus)
112
+ return;
113
+ if (!ctl.board.getAgentByName(focus))
114
+ setFocus(null);
115
+ }, [ctl, focus, tick]);
95
116
  // Session server: lets `parallel attach <agent>` open per-agent terminals.
96
117
  useEffect(() => {
97
118
  if (!ctl)
@@ -132,6 +153,22 @@ export function App({ config, initialFolder }) {
132
153
  { text: t('m.copyDone', { name: latest.name }), level: 'ok' },
133
154
  ]);
134
155
  },
156
+ openProject: (nextFolder) => {
157
+ leaveCurrentProject();
158
+ if (nextFolder) {
159
+ chooseFolder(nextFolder);
160
+ return;
161
+ }
162
+ ctlRef.current = null;
163
+ setFolder('');
164
+ setPhase('folder');
165
+ },
166
+ openWizard: () => {
167
+ leaveCurrentProject();
168
+ ctlRef.current = null;
169
+ setFolder('');
170
+ setPhase('lang');
171
+ },
135
172
  }), [exit]);
136
173
  // ---------- wizard transitions ----------
137
174
  // In normal launches, a complete config goes straight to the main TUI.
@@ -193,20 +230,32 @@ export function App({ config, initialFolder }) {
193
230
  if (providerStep.id === 'pick') {
194
231
  setPhase(sessions.length > 0 ? 'session' : 'folder');
195
232
  }
196
- else if (providerStep.id === 'key') {
233
+ else if (providerStep.id === 'presetModel') {
197
234
  setProviderStep({ id: 'pick' });
198
235
  }
236
+ else if (providerStep.id === 'endpoint') {
237
+ const isPreset = PROVIDER_PRESETS.some((p) => p.name.toLowerCase() === providerStep.provider.name.toLowerCase());
238
+ setProviderStep(isPreset
239
+ ? { id: 'presetModel', provider: providerStep.provider }
240
+ : { id: 'customModel', name: providerStep.provider.name, url: providerStep.provider.baseUrl });
241
+ }
242
+ else if (providerStep.id === 'editEndpoint') {
243
+ setProviderStep({ id: 'endpoint', provider: providerStep.provider });
244
+ }
245
+ else if (providerStep.id === 'key') {
246
+ setProviderStep({ id: 'endpoint', provider: providerStep.provider });
247
+ }
199
248
  else if (providerStep.id === 'name') {
200
249
  setProviderStep({ id: 'pick' });
201
250
  }
202
251
  else if (providerStep.id === 'url') {
203
252
  setProviderStep({ id: 'name' });
204
253
  }
205
- else if (providerStep.id === 'model') {
254
+ else if (providerStep.id === 'customModel') {
206
255
  setProviderStep({ id: 'url', name: providerStep.name });
207
256
  }
208
257
  else if (providerStep.id === 'newKey') {
209
- setProviderStep({ id: 'model', name: providerStep.name, url: providerStep.url });
258
+ setProviderStep({ id: 'customModel', name: providerStep.provider.name, url: providerStep.provider.baseUrl });
210
259
  }
211
260
  }
212
261
  };
@@ -221,6 +270,9 @@ export function App({ config, initialFolder }) {
221
270
  const finishProvider = (p) => {
222
271
  ctlRef.current?.saveProvider(p);
223
272
  ctlRef.current?.setDefaultProvider(p.name);
273
+ ctlRef.current?.setSessionProvider(p.name);
274
+ if (p.defaultModel || p.models[0])
275
+ ctlRef.current?.setSessionModel(`${p.name}:${p.defaultModel || p.models[0]}`);
224
276
  setProviderStep({ id: 'pick' });
225
277
  enterMain();
226
278
  };
@@ -259,12 +311,12 @@ export function App({ config, initialFolder }) {
259
311
  if (phase !== 'main') {
260
312
  const totalSteps = 5;
261
313
  const sessionProvider = ctl ? ctl.sessionProvider() : getProvider(config);
262
- 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: [
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: [
263
315
  { label: process.cwd(), value: process.cwd(), hint: t('wiz.folder.current') },
264
316
  ...config.recentFolders
265
317
  .filter((f) => f !== process.cwd())
266
318
  .map((f) => ({ label: f, value: f, hint: t('wiz.folder.recent') })),
267
- ], 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: [
319
+ ], height: wizardListHeight, 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: [
268
320
  { label: t('wiz.session.new'), value: '__new__', hint: t('wiz.session.newHint') },
269
321
  ...sessions.map((s) => ({
270
322
  label: t('wiz.session.item', { date: new Date(s.data.savedAt).toLocaleString() }),
@@ -274,39 +326,46 @@ export function App({ config, initialFolder }) {
274
326
  .join(', ')
275
327
  .slice(0, 60)})`,
276
328
  })),
277
- ], onBack: wizardBack, onSelect: chooseSession }) })), phase === 'provider' && providerStep.id === 'pick' && (_jsx(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.title'), children: _jsx(SelectList, { items: (() => {
329
+ ], height: wizardListHeight, onBack: wizardBack, onSelect: chooseSession }) })), phase === 'provider' && providerStep.id === 'pick' && (_jsx(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.title'), children: _jsx(SelectList, { items: (() => {
278
330
  const items = [];
331
+ const configuredNames = new Set(config.providers.map((p) => p.name.toLowerCase()));
279
332
  // Section: Configured
280
- const configured = config.providers.map((p) => ({
281
- label: p.name,
282
- value: p.name,
283
- detail: p.apiKey ? undefined : t('wiz.provider.needsKey'),
284
- }));
285
- if (configured.length > 0) {
333
+ if (config.providers.length > 0) {
286
334
  items.push({ label: t('wiz.provider.section.configured'), value: '', section: true });
287
- items.push(...configured);
288
- }
289
- // Section: Cloud Providers (presets except Ollama, not already configured)
290
- const cloudPresets = PROVIDER_PRESETS.filter((preset) => preset.name !== 'Ollama' &&
291
- !config.providers.some((p) => p.name.toLowerCase() === preset.name.toLowerCase()));
292
- if (cloudPresets.length > 0) {
293
- items.push({ label: t('wiz.provider.section.cloud'), value: '', section: true });
294
- items.push(...cloudPresets.map((preset) => ({
295
- label: preset.name,
296
- value: preset.name,
297
- detail: preset.defaultModel,
298
- })));
335
+ for (const p of config.providers) {
336
+ items.push({
337
+ label: p.name,
338
+ value: p.name,
339
+ detail: p.apiKey ? undefined : t('wiz.provider.needsKey'),
340
+ });
341
+ }
299
342
  }
300
- // Section: Local (Ollama only, if not already configured)
301
- const ollamaPreset = PROVIDER_PRESETS.find((p) => p.name === 'Ollama');
302
- const ollamaConfigured = config.providers.some((p) => p.name.toLowerCase() === 'ollama');
303
- if (ollamaPreset && !ollamaConfigured) {
304
- items.push({ label: t('wiz.provider.section.local'), value: '', section: true });
305
- items.push({
306
- label: ollamaPreset.name,
307
- value: ollamaPreset.name,
308
- detail: t('wiz.provider.ollamaDetail'),
309
- });
343
+ // Sections per category: western, chinese, gateways, inference, local
344
+ const catOrder = ['western', 'chinese', 'gateways', 'inference', 'local'];
345
+ const emoji = {
346
+ western: '\u{1F1FA}\u{1F1F8} ',
347
+ chinese: '\u{1F1E8}\u{1F1F3} ',
348
+ gateways: '\u{1F310} ',
349
+ inference: '\u26A1 ',
350
+ local: '\u{1F3E0} ',
351
+ };
352
+ for (const cat of catOrder) {
353
+ const presetsInCat = PROVIDER_PRESETS.filter((p) => p.category === cat && !configuredNames.has(p.name.toLowerCase()));
354
+ if (presetsInCat.length === 0)
355
+ continue;
356
+ const key = `wiz.provider.section.${cat}`;
357
+ const sectionLabel = emoji[cat] + t(key);
358
+ items.push({ value: '', label: sectionLabel, section: true });
359
+ for (const preset of presetsInCat) {
360
+ const detail = preset.models.length > 0
361
+ ? `${preset.models.length} model${preset.models.length > 1 ? 's' : ''}`
362
+ : undefined;
363
+ items.push({
364
+ value: preset.name,
365
+ label: preset.name,
366
+ detail,
367
+ });
368
+ }
310
369
  }
311
370
  // Custom always last
312
371
  items.push({
@@ -315,19 +374,19 @@ export function App({ config, initialFolder }) {
315
374
  detail: t('wiz.provider.customDetail'),
316
375
  });
317
376
  return items;
318
- })(), onBack: wizardBack, onSelect: async (v) => {
377
+ })(), height: wizardListHeight, onBack: wizardBack, onSelect: async (v) => {
319
378
  if (v === '__custom__')
320
379
  return setProviderStep({ id: 'name' });
321
380
  // Already-configured provider?
322
381
  const existing = config.providers.find((x) => x.name === v);
323
382
  if (existing) {
324
- if (existing.apiKey) {
383
+ if (providerReady(existing)) {
325
384
  ctlRef.current?.setDefaultProvider(v);
326
385
  ctlRef.current?.setSessionProvider(v);
327
- enterMain();
386
+ setPhase('model');
328
387
  }
329
388
  else {
330
- setProviderStep({ id: 'key', preset: existing });
389
+ setProviderStep({ id: 'presetModel', provider: existing });
331
390
  }
332
391
  return;
333
392
  }
@@ -341,45 +400,87 @@ export function App({ config, initialFolder }) {
341
400
  ...ls.slice(-5),
342
401
  { text: t('wiz.provider.ollama.checking', { url: preset.baseUrl }), level: 'info' },
343
402
  ]);
344
- let models = [...preset.models];
345
- let defaultModel = preset.defaultModel;
346
- try {
347
- const controller = new AbortController();
348
- const timeout = setTimeout(() => controller.abort(), 2000);
349
- const resp = await fetch(preset.baseUrl + '/models', { signal: controller.signal });
350
- clearTimeout(timeout);
351
- if (resp.ok) {
352
- const data = (await resp.json());
353
- const detected = data?.data?.map((m) => m.id).filter(Boolean) ?? [];
354
- if (detected.length > 0) {
355
- models = detected;
356
- defaultModel = detected[0];
357
- setSystemLines((ls) => [
358
- ...ls.slice(-5),
359
- { text: t('wiz.provider.ollama.found', { n: detected.length }), level: 'ok' },
360
- ]);
361
- }
362
- }
403
+ const detected = await detectProviderModels(preset);
404
+ let models = detected?.models ?? [...preset.models];
405
+ let defaultModel = detected?.defaultModel ?? preset.defaultModel;
406
+ if (detected) {
407
+ setSystemLines((ls) => [
408
+ ...ls.slice(-5),
409
+ { text: t('wiz.provider.ollama.found', { n: detected.models.length }), level: 'ok' },
410
+ ]);
363
411
  }
364
- catch {
412
+ else {
365
413
  setSystemLines((ls) => [
366
414
  ...ls.slice(-5),
367
415
  { text: t('wiz.provider.ollama.notFound', { url: preset.baseUrl }), level: 'warn' },
368
416
  ]);
369
417
  }
370
418
  const ollamaProvider = { ...preset, apiKey: 'ollama-local', models, defaultModel };
371
- ctlRef.current?.saveProvider(ollamaProvider);
372
- ctlRef.current?.setSessionProvider(ollamaProvider.name);
373
- setPhase('model');
419
+ setProviderStep({ id: 'presetModel', provider: ollamaProvider });
374
420
  return;
375
421
  }
376
- setProviderStep({ id: 'key', preset: { ...preset, models: [...preset.models] } });
377
- } }) })), phase === 'provider' && providerStep.id === 'key' && (_jsxs(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.key.title', { name: providerStep.preset.name }), footer: t('wiz.provider.key.footer'), children: [_jsx(Text, { color: "gray", children: providerStep.preset.baseUrl }), _jsx(SelectList, { items: [], allowInput: true, mask: true, inputPlaceholder: "sk-\u2026", onBack: wizardBack, onInput: (k) => finishProvider({ ...providerStep.preset, apiKey: k.trim() }) })] })), phase === 'provider' && providerStep.id === 'name' && (_jsx(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.name.title'), footer: t('wiz.footer.type'), children: _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: t('wiz.provider.name.ph'), onBack: wizardBack, onInput: (name) => setProviderStep({ id: 'url', name }) }) })), phase === 'provider' && providerStep.id === 'url' && (_jsx(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.url.title'), footer: t('wiz.footer.type'), children: _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: t('wiz.provider.url.ph'), onBack: wizardBack, onInput: (url) => setProviderStep({ id: 'model', name: providerStep.name, url }) }) })), phase === 'provider' && providerStep.id === 'model' && (_jsx(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.model.title'), footer: t('wiz.footer.type'), children: _jsx(SelectList, { items: [], allowInput: true, inputPlaceholder: t('wiz.provider.model.ph'), onBack: wizardBack, onInput: (model) => setProviderStep({ id: 'newKey', name: providerStep.name, url: providerStep.url, model }) }) })), phase === 'provider' && providerStep.id === 'newKey' && (_jsx(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.key.title', { name: providerStep.name }), footer: t('wiz.provider.key.footer'), children: _jsx(SelectList, { items: [], allowInput: true, mask: true, inputPlaceholder: "sk-\u2026", onBack: wizardBack, onInput: (key) => finishProvider({
378
- name: providerStep.name,
379
- baseUrl: providerStep.url,
422
+ setProviderStep({ id: 'presetModel', provider: { ...preset, models: [...preset.models] } });
423
+ } }) })), phase === 'provider' && providerStep.id === 'presetModel' && (_jsxs(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.model.title'), children: [_jsx(Text, { color: "gray", children: t('wiz.model.provider', { name: providerStep.provider.name, url: providerStep.provider.baseUrl }) }), _jsx(SelectList, { items: [
424
+ ...providerStep.provider.models.map((m) => ({
425
+ label: m,
426
+ value: m,
427
+ hint: m === providerStep.provider.defaultModel ? t('wiz.model.default') : undefined,
428
+ })),
429
+ ], height: wizardListHeight, allowInput: true, inputPlaceholder: t('wiz.provider.model.ph'), onBack: wizardBack, onSelect: (v) => {
430
+ const provider = { ...providerStep.provider };
431
+ provider.defaultModel = v;
432
+ if (!provider.models.includes(v))
433
+ provider.models.push(v);
434
+ setProviderStep({ id: 'endpoint', provider });
435
+ }, onInput: (m) => {
436
+ const model = m.trim();
437
+ if (!model)
438
+ return;
439
+ const provider = { ...providerStep.provider, models: [...providerStep.provider.models] };
440
+ provider.defaultModel = model;
441
+ if (!provider.models.includes(model))
442
+ provider.models.push(model);
443
+ setProviderStep({ id: 'endpoint', provider });
444
+ } })] })), phase === 'provider' && providerStep.id === 'endpoint' && (_jsxs(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.endpoint.title', { name: providerStep.provider.name }), children: [_jsx(Text, { color: "gray", children: providerStep.provider.baseUrl }), _jsx(Text, { color: "gray", children: t('wiz.provider.endpoint.model', { model: providerStep.provider.defaultModel || providerStep.provider.models[0] || '—' }) }), _jsx(SelectList, { items: [
445
+ { label: t('wiz.provider.endpoint.use'), value: 'use' },
446
+ { label: t('wiz.provider.endpoint.edit'), value: 'edit' },
447
+ ], height: wizardListHeight, onBack: wizardBack, onSelect: (v) => {
448
+ if (v === 'edit')
449
+ return setProviderStep({ id: 'editEndpoint', provider: providerStep.provider });
450
+ if (providerNeedsApiKey(providerStep.provider))
451
+ return setProviderStep({ id: 'key', provider: providerStep.provider });
452
+ finishProvider(providerStep.provider);
453
+ } })] })), phase === 'provider' && providerStep.id === 'editEndpoint' && (_jsx(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.url.title'), footer: t('wiz.footer.type'), children: _jsx(SelectList, { items: [], height: wizardListHeight, allowInput: true, inputPlaceholder: providerStep.provider.baseUrl, onBack: wizardBack, onInput: (url) => {
454
+ const baseUrl = url.trim();
455
+ setProviderStep({
456
+ id: 'endpoint',
457
+ provider: {
458
+ ...providerStep.provider,
459
+ baseUrl,
460
+ requiresApiKey: !isLocalProvider({ baseUrl }),
461
+ },
462
+ });
463
+ } }) })), phase === 'provider' && providerStep.id === 'key' && (_jsxs(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.key.title', { name: providerStep.provider.name }), footer: t('wiz.provider.key.footer'), children: [_jsx(Text, { color: "gray", children: providerStep.provider.baseUrl }), _jsx(SelectList, { items: [], height: wizardListHeight, allowInput: true, mask: true, inputPlaceholder: "sk-\u2026", onBack: wizardBack, onInput: (k) => finishProvider({ ...providerStep.provider, 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: [], height: wizardListHeight, 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: [], height: wizardListHeight, allowInput: true, inputPlaceholder: t('wiz.provider.url.ph'), onBack: wizardBack, onInput: (url) => setProviderStep({ id: 'customModel', name: providerStep.name, url }) }) })), phase === 'provider' && providerStep.id === 'customModel' && (_jsx(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.model.title'), footer: t('wiz.footer.type'), children: _jsx(SelectList, { items: [], height: wizardListHeight, allowInput: true, inputPlaceholder: t('wiz.provider.model.ph'), onBack: wizardBack, onInput: (model) => {
464
+ const trimmed = model.trim();
465
+ if (isPlaceholderModel(trimmed)) {
466
+ setSystemLines((ls) => [...ls.slice(-5), { text: t('set.modelPlaceholder'), level: 'warn' }]);
467
+ return;
468
+ }
469
+ const local = isLocalProvider({ baseUrl: providerStep.url });
470
+ setProviderStep({
471
+ id: 'endpoint',
472
+ provider: {
473
+ name: providerStep.name,
474
+ baseUrl: providerStep.url,
475
+ apiKey: '',
476
+ models: [trimmed],
477
+ defaultModel: trimmed,
478
+ requiresApiKey: !local,
479
+ },
480
+ });
481
+ } }) })), phase === 'provider' && providerStep.id === 'newKey' && (_jsx(WizardStep, { step: 4, total: totalSteps, title: t('wiz.provider.key.title', { name: providerStep.provider.name }), footer: t('wiz.provider.key.footer'), children: _jsx(SelectList, { items: [], height: wizardListHeight, allowInput: true, mask: true, inputPlaceholder: "sk-\u2026", onBack: wizardBack, onInput: (key) => finishProvider({
482
+ ...providerStep.provider,
380
483
  apiKey: key.trim(),
381
- models: [providerStep.model],
382
- defaultModel: providerStep.model,
383
484
  }) }) })), 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: [
384
485
  ...sessionProvider.models.map((m) => ({
385
486
  label: m,
@@ -388,7 +489,7 @@ export function App({ config, initialFolder }) {
388
489
  })),
389
490
  { label: t('wiz.model.custom'), value: '__custom__', hint: t('wiz.model.customHint') },
390
491
  { label: t('wiz.model.addProvider'), value: '__provider__' },
391
- ], 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) => {
492
+ ], height: wizardListHeight, 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: [], height: wizardListHeight, allowInput: true, inputPlaceholder: t('wiz.provider.model.ph'), onBack: wizardBack, onInput: (m) => {
392
493
  setModelCustom(false);
393
494
  chooseModel(m);
394
495
  } }) }))] })] }));
@@ -402,7 +503,8 @@ export function App({ config, initialFolder }) {
402
503
  const approval = ctl.approvals[0];
403
504
  const question = approval ? undefined : ctl.questions[0]; // approvals take priority
404
505
  const settingsOpen = view === 'settings' || view === 'settings-session';
405
- const inputActive = inputReady && !approval && !question && !settingsOpen;
506
+ const viewOwnsKeyboard = view !== 'agents' && !settingsOpen;
507
+ const inputActive = inputReady && !approval && !question && !settingsOpen && !viewOwnsKeyboard;
406
508
  return (_jsx(MainScreen, { ctl: ctl, folder: folder, view: view, focus: focus, rawLogs: rawLogs, systemLines: systemLines, agentNames: agentNames, approval: approval, question: question, inputActive: inputActive, onInput: (value, images) => {
407
509
  const v = value.trim();
408
510
  // Focus mode: plain text goes straight to the focused agent.
@@ -425,8 +527,8 @@ function MainScreen({ ctl, folder, view, focus, rawLogs, systemLines, agentNames
425
527
  const agents = [...ctl.board.agents.values()];
426
528
  // Adapt the layout to the REAL terminal size (never resize the user's terminal).
427
529
  const { stdout } = useStdout();
428
- const cols = stdout?.columns ?? 100;
429
- const rows = stdout?.rows ?? 30;
530
+ const cols = Math.max(20, stdout?.columns ?? 100);
531
+ const rows = Math.max(12, stdout?.rows ?? 30);
430
532
  const settingsOpen = view === 'settings' || view === 'settings-session';
431
533
  // Height budget: fixed sections → body gets the remainder.
432
534
  const headerLines = 4; // border-box header (top border + 2 content lines + bottom border)
@@ -463,8 +565,8 @@ function MainScreen({ ctl, folder, view, focus, rawLogs, systemLines, agentNames
463
565
  : [];
464
566
  const [hubScroll, setHubScroll] = useState(0);
465
567
  const [hubFollowTail, setHubFollowTail] = useState(true);
466
- const hubRows = Math.max(6, bodyHeight - 2);
467
- const maxHubScroll = Math.max(0, agents.length - hubRows);
568
+ const hubRows = Math.max(3, bodyHeight - 2);
569
+ const maxHubScroll = Math.max(0, agents.length - Math.max(1, Math.floor(hubRows / 2)));
468
570
  const clampedHub = Math.min(hubScroll, maxHubScroll);
469
571
  const logSeq = ctl.board.logs.length > 0 ? ctl.board.logs[ctl.board.logs.length - 1].seq ?? ctl.board.logs.length : 0;
470
572
  useEffect(() => {
@@ -475,7 +577,7 @@ function MainScreen({ ctl, folder, view, focus, rawLogs, systemLines, agentNames
475
577
  if (hubFollowTail)
476
578
  setHubScroll(0);
477
579
  }, [logSeq, agents.length, hubFollowTail]);
478
- // Scroll helpers (also used by mouse wheel handler below).
580
+ // Scroll helpers.
479
581
  const scrollFocusUp = () => {
480
582
  setFocusFollowTail(false);
481
583
  setScroll((s) => Math.min(s + 1, maxScroll));
@@ -500,25 +602,23 @@ function MainScreen({ ctl, folder, view, focus, rawLogs, systemLines, agentNames
500
602
  return next;
501
603
  });
502
604
  };
503
- // Keyboard: Esc / PgUp-PgDn / Up-Down arrows.
504
- // When CommandInput is NOT focused, Up/Down scroll the hub or focused agent;
505
- // when it IS focused, CommandInput's own useInput sees them first (history nav).
605
+ // Keyboard: PgUp/PgDn always scroll hub/focus. Up/Down only scroll when input is inactive.
506
606
  useInput((_input, key) => {
507
- if (key.escape)
607
+ if (key.escape && !settingsOpen)
508
608
  onEscape();
509
609
  if (focused) {
510
- if (key.pageUp || key.upArrow)
610
+ if (key.pageUp || (!inputActive && key.upArrow))
511
611
  scrollFocusUp();
512
- if (key.pageDown || key.downArrow)
612
+ if (key.pageDown || (!inputActive && key.downArrow))
513
613
  scrollFocusDown();
514
614
  }
515
615
  else if (view === 'agents') {
516
- if (key.pageUp || key.upArrow)
616
+ if (key.pageUp || (!inputActive && key.upArrow))
517
617
  scrollHubUp();
518
- if (key.pageDown || key.downArrow)
618
+ if (key.pageDown || (!inputActive && key.downArrow))
519
619
  scrollHubDown();
520
620
  }
521
- }, { isActive: !inputActive });
621
+ }, { isActive: !approval && !question });
522
622
  const idleCount = agents.filter((a) => a.state === 'idle').length;
523
623
  const workingCount = agents.filter((a) => ['working', 'thinking', 'listening'].includes(a.state)).length;
524
624
  const doneCount = agents.filter((a) => a.state === 'done').length;
@@ -542,7 +642,7 @@ function MainScreen({ ctl, folder, view, focus, rawLogs, systemLines, agentNames
542
642
  specialists: 'specialists',
543
643
  };
544
644
  const viewLabel = VIEW_LABEL[view] ?? 'control room';
545
- return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: CHROME.muted, children: ["\u256D", '─'.repeat(cols - 2), "\u256E"] }), _jsxs(Box, { flexDirection: "row", width: cols, children: [_jsx(Text, { color: CHROME.muted, children: "\u2502 " }), _jsxs(Box, { flexDirection: "row", width: cols - 4, justifyContent: "space-between", children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { bold: true, color: BRAND.primary, children: "PARALLEL" }), _jsx(Text, { color: globalDotColor, children: " \u25CF" }), _jsxs(Text, { color: view === 'agents' ? CHROME.muted : BRAND.muted, children: [" ", viewLabel] }), rawLogs ? _jsx(Text, { color: UI.warn, children: " [RAW]" }) : null] }), _jsx(Text, { color: CHROME.muted, children: middleTruncate(folder, folderMax) })] }), _jsx(Text, { color: CHROME.muted, children: " \u2502" })] }), _jsxs(Box, { flexDirection: "row", width: cols, children: [_jsx(Text, { color: CHROME.muted, children: "\u2502 " }), _jsxs(Box, { flexDirection: "row", width: cols - 4, justifyContent: agents.length > 0 ? 'space-between' : 'flex-end', children: [agents.length > 0 ? (_jsx(Box, { flexDirection: "row", children: _jsxs(Text, { children: [_jsxs(Text, { color: CHROME.muted, children: ["\u25C7 ", idleCount, " idle"] }), ' · ', _jsxs(Text, { color: workingCount > 0 ? STATE.working : CHROME.muted, children: ["\u25CF ", workingCount, " active"] }), ' · ', _jsxs(Text, { color: doneCount > 0 ? STATE.done : CHROME.muted, children: ["\u2713 ", doneCount, " done"] }), ' · ', _jsxs(Text, { color: errorCount > 0 ? STATE.error : CHROME.muted, children: ["\u2717 ", errorCount, " err"] })] }) })) : null, _jsxs(Text, { color: CHROME.muted, children: ["v", VERSION] })] }), _jsx(Text, { color: CHROME.muted, children: " \u2502" })] }), _jsxs(Text, { color: CHROME.muted, children: ["\u2570", '─'.repeat(cols - 2), "\u256F"] })] }), _jsx(Text, { children: " " }), _jsx(Box, { height: bodyHeight, overflow: "hidden", flexDirection: "column", children: view === 'settings' ? (_jsx(SettingsPanel, { ctl: ctl, scope: "global", onClose: onEscape })) : view === 'settings-session' ? (_jsx(SettingsPanel, { ctl: ctl, scope: "session", onClose: onEscape })) : view === 'board' ? (_jsx(BoardView, { board: ctl.board })) : view === 'notes' ? (_jsx(NotesView, { board: ctl.board })) : view === 'sessions' ? (_jsx(SessionsView, { projectRoot: ctl.projectRoot })) : view === 'diff' ? (_jsx(DiffView, { board: ctl.board })) : view === 'cost' ? (_jsx(CostView, { board: ctl.board })) : view === 'skills' ? (_jsx(SkillsView, { skills: ctl.getSkills() })) : view === 'specialists' ? (_jsx(SpecialistsView, { specialists: ctl.getSpecialists() })) : view === 'help' ? (_jsx(HelpView, {})) : agents.length === 0 ? (_jsx(Box, { borderStyle: "single", borderColor: "gray", flexDirection: "column", paddingX: 1, children: _jsx(Text, { color: "gray", children: t('main.empty') }) })) : focused ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(AgentTranscript, { agent: focused, logs: visibleLogs, raw: rawLogs, scrolled: clampedScroll }), !focusFollowTail ? _jsx(Text, { color: UI.warn, children: "Viewing older \u00B7 PgDn to latest" }) : null] })) : (_jsx(AgentHub, { agents: agents, ctl: ctl, cols: cols, scroll: clampedHub, visibleRows: hubRows })) }), systemLines.length > 0 && !settingsOpen && (_jsxs(Box, { flexDirection: "column", children: [agents.length > 0 ? _jsx(Text, { color: UI.muted, bold: true, children: "Session" }) : null, (agents.length > 0
645
+ return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: CHROME.muted, children: ["\u256D", '─'.repeat(cols - 2), "\u256E"] }), _jsxs(Box, { flexDirection: "row", width: cols, children: [_jsx(Text, { color: CHROME.muted, children: "\u2502 " }), _jsxs(Box, { flexDirection: "row", width: cols - 4, justifyContent: "space-between", children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { bold: true, color: BRAND.primary, children: "PARALLEL" }), _jsx(Text, { color: globalDotColor, children: " \u25CF" }), _jsxs(Text, { color: view === 'agents' ? CHROME.muted : BRAND.muted, children: [" ", viewLabel] }), rawLogs ? _jsx(Text, { color: UI.warn, children: " [RAW]" }) : null] }), _jsx(Text, { color: CHROME.muted, children: middleTruncate(folder, folderMax) })] }), _jsx(Text, { color: CHROME.muted, children: " \u2502" })] }), _jsxs(Box, { flexDirection: "row", width: cols, children: [_jsx(Text, { color: CHROME.muted, children: "\u2502 " }), _jsxs(Box, { flexDirection: "row", width: cols - 4, justifyContent: agents.length > 0 ? 'space-between' : 'flex-end', children: [agents.length > 0 ? (_jsx(Box, { flexDirection: "row", children: _jsxs(Text, { children: [_jsxs(Text, { color: CHROME.muted, children: ["\u25C7 ", idleCount, " idle"] }), ' · ', _jsxs(Text, { color: workingCount > 0 ? STATE.working : CHROME.muted, children: ["\u25CF ", workingCount, " active"] }), ' · ', _jsxs(Text, { color: doneCount > 0 ? STATE.done : CHROME.muted, children: ["\u2713 ", doneCount, " done"] }), ' · ', _jsxs(Text, { color: errorCount > 0 ? STATE.error : CHROME.muted, children: ["\u2717 ", errorCount, " err"] })] }) })) : null, _jsxs(Text, { color: CHROME.muted, children: ["v", VERSION] })] }), _jsx(Text, { color: CHROME.muted, children: " \u2502" })] }), _jsxs(Text, { color: CHROME.muted, children: ["\u2570", '─'.repeat(cols - 2), "\u256F"] })] }), _jsx(Text, { children: " " }), _jsx(Box, { height: bodyHeight, overflow: "hidden", flexDirection: "column", children: view === 'settings' ? (_jsx(SettingsPanel, { ctl: ctl, scope: "global", height: bodyHeight, onClose: onEscape })) : view === 'settings-session' ? (_jsx(SettingsPanel, { ctl: ctl, scope: "session", height: bodyHeight, onClose: onEscape })) : view === 'board' ? (_jsx(BoardView, { board: ctl.board, bodyHeight: bodyHeight })) : view === 'notes' ? (_jsx(NotesView, { board: ctl.board, bodyHeight: bodyHeight })) : view === 'sessions' ? (_jsx(SessionsView, { projectRoot: ctl.projectRoot, bodyHeight: bodyHeight })) : view === 'diff' ? (_jsx(DiffView, { board: ctl.board, bodyHeight: bodyHeight })) : view === 'cost' ? (_jsx(CostView, { board: ctl.board, bodyHeight: bodyHeight })) : view === 'skills' ? (_jsx(SkillsView, { skills: ctl.getSkills(), bodyHeight: bodyHeight })) : view === 'specialists' ? (_jsx(SpecialistsView, { specialists: ctl.getSpecialists(), bodyHeight: bodyHeight })) : view === 'help' ? (_jsx(HelpView, { bodyHeight: bodyHeight })) : agents.length === 0 ? (_jsx(Box, { borderStyle: "single", borderColor: "gray", flexDirection: "column", paddingX: 1, children: _jsx(Text, { color: "gray", children: t('main.empty') }) })) : focused ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(AgentTranscript, { agent: focused, logs: visibleLogs, raw: rawLogs, scrolled: clampedScroll }), !focusFollowTail ? _jsx(Text, { color: UI.warn, children: "Viewing older \u00B7 PgDn to latest" }) : null] })) : (_jsx(AgentHub, { agents: agents, ctl: ctl, cols: cols, scroll: clampedHub, visibleRows: hubRows })) }), systemLines.length > 0 && !settingsOpen && (_jsxs(Box, { flexDirection: "column", children: [agents.length > 0 ? _jsx(Text, { color: UI.muted, bold: true, children: "Session" }) : null, (agents.length > 0
546
646
  ? systemLines
547
647
  .filter((l) => !/^Ready|^Type a task|^⚡ Ready|^Default \/task|^Agent .* launched/.test(l.text))
548
648
  .slice(-2)
@@ -573,27 +673,33 @@ function groupAgents(agents) {
573
673
  function AgentHub({ agents, ctl, cols, scroll, visibleRows, }) {
574
674
  const groups = groupAgents([...agents].sort((a, b) => STATE_META[a.state].rank - STATE_META[b.state].rank || a.startedAt - b.startedAt));
575
675
  let skipped = scroll;
576
- let rendered = 0;
676
+ let renderedAgents = 0;
677
+ let renderedLines = 0;
577
678
  const rows = [];
679
+ let full = false;
578
680
  for (const group of groups) {
579
- const groupRows = [];
580
681
  for (const agent of group.agents) {
581
682
  if (skipped > 0) {
582
683
  skipped--;
583
684
  continue;
584
685
  }
585
- if (rendered >= visibleRows)
586
- continue;
587
- rendered++;
588
- groupRows.push(_jsx(AgentRow, { agent: agent, logs: ctl.board.logsFor(agent.id, 8), cols: cols }, agent.id));
589
- }
590
- if (groupRows.length === 0)
591
- continue;
592
- if (rows.length > 0) {
593
- rows.push(_jsx(Text, { color: CHROME.separator, children: '─'.repeat(cols - 2) }, `sep-${group.title}`));
686
+ const needsSeparator = rows.length > 0;
687
+ const neededLines = 2 + (needsSeparator ? 1 : 0);
688
+ if (renderedLines + neededLines > visibleRows) {
689
+ full = true;
690
+ break;
691
+ }
692
+ if (needsSeparator) {
693
+ rows.push(_jsx(Text, { color: CHROME.separator, children: '─'.repeat(cols - 2) }, `sep-${group.title}-${agent.id}`));
694
+ renderedLines++;
695
+ }
696
+ renderedAgents++;
697
+ renderedLines += 2;
698
+ rows.push(_jsx(AgentRow, { agent: agent, logs: ctl.board.logsFor(agent.id, 8), cols: cols }, agent.id));
594
699
  }
595
- rows.push(_jsx(Box, { flexDirection: "column", children: groupRows }, group.title));
700
+ if (full)
701
+ break;
596
702
  }
597
- const below = Math.max(0, agents.length - scroll - rendered);
703
+ const below = Math.max(0, agents.length - scroll - renderedAgents);
598
704
  return (_jsxs(Box, { flexDirection: "column", children: [scroll > 0 ? _jsxs(Text, { color: CHROME.muted, children: ["\u25B2 ", scroll, " older \u00B7 PgDn to latest"] }) : null, rows, below > 0 ? _jsxs(Text, { color: CHROME.muted, children: ["\u25BC ", below, " more \u00B7 PgUp"] }) : null] }));
599
705
  }