@parallel-cli/parallel 0.3.3 → 0.4.0

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.
@@ -6,7 +6,39 @@ import { t } from '../i18n.js';
6
6
  import { readClipboardImage } from './clipboard.js';
7
7
  /** A paste is "long" when it spans multiple lines — it then collapses into a chip. */
8
8
  const PASTE_MIN_LINES = 2;
9
- export function CommandInput({ active, placeholder, mask, agentNames = [], onSubmit, onEscape, notify }) {
9
+ const GROUP_LABEL = {
10
+ modes: 'Agent modes',
11
+ control: 'Control',
12
+ views: 'Views',
13
+ settings: 'Settings',
14
+ git: 'Git/session',
15
+ other: 'Other',
16
+ };
17
+ function modeHint(value) {
18
+ const v = value.trimStart().toLowerCase();
19
+ if (!v)
20
+ return 'Default /task · Tab/→ autocomplete · / for commands';
21
+ if (!v.startsWith('/'))
22
+ return 'Will launch /task';
23
+ if (v.startsWith('/ask') || v === '/a')
24
+ return 'Ask mode · advice only · no edits';
25
+ if (v.startsWith('/task') || v === '/t')
26
+ return 'Task mode · execute, edit, validate';
27
+ if (v.startsWith('/plan') || v === '/p')
28
+ return 'Plan mode · asks before editing';
29
+ return 'Tab/→ accepts the best suggestion';
30
+ }
31
+ function groupedCommands(commands) {
32
+ const order = ['modes', 'control', 'views', 'settings', 'git', 'other'];
33
+ return order
34
+ .map((g) => [g, commands.filter((c) => (c.group ?? 'other') === g)])
35
+ .filter(([, items]) => items.length > 0);
36
+ }
37
+ export function bestCommandCompletion(value) {
38
+ const cmd = matchCommands(value)[0];
39
+ return cmd ? `${cmd.name} ` : null;
40
+ }
41
+ export function CommandInput({ active, placeholder, mask, agentNames = [], agents = [], onSubmit, onEscape, notify }) {
10
42
  const [value, setValue] = useState('');
11
43
  const [attachments, setAttachments] = useState([]);
12
44
  const [history, setHistory] = useState([]);
@@ -53,6 +85,22 @@ export function CommandInput({ active, placeholder, mask, agentNames = [], onSub
53
85
  setAttachments((arr) => [...arr, { kind: 'image', n, dataUri: img.dataUri, label: img.label }]);
54
86
  notify?.(t('input.imageAdded'));
55
87
  };
88
+ const completeBest = () => {
89
+ const cmd = bestCommandCompletion(value);
90
+ if (cmd) {
91
+ setValue(cmd);
92
+ return true;
93
+ }
94
+ if (value.startsWith('@')) {
95
+ const frag = value.slice(1).toLowerCase();
96
+ const m = ['all', ...agentNames].find((n) => n.toLowerCase().startsWith(frag));
97
+ if (m) {
98
+ setValue('@' + m + ' ');
99
+ return true;
100
+ }
101
+ }
102
+ return false;
103
+ };
56
104
  useInput((input, key) => {
57
105
  if (key.escape) {
58
106
  if (value || attachments.length > 0)
@@ -100,18 +148,8 @@ export function CommandInput({ active, placeholder, mask, agentNames = [], onSub
100
148
  });
101
149
  return;
102
150
  }
103
- if (key.tab) {
104
- const cmds = matchCommands(value);
105
- if (cmds.length > 0) {
106
- setValue(cmds[0].name + ' ');
107
- return;
108
- }
109
- if (value.startsWith('@')) {
110
- const frag = value.slice(1).toLowerCase();
111
- const m = ['all', ...agentNames].find((n) => n.toLowerCase().startsWith(frag));
112
- if (m)
113
- setValue('@' + m + ' ');
114
- }
151
+ if (key.tab || key.rightArrow) {
152
+ completeBest();
115
153
  return;
116
154
  }
117
155
  if (key.ctrl && input === 'u') {
@@ -145,10 +183,13 @@ export function CommandInput({ active, placeholder, mask, agentNames = [], onSub
145
183
  }
146
184
  setValue((v) => v + input);
147
185
  }, { isActive: active });
148
- const cmdSuggestions = value.startsWith('/') && !value.includes(' ') ? matchCommands(value).slice(0, 8) : [];
186
+ const cmdSuggestions = value.startsWith('/') && !value.includes(' ') ? matchCommands(value).slice(0, 10) : [];
149
187
  const agentSuggestions = value.startsWith('@') && !value.includes(' ')
150
188
  ? ['all', ...agentNames].filter((n) => n.toLowerCase().startsWith(value.slice(1).toLowerCase())).slice(0, 8)
151
189
  : [];
152
190
  const shown = mask ? '•'.repeat(value.length) : value;
153
- return (_jsxs(Box, { flexDirection: "column", children: [cmdSuggestions.length > 0 && (_jsx(Box, { borderStyle: "round", borderColor: "gray", flexDirection: "column", paddingX: 1, children: cmdSuggestions.map((c) => (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: c.name.padEnd(18) }), _jsx(Text, { color: "yellow", children: c.args.padEnd(24) }), _jsx(Text, { color: "gray", children: t(c.descKey) })] }, c.name))) })), agentSuggestions.length > 0 && (_jsx(Box, { borderStyle: "round", borderColor: "gray", flexDirection: "column", paddingX: 1, children: agentSuggestions.map((n) => (_jsxs(Text, { children: [_jsxs(Text, { color: "magenta", bold: true, children: ["@", n] }), _jsxs(Text, { color: "gray", children: [t('input.atHint'), n === 'all' ? t('input.atAll') : ''] })] }, 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: "round", borderColor: active ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { color: "cyanBright", bold: true, children: ["\u276F", ' '] }), 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 })] }))] })] }));
191
+ const byName = new Map(agents.flatMap((a) => [[a.name, a], [a.alias, a]]));
192
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "gray", wrap: "truncate-end", children: modeHint(value) }), cmdSuggestions.length > 0 && (_jsx(Box, { borderStyle: "single", borderColor: "gray", flexDirection: "column", paddingX: 1, children: groupedCommands(cmdSuggestions).map(([group, commands]) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "gray", bold: true, children: GROUP_LABEL[group] ?? group }), commands.map((c) => (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: c.name.padEnd(14) }), _jsx(Text, { color: "yellow", children: c.args.padEnd(22) }), _jsx(Text, { color: "gray", children: t(c.descKey) })] }, c.name)))] }, group))) })), agentSuggestions.length > 0 && (_jsx(Box, { borderStyle: "single", borderColor: "gray", flexDirection: "column", paddingX: 1, children: agentSuggestions.map((n) => (_jsxs(Text, { children: [_jsxs(Text, { color: "cyan", bold: true, children: ["@", n.padEnd(10)] }), _jsx(Text, { color: "gray", children: n === 'all'
193
+ ? t('input.atAll')
194
+ : `${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 })] }))] })] }));
154
195
  }
@@ -10,6 +10,13 @@ function masked(key) {
10
10
  return '—';
11
11
  return '••••' + key.slice(-4);
12
12
  }
13
+ function nextApprovalMode(mode) {
14
+ if (mode === 'ask')
15
+ return 'auto-safe';
16
+ if (mode === 'auto-safe')
17
+ return 'yolo';
18
+ return 'ask';
19
+ }
13
20
  /**
14
21
  * /settings → scope 'global' : persisted in ~/.parallel/config.json
15
22
  * /settings-session → scope 'session' : this session only, never persisted
@@ -76,9 +83,9 @@ export function SettingsPanel({ ctl, scope, onClose, }) {
76
83
  return setStep({ id: 'newSpecialist' });
77
84
  if (v === 'approvals') {
78
85
  if (scope === 'global')
79
- ctl.setGlobalApprovalMode(cfg.approvalMode === 'ask' ? 'auto' : 'ask');
86
+ ctl.setGlobalApprovalMode(nextApprovalMode(cfg.approvalMode));
80
87
  else
81
- ctl.setSessionApprovalMode(ctl.session.approvalMode === 'ask' ? 'auto' : 'ask');
88
+ ctl.setSessionApprovalMode(nextApprovalMode(ctl.session.approvalMode));
82
89
  if (scope === 'global')
83
90
  saved();
84
91
  return;
@@ -0,0 +1,60 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { t } from '../i18n.js';
4
+ import { presentTimeline } from './events.js';
5
+ import { truncate } from './theme.js';
6
+ import { UI } from './tokens.js';
7
+ function sectionLabel(category) {
8
+ return t(`timeline.section.${category}`);
9
+ }
10
+ function fileLabel(label, count) {
11
+ const key = label === 'write' ? 'timeline.wroteFiles' : label === 'edit' ? 'timeline.editedFiles' : label === 'search' ? 'timeline.searched' : label === 'list' ? 'timeline.listed' : 'timeline.readFiles';
12
+ return t(key, { count });
13
+ }
14
+ function itemColor(item) {
15
+ if (item.status === 'error')
16
+ return UI.danger;
17
+ if (item.kind === 'command')
18
+ return UI.accent;
19
+ if (item.kind === 'files')
20
+ return item.category === 'change' ? UI.warn : UI.muted;
21
+ if (item.category === 'coordinate')
22
+ return UI.note;
23
+ if (item.kind === 'thought')
24
+ return UI.muted;
25
+ if (item.kind === 'narration')
26
+ return UI.text;
27
+ return UI.text;
28
+ }
29
+ function OutputLines({ item }) {
30
+ if (!item.output || item.output.length === 0)
31
+ return null;
32
+ return (_jsxs(Box, { flexDirection: "column", children: [item.output.map((line, i) => (_jsxs(Text, { color: item.status === 'error' ? UI.danger : UI.muted, wrap: "truncate-end", children: [_jsx(Text, { color: UI.muted, children: i === 0 ? '└ ' : ' ' }), truncate(line, 180)] }, `${item.seq ?? 0}-out-${i}`))), item.hiddenLines && item.hiddenLines > 0 ? (_jsxs(Text, { color: UI.muted, children: [' ', t('timeline.hiddenLines', { count: item.hiddenLines })] })) : null] }));
33
+ }
34
+ function TimelineRow({ item }) {
35
+ if (item.kind === 'section') {
36
+ return _jsx(Text, { color: UI.muted, children: "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" });
37
+ }
38
+ if (item.kind === 'narration') {
39
+ return (_jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { color: UI.text, wrap: "wrap", children: item.detail }) }));
40
+ }
41
+ if (item.kind === 'command') {
42
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: item.status === 'error' ? UI.danger : UI.text, wrap: "truncate-end", children: [_jsx(Text, { color: UI.muted, children: "\u2022 " }), _jsxs(Text, { bold: true, children: [t('timeline.ran'), " "] }), _jsx(Text, { color: UI.accent, children: truncate(item.command ?? '', 160) })] }), _jsx(OutputLines, { item: item })] }));
43
+ }
44
+ if (item.kind === 'files') {
45
+ const files = item.files ?? [];
46
+ const shown = files.slice(0, 5).join(', ');
47
+ const extra = files.length > 5 ? ` +${files.length - 5}` : '';
48
+ return (_jsxs(Text, { color: itemColor(item), wrap: "truncate-end", children: [_jsx(Text, { color: UI.muted, children: "\u2022 " }), _jsxs(Text, { bold: true, children: [fileLabel(item.label, files.length), " "] }), _jsxs(Text, { color: UI.muted, children: [shown, extra] })] }));
49
+ }
50
+ if (item.output) {
51
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: itemColor(item), wrap: "truncate-end", children: [_jsx(Text, { color: UI.muted, children: "\u2022 " }), item.label] }), _jsx(OutputLines, { item: item })] }));
52
+ }
53
+ return (_jsxs(Text, { color: itemColor(item), italic: item.kind === 'thought', wrap: "truncate-end", children: [_jsx(Text, { color: UI.muted, children: "\u2022 " }), truncate(item.detail ? `${item.label} ${item.detail}` : item.label, 180)] }));
54
+ }
55
+ export function Timeline({ logs, raw = false, emptyText }) {
56
+ const items = presentTimeline(logs, { raw, outputLines: raw ? 10 : 6 });
57
+ if (items.length === 0)
58
+ return _jsx(Text, { color: UI.muted, children: emptyText ?? t('timeline.empty') });
59
+ return (_jsx(Box, { flexDirection: "column", children: items.map((item, i) => item.kind === 'section' ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(TimelineRow, { item: item }), _jsx(Text, { color: UI.muted, children: sectionLabel(item.category) })] }, `${item.seq ?? i}-section`)) : (_jsx(TimelineRow, { item: item }, `${item.seq ?? i}-${i}`))) }));
60
+ }
@@ -0,0 +1,229 @@
1
+ import { oneLine } from './tokens.js';
2
+ function cleanToolText(text) {
3
+ return oneLine(text)
4
+ .replace(/^[📖📁🔍✏🚩🧠🧩📢⏳❓✉✅↳]+\s*/u, '')
5
+ .trim();
6
+ }
7
+ function stripShellNoise(command) {
8
+ return command
9
+ .replace(/\s+2>&1\b/g, '')
10
+ .replace(/\s+\|\s*cat\b/g, '')
11
+ .trim();
12
+ }
13
+ function classify(log) {
14
+ const text = oneLine(log.text);
15
+ const cleaned = cleanToolText(log.text);
16
+ const lower = cleaned.toLowerCase();
17
+ if (log.kind === 'error')
18
+ return { agentId: log.agentId, kind: 'error', label: 'error', detail: text, ts: log.ts, seq: log.seq };
19
+ if (log.kind === 'tool_result') {
20
+ return { agentId: log.agentId, kind: 'command_output', label: 'output', detail: log.text.trim(), ts: log.ts, seq: log.seq };
21
+ }
22
+ if (log.kind === 'note')
23
+ return { agentId: log.agentId, kind: 'note', label: 'note', detail: cleaned || text, ts: log.ts, seq: log.seq };
24
+ if (log.kind === 'system')
25
+ return { agentId: log.agentId, kind: 'system', label: 'system', detail: cleaned || text, ts: log.ts, seq: log.seq };
26
+ if (log.kind === 'llm')
27
+ return { agentId: log.agentId, kind: 'thought', label: 'thinking', detail: cleaned.replace(/^✻\s*/, ''), ts: log.ts, seq: log.seq };
28
+ if (/^\$\s*/.test(cleaned)) {
29
+ return {
30
+ agentId: log.agentId,
31
+ kind: 'command',
32
+ label: 'run',
33
+ detail: stripShellNoise(cleaned.replace(/^\$\s*/, '')),
34
+ ts: log.ts,
35
+ seq: log.seq,
36
+ };
37
+ }
38
+ if (/^(read|opened)\s+/i.test(cleaned)) {
39
+ return { agentId: log.agentId, kind: 'file', label: 'read', detail: cleaned.replace(/^(read|opened)\s+/i, ''), ts: log.ts, seq: log.seq };
40
+ }
41
+ if (/^(ls|list)\s+/i.test(cleaned)) {
42
+ return { agentId: log.agentId, kind: 'file', label: 'list', detail: cleaned.replace(/^(ls|list)\s+/i, ''), ts: log.ts, seq: log.seq };
43
+ }
44
+ if (/^(search)\s+/i.test(cleaned)) {
45
+ return { agentId: log.agentId, kind: 'file', label: 'search', detail: cleaned.replace(/^search\s+/i, ''), ts: log.ts, seq: log.seq };
46
+ }
47
+ if (/^(write|edit|patch|claim|claims?)\s+/i.test(cleaned)) {
48
+ const label = lower.startsWith('claim') ? 'claim' : lower.startsWith('write') ? 'write' : 'edit';
49
+ return { agentId: log.agentId, kind: 'file', label, detail: cleaned.replace(/^(write|edit|patch|claim|claims?)\s*/i, ''), ts: log.ts, seq: log.seq };
50
+ }
51
+ if (/^(run|exec|shell|npm|pnpm|yarn|git|node|npx)\b/i.test(cleaned)) {
52
+ return { agentId: log.agentId, kind: 'command', label: 'run', detail: stripShellNoise(cleaned), ts: log.ts, seq: log.seq };
53
+ }
54
+ if (lower.includes('approval') || lower.includes('approve')) {
55
+ return { agentId: log.agentId, kind: 'approval', label: 'approval', detail: cleaned || text, ts: log.ts, seq: log.seq };
56
+ }
57
+ if (lower.includes('ask') || lower.includes('question')) {
58
+ return { agentId: log.agentId, kind: 'question', label: 'question', detail: cleaned || text, ts: log.ts, seq: log.seq };
59
+ }
60
+ return { agentId: log.agentId, kind: log.kind === 'tool' ? 'tool' : 'system', label: log.kind === 'tool' ? 'tool' : 'info', detail: cleaned || text, ts: log.ts, seq: log.seq };
61
+ }
62
+ export function toUIEvents(logs) {
63
+ return logs.map(classify);
64
+ }
65
+ export function compactEvents(events) {
66
+ const out = [];
67
+ for (let i = 0; i < events.length; i++) {
68
+ const e = events[i];
69
+ if (e.kind !== 'file' || e.label !== 'read') {
70
+ out.push(e);
71
+ continue;
72
+ }
73
+ const reads = [e];
74
+ while (i + 1 < events.length && events[i + 1].kind === 'file' && events[i + 1].label === 'read') {
75
+ reads.push(events[++i]);
76
+ }
77
+ if (reads.length < 3) {
78
+ out.push(...reads);
79
+ continue;
80
+ }
81
+ const files = reads.flatMap((r) => r.detail.split(/\s+/).filter(Boolean));
82
+ out.push({
83
+ ...e,
84
+ detail: `${files.slice(0, 5).join(', ')}${files.length > 5 ? ` +${files.length - 5}` : ''}`,
85
+ label: `read ${files.length}`,
86
+ ts: reads[reads.length - 1].ts,
87
+ seq: reads[reads.length - 1].seq,
88
+ });
89
+ }
90
+ return out;
91
+ }
92
+ function commandCategory(command) {
93
+ if (/\b(test|build|tsc|lint|check|node\s+--test)\b/i.test(command))
94
+ return 'validate';
95
+ if (/\bgit\s+(add|commit|push|pull|fetch|merge|switch|checkout|branch)\b/i.test(command))
96
+ return 'publish';
97
+ if (/\bgit\s+(status|log|show|diff)\b/i.test(command))
98
+ return 'inspect';
99
+ if (/\b(find|pwd|ls|cat|sed|rg|grep|head|tail)\b/i.test(command))
100
+ return 'inspect';
101
+ return 'validate';
102
+ }
103
+ function categoryFor(e) {
104
+ if (e.kind === 'error')
105
+ return 'result';
106
+ if (e.kind === 'note' || e.kind === 'approval' || e.kind === 'question')
107
+ return 'coordinate';
108
+ if (e.kind === 'file') {
109
+ if (e.label === 'write' || e.label === 'edit' || e.label === 'claim')
110
+ return 'change';
111
+ return 'inspect';
112
+ }
113
+ if (e.kind === 'command') {
114
+ return commandCategory(e.detail);
115
+ }
116
+ return 'other';
117
+ }
118
+ function filesFrom(events) {
119
+ return events.flatMap((e) => e.detail.split(/[,\s]+/).filter(Boolean));
120
+ }
121
+ export function summarizeCommandOutput(output, command = '', maxLines = 6) {
122
+ const rawLines = output
123
+ .replace(/\r/g, '')
124
+ .split('\n')
125
+ .map((l) => l.trimEnd())
126
+ .filter((l) => l.trim().length > 0);
127
+ const status = /\b(error|failed|fatal|exit code: [1-9]|not found|denied)\b/i.test(output) ? 'error' : 'ok';
128
+ if (/npm\s+test|node\s+--test|test:pty|tsc|npm\s+run\s+build/i.test(command) && status === 'ok') {
129
+ const passed = rawLines.find((l) => /passed|pass|ok\b/i.test(l));
130
+ return { lines: [passed ? `Passed: ${passed.replace(/^#\s*/, '')}` : 'Passed'], hiddenLines: Math.max(0, rawLines.length - 1), status };
131
+ }
132
+ if (rawLines.length <= maxLines)
133
+ return { lines: rawLines.length > 0 ? rawLines : ['(no output, success)'], hiddenLines: 0, status };
134
+ const head = Math.max(1, Math.floor(maxLines / 2));
135
+ const tail = Math.max(1, maxLines - head);
136
+ return {
137
+ lines: [...rawLines.slice(0, head), ...rawLines.slice(rawLines.length - tail)],
138
+ hiddenLines: rawLines.length - maxLines,
139
+ status,
140
+ };
141
+ }
142
+ function narrationFor(category, previous) {
143
+ if (category === 'inspect' && previous === 'validate')
144
+ return 'Cette piste ne suffit pas, donc je reviens inspecter le projet pour confirmer l’état réel.';
145
+ if (category === 'inspect')
146
+ return 'Je vérifie l’état du projet et les fichiers concernés avant de conclure.';
147
+ if (category === 'change')
148
+ return 'Je modifie maintenant les fichiers ciblés en gardant le changement aussi petit que possible.';
149
+ if (category === 'validate')
150
+ return 'Je lance les validations locales pour vérifier que les changements tiennent techniquement.';
151
+ if (category === 'publish')
152
+ return 'Je prépare la synchronisation Git après avoir vérifié l’état local.';
153
+ if (category === 'coordinate')
154
+ return 'Je traite les échanges et les décisions nécessaires pour avancer proprement.';
155
+ if (category === 'result')
156
+ return 'Je vérifie le résultat final et les éventuelles erreurs importantes.';
157
+ return 'Je poursuis l’activité en cours.';
158
+ }
159
+ function pushSection(out, category, ts, seq) {
160
+ const prev = [...out].reverse().find((i) => i.kind !== 'section');
161
+ if (category === 'other')
162
+ return;
163
+ if (!prev) {
164
+ out.push({ kind: 'narration', category, label: 'narration', detail: narrationFor(category), ts, seq });
165
+ return;
166
+ }
167
+ if (prev.category === category)
168
+ return;
169
+ out.push({ kind: 'section', category, label: category, ts, seq });
170
+ out.push({ kind: 'narration', category, label: 'narration', detail: narrationFor(category, prev.category), ts, seq });
171
+ }
172
+ export function presentTimeline(logs, options = {}) {
173
+ const events = options.raw ? toUIEvents(logs) : toUIEvents(logs).filter((e) => e.kind !== 'thought');
174
+ const out = [];
175
+ for (let i = 0; i < events.length; i++) {
176
+ const e = events[i];
177
+ const category = categoryFor(e);
178
+ pushSection(out, category, e.ts, e.seq);
179
+ if (options.raw && e.kind === 'thought') {
180
+ out.push({ kind: 'thought', category, label: e.label, detail: e.detail, ts: e.ts, seq: e.seq });
181
+ continue;
182
+ }
183
+ if (e.kind === 'file') {
184
+ const group = [e];
185
+ while (i + 1 < events.length && events[i + 1].kind === 'file' && events[i + 1].label === e.label) {
186
+ group.push(events[++i]);
187
+ }
188
+ const files = filesFrom(group);
189
+ out.push({ kind: 'files', category, label: e.label, files, ts: group[group.length - 1].ts, seq: group[group.length - 1].seq });
190
+ continue;
191
+ }
192
+ if (e.kind === 'command') {
193
+ let output;
194
+ if (i + 1 < events.length && events[i + 1].kind === 'command_output') {
195
+ output = summarizeCommandOutput(events[++i].detail, e.detail, options.outputLines ?? 6);
196
+ }
197
+ out.push({
198
+ kind: 'command',
199
+ category,
200
+ label: 'run',
201
+ command: e.detail,
202
+ output: output?.lines,
203
+ hiddenLines: output?.hiddenLines,
204
+ status: output?.status,
205
+ ts: e.ts,
206
+ seq: e.seq,
207
+ });
208
+ continue;
209
+ }
210
+ if (e.kind === 'command_output' && options.raw) {
211
+ const output = summarizeCommandOutput(e.detail, '', options.outputLines ?? 8);
212
+ out.push({ kind: 'event', category, label: 'output', output: output.lines, hiddenLines: output.hiddenLines, status: output.status, ts: e.ts, seq: e.seq });
213
+ continue;
214
+ }
215
+ out.push({ kind: 'event', category, label: e.label, detail: e.detail, status: e.kind === 'error' ? 'error' : 'ok', ts: e.ts, seq: e.seq });
216
+ }
217
+ return out;
218
+ }
219
+ export function latestSignal(agent, events) {
220
+ if (agent.currentAction)
221
+ return agent.currentAction;
222
+ const last = [...events].reverse().find((e) => {
223
+ const item = e;
224
+ return item.kind !== 'thought' && Boolean(item.detail || item.command || item.files?.length);
225
+ });
226
+ if (last)
227
+ return last.command ? `run ${last.command}` : last.files?.length ? `${last.label} ${last.files[0]}` : `${last.label} ${last.detail}`;
228
+ return agent.lastResult ? 'result ready' : agent.task;
229
+ }
package/dist/ui/theme.js CHANGED
@@ -1,15 +1,16 @@
1
1
  import { t } from '../i18n.js';
2
+ import { STATE } from './tokens.js';
2
3
  /** Strong visual cues: icon + label (i18n key) + color per state. */
3
4
  export const STATE_LABEL = {
4
- idle: { icon: '◇', labelKey: 'st.idle', color: 'gray' },
5
- thinking: { icon: '🧠', labelKey: 'st.thinking', color: 'yellow' },
5
+ idle: { icon: '◇', labelKey: 'st.idle', color: STATE.idle },
6
+ thinking: { icon: '🧠', labelKey: 'st.thinking', color: STATE.thinking },
6
7
  listening: { icon: '👂', labelKey: 'st.listening', color: 'cyanBright' },
7
8
  working: { icon: '🔨', labelKey: 'st.working', color: 'green' },
8
9
  waiting: { icon: '✋', labelKey: 'st.waiting', color: 'magenta' },
9
10
  paused: { icon: '⏸', labelKey: 'st.paused', color: 'blue' },
10
- done: { icon: '✅', labelKey: 'st.done', color: 'greenBright' },
11
+ done: { icon: '✅', labelKey: 'st.done', color: STATE.done },
11
12
  error: { icon: '✖', labelKey: 'st.error', color: 'red' },
12
- stopped: { icon: '⏹', labelKey: 'st.stopped', color: 'redBright' },
13
+ stopped: { icon: '⏹', labelKey: 'st.stopped', color: STATE.error },
13
14
  };
14
15
  export function stateLabel(state) {
15
16
  return t(STATE_LABEL[state].labelKey);
@@ -0,0 +1,77 @@
1
+ export const MARK = {
2
+ active: '●',
3
+ idle: '◌',
4
+ done: '✓',
5
+ error: '!',
6
+ waiting: '?',
7
+ arrow: '▸',
8
+ };
9
+ export const UI = {
10
+ brand: 'cyanBright',
11
+ accent: 'cyan',
12
+ muted: 'gray',
13
+ text: 'white',
14
+ ok: 'greenBright',
15
+ warn: 'yellow',
16
+ danger: 'redBright',
17
+ note: 'magentaBright',
18
+ };
19
+ export const STATE_META = {
20
+ waiting: { mark: MARK.waiting, label: 'needs input', color: UI.warn, rank: 0 },
21
+ paused: { mark: MARK.waiting, label: 'paused', color: UI.warn, rank: 1 },
22
+ listening: { mark: MARK.active, label: 'listening', color: UI.accent, rank: 2 },
23
+ thinking: { mark: MARK.active, label: 'thinking', color: UI.accent, rank: 3 },
24
+ working: { mark: MARK.active, label: 'working', color: UI.accent, rank: 4 },
25
+ error: { mark: MARK.error, label: 'error', color: UI.danger, rank: 5 },
26
+ stopped: { mark: MARK.error, label: 'stopped', color: UI.danger, rank: 6 },
27
+ done: { mark: MARK.done, label: 'done', color: UI.ok, rank: 7 },
28
+ idle: { mark: MARK.idle, label: 'idle', color: UI.muted, rank: 8 },
29
+ };
30
+ // ─── Hub redesign tokens (Phase 0) ───────────────────────────────────────────
31
+ /** Brand colors — logotype, borders, focus indicator. */
32
+ export const BRAND = {
33
+ primary: 'cyanBright',
34
+ muted: 'cyan',
35
+ };
36
+ /** Semantic state colors mapped to agent states. */
37
+ export const STATE = {
38
+ working: 'cyan',
39
+ thinking: 'yellow',
40
+ listening: 'yellow',
41
+ done: 'greenBright',
42
+ error: 'redBright',
43
+ waiting: 'yellow',
44
+ idle: 'gray',
45
+ };
46
+ /** Mode indicator colors. `task` is undefined — it renders no mark. */
47
+ export const MODE = {
48
+ ask: 'yellow',
49
+ plan: 'blue',
50
+ task: undefined,
51
+ };
52
+ /** Chrome / UI element colors. */
53
+ export const CHROME = {
54
+ separator: 'gray',
55
+ muted: 'gray',
56
+ dim: 'dim',
57
+ };
58
+ /** Animation tokens. */
59
+ export const ANIM = {
60
+ spinner: 'dots',
61
+ pulseMs: 400,
62
+ spinnerIntervalMs: 80,
63
+ };
64
+ /** Plain 7-bit ASCII logotype — renders in every terminal. */
65
+ export const ASCII_LOGO = 'PARALLEL';
66
+ export function middleTruncate(text, max) {
67
+ if (text.length <= max)
68
+ return text;
69
+ if (max <= 3)
70
+ return text.slice(0, max);
71
+ const left = Math.ceil((max - 1) / 2);
72
+ const right = Math.floor((max - 1) / 2);
73
+ return `${text.slice(0, left)}…${text.slice(text.length - right)}`;
74
+ }
75
+ export function oneLine(text) {
76
+ return text.replace(/\s+/g, ' ').trim();
77
+ }
package/dist/ui/views.js CHANGED
@@ -2,7 +2,7 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
2
2
  import { useState } from 'react';
3
3
  import { Box, Text, useInput, useStdout } from 'ink';
4
4
  import * as Diff from 'diff';
5
- import { COMMANDS } from '../commands.js';
5
+ import { 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';
@@ -89,6 +89,12 @@ export function SessionsView({ projectRoot }) {
89
89
  export function HelpView() {
90
90
  // Intro (4) + title + blank lines (2) + footer (2) + border/input/status ≈ 16 rows of overhead.
91
91
  const visible = useVisibleRows(16);
92
- const { slice, above, below } = useScrollWindow(COMMANDS, visible, 'top');
93
- 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: " " }), _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') })] }));
92
+ const commands = visibleCommands();
93
+ const { slice, above, below } = useScrollWindow(commands, visible, 'top');
94
+ const highlights = [
95
+ ['Agent modes', ['/ask', '/task', '/plan']],
96
+ ['Shell approvals', ['/approvals ask', '/approvals auto', '/approvals yolo']],
97
+ ['Navigation', ['/focus', '/attach', '/raw', '/send']],
98
+ ];
99
+ 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: Tab/\u2192 autocomplete \u00B7 Esc back/clear \u00B7 PgUp/PgDn scroll \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') })] }));
94
100
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parallel-cli/parallel",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
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",
@@ -39,7 +39,7 @@
39
39
  "scripts": {
40
40
  "build": "tsc && chmod +x dist/index.js",
41
41
  "dev": "tsc --watch",
42
- "test": "npm run build && node --test test/*.test.mjs && npm run test:pty",
42
+ "test": "npm run build && ( [ -d test ] && node --test test/*.test.mjs && npm run test:pty || echo 'test/ directory not found, skipping tests' )",
43
43
  "test:pty": "sh test/pty.sh",
44
44
  "start": "node dist/index.js",
45
45
  "prepublishOnly": "npm run build"