@parallel-cli/parallel 0.4.6 → 0.4.8

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.
@@ -1,19 +1,20 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from 'react';
3
3
  import net from 'node:net';
4
- import { Box, Static, Text, useApp } from 'ink';
4
+ import { Box, Static, Text, useApp, useInput, useStdout } from 'ink';
5
5
  import { ApprovalPrompt } from './ApprovalPrompt.js';
6
6
  import { CommandInput } from './CommandInput.js';
7
- import { formatAgentTelemetry, KIND_COLOR, KIND_DIM } from './AgentPanel.js';
7
+ import { formatAgentTelemetry, KIND_COLOR, KIND_DIM, modeBadge, ProgressSteps } from './AgentPanel.js';
8
8
  import { Md } from './Md.js';
9
9
  import { QuestionPrompt } from './QuestionPrompt.js';
10
- import { Spinner } from './Spinner.js';
11
10
  import { Timeline } from './Timeline.js';
11
+ import { toUIEvents } from './events.js';
12
12
  import { stateLabel, elapsed, truncate } from './theme.js';
13
13
  import { fmtCost } from '../pricing.js';
14
14
  import { t } from '../i18n.js';
15
15
  import { COLOR, STATE_META, UI, middleTruncate } from './tokens.js';
16
16
  const noop = () => { };
17
+ const TERMINAL_STATES = new Set(['done', 'error', 'stopped']);
17
18
  export function parseAttachCommand(text) {
18
19
  const v = text.trim();
19
20
  if (!v)
@@ -22,12 +23,23 @@ export function parseAttachCommand(text) {
22
23
  return { type: 'detach' };
23
24
  if (v === '/raw')
24
25
  return { type: 'raw' };
26
+ const stop = v.match(/^\/stop(?:\s+(\S+))?$/s);
27
+ if (stop)
28
+ return { type: 'stop', target: stop[1]?.trim() };
25
29
  const at = v.match(/^@(\S+)\s+(.+)$/s);
26
30
  if (at)
27
31
  return { type: 'send', target: at[1], text: at[2].trim() };
28
32
  const send = v.match(/^\/send\s+(\S+)\s+(.+)$/s);
29
33
  if (send)
30
34
  return { type: 'send', target: send[1], text: send[2].trim() };
35
+ const review = v.match(/^\/review\s+(.+)$/s);
36
+ if (review) {
37
+ return {
38
+ type: 'spawn',
39
+ text: `Review current shared-tree work: ${review[1].trim()}. Return Verdict: APPROVE | REVISE | BLOCK, Risks, Tests to run, Files to inspect, and Notes.`,
40
+ mode: 'ask',
41
+ };
42
+ }
31
43
  const m = v.match(/^\/(ask|a|task|t|plan|p)\s+(.+)$/s);
32
44
  if (m) {
33
45
  const mode = m[1] === 'ask' || m[1] === 'a' ? 'ask' : m[1] === 'plan' || m[1] === 'p' ? 'plan' : 'task';
@@ -38,13 +50,42 @@ export function parseAttachCommand(text) {
38
50
  export function formatAttachFooter(info) {
39
51
  if (!info)
40
52
  return 'Waiting for agent · /quit';
41
- return `${middleTruncate(info.model, 28)} · ${formatAgentTelemetry(info)} · plain text steers · /task new · /quit`;
53
+ const control = ['thinking', 'working', 'listening', 'waiting', 'paused'].includes(info.state) ? ' · /stop' : '';
54
+ return `${middleTruncate(info.model, 28)} · ${formatAgentTelemetry(info)} · plain text steers${control} · /task new · /quit`;
55
+ }
56
+ function AttachStaticLine({ item, raw }) {
57
+ if (raw) {
58
+ return (_jsx(Text, { color: KIND_COLOR[item.log.kind] ?? 'white', italic: KIND_DIM[item.log.kind] ?? false, wrap: "wrap", children: item.log.text }));
59
+ }
60
+ const event = toUIEvents([item.log])[0];
61
+ if (!event || event.kind === 'thought')
62
+ return _jsx(Text, { color: UI.muted, children: " " });
63
+ const color = event.kind === 'error' ? UI.danger : event.kind === 'note' ? UI.note : event.kind === 'command' ? UI.accent : UI.muted;
64
+ const detail = event.detail.replace(/\r/g, '').split('\n').filter(Boolean).slice(0, 3).join(' ↳ ');
65
+ return (_jsxs(Text, { color: color, wrap: "truncate-end", children: [_jsx(Text, { color: UI.muted, children: "\u2022 " }), _jsx(Text, { bold: true, children: event.label }), detail ? _jsxs(Text, { color: event.kind === 'command_output' ? UI.muted : color, children: [" ", truncate(detail, process.stdout.columns ? process.stdout.columns - 8 : 120)] }) : null] }));
66
+ }
67
+ export function isLaunchSystemLog(log) {
68
+ return log.kind === 'system' && /\bAgent\s+.+\slaunched\b|Terminal dédié ouvert|Dedicated terminal/i.test(log.text);
69
+ }
70
+ function AttachLaunchHeader({ item }) {
71
+ const { info } = item;
72
+ const mode = modeBadge(info.mode);
73
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: info.color, paddingX: 1, marginBottom: 1, children: [_jsx(Text, { color: UI.brand, bold: true, children: "Parallel agent terminal" }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: info.color, bold: true, children: info.name }), info.alias && info.alias !== info.name ? _jsxs(Text, { color: UI.muted, children: [" @", info.alias] }) : null, _jsxs(Text, { color: mode.color, children: [" [", mode.label, "]"] }), _jsx(Text, { color: UI.muted, children: " \u00B7 " }), _jsx(Text, { color: UI.text, children: middleTruncate(info.model, 36) })] }), _jsxs(Text, { color: UI.muted, wrap: "wrap", children: ["Task ", _jsx(Text, { color: UI.text, children: info.task })] }), _jsx(Text, { color: COLOR.creamMuted, children: "Dedicated terminal is ready." }), _jsx(Text, { children: " " }), _jsx(Text, { children: " " })] }));
74
+ }
75
+ function AttachResultCard({ item }) {
76
+ const st = STATE_META[item.info.state];
77
+ return (_jsxs(Box, { borderStyle: "single", borderColor: st.color, flexDirection: "column", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: COLOR.cream, bold: true, children: ["Result \u00B7 ", item.info.name, " [", st.label, "]"] }), _jsx(Md, { text: item.result })] }));
42
78
  }
43
79
  export function AttachApp({ agentRef, sock }) {
44
80
  const { exit } = useApp();
81
+ const { stdout } = useStdout();
45
82
  const [info, setInfo] = useState(null);
46
83
  const [others, setOthers] = useState([]);
84
+ const [launchCards, setLaunchCards] = useState([]);
85
+ const [resultCards, setResultCards] = useState([]);
47
86
  const [lines, setLines] = useState([]);
87
+ const [timelineScroll, setTimelineScroll] = useState(0);
88
+ const [timelineFollowTail, setTimelineFollowTail] = useState(true);
48
89
  const [approval, setApproval] = useState(null);
49
90
  const [question, setQuestion] = useState(null);
50
91
  const [gone, setGone] = useState(false);
@@ -52,6 +93,8 @@ export function AttachApp({ agentRef, sock }) {
52
93
  const socketRef = useRef(null);
53
94
  const keySeq = useRef(0);
54
95
  const lastBellId = useRef('');
96
+ const launchRendered = useRef(false);
97
+ const renderedResultKey = useRef('');
55
98
  useEffect(() => {
56
99
  const socket = net.connect(sock);
57
100
  socketRef.current = socket;
@@ -96,6 +139,21 @@ export function AttachApp({ agentRef, sock }) {
96
139
  socket.destroy();
97
140
  };
98
141
  }, [agentRef, sock]);
142
+ useEffect(() => {
143
+ if (!info || launchRendered.current)
144
+ return;
145
+ launchRendered.current = true;
146
+ setLaunchCards([{ key: ++keySeq.current, info }]);
147
+ }, [info?.id]);
148
+ useEffect(() => {
149
+ if (!info || !TERMINAL_STATES.has(info.state) || !info.lastResult)
150
+ return;
151
+ const key = `${info.id}:${info.state}:${info.lastResult.length}:${info.lastResult.slice(0, 24)}`;
152
+ if (renderedResultKey.current === key)
153
+ return;
154
+ renderedResultKey.current = key;
155
+ setResultCards((prev) => [...prev, { key: ++keySeq.current, info: { ...info }, result: info.lastResult ?? '' }]);
156
+ }, [info?.id, info?.state, info?.lastResult]);
99
157
  // Audible alert in THIS terminal when a new interaction arrives — the hub
100
158
  // also rings, but the user may well be looking at the agent's terminal.
101
159
  useEffect(() => {
@@ -123,7 +181,11 @@ export function AttachApp({ agentRef, sock }) {
123
181
  setRaw((r) => !r);
124
182
  return;
125
183
  }
126
- // /task|/ask|/plan <text> launch agent N+1 from this terminal.
184
+ if (cmd.type === 'stop') {
185
+ wire({ type: 'stop', target: cmd.target || agentRef });
186
+ return;
187
+ }
188
+ // /task|/ask|/plan|/review <text> — launch agent N+1 from this terminal.
127
189
  if (cmd.type === 'spawn') {
128
190
  wire({ type: 'spawn', text: cmd.text, mode: cmd.mode });
129
191
  return;
@@ -136,26 +198,58 @@ export function AttachApp({ agentRef, sock }) {
136
198
  };
137
199
  const st = info ? STATE_META[info.state] : null;
138
200
  const busy = info ? ['thinking', 'working', 'listening'].includes(info.state) : false;
201
+ const terminal = info ? TERMINAL_STATES.has(info.state) : false;
139
202
  const interacting = Boolean(approval || question);
203
+ const logs = lines.map((l) => l.log);
204
+ const staticLines = raw ? lines : lines.filter((l) => l.log.kind !== 'llm' && !isLaunchSystemLog(l.log));
205
+ const timelineVisibleLogs = Math.max(8, (stdout?.rows ?? 30) - 14);
206
+ const maxTimelineScroll = Math.max(0, logs.length - timelineVisibleLogs);
207
+ const clampedTimelineScroll = Math.min(timelineScroll, maxTimelineScroll);
208
+ const timelineWindow = logs.slice(Math.max(0, logs.length - timelineVisibleLogs - clampedTimelineScroll), logs.length - clampedTimelineScroll);
209
+ const liveTimelineLogs = logs.slice(-Math.max(6, Math.min(14, Math.floor((stdout?.rows ?? 30) / 2))));
210
+ useEffect(() => {
211
+ if (timelineFollowTail)
212
+ setTimelineScroll(0);
213
+ }, [logs.length, timelineFollowTail]);
214
+ const scrollTimeline = (direction) => {
215
+ if (direction === 'up') {
216
+ setTimelineFollowTail(false);
217
+ setTimelineScroll((s) => Math.min(Math.min(s, maxTimelineScroll) + Math.max(1, Math.floor(timelineVisibleLogs / 2)), maxTimelineScroll));
218
+ return;
219
+ }
220
+ setTimelineScroll((s) => {
221
+ const next = Math.max(0, Math.min(s, maxTimelineScroll) - Math.max(1, Math.floor(timelineVisibleLogs / 2)));
222
+ if (next === 0)
223
+ setTimelineFollowTail(true);
224
+ return next;
225
+ });
226
+ };
227
+ useInput((_input, key) => {
228
+ if (!busy || interacting || raw)
229
+ return;
230
+ if (key.pageUp)
231
+ scrollTimeline('up');
232
+ if (key.pageDown)
233
+ scrollTimeline('down');
234
+ }, { isActive: Boolean(busy && !interacting && !raw) });
140
235
  const banner = (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: UI.brand, bold: true, children: t('attach.banner') }), info ? (_jsxs(Text, { color: info.color, bold: true, children: [' ', info.name, info.alias && info.alias !== info.name ? _jsxs(Text, { color: UI.muted, children: [" @", info.alias] }) : null] })) : null] }));
141
- return (_jsxs(Box, { flexDirection: "column", children: [raw ? (_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)) })) : null, busy && info && st && !interacting ? (
142
- /* COMPACT region while the agent runs: small + borderless, so Ink's
143
- * constant repaints (spinner ticks) never erase tall zones this is
144
- * what used to leave stray blank lines in the native scrollback. */
145
- _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: info.color, bold: true, children: info.alias || info.name }), ' ', _jsxs(Text, { color: st.color, bold: true, children: [st.mark, " ", st.label] }), ' ', _jsx(Spinner, { color: info.color }), _jsxs(Text, { color: UI.muted, children: [' ', "\u00B7 ", elapsed(info.startedAt), " \u00B7 ", info.steps, " st \u00B7", ' ', Math.round((info.tokensIn + info.tokensOut) / 1000), "k \u00B7", ' '] }), _jsx(Text, { color: UI.ok, children: info.cost === null ? '$-' : fmtCost(info.cost) })] }), info.currentAction ? (_jsxs(Text, { color: info.color, wrap: "truncate-end", children: ["Current ", truncate(info.currentAction, 120)] })) : null, others.length > 0 ? (_jsxs(Text, { color: UI.muted, wrap: "truncate-end", children: ["Others ", ' ', others
236
+ return (_jsxs(Box, { flexDirection: "column", children: [!raw ? (_jsx(Static, { items: launchCards, children: (item) => _jsx(AttachLaunchHeader, { item: item }, item.key) })) : null, _jsx(Static, { items: staticLines, children: (item) => (_jsx(AttachStaticLine, { item: item, raw: raw }, item.key)) }), !raw ? (_jsx(Static, { items: resultCards, children: (item) => _jsx(AttachResultCard, { item: item }, item.key) })) : null, !raw && staticLines.length > 0 ? _jsx(Text, { color: UI.muted, children: '─'.repeat(Math.min(Math.max(20, (stdout?.columns ?? 100) - 4), 80)) }) : null, (busy || terminal) && info && st && !interacting ? (
237
+ /* While running, keep the native terminal scrollback stable: activity is
238
+ * appended once above via <Static>, and this live region stays tiny. */
239
+ _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: info.color, bold: true, children: info.alias || info.name }), ' ', _jsxs(Text, { color: modeBadge(info.mode).color, children: ["[", modeBadge(info.mode).label, "]"] }), ' ', _jsxs(Text, { color: st.color, bold: true, children: [st.mark, " ", st.label] }), _jsxs(Text, { color: UI.muted, children: [' ', "\u00B7 ", elapsed(info.startedAt, info.endedAt), " \u00B7 ", info.steps, " st \u00B7", ' ', Math.round((info.tokensIn + info.tokensOut) / 1000), "k \u00B7", ' '] }), _jsx(Text, { color: UI.ok, children: info.cost === null ? '$-' : fmtCost(info.cost) })] }), info.currentAction ? (_jsxs(Text, { color: info.color, wrap: "truncate-end", children: ["Current ", truncate(info.currentAction, 120)] })) : null, _jsx(ProgressSteps, { agent: info, max: 6, cols: process.stdout.columns || 100 }), busy && timelineFollowTail && liveTimelineLogs.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: UI.muted, bold: true, children: "Live activity" }), _jsx(Timeline, { logs: liveTimelineLogs, cols: process.stdout.columns || 100 })] })) : null, terminal && info.lastResult ? (_jsx(Text, { color: COLOR.creamMuted, children: "Result was appended above; native mouse scroll stays available." })) : null, others.length > 0 ? (_jsxs(Text, { color: UI.muted, wrap: "truncate-end", children: ["Others ", ' ', others
146
240
  .map((o) => `${o.name} [${stateLabel(o.state)}] ${truncate(o.currentAction || o.task, 40)}`)
147
- .join(' · ')] })) : null, !raw ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: UI.muted, bold: true, children: t('timeline.activity') }), _jsx(Timeline, { logs: lines.map((l) => l.log), cols: process.stdout.columns || 100 })] })) : null] })) : (
148
- /* FULL panel when idle / waiting / done repaints are rare here. */
149
- _jsxs(Box, { borderStyle: "single", borderColor: info?.color ?? 'gray', flexDirection: "column", paddingX: 1, marginTop: 1, children: [info && st ? (_jsxs(_Fragment, { children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { children: [banner, _jsxs(Text, { color: st.color, bold: true, children: [' ', st.mark, " ", st.label] })] }), _jsxs(Text, { color: UI.muted, wrap: "truncate-end", children: [middleTruncate(info.model, 18), " \u00B7 ", elapsed(info.startedAt), " \u00B7 ", info.steps, " st \u00B7", ' ', Math.round((info.tokensIn + info.tokensOut) / 1000), "k \u00B7", ' ', info.ctxPct !== undefined ? (_jsxs(Text, { color: info.ctxPct >= 90 ? UI.danger : info.ctxPct >= 70 ? UI.warn : UI.muted, children: [info.ctxPct, "% \u00B7", ' '] })) : null, _jsx(Text, { color: UI.ok, children: info.cost === null ? '$-' : fmtCost(info.cost) })] })] }), _jsxs(Text, { color: UI.muted, wrap: "wrap", children: ["Task ", _jsx(Text, { color: UI.text, children: info.task })] }), info.currentAction ? (_jsxs(Text, { color: info.color, wrap: "truncate-end", children: ["Current ", truncate(info.currentAction, 160)] })) : null, others.length > 0 ? (
241
+ .join(' · ')] })) : null, !timelineFollowTail ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: UI.warn, children: "Viewing older activity \u00B7 \u2193/PgDn to latest" }), _jsx(Timeline, { logs: timelineWindow, cols: process.stdout.columns || 100 })] })) : null] })) : (
242
+ /* FULL panel for idle/waiting/interactionsterminal states stay compact. */
243
+ _jsxs(Box, { borderStyle: "single", borderColor: info?.color ?? 'gray', flexDirection: "column", paddingX: 1, marginTop: 1, children: [info && st ? (_jsxs(_Fragment, { children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { children: [banner, _jsxs(Text, { color: modeBadge(info.mode).color, children: [" [", modeBadge(info.mode).label, "]"] }), _jsxs(Text, { color: st.color, bold: true, children: [' ', st.mark, " ", st.label] })] }), _jsxs(Text, { color: UI.muted, wrap: "truncate-end", children: [middleTruncate(info.model, 18), " \u00B7 ", elapsed(info.startedAt, info.endedAt), " \u00B7 ", info.steps, " st \u00B7", ' ', Math.round((info.tokensIn + info.tokensOut) / 1000), "k \u00B7", ' ', info.ctxPct !== undefined ? (_jsxs(Text, { color: info.ctxPct >= 90 ? UI.danger : info.ctxPct >= 70 ? UI.warn : UI.muted, children: [info.ctxPct, "% \u00B7", ' '] })) : null, _jsx(Text, { color: UI.ok, children: info.cost === null ? '$-' : fmtCost(info.cost) })] })] }), _jsxs(Text, { color: UI.muted, wrap: "wrap", children: ["Task ", _jsx(Text, { color: UI.text, children: info.task })] }), info.currentAction ? (_jsxs(Text, { color: info.color, wrap: "truncate-end", children: ["Current ", truncate(info.currentAction, 160)] })) : null, _jsx(ProgressSteps, { agent: info, max: 6, cols: process.stdout.columns || 100 }), others.length > 0 ? (
150
244
  // The session's shared awareness, visible here too: what the
151
245
  // OTHER agents are doing right now (live, same feed the agents get).
152
246
  _jsxs(Text, { color: UI.muted, wrap: "truncate-end", children: ["Others ", ' ', others
153
247
  .map((o) => `${o.name} [${stateLabel(o.state)}] ${truncate(o.currentAction || o.task, 40)}`)
154
- .join(' · ')] })) : null, !raw ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: UI.muted, bold: true, children: t('timeline.activity') }), _jsx(Timeline, { logs: lines.map((l) => l.log), cols: process.stdout.columns || 100 })] })) : null, info.lastResult && (info.state === 'done' || info.state === 'error' || info.state === 'stopped') ? (_jsxs(Box, { borderStyle: "single", borderColor: "gray", flexDirection: "column", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: UI.ok, bold: true, children: "Result" }), _jsx(Md, { text: info.lastResult })] })) : null] })) : (_jsx(Text, { color: "gray", children: gone ? t('attach.gone') : t('attach.waiting', { agent: agentRef }) })), gone && info ? _jsx(Text, { color: UI.danger, children: t('attach.gone') }) : null] })), approval ? (_jsx(ApprovalPrompt, { request: { ...approval, agentId: info?.id ?? '', resolve: noop }, pendingCount: 1, onAnswer: (id, ok, always) => {
248
+ .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: UI.ok, bold: true, children: "Result" }), _jsx(Md, { text: info.lastResult })] })) : null] })) : (_jsx(Text, { color: "gray", children: gone ? t('attach.gone') : t('attach.waiting', { agent: agentRef }) })), gone && info ? _jsx(Text, { color: UI.danger, children: t('attach.gone') }) : null] })), approval ? (_jsx(ApprovalPrompt, { request: { ...approval, agentId: info?.id ?? '', resolve: noop }, pendingCount: 1, onAnswer: (id, ok, always) => {
155
249
  wire({ type: 'approve', id, approved: ok, always: !!always });
156
250
  setApproval(null);
157
251
  } })) : question ? (_jsx(QuestionPrompt, { question: { ...question, agentId: info?.id ?? '', resolve: noop }, pendingCount: 1, onAnswer: (id, answer) => {
158
252
  wire({ type: 'answer', id, text: answer });
159
253
  setQuestion(null);
160
- } }, question.id)) : null, _jsx(CommandInput, { active: !gone && !interacting, placeholder: t('attach.placeholder', { agent: info?.name ?? agentRef }), context: "attach", targetAgent: info?.name ?? agentRef, modelLabel: info?.model, agentNames: [info?.alias, info?.name, ...others.flatMap((o) => [o.alias, o.name])].filter((n) => Boolean(n)), agents: info ? [info] : [], width: process.stdout.columns || 100, onSubmit: send, onEscape: () => exit() }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: COLOR.creamMuted, wrap: "truncate-end", children: formatAttachFooter(info) }) })] }));
254
+ } }, question.id)) : null, _jsx(Text, { children: " " }), _jsx(CommandInput, { active: !gone && !interacting, placeholder: t('attach.placeholder', { agent: info?.name ?? agentRef }), context: "attach", targetAgent: info?.name ?? agentRef, modelLabel: info?.model, agentNames: [info?.alias, info?.name, ...others.flatMap((o) => [o.alias, o.name])].filter((n) => Boolean(n)), agents: info ? [info] : [], width: process.stdout.columns || 100, onIdleNavigation: busy && !raw ? scrollTimeline : undefined, onSubmit: send, onEscape: () => exit() }), _jsx(Text, { children: " " }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: COLOR.creamMuted, wrap: "truncate-end", children: formatAttachFooter(info) }) })] }));
161
255
  }
@@ -32,6 +32,8 @@ function modeHint(value, context, targetAgent) {
32
32
  return 'Task mode · execute, edit, validate';
33
33
  if (v.startsWith('/plan') || v === '/p')
34
34
  return 'Plan mode · asks before editing';
35
+ if (v.startsWith('/review'))
36
+ return 'Review mode · verdict, risks, tests';
35
37
  return '↑/↓ select · Enter accept';
36
38
  }
37
39
  export function bestCommandCompletion(value) {
@@ -41,7 +43,7 @@ export function bestCommandCompletion(value) {
41
43
  export function commandNamesForContext(context) {
42
44
  if (context !== 'attach')
43
45
  return undefined;
44
- return ['/ask', '/a', '/task', '/t', '/plan', '/p', '/send', '/raw', '/quit', '/exit', '/detach'];
46
+ return ['/ask', '/a', '/task', '/t', '/plan', '/p', '/review', '/send', '/stop', '/raw', '/quit', '/exit', '/detach'];
45
47
  }
46
48
  export function agentArgCommand(value) {
47
49
  const m = value.match(/^(\/\S+)\s+([^\s]*)$/);
@@ -79,7 +81,7 @@ export function wrappedPromptLines(text, width) {
79
81
  function paintLine(text, width) {
80
82
  return text.length >= width ? text.slice(0, width) : text.padEnd(width, ' ');
81
83
  }
82
- export function CommandInput({ active, placeholder, mask, context = 'hub', targetAgent, modelLabel, commandNames, agentNames = [], agents = [], width, onHeightChange, onSubmit, onEscape, notify, }) {
84
+ export function CommandInput({ active, placeholder, mask, context = 'hub', targetAgent, modelLabel, commandNames, agentNames = [], agents = [], width, onHeightChange, onIdleNavigation, onSubmit, onEscape, notify, }) {
83
85
  const [value, setValue] = useState('');
84
86
  const [attachments, setAttachments] = useState([]);
85
87
  const [history, setHistory] = useState([]);
@@ -236,6 +238,10 @@ export function CommandInput({ active, placeholder, mask, context = 'hub', targe
236
238
  setSelectedSuggestion((i) => clampSuggestionIndex(i - 1, suggestionCount));
237
239
  return;
238
240
  }
241
+ if (!value && attachments.length === 0 && onIdleNavigation) {
242
+ onIdleNavigation('up');
243
+ return;
244
+ }
239
245
  setHistIdx((i) => {
240
246
  const ni = i === -1 ? history.length - 1 : Math.max(0, i - 1);
241
247
  if (history[ni] !== undefined)
@@ -249,6 +255,10 @@ export function CommandInput({ active, placeholder, mask, context = 'hub', targe
249
255
  setSelectedSuggestion((i) => clampSuggestionIndex(i + 1, suggestionCount));
250
256
  return;
251
257
  }
258
+ if (!value && attachments.length === 0 && onIdleNavigation) {
259
+ onIdleNavigation('down');
260
+ return;
261
+ }
252
262
  setHistIdx((i) => {
253
263
  if (i === -1)
254
264
  return -1;
@@ -20,6 +20,8 @@ function itemColor(item) {
20
20
  return item.category === 'change' ? UI.warn : UI.muted;
21
21
  if (item.category === 'coordinate')
22
22
  return UI.note;
23
+ if (item.label === 'next')
24
+ return UI.accent;
23
25
  if (item.kind === 'thought')
24
26
  return UI.muted;
25
27
  if (item.kind === 'narration')
@@ -43,6 +45,9 @@ function TimelineRow({ item, cols }) {
43
45
  if (item.kind === 'command') {
44
46
  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 ?? '', max) })] }), _jsx(OutputLines, { item: item, cols: cols })] }));
45
47
  }
48
+ if (item.label === 'next') {
49
+ return (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: UI.accent, wrap: "wrap", children: [_jsx(Text, { color: UI.muted, children: "\u2192 " }), truncate(item.detail ?? '', max)] }) }));
50
+ }
46
51
  if (item.kind === 'files') {
47
52
  const files = item.files ?? [];
48
53
  const shown = files.slice(0, 5).join(', ');
package/dist/ui/events.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { oneLine } from './tokens.js';
2
+ import { t } from '../i18n.js';
2
3
  function cleanToolText(text) {
3
4
  return oneLine(text)
4
- .replace(/^[📖📁🔍✏🚩🧠🧩📢⏳❓✉✅↳]+\s*/u, '')
5
+ .replace(/^[📖📁🔍🔎📚✏🚩🧠🧩📢☑⏳❓✉✅↳]+\s*/u, '')
5
6
  .trim();
6
7
  }
7
8
  function stripShellNoise(command) {
@@ -19,6 +20,12 @@ function classify(log) {
19
20
  if (log.kind === 'tool_result') {
20
21
  return { agentId: log.agentId, kind: 'command_output', label: 'output', detail: log.text.trim(), ts: log.ts, seq: log.seq };
21
22
  }
23
+ if (log.kind === 'tool' && /^\s*📢/u.test(log.text)) {
24
+ return { agentId: log.agentId, kind: 'intent', label: 'next', detail: cleaned || text, ts: log.ts, seq: log.seq };
25
+ }
26
+ if (log.kind === 'tool' && /^\s*☑/u.test(log.text)) {
27
+ return { agentId: log.agentId, kind: 'note', label: 'steps', detail: cleaned || text, ts: log.ts, seq: log.seq };
28
+ }
22
29
  if (log.kind === 'note')
23
30
  return { agentId: log.agentId, kind: 'note', label: 'note', detail: cleaned || text, ts: log.ts, seq: log.seq };
24
31
  if (log.kind === 'system')
@@ -44,8 +51,14 @@ function classify(log) {
44
51
  if (/^(search)\s+/i.test(cleaned)) {
45
52
  return { agentId: log.agentId, kind: 'file', label: 'search', detail: cleaned.replace(/^search\s+/i, ''), ts: log.ts, seq: log.seq };
46
53
  }
47
- if (/^(write|edit|patch|claim|claims?)\s+/i.test(cleaned)) {
48
- const label = lower.startsWith('claim') ? 'claim' : lower.startsWith('write') ? 'write' : 'edit';
54
+ if (/^inspect project/i.test(cleaned)) {
55
+ return { agentId: log.agentId, kind: 'file', label: 'search', detail: 'project', ts: log.ts, seq: log.seq };
56
+ }
57
+ if (/^(claim|claims?):?\s+/i.test(cleaned)) {
58
+ return { agentId: log.agentId, kind: 'note', label: 'claim', detail: cleaned.replace(/^(claim|claims?):?\s*/i, ''), ts: log.ts, seq: log.seq };
59
+ }
60
+ if (/^(write|edit|patch)\s+/i.test(cleaned)) {
61
+ const label = lower.startsWith('write') ? 'write' : 'edit';
49
62
  return { agentId: log.agentId, kind: 'file', label, detail: cleaned.replace(/^(write|edit|patch|claim|claims?)\s*/i, ''), ts: log.ts, seq: log.seq };
50
63
  }
51
64
  if (/^(run|exec|shell|npm|pnpm|yarn|git|node|npx)\b/i.test(cleaned)) {
@@ -105,6 +118,8 @@ function categoryFor(e) {
105
118
  return 'result';
106
119
  if (e.kind === 'note' || e.kind === 'approval' || e.kind === 'question')
107
120
  return 'coordinate';
121
+ if (e.kind === 'intent')
122
+ return 'other';
108
123
  if (e.kind === 'file') {
109
124
  if (e.label === 'write' || e.label === 'edit' || e.label === 'claim')
110
125
  return 'change';
@@ -141,20 +156,8 @@ export function summarizeCommandOutput(output, command = '', maxLines = 6) {
141
156
  }
142
157
  function narrationFor(category, previous) {
143
158
  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.';
159
+ return t('timeline.narration.inspectAfterValidate');
160
+ return t(`timeline.narration.${category}`);
158
161
  }
159
162
  function pushSection(out, category, ts, seq) {
160
163
  const prev = [...out].reverse().find((i) => i.kind !== 'section');
package/dist/ui/theme.js CHANGED
@@ -15,8 +15,8 @@ export const STATE_LABEL = {
15
15
  export function stateLabel(state) {
16
16
  return t(STATE_LABEL[state].labelKey);
17
17
  }
18
- export function elapsed(since) {
19
- const s = Math.floor((Date.now() - since) / 1000);
18
+ export function elapsed(since, until = Date.now()) {
19
+ const s = Math.max(0, Math.floor((until - since) / 1000));
20
20
  if (s < 60)
21
21
  return `${s}s`;
22
22
  const m = Math.floor(s / 60);
package/dist/ui/tokens.js CHANGED
@@ -48,11 +48,11 @@ export const STATE = {
48
48
  waiting: 'yellow',
49
49
  idle: 'gray',
50
50
  };
51
- /** Mode indicator colors. `task` is undefined — it renders no mark. */
51
+ /** Mode indicator colors. */
52
52
  export const MODE = {
53
53
  ask: 'yellow',
54
54
  plan: COLOR.creamMuted,
55
- task: undefined,
55
+ task: COLOR.cream,
56
56
  };
57
57
  /** Chrome / UI element colors. */
58
58
  export const CHROME = {
package/dist/ui/views.js CHANGED
@@ -73,6 +73,13 @@ function useScrollWindow(items, visible, anchor = 'top') {
73
73
  }
74
74
  const Above = ({ n }) => (n > 0 ? _jsxs(Text, { color: "gray", children: ["\u25B2 ", n, " \u00B7 PgUp"] }) : null);
75
75
  const Below = ({ n }) => (n > 0 ? _jsxs(Text, { color: "gray", children: ["\u25BC ", n, " \u00B7 PgDn"] }) : null);
76
+ function shortTime(ts) {
77
+ const seconds = Math.max(0, Math.round((Date.now() - ts) / 1000));
78
+ if (seconds < 60)
79
+ return t('board.secondsAgo', { n: seconds });
80
+ const minutes = Math.round(seconds / 60);
81
+ return t('board.minutesAgo', { n: minutes });
82
+ }
76
83
  /** Usable rows for a view's list, from the REAL terminal height. */
77
84
  function useVisibleRows(overhead, min = 6) {
78
85
  const { stdout } = useStdout();
@@ -81,13 +88,17 @@ function useVisibleRows(overhead, min = 6) {
81
88
  export function BoardView({ board, bodyHeight }) {
82
89
  const agents = [...board.agents.values()];
83
90
  const fallbackVisible = useVisibleRows(12);
84
- const visibleAgents = bodyHeight ? Math.max(1, Math.floor((bodyHeight - 7) / 3)) : fallbackVisible;
91
+ const visibleAgents = bodyHeight ? Math.max(1, Math.floor((bodyHeight - 10) / 3)) : fallbackVisible;
85
92
  const { slice: agentSlice, above, below } = useScrollWindow(agents, visibleAgents, 'top');
86
- const sideRows = bodyHeight ? Math.max(1, Math.floor((bodyHeight - visibleAgents - 5) / 2)) : 8;
93
+ const sideRows = bodyHeight ? Math.max(1, Math.floor((bodyHeight - visibleAgents - 8) / 2)) : 8;
87
94
  const activities = [...board.fileActivity.values()].sort((a, b) => b.ts - a.ts).slice(0, sideRows);
88
95
  const notes = board.notes.slice(-sideRows);
89
96
  const warnings = board.workMapWarnings.slice(-Math.max(2, Math.min(4, sideRows)));
90
- return (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "yellow", children: t('board.title') }), _jsx(Text, { bold: true, children: t('board.agents') }), agents.length === 0 ? (_jsxs(Text, { color: "gray", children: [" ", t('board.none')] })) : (_jsxs(_Fragment, { children: [_jsx(Above, { n: above }), agentSlice.map((a) => (_jsxs(Text, { wrap: "truncate-end", children: [' ', _jsx(Text, { color: a.color, bold: true, children: a.name }), _jsxs(Text, { color: STATE_LABEL[a.state].color, children: [' ', STATE_LABEL[a.state].icon, " ", stateLabel(a.state)] }), _jsxs(Text, { color: "gray", children: [" ", truncate(a.currentAction || a.task, 80)] }), a.claims && a.claims.length > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 ", truncate(a.claims.join(', '), 45)] }) : null] }, a.id))), _jsx(Below, { n: below })] })), _jsx(Text, { bold: true, children: t('board.activity') }), warnings.length > 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, color: "yellowBright", children: t('board.workMap') }), warnings.map((w) => (_jsxs(Text, { wrap: "truncate-end", children: [' ', _jsxs(Text, { color: w.level === 'conflict' ? 'redBright' : 'yellow', children: [w.level === 'conflict' ? '!' : '⚠', " "] }), _jsx(Text, { color: "yellow", children: w.title }), _jsxs(Text, { color: "gray", children: [" \u2014 ", truncate(w.detail, 120)] })] }, w.id)))] })) : null, activities.length === 0 ? (_jsxs(Text, { color: "gray", children: [" ", t('board.noActivity')] })) : (activities.map((act) => (_jsxs(Text, { wrap: "truncate-end", children: [' ', "\u270F ", act.path, " ", _jsxs(Text, { color: "gray", children: ["\u2014 ", act.agentName, " (", act.op, ", ", Math.round((Date.now() - act.ts) / 1000), "s)"] })] }, act.path)))), _jsx(Text, { bold: true, children: t('board.notes') }), notes.map((n) => (_jsxs(Text, { wrap: "truncate-end", children: [' ', _jsxs(Text, { color: "magenta", children: [n.from, " \u2192 ", n.to] }), _jsxs(Text, { children: [": ", truncate(n.content, 140)] })] }, n.id)))] }));
97
+ return (_jsxs(Box, { borderStyle: "round", borderColor: BRAND.muted, flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: BRAND.primary, children: t('board.title') }), _jsx(Text, { bold: true, color: warnings.length > 0 ? COLOR.cream : BRAND.primary, children: t('board.workMap') }), warnings.length > 0 ? (warnings.map((w) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { wrap: "truncate-end", children: [' ', _jsxs(Text, { color: w.level === 'conflict' ? 'redBright' : 'yellow', children: [w.level === 'conflict' ? '!' : '⚠', " "] }), _jsx(Text, { color: BRAND.primary, children: w.title }), _jsxs(Text, { color: "gray", children: [" \u2014 ", truncate(w.detail, 100)] })] }), _jsxs(Text, { color: "gray", wrap: "truncate-end", children: [' ', t('board.warningMeta', {
98
+ agents: w.agentNames.join(', ') || 'agents',
99
+ paths: w.paths.join(', ') || 'paths',
100
+ time: shortTime(w.ts),
101
+ })] })] }, w.id)))) : (_jsxs(Text, { color: "gray", children: [" ", t('board.workMapOk')] })), warnings.length > 0 ? _jsxs(Text, { color: COLOR.creamMuted, children: [" ", t('board.warningSuggestion')] }) : null, _jsx(Text, { bold: true, children: t('board.agents') }), agents.length === 0 ? (_jsxs(Text, { color: "gray", children: [" ", t('board.none')] })) : (_jsxs(_Fragment, { children: [_jsx(Above, { n: above }), agentSlice.map((a) => (_jsxs(Text, { wrap: "truncate-end", children: [' ', _jsx(Text, { color: a.color, bold: true, children: a.name }), _jsxs(Text, { color: STATE_LABEL[a.state].color, children: [' ', STATE_LABEL[a.state].icon, " ", stateLabel(a.state)] }), _jsxs(Text, { color: "gray", children: [" ", truncate(a.currentAction || a.task, 80)] }), a.claims && a.claims.length > 0 ? _jsxs(Text, { color: COLOR.cream, children: [" \u00B7 \u2691 ", truncate(a.claims.join(', '), 45)] }) : null] }, a.id))), _jsx(Below, { n: below })] })), _jsx(Text, { bold: true, children: t('board.activity') }), activities.length === 0 ? (_jsxs(Text, { color: "gray", children: [" ", t('board.noActivity')] })) : (activities.map((act) => (_jsxs(Text, { wrap: "truncate-end", children: [' ', "\u270F ", act.path, " ", _jsxs(Text, { color: "gray", children: ["\u2014 ", act.agentName, " (", act.op, ", ", Math.round((Date.now() - act.ts) / 1000), "s)"] })] }, act.path)))), _jsx(Text, { bold: true, children: t('board.notes') }), notes.map((n) => (_jsxs(Text, { wrap: "truncate-end", children: [' ', _jsxs(Text, { color: "magenta", children: [n.from, " \u2192 ", n.to] }), _jsxs(Text, { children: [": ", truncate(n.content, 140)] })] }, n.id)))] }));
91
102
  }
92
103
  export function NotesView({ board, bodyHeight }) {
93
104
  const fallbackVisible = useVisibleRows(7);
@@ -102,7 +113,8 @@ export function DiffView({ board, bodyHeight }) {
102
113
  const rows = bodyHeight ? Math.max(8, bodyHeight - 4) : fallbackRows;
103
114
  const perChange = Math.max(1, Math.floor(rows / 34));
104
115
  const { slice: changes, above, below } = useScrollWindow(board.changes, perChange, 'bottom');
105
- return (_jsxs(Box, { borderStyle: "round", borderColor: "green", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "green", children: t('diff.title', { total: board.changes.length }) }), board.changes.length === 0 ? (_jsx(Text, { color: "gray", children: t('diff.empty') })) : (_jsxs(_Fragment, { children: [_jsx(Above, { n: above }), changes.map((c) => {
116
+ const warnings = board.workMapWarnings.filter((w) => w.level !== 'info').slice(-2);
117
+ return (_jsxs(Box, { borderStyle: "round", borderColor: "green", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "green", children: t('diff.title', { total: board.changes.length }) }), warnings.map((w) => (_jsxs(Text, { color: w.level === 'conflict' ? 'redBright' : 'yellow', wrap: "truncate-end", children: ["\u26A0 ", w.title, ": ", truncate(w.paths.join(', ') || w.detail, 110)] }, w.id))), board.changes.length === 0 ? (_jsx(Text, { color: "gray", children: t('diff.empty') })) : (_jsxs(_Fragment, { children: [_jsx(Above, { n: above }), changes.map((c) => {
106
118
  const patch = Diff.createPatch(c.path, c.before, c.after, '', '', { context: 2 });
107
119
  const lines = patch.split('\n').slice(4, 34);
108
120
  return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { bold: true, children: [_jsx(Text, { color: BRAND.primary, children: c.path }), _jsxs(Text, { color: "gray", children: [' ', t('diff.by', { agent: c.agentName, time: new Date(c.ts).toLocaleTimeString() })] })] }), lines.map((l, i) => (_jsx(Text, { color: l.startsWith('+') ? 'green' : l.startsWith('-') ? 'red' : l.startsWith('@') ? BRAND.primary : 'gray', wrap: "truncate-end", children: l || ' ' }, i))), patch.split('\n').length > 38 ? _jsx(Text, { color: "gray", children: t('diff.trunc') }) : null] }, c.id));
@@ -159,7 +171,7 @@ export function HelpView({ bodyHeight, onSelect }) {
159
171
  const above = start;
160
172
  const below = Math.max(0, commands.length - start - slice.length);
161
173
  const highlights = [
162
- ['Agent modes', ['/ask', '/task', '/plan']],
174
+ ['Agent modes', ['/ask', '/task', '/plan', '/review']],
163
175
  ['Shell approvals', ['/approvals ask', '/approvals auto', '/approvals yolo']],
164
176
  ['Navigation', ['/focus', '/attach', '/raw', '/send']],
165
177
  ];
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  export const PACKAGE_NAME = '@parallel-cli/parallel';
2
- export const VERSION = '0.4.6';
2
+ export const VERSION = '0.4.7';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@parallel-cli/parallel",
3
- "version": "0.4.6",
4
- "description": "Real-time multi-agent coding CLI with shared context, adaptive co-editing, dedicated agent terminals, and headless CI runs.",
3
+ "version": "0.4.8",
4
+ "description": "Real-time coding agents that work like a live team on one shared repository.",
5
5
  "keywords": [
6
6
  "cli",
7
7
  "ai",