@parallel-cli/parallel 0.4.4 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  All notable changes to Parallel are documented here.
4
4
 
5
+ ## 0.4.5 - 2026-06-23
6
+
7
+ ### 0.4.5 Added
8
+
9
+ - Added explicit hub, focus, and attach input contexts with context-specific hints and filtered command suggestions.
10
+ - Added agent argument autocomplete for `/focus`, `/send`, `/attach`, `/pause`, `/resume`, `/stop`, `/restore`, and `/commit`.
11
+ - Added attach-terminal routing for `@all`, `@agent`, and `/send`, so dedicated terminals can broadcast or steer another agent through the main session.
12
+ - Added richer hub rows using latest useful agent signals, specialist badges, context percentage, and responsive timeline widths.
13
+ - Added durable session memory for claims, recent diff excerpts, file activity, work-map warnings, agent aliases, provider/model metadata, specialist, and context usage.
14
+ - Added shell mutation tracking: files changed by `run_command` now appear in live diffs, file activity, and agent commit ownership.
15
+ - Added a non-blocking work map that surfaces overlapping claims and repeated co-edit conflicts in `/board` and agent context.
16
+
17
+ ### 0.4.5 Changed
18
+
19
+ - Clarified agent naming around `Name: task` syntax in README examples.
20
+ - Made `/raw` visible only when it affects the active focus view.
21
+ - Reset focus scroll follow-tail behavior when switching focused agents.
22
+ - Improved session restore so `/restore` can target saved agents by name, alias, or id when their conversation is available.
23
+
24
+ ### 0.4.5 Fixed
25
+
26
+ - Fixed attach-terminal `@all` being treated as plain text to the attached agent.
27
+ - Fixed restored note ids drifting by resynchronizing blackboard note and change sequences after loading session data.
28
+ - Fixed shell-created edits being invisible to `/diff`, `/board`, and `/commit`.
29
+
5
30
  ## 0.4.4 - 2026-06-23
6
31
 
7
32
  ### 0.4.4 Added
package/README.md CHANGED
@@ -16,9 +16,12 @@ Parallel lets you run several AI coding agents on the same repository at the sam
16
16
  - Run multiple agents in parallel on one project.
17
17
  - Choose explicit modes: `/ask`, `/task`, and `/plan`.
18
18
  - Type plain text to launch a task agent immediately.
19
+ - Use context-aware input in the hub, focus view, and attached agent terminals.
19
20
  - Steer one agent with `@a1 ...` or broadcast with `@all ...`.
20
21
  - Open dedicated agent terminals with native scrollback.
22
+ - See a richer live hub with latest agent signals, mode badges, context usage, and responsive timelines.
21
23
  - Review agents, notes, file activity, diffs, cost, skills, specialists, and saved sessions from the TUI.
24
+ - Track shell-created file mutations in the same live diff feed as agent edits.
22
25
  - Configure OpenAI-compatible providers through a guided wizard and settings panel.
23
26
  - Use 29 provider presets across Western, Chinese, Gateway, Inference, and Local categories.
24
27
  - Support local no-key endpoints such as Ollama and vLLM/SGLang.
@@ -79,9 +82,9 @@ Plain text launches a `/task` agent. You can launch another agent while the firs
79
82
  Use explicit modes when intent matters:
80
83
 
81
84
  ```text
82
- /ask reviewer should we split the CLI parser?
83
- /plan migration propose the safest rollout for the config change
84
- /task builder implement the approved plan
85
+ /ask Reviewer: should we split the CLI parser?
86
+ /plan Migration: propose the safest rollout for the config change
87
+ /task Builder: implement the approved plan
85
88
  ```
86
89
 
87
90
  Steer a running agent:
@@ -122,6 +125,19 @@ The main TUI is the Parallel hub. It is designed to answer:
122
125
  - what changed in the project
123
126
  - what model, provider, shell mode, and cost are active
124
127
 
128
+ Input has three explicit contexts:
129
+
130
+ - Hub: plain text launches a new `/task` agent. Slash suggestions show hub commands and agent arguments autocomplete for `/focus`, `/send`, `/attach`, `/pause`, `/resume`, `/stop`, `/restore`, and `/commit`.
131
+ - Focus: after `/focus a1`, plain text talks to the focused agent instead of spawning a new one. `/raw` affects this view only.
132
+ - Attach: in `parallel attach a1`, plain text steers the attached agent. `/task`, `/ask`, and `/plan` spawn new agents from that terminal, while `@all ...`, `@a2 ...`, and `/send ...` route instructions through the main session.
133
+
134
+ Use `Name: task` when naming an agent:
135
+
136
+ ```text
137
+ /task Tests: add regression coverage for the auth middleware
138
+ /plan Migration: outline the safest database rollout
139
+ ```
140
+
125
141
  Common hub commands:
126
142
 
127
143
  - `/agents`: agent overview.
@@ -170,9 +186,12 @@ From an attached terminal:
170
186
 
171
187
  ```text
172
188
  plain text sends a message to this agent
173
- /task write parser regression tests
174
- /ask is this result safe to merge?
175
- /plan prepare a migration plan
189
+ @all pause public interface changes until tests finish
190
+ @a2 re-read the API client before editing it
191
+ /send a2 check the new parser contract
192
+ /task Tests: write parser regression tests
193
+ /ask Reviewer: is this result safe to merge?
194
+ /plan Migration: prepare a migration plan
176
195
  /raw
177
196
  /quit
178
197
  ```
@@ -266,7 +285,14 @@ Environment variables:
266
285
  - `/save [name]`: save the current session.
267
286
  - `/sessions`: list saved sessions.
268
287
  - `/session <n|latest>`: load a saved session snapshot. If active agents are running, use `/session <n|latest> --force` after saving/stopping what you need.
269
- - `/restore <agent>`: relaunch a restored agent with its conversation history.
288
+ - `/restore <agent>`: relaunch a restored agent by name, alias, or saved id when its conversation history is still available.
289
+
290
+ Session memory has two layers:
291
+
292
+ - Live memory: active agents see statuses, notes, claims, work-map warnings, file activity, and recent diffs before every model action.
293
+ - Durable memory: `/save` and autosave persist notes, claims, recent diff excerpts, file activity, work-map warnings, agent aliases, model/provider metadata, context usage, and conversation paths for restore.
294
+
295
+ Restore is best effort and explicit. `/session` reloads coordination memory into the blackboard; `/restore <agent>` relaunches an agent only when the saved conversation file still exists. Restored agents keep their prior task, mode, model, specialist, and conversation when available.
270
296
 
271
297
  ### Settings And Exit
272
298
 
@@ -336,6 +362,10 @@ When an agent writes a file:
336
362
 
337
363
  This keeps agents moving without allowing silent overwrites.
338
364
 
365
+ Commands run through `run_command` are also snapshotted before and after execution. If a shell command edits, creates, or deletes tracked project files, Parallel records those mutations in `/diff`, `/board`, and `/commit` ownership just like tool-based edits.
366
+
367
+ The work map is advisory, not a lock. Agents can declare claims with `claim_files`; Parallel detects overlapping claims and repeated conflicts, then shows non-blocking warnings in `/board` and injects them into agent context so agents can coordinate before collisions become expensive.
368
+
339
369
  ## Headless Mode
340
370
 
341
371
  For CI and scripts, run without the TUI:
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { exec } from 'node:child_process';
4
4
  import * as Diff from 'diff';
5
- const IGNORED = new Set(['node_modules', '.git', '.parallel', 'dist', '__pycache__', '.venv', 'venv']);
5
+ const IGNORED = new Set(['node_modules', '.git', '.parallel', '.cursor', 'dist', '__pycache__', '.venv', 'venv']);
6
6
  const MAX_OUTPUT = 12_000;
7
7
  const MUTATING_TOOLS = new Set(['write_file', 'edit_file', 'claim_files', 'remember']);
8
8
  function isMutatingShell(command) {
@@ -269,6 +269,60 @@ export class ToolExecutor {
269
269
  relOf(p) {
270
270
  return path.relative(this.projectRoot, this.resolve(p)) || '.';
271
271
  }
272
+ snapshotProject() {
273
+ const snapshot = new Map();
274
+ const walk = (dir, depth) => {
275
+ if (depth > 8)
276
+ return;
277
+ let entries;
278
+ try {
279
+ entries = fs.readdirSync(dir, { withFileTypes: true });
280
+ }
281
+ catch {
282
+ return;
283
+ }
284
+ for (const e of entries) {
285
+ if (IGNORED.has(e.name) || e.name.startsWith('.git'))
286
+ continue;
287
+ const full = path.join(dir, e.name);
288
+ const relPath = path.relative(this.projectRoot, full);
289
+ if (e.isDirectory()) {
290
+ walk(full, depth + 1);
291
+ continue;
292
+ }
293
+ if (!e.isFile())
294
+ continue;
295
+ try {
296
+ const stat = fs.statSync(full);
297
+ if (stat.size > 750_000)
298
+ continue;
299
+ snapshot.set(relPath, fs.readFileSync(full, 'utf8'));
300
+ }
301
+ catch {
302
+ /* binary/unreadable files are ignored for diff tracking */
303
+ }
304
+ }
305
+ };
306
+ walk(this.projectRoot, 0);
307
+ return snapshot;
308
+ }
309
+ recordShellMutations(before) {
310
+ const after = this.snapshotProject();
311
+ const paths = new Set([...before.keys(), ...after.keys()]);
312
+ let count = 0;
313
+ for (const relPath of [...paths].sort()) {
314
+ const oldContent = before.get(relPath);
315
+ const newContent = after.get(relPath);
316
+ if (oldContent === newContent)
317
+ continue;
318
+ this.board.addChange(this.agentId, relPath, oldContent ?? '', newContent ?? '');
319
+ this.board.recordActivity(relPath, this.agentId, 'shell');
320
+ count++;
321
+ }
322
+ if (count > 0)
323
+ this.board.log(this.agentId, 'tool', `✏ shell changed ${count} file${count === 1 ? '' : 's'}`);
324
+ return count;
325
+ }
272
326
  async execute(name, args) {
273
327
  try {
274
328
  const guard = this.guardTool(name, args);
@@ -592,6 +646,7 @@ export class ToolExecutor {
592
646
  }
593
647
  this.board.setAgentState(this.agentId, 'working', `$ ${command.slice(0, 60)}`);
594
648
  this.board.log(this.agentId, 'tool', `$ ${command}`);
649
+ const before = this.snapshotProject();
595
650
  return new Promise((resolve) => {
596
651
  exec(command, { cwd: this.projectRoot, timeout: 120_000, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
597
652
  let out = '';
@@ -606,8 +661,9 @@ export class ToolExecutor {
606
661
  if (out.length > MAX_OUTPUT)
607
662
  out = out.slice(0, MAX_OUTPUT) + '\n... (output truncated)';
608
663
  const result = out || '(no output, success)';
664
+ const changed = this.recordShellMutations(before);
609
665
  this.board.log(this.agentId, 'tool_result', result);
610
- resolve(result);
666
+ resolve(changed > 0 ? `${result}\n\nTracked shell mutations: ${changed} file${changed === 1 ? '' : 's'}.` : result);
611
667
  });
612
668
  });
613
669
  }
@@ -388,7 +388,8 @@ export class Controller extends EventEmitter {
388
388
  * memory intact instead of starting from scratch.
389
389
  */
390
390
  respawnAgent(name) {
391
- const sa = this.loadedSession?.agents.find((a) => a.name.toLowerCase() === name.toLowerCase());
391
+ const ref = name.toLowerCase();
392
+ const sa = this.loadedSession?.agents.find((a) => a.name.toLowerCase() === ref || a.alias?.toLowerCase() === ref || a.id?.toLowerCase() === ref);
392
393
  if (!sa)
393
394
  return 'no-agent';
394
395
  if (!sa.conversation || !fs.existsSync(sa.conversation))
@@ -406,7 +407,8 @@ export class Controller extends EventEmitter {
406
407
  }
407
408
  if (history.length === 0)
408
409
  return 'no-conversation';
409
- return this.spawnAgent(sa.task, sa.name, sa.model, undefined, undefined, history, sa.mode ?? 'task');
410
+ const modelSpec = sa.model ? (sa.providerName ? `${sa.providerName}:${sa.model}` : sa.model) : undefined;
411
+ return this.spawnAgent(sa.task, sa.name, modelSpec, undefined, sa.specialist, history, sa.mode ?? 'task');
410
412
  }
411
413
  pauseAgent(name) {
412
414
  const a = this.findAgent(name);
@@ -445,7 +447,12 @@ export class Controller extends EventEmitter {
445
447
  return true;
446
448
  }
447
449
  broadcast(content) {
448
- this.board.addNote('user', 'all', content);
450
+ const stamp = content.trim();
451
+ const recent = [...this.board.notes]
452
+ .reverse()
453
+ .find((n) => n.from === 'user' && n.to === 'all' && Date.now() - n.ts < 1500);
454
+ if (stamp && recent?.content !== stamp)
455
+ this.board.addNote('user', 'all', stamp);
449
456
  let n = 0;
450
457
  for (const [id, agent] of this.agents.entries()) {
451
458
  const info = this.board.agents.get(id);
@@ -608,12 +615,20 @@ export class Controller extends EventEmitter {
608
615
  try {
609
616
  const dir = this.sessionsDir();
610
617
  fs.mkdirSync(dir, { recursive: true });
618
+ const providerName = this.sessionProvider()?.name ?? this.session.providerName;
619
+ const trimChange = (c) => ({
620
+ ...c,
621
+ before: c.before.length > 40_000 ? `${c.before.slice(0, 40_000)}\n/* truncated */` : c.before,
622
+ after: c.after.length > 40_000 ? `${c.after.slice(0, 40_000)}\n/* truncated */` : c.after,
623
+ });
611
624
  const data = {
612
625
  savedAt: new Date().toISOString(),
613
626
  name: this.sessionName,
614
627
  projectRoot: this.projectRoot,
615
628
  agents: [...this.board.agents.values()].map((a) => ({
629
+ id: a.id,
616
630
  name: a.name,
631
+ alias: a.alias,
617
632
  task: a.task,
618
633
  mode: a.mode,
619
634
  state: a.state,
@@ -622,10 +637,17 @@ export class Controller extends EventEmitter {
622
637
  tokensIn: a.tokensIn,
623
638
  tokensOut: a.tokensOut,
624
639
  cost: a.cost,
640
+ providerName,
625
641
  model: a.model,
642
+ specialist: a.specialist,
643
+ claims: a.claims,
644
+ ctxPct: a.ctxPct,
626
645
  conversation: this.conversationFiles.get(a.id),
627
646
  })),
628
647
  notes: this.board.notes.slice(-200),
648
+ changes: this.board.changes.slice(-80).map(trimChange),
649
+ fileActivity: [...this.board.fileActivity.values()].slice(-100),
650
+ workMapWarnings: this.board.workMapWarnings.slice(-80),
629
651
  changedFiles: [...new Set(this.board.changes.map((c) => c.path))],
630
652
  };
631
653
  const file = path.join(dir, `session-${this.sessionStamp}.json`);
@@ -661,10 +683,13 @@ export class Controller extends EventEmitter {
661
683
  if (data.name)
662
684
  this.sessionName = data.name;
663
685
  const tasks = data.agents.map((a) => `${a.name} [${a.state}] : ${a.task}${a.lastResult ? ` → ${a.lastResult}` : ''}`);
664
- this.board.addNote('system', 'all', `Previous session restored (${data.savedAt}). Past work:\n${tasks.join('\n')}\nFiles changed then: ${data.changedFiles.join(', ') || '(none)'}`);
665
- for (const n of data.notes.slice(-50)) {
666
- this.board.notes.push({ ...n, id: this.board.notes.length + 1 });
667
- }
686
+ this.board.hydrate({
687
+ notes: data.notes ?? [],
688
+ changes: data.changes ?? [],
689
+ fileActivity: data.fileActivity ?? [],
690
+ workMapWarnings: data.workMapWarnings ?? [],
691
+ });
692
+ this.board.addNote('system', 'all', `Previous session restored (${data.savedAt}). Past work:\n${tasks.join('\n')}\nFiles changed then: ${(data.changedFiles ?? []).join(', ') || '(none)'}`);
668
693
  this.board.log('', 'system', t('m.sessionRestored', { date: new Date(data.savedAt).toLocaleString() }));
669
694
  // Financial history: per-agent cost/steps/tokens of the restored session.
670
695
  const withCost = data.agents.filter((a) => a.cost !== undefined || a.tokensIn !== undefined);
@@ -17,6 +17,7 @@ export class Blackboard extends EventEmitter {
17
17
  notes = [];
18
18
  changes = [];
19
19
  logs = [];
20
+ workMapWarnings = [];
20
21
  noteSeq = 0;
21
22
  changeSeq = 0;
22
23
  logSeq = 0;
@@ -42,6 +43,8 @@ export class Blackboard extends EventEmitter {
42
43
  if (!a)
43
44
  return;
44
45
  Object.assign(a, patch);
46
+ if ('claims' in patch)
47
+ this.recomputeWorkMap();
45
48
  this.touch();
46
49
  }
47
50
  setAgentState(id, state, action) {
@@ -142,11 +145,65 @@ export class Blackboard extends EventEmitter {
142
145
  this.conflictCounts.set(relPath, n);
143
146
  if (n === 3)
144
147
  this.emit('agent-event', { type: 'conflict', path: relPath });
148
+ this.upsertWorkWarning({
149
+ id: `conflict:${relPath}`,
150
+ level: n >= 3 ? 'conflict' : 'warn',
151
+ title: 'Repeated edit conflict',
152
+ detail: `${relPath} has ${n} recorded co-edit collision${n === 1 ? '' : 's'}. Coordinate before touching it again.`,
153
+ paths: [relPath],
154
+ agentNames: [],
155
+ ts: Date.now(),
156
+ count: n,
157
+ });
145
158
  return n;
146
159
  }
147
160
  lastChangeId() {
148
161
  return this.changes.length > 0 ? this.changes[this.changes.length - 1].id : 0;
149
162
  }
163
+ static normClaim(p) {
164
+ return p.trim().replace(/\\/g, '/').replace(/^\.\/+/, '').replace(/\/+$/, '');
165
+ }
166
+ static overlaps(a, b) {
167
+ const x = Blackboard.normClaim(a);
168
+ const y = Blackboard.normClaim(b);
169
+ if (!x || !y)
170
+ return false;
171
+ return x === y || x.startsWith(`${y}/`) || y.startsWith(`${x}/`);
172
+ }
173
+ upsertWorkWarning(warning) {
174
+ const idx = this.workMapWarnings.findIndex((w) => w.id === warning.id);
175
+ if (idx >= 0)
176
+ this.workMapWarnings[idx] = warning;
177
+ else
178
+ this.workMapWarnings.push(warning);
179
+ if (this.workMapWarnings.length > 100)
180
+ this.workMapWarnings.splice(0, this.workMapWarnings.length - 100);
181
+ }
182
+ recomputeWorkMap() {
183
+ const agents = [...this.agents.values()].filter((a) => a.claims && a.claims.length > 0);
184
+ const warnings = [];
185
+ for (let i = 0; i < agents.length; i++) {
186
+ for (let j = i + 1; j < agents.length; j++) {
187
+ const a = agents[i];
188
+ const b = agents[j];
189
+ const paths = (a.claims ?? []).filter((left) => (b.claims ?? []).some((right) => Blackboard.overlaps(left, right)));
190
+ if (paths.length === 0)
191
+ continue;
192
+ warnings.push({
193
+ id: `overlap:${[a.id, b.id].sort().join(':')}`,
194
+ level: 'warn',
195
+ title: 'Overlapping work areas',
196
+ detail: `${a.name} and ${b.name} both declared ${paths.join(', ')}.`,
197
+ paths,
198
+ agentNames: [a.name, b.name],
199
+ ts: Date.now(),
200
+ });
201
+ }
202
+ }
203
+ const conflictWarnings = this.workMapWarnings.filter((w) => w.id.startsWith('conflict:')).slice(-20);
204
+ this.workMapWarnings = [...conflictWarnings, ...warnings].slice(-100);
205
+ return this.workMapWarnings;
206
+ }
150
207
  // ---------- logs ----------
151
208
  log(agentId, kind, text) {
152
209
  this.logs.push({ agentId, kind, text, ts: Date.now(), seq: ++this.logSeq });
@@ -189,6 +246,13 @@ export class Blackboard extends EventEmitter {
189
246
  lines.push(` • ${act.path} — ${mine ? 'you' : act.agentName} (${act.op}, ${age}s ago)`);
190
247
  }
191
248
  }
249
+ const warnings = this.workMapWarnings.filter((w) => w.level !== 'info').slice(-5);
250
+ if (warnings.length > 0) {
251
+ lines.push('Work map warnings (advisory, do not block):');
252
+ for (const w of warnings) {
253
+ lines.push(` • ${w.title}: ${w.detail}`);
254
+ }
255
+ }
192
256
  if (me)
193
257
  lines.push(`Reminder — your task: ${me.task}`);
194
258
  lines.push('=== END OF REAL-TIME STATE ===');
@@ -214,6 +278,8 @@ export class Blackboard extends EventEmitter {
214
278
  })),
215
279
  fileActivity: [...this.fileActivity.values()],
216
280
  notes: this.notes.slice(-100),
281
+ changes: this.changes.slice(-50),
282
+ workMapWarnings: this.workMapWarnings.slice(-50),
217
283
  };
218
284
  fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify(state, null, 2));
219
285
  }
@@ -222,4 +288,13 @@ export class Blackboard extends EventEmitter {
222
288
  }
223
289
  }, 500);
224
290
  }
291
+ hydrate(data) {
292
+ this.notes = [...(data.notes ?? [])].sort((a, b) => a.id - b.id);
293
+ this.changes = [...(data.changes ?? [])].sort((a, b) => a.id - b.id);
294
+ this.fileActivity = new Map((data.fileActivity ?? []).map((a) => [a.path, a]));
295
+ this.workMapWarnings = [...(data.workMapWarnings ?? [])].sort((a, b) => a.ts - b.ts);
296
+ this.noteSeq = this.notes.reduce((max, n) => Math.max(max, n.id), 0);
297
+ this.changeSeq = this.changes.reduce((max, c) => Math.max(max, c.id), 0);
298
+ this.touch();
299
+ }
225
300
  }
package/dist/i18n.js CHANGED
@@ -99,6 +99,7 @@ const en = {
99
99
  'board.agents': 'Agents:',
100
100
  'board.none': '(no agents)',
101
101
  'board.activity': 'Who works where (file activity, no locks):',
102
+ 'board.workMap': 'Work map warnings:',
102
103
  'board.noActivity': '(no edits yet)',
103
104
  'board.notes': 'Latest notes:',
104
105
  'notes.title': '✉ INTER-AGENT NOTES (last 30)',
@@ -239,7 +240,7 @@ const en = {
239
240
  'attach.placeholder': 'Message to {agent} (Enter to send, /quit to detach)…',
240
241
  'attach.waiting': 'Waiting for agent {agent}… (is the main Parallel session running?)',
241
242
  'attach.gone': 'Session ended — this terminal is detached.',
242
- 'attach.hint': 'plain text steers this agent · /task creates a new agent · /ask asks · /plan plans · /quit detaches',
243
+ 'attach.hint': 'plain text steers this agent · @all broadcasts · /send routes · /task creates · /quit detaches',
243
244
  'grid.above': '▲ {n} agent(s) above — PgUp',
244
245
  'grid.below': '▼ {n} agent(s) below — PgDn',
245
246
  'm.nothing': 'Nothing to save yet.',
@@ -473,6 +474,7 @@ const fr = {
473
474
  'board.agents': 'Agents :',
474
475
  'board.none': '(aucun agent)',
475
476
  'board.activity': 'Qui travaille où (activité fichiers, sans verrou) :',
477
+ 'board.workMap': 'Alertes work map :',
476
478
  'board.noActivity': '(aucune modification pour le moment)',
477
479
  'board.notes': 'Dernières notes :',
478
480
  'notes.title': '✉ NOTES INTER-AGENTS (30 dernières)',
@@ -611,7 +613,7 @@ const fr = {
611
613
  'attach.placeholder': 'Message à {agent} (Entrée pour envoyer, /quit pour détacher)…',
612
614
  'attach.waiting': 'En attente de l’agent {agent}… (la session Parallel principale tourne-t-elle ?)',
613
615
  'attach.gone': 'Session terminée — ce terminal est détaché.',
614
- 'attach.hint': 'texte libre pilote cet agent · /task crée un nouvel agent · /ask questionne · /plan planifie · /quit détache',
616
+ 'attach.hint': 'texte libre pilote cet agent · @all diffuse · /send route · /task crée · /quit détache',
615
617
  'grid.above': '▲ {n} agent(s) au-dessus — PgUp',
616
618
  'grid.below': '▼ {n} agent(s) en dessous — PgDn',
617
619
  'm.nothing': 'Rien à sauvegarder pour le moment.',
@@ -838,6 +840,7 @@ const es = {
838
840
  'board.agents': 'Agentes:',
839
841
  'board.none': '(sin agentes)',
840
842
  'board.activity': 'Quién trabaja dónde (actividad de archivos, sin bloqueos):',
843
+ 'board.workMap': 'Avisos del mapa de trabajo:',
841
844
  'board.noActivity': '(sin modificaciones por ahora)',
842
845
  'board.notes': 'Últimas notas:',
843
846
  'notes.title': '✉ NOTAS ENTRE AGENTES (últimas 30)',
@@ -976,7 +979,7 @@ const es = {
976
979
  'attach.placeholder': 'Mensaje a {agent} (Enter para enviar, /quit para desconectar)…',
977
980
  'attach.waiting': 'Esperando al agente {agent}… (¿está corriendo la sesión principal de Parallel?)',
978
981
  'attach.gone': 'Sesión terminada — esta terminal está desconectada.',
979
- 'attach.hint': 'texto libre dirige a este agente · /task crea un agente nuevo · /ask pregunta · /plan planifica · /quit desconecta',
982
+ 'attach.hint': 'texto libre dirige a este agente · @all difunde · /send enruta · /task crea · /quit desconecta',
980
983
  'grid.above': '▲ {n} agente(s) arriba — PgUp',
981
984
  'grid.below': '▼ {n} agente(s) abajo — PgDn',
982
985
  'm.nothing': 'Nada que guardar por ahora.',
@@ -1203,6 +1206,7 @@ const zh = {
1203
1206
  'board.agents': '智能体:',
1204
1207
  'board.none': '(无智能体)',
1205
1208
  'board.activity': '谁在哪里工作(文件活动,无锁定):',
1209
+ 'board.workMap': '工作地图提醒:',
1206
1210
  'board.noActivity': '(暂无修改)',
1207
1211
  'board.notes': '最新便签:',
1208
1212
  'notes.title': '✉ 智能体间便签(最近 30 条)',
@@ -1341,7 +1345,7 @@ const zh = {
1341
1345
  'attach.placeholder': '发给 {agent} 的消息(回车发送,/quit 断开)…',
1342
1346
  'attach.waiting': '等待代理 {agent}…(Parallel 主会话在运行吗?)',
1343
1347
  'attach.gone': '会话已结束 — 此终端已断开。',
1344
- 'attach.hint': '直接输入文字可指挥此代理 · /task 创建新代理 · /ask 提问 · /plan 规划 · /quit 断开',
1348
+ 'attach.hint': '直接输入文字可指挥此代理 · @all 广播 · /send 路由 · /task 创建 · /quit 断开',
1345
1349
  'grid.above': '▲ 上方还有 {n} 个代理 — PgUp',
1346
1350
  'grid.below': '▼ 下方还有 {n} 个代理 — PgDn',
1347
1351
  'm.nothing': '暂无可保存的内容。',
package/dist/server.js CHANGED
@@ -108,6 +108,16 @@ export function startSessionServer(ctl) {
108
108
  else if (msg.type === 'answer' && typeof msg.id === 'number' && typeof msg.text === 'string') {
109
109
  ctl.answerQuestion(msg.id, msg.text);
110
110
  }
111
+ else if (msg.type === 'send' && typeof msg.target === 'string' && typeof msg.text === 'string') {
112
+ const text = msg.text.trim();
113
+ const target = msg.target.trim();
114
+ if (!text || !target)
115
+ continue;
116
+ if (target.toLowerCase() === 'all')
117
+ ctl.broadcast(text);
118
+ else
119
+ ctl.sendToAgent(target, text);
120
+ }
111
121
  else if (msg.type === 'spawn' && typeof msg.text === 'string') {
112
122
  // Agent N+1 can be launched from ANY terminal of the session —
113
123
  // its own dedicated terminal then opens automatically.
@@ -7,6 +7,7 @@ import { Md } from './Md.js';
7
7
  import { Spinner } from './Spinner.js';
8
8
  import { Timeline } from './Timeline.js';
9
9
  import { MARK, MODE, STATE_META, UI, ANIM } from './tokens.js';
10
+ import { latestSignal, toUIEvents } from './events.js';
10
11
  export const KIND_COLOR = {
11
12
  tool: UI.accent,
12
13
  llm: UI.muted,
@@ -26,7 +27,19 @@ export function cleanHubSummary(text) {
26
27
  .trim();
27
28
  }
28
29
  export function formatAgentTelemetry(agent) {
29
- return `${elapsed(agent.startedAt)} · ${agent.cost === null ? '$-' : fmtCost(agent.cost)}`;
30
+ const ctx = agent.ctxPct !== undefined ? ` · ${agent.ctxPct}% ctx` : '';
31
+ return `${elapsed(agent.startedAt)}${ctx} · ${agent.cost === null ? '$-' : fmtCost(agent.cost)}`;
32
+ }
33
+ function compactResultSummary(text, max) {
34
+ const clean = cleanHubSummary(text);
35
+ const validation = text.match(/validation[^:\n]*[:\n]\s*([^\n]+)/i)?.[1]?.trim();
36
+ const risk = text.match(/risks?[^:\n]*[:\n]\s*([^\n]+)/i)?.[1]?.trim() ?? text.match(/risques?[^:\n]*[:\n]\s*([^\n]+)/i)?.[1]?.trim();
37
+ const parts = [clean.slice(0, Math.max(40, Math.floor(max * 0.55)))];
38
+ if (validation)
39
+ parts.push(`V: ${validation}`);
40
+ if (risk)
41
+ parts.push(`R: ${risk}`);
42
+ return truncate(parts.join(' · '), max);
30
43
  }
31
44
  function ResultBlock({ agent, compact = false }) {
32
45
  if (!agent.lastResult)
@@ -72,22 +85,24 @@ export function AgentRow({ agent, logs, cols, }) {
72
85
  const taskMax = Math.max(10, cols - 18);
73
86
  const line2Max = Math.max(10, cols - 2);
74
87
  const telemetry = formatAgentTelemetry(agent);
88
+ const signal = latestSignal(agent, toUIEvents(logs));
89
+ const specialist = agent.specialist ? ` #${agent.specialist}` : '';
75
90
  // Line 2 content
76
91
  let line2 = null;
77
92
  if (agent.lastResult) {
78
- line2 = { text: `✓ ${truncate(cleanHubSummary(agent.lastResult), line2Max)}`, color: UI.ok };
93
+ line2 = { text: `✓ ${compactResultSummary(agent.lastResult, line2Max)}`, color: UI.ok };
79
94
  }
80
- else if (agent.currentAction) {
81
- line2 = { text: `▸ ${truncate(agent.currentAction, line2Max)}`, color: UI.accent };
95
+ else if (signal) {
96
+ line2 = { text: `▸ ${truncate(signal, line2Max)}`, color: UI.accent };
82
97
  }
83
98
  else {
84
99
  line2 = { text: meta.label, color: meta.color };
85
100
  }
86
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 0, paddingLeft: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [SPINNER_STATES.has(agent.state) ? (_jsx(Spinner, { color: pulseColor ?? spinnerColor(agent.state) })) : (_jsx(Text, { color: pulseColor ?? meta.color, bold: true, children: meta.mark })), _jsx(Text, { children: " " }), _jsx(Text, { color: agent.color, bold: true, children: name }), mode ? (_jsxs(Text, { color: mode.color, children: [" ", mode.char] })) : null, _jsxs(Text, { color: UI.text, children: [" ", truncate(agent.task, taskMax)] })] }), _jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(Text, { color: line2.color, wrap: "truncate-end", children: line2.text }), _jsx(Text, { color: UI.muted, children: telemetry })] })] }));
101
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 0, paddingLeft: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [SPINNER_STATES.has(agent.state) ? (_jsx(Spinner, { color: pulseColor ?? spinnerColor(agent.state) })) : (_jsx(Text, { color: pulseColor ?? meta.color, bold: true, children: meta.mark })), _jsx(Text, { children: " " }), _jsx(Text, { color: agent.color, bold: true, children: name }), mode ? (_jsxs(Text, { color: mode.color, children: [" ", mode.char] })) : null, specialist ? _jsx(Text, { color: UI.note, children: specialist }) : null, _jsxs(Text, { color: UI.text, children: [" ", truncate(agent.task, taskMax)] })] }), _jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(Text, { color: line2.color, wrap: "truncate-end", children: line2.text }), _jsx(Text, { color: UI.muted, children: telemetry })] })] }));
87
102
  }
88
- export function AgentTranscript({ agent, logs, raw = false, scrolled = 0, }) {
103
+ export function AgentTranscript({ agent, logs, raw = false, scrolled = 0, cols = 100, }) {
89
104
  const meta = STATE_META[agent.state];
90
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { children: [_jsx(Text, { color: agent.color, bold: true, children: agent.name }), agent.alias && agent.alias !== agent.name ? _jsxs(Text, { color: UI.muted, children: [" @", agent.alias] }) : null, _jsx(Text, { color: UI.muted, children: " " }), _jsxs(Text, { color: meta.color, bold: true, children: [meta.mark, " ", meta.label] })] }), _jsxs(Text, { color: UI.muted, wrap: "truncate-end", children: [agent.model, " \u00B7 ", formatAgentTelemetry(agent)] })] }), _jsxs(Text, { color: UI.muted, wrap: "wrap", children: ["Task ", _jsx(Text, { color: UI.text, children: agent.task })] }), agent.claims && agent.claims.length > 0 ? (_jsxs(Text, { color: UI.warn, wrap: "truncate-end", children: ["Claims ", agent.claims.join(' ')] })) : null, agent.currentAction ? (_jsxs(Text, { color: UI.accent, wrap: "truncate-end", children: ["Current ", truncate(agent.currentAction, 140)] })) : null, agent.state === 'done' || agent.lastResult ? _jsx(ResultBlock, { agent: agent }) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: UI.muted, bold: true, children: ["Activity", raw ? ' raw' : ''] }), _jsx(Timeline, { logs: logs, raw: raw })] }), _jsxs(Text, { color: UI.muted, wrap: "truncate-end", children: ["PgUp/PgDn scroll \u00B7 /raw toggles detail \u00B7 Esc returns", scrolled > 0 ? ` · ${scrolled} older` : ''] })] }));
105
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { children: [_jsx(Text, { color: agent.color, bold: true, children: agent.name }), agent.alias && agent.alias !== agent.name ? _jsxs(Text, { color: UI.muted, children: [" @", agent.alias] }) : null, _jsx(Text, { color: UI.muted, children: " " }), _jsxs(Text, { color: meta.color, bold: true, children: [meta.mark, " ", meta.label] })] }), _jsxs(Text, { color: UI.muted, wrap: "truncate-end", children: [agent.model, " \u00B7 ", formatAgentTelemetry(agent)] })] }), _jsxs(Text, { color: UI.muted, wrap: "wrap", children: ["Task ", _jsx(Text, { color: UI.text, children: agent.task })] }), agent.claims && agent.claims.length > 0 ? (_jsxs(Text, { color: UI.warn, wrap: "truncate-end", children: ["Claims ", agent.claims.join(' ')] })) : null, agent.currentAction ? (_jsxs(Text, { color: UI.accent, wrap: "truncate-end", children: ["Current ", truncate(agent.currentAction, 140)] })) : null, agent.state === 'done' || agent.lastResult ? _jsx(ResultBlock, { agent: agent }) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: UI.muted, bold: true, children: ["Activity", raw ? ' raw' : ''] }), _jsx(Timeline, { logs: logs, raw: raw, cols: cols })] }), _jsxs(Text, { color: UI.muted, wrap: "truncate-end", children: ["PgUp/PgDn scroll \u00B7 /raw toggles detail \u00B7 Esc returns", scrolled > 0 ? ` · ${scrolled} older` : ''] })] }));
91
106
  }
92
107
  export function AgentPanel({ agent, logs, width, expanded = false, }) {
93
108
  return (_jsx(Box, { width: width, flexDirection: "column", children: expanded ? _jsx(AgentTranscript, { agent: agent, logs: logs }) : _jsx(AgentRow, { agent: agent, logs: logs, cols: 100 }) }));
package/dist/ui/App.js CHANGED
@@ -18,7 +18,7 @@ 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
20
  // Version from package.json. Hardcoded — rootDir: "src" prevents importing ../../package.json.
21
- const VERSION = '0.4.4';
21
+ const VERSION = '0.4.5';
22
22
  function usableProvider(config) {
23
23
  const p = getProvider(config);
24
24
  return p && providerReady(p) && (p.defaultModel || p.models[0]) ? p : undefined;
@@ -555,7 +555,10 @@ function MainScreen({ ctl, folder, view, focus, rawLogs, systemLines, agentNames
555
555
  : undefined;
556
556
  const [scroll, setScroll] = useState(0);
557
557
  const [focusFollowTail, setFocusFollowTail] = useState(true);
558
- useEffect(() => setScroll(0), [focus]);
558
+ useEffect(() => {
559
+ setScroll(0);
560
+ setFocusFollowTail(true);
561
+ }, [focus]);
559
562
  const FOCUS_LOGS = Math.max(8, bodyHeight - 1);
560
563
  const focusedLogs = focused ? ctl.board.logs.filter((l) => l.agentId === focused.id) : [];
561
564
  const maxScroll = Math.max(0, focusedLogs.length - FOCUS_LOGS);
@@ -642,7 +645,7 @@ function MainScreen({ ctl, folder, view, focus, rawLogs, systemLines, agentNames
642
645
  specialists: 'specialists',
643
646
  };
644
647
  const viewLabel = VIEW_LABEL[view] ?? 'control room';
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
648
+ return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: CHROME.muted, children: ["\u256D", '─'.repeat(cols - 2), "\u256E"] }), _jsxs(Box, { flexDirection: "row", width: cols, children: [_jsx(Text, { color: CHROME.muted, children: "\u2502 " }), _jsxs(Box, { flexDirection: "row", width: cols - 4, justifyContent: "space-between", children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { bold: true, color: BRAND.primary, children: "PARALLEL" }), _jsx(Text, { color: globalDotColor, children: " \u25CF" }), _jsxs(Text, { color: view === 'agents' ? CHROME.muted : BRAND.muted, children: [" ", viewLabel] }), rawLogs && focused ? _jsx(Text, { color: UI.warn, children: " [RAW]" }) : null] }), _jsx(Text, { color: CHROME.muted, children: middleTruncate(folder, folderMax) })] }), _jsx(Text, { color: CHROME.muted, children: " \u2502" })] }), _jsxs(Box, { flexDirection: "row", width: cols, children: [_jsx(Text, { color: CHROME.muted, children: "\u2502 " }), _jsxs(Box, { flexDirection: "row", width: cols - 4, justifyContent: agents.length > 0 ? 'space-between' : 'flex-end', children: [agents.length > 0 ? (_jsx(Box, { flexDirection: "row", children: _jsxs(Text, { children: [_jsxs(Text, { color: CHROME.muted, children: ["\u25C7 ", idleCount, " idle"] }), ' · ', _jsxs(Text, { color: workingCount > 0 ? STATE.working : CHROME.muted, children: ["\u25CF ", workingCount, " active"] }), ' · ', _jsxs(Text, { color: doneCount > 0 ? STATE.done : CHROME.muted, children: ["\u2713 ", doneCount, " done"] }), ' · ', _jsxs(Text, { color: errorCount > 0 ? STATE.error : CHROME.muted, children: ["\u2717 ", errorCount, " err"] })] }) })) : null, _jsxs(Text, { color: CHROME.muted, children: ["v", VERSION] })] }), _jsx(Text, { color: CHROME.muted, children: " \u2502" })] }), _jsxs(Text, { color: CHROME.muted, children: ["\u2570", '─'.repeat(cols - 2), "\u256F"] })] }), _jsx(Text, { children: " " }), _jsx(Box, { height: bodyHeight, overflow: "hidden", flexDirection: "column", children: view === 'settings' ? (_jsx(SettingsPanel, { ctl: ctl, scope: "global", height: bodyHeight, onClose: onEscape })) : view === 'settings-session' ? (_jsx(SettingsPanel, { ctl: ctl, scope: "session", height: bodyHeight, onClose: onEscape })) : view === 'board' ? (_jsx(BoardView, { board: ctl.board, bodyHeight: bodyHeight })) : view === 'notes' ? (_jsx(NotesView, { board: ctl.board, bodyHeight: bodyHeight })) : view === 'sessions' ? (_jsx(SessionsView, { projectRoot: ctl.projectRoot, bodyHeight: bodyHeight })) : view === 'diff' ? (_jsx(DiffView, { board: ctl.board, bodyHeight: bodyHeight })) : view === 'cost' ? (_jsx(CostView, { board: ctl.board, bodyHeight: bodyHeight })) : view === 'skills' ? (_jsx(SkillsView, { skills: ctl.getSkills(), bodyHeight: bodyHeight })) : view === 'specialists' ? (_jsx(SpecialistsView, { specialists: ctl.getSpecialists(), bodyHeight: bodyHeight })) : view === 'help' ? (_jsx(HelpView, { bodyHeight: bodyHeight })) : agents.length === 0 ? (_jsx(Box, { borderStyle: "single", borderColor: "gray", flexDirection: "column", paddingX: 1, children: _jsx(Text, { color: "gray", children: t('main.empty') }) })) : focused ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(AgentTranscript, { agent: focused, logs: visibleLogs, raw: rawLogs, scrolled: clampedScroll, cols: cols }), !focusFollowTail ? _jsx(Text, { color: UI.warn, children: "Viewing older \u00B7 PgDn to latest" }) : null] })) : (_jsx(AgentHub, { agents: agents, ctl: ctl, cols: cols, scroll: clampedHub, visibleRows: hubRows })) }), systemLines.length > 0 && !settingsOpen && (_jsxs(Box, { flexDirection: "column", children: [agents.length > 0 ? _jsx(Text, { color: UI.muted, bold: true, children: "Session" }) : null, (agents.length > 0
646
649
  ? systemLines
647
650
  .filter((l) => !/^Ready|^Type a task|^⚡ Ready|^Default \/task|^Agent .* launched/.test(l.text))
648
651
  .slice(-2)
@@ -654,7 +657,7 @@ function MainScreen({ ctl, folder, view, focus, rawLogs, systemLines, agentNames
654
657
  // Split on \n so multiline i18n messages render correctly (Ink <Text> doesn't interpret \n).
655
658
  const lines = l.text.split('\n');
656
659
  return lines.map((line, j) => (_jsx(Text, { color: levelColor, wrap: "truncate-end", children: line }, `${i}-${j}`)));
657
- })] })), approval && (_jsx(ApprovalPrompt, { request: approval, pendingCount: ctl.approvals.length, onAnswer: (id, ok, always) => ctl.answerApproval(id, ok, always) })), question && (_jsx(QuestionPrompt, { question: question, pendingCount: ctl.questions.length, onAnswer: (id, answer, auto) => ctl.answerQuestion(id, answer, auto) }, question.id)), _jsx(CommandInput, { active: inputActive, placeholder: focus ? `Message ${focus} or /command` : 'Task mode: describe work to run · /ask question · /plan proposal · / for commands', agentNames: agentNames, agents: agents, onSubmit: onInput, onEscape: onEscape, notify: notify }), _jsx(Text, { children: " " }), _jsxs(Box, { flexDirection: "column", children: [agents.length === 0 ? (_jsxs(Text, { children: [_jsx(Text, { color: BRAND.muted, children: "/ask /task /plan" }), _jsx(Text, { color: CHROME.muted, children: " \u00B7 Tab autocompletes \u00B7 Esc clears" })] })) : null, _jsxs(Text, { children: [_jsx(Text, { color: CHROME.muted, children: "\u2318 Parallel" }), _jsx(Text, { color: CHROME.muted, children: " \u00B7 Shell " }), _jsx(Text, { color: ctl.session.approvalMode === 'ask' ? UI.warn :
660
+ })] })), approval && (_jsx(ApprovalPrompt, { request: approval, pendingCount: ctl.approvals.length, onAnswer: (id, ok, always) => ctl.answerApproval(id, ok, always) })), question && (_jsx(QuestionPrompt, { question: question, pendingCount: ctl.questions.length, onAnswer: (id, answer, auto) => ctl.answerQuestion(id, answer, auto) }, question.id)), _jsx(CommandInput, { active: inputActive, placeholder: focus ? `Message ${focus} or /command` : 'Task mode: describe work to run · /ask question · /plan proposal · / for commands', context: focus ? 'focus' : 'hub', targetAgent: focused?.name, modelLabel: ctl.sessionProvider() ? `${ctl.sessionProvider()?.name}:${ctl.session.model}` : undefined, agentNames: agentNames, agents: agents, onSubmit: onInput, onEscape: onEscape, notify: notify }), _jsx(Text, { children: " " }), _jsxs(Box, { flexDirection: "column", children: [agents.length === 0 ? (_jsxs(Text, { children: [_jsx(Text, { color: BRAND.muted, children: "/ask /task /plan" }), _jsx(Text, { color: CHROME.muted, children: " \u00B7 Tab autocompletes \u00B7 Esc clears" })] })) : null, _jsxs(Text, { children: [_jsx(Text, { color: CHROME.muted, children: "\u2318 Parallel" }), _jsx(Text, { color: CHROME.muted, children: " \u00B7 Shell " }), _jsx(Text, { color: ctl.session.approvalMode === 'ask' ? UI.warn :
658
661
  ctl.session.approvalMode === 'yolo' ? UI.danger :
659
662
  UI.ok, children: ctl.session.approvalMode === 'auto-safe' ? 'auto' : ctl.session.approvalMode }), _jsxs(Text, { color: CHROME.muted, children: [" \u00B7 Sessions: ", Controller.listSessions(ctl.projectRoot).length] }), ctl.questions.length > 0 ? (_jsxs(Text, { color: UI.warn, children: [" \u00B7 \u2753", ctl.questions.length] })) : null, ctl.approvals.length > 0 ? (_jsxs(Text, { color: UI.warn, children: [" \u00B7 \u23F3", ctl.approvals.length] })) : null, focused ? (_jsxs(Text, { color: BRAND.muted, children: [" \u00B7 \uD83C\uDFAF ", focused.name] })) : null] })] })] }));
660
663
  }
@@ -22,6 +22,12 @@ export function parseAttachCommand(text) {
22
22
  return { type: 'detach' };
23
23
  if (v === '/raw')
24
24
  return { type: 'raw' };
25
+ const at = v.match(/^@(\S+)\s+(.+)$/s);
26
+ if (at)
27
+ return { type: 'send', target: at[1], text: at[2].trim() };
28
+ const send = v.match(/^\/send\s+(\S+)\s+(.+)$/s);
29
+ if (send)
30
+ return { type: 'send', target: send[1], text: send[2].trim() };
25
31
  const m = v.match(/^\/(ask|a|task|t|plan|p)\s+(.+)$/s);
26
32
  if (m) {
27
33
  const mode = m[1] === 'ask' || m[1] === 'a' ? 'ask' : m[1] === 'plan' || m[1] === 'p' ? 'plan' : 'task';
@@ -122,6 +128,10 @@ export function AttachApp({ agentRef, sock }) {
122
128
  wire({ type: 'spawn', text: cmd.text, mode: cmd.mode });
123
129
  return;
124
130
  }
131
+ if (cmd.type === 'send') {
132
+ wire({ type: 'send', target: cmd.target, text: cmd.text });
133
+ return;
134
+ }
125
135
  wire({ type: 'input', agent: agentRef, text: cmd.text });
126
136
  };
127
137
  const st = info ? STATE_META[info.state] : null;
@@ -134,18 +144,18 @@ export function AttachApp({ agentRef, sock }) {
134
144
  * what used to leave stray blank lines in the native scrollback. */
135
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
136
146
  .map((o) => `${o.name} [${stateLabel(o.state)}] ${truncate(o.currentAction || o.task, 40)}`)
137
- .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) })] })) : null] })) : (
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] })) : (
138
148
  /* FULL panel when idle / waiting / done — repaints are rare here. */
139
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 ? (
140
150
  // The session's shared awareness, visible here too: what the
141
151
  // OTHER agents are doing right now (live, same feed the agents get).
142
152
  _jsxs(Text, { color: UI.muted, wrap: "truncate-end", children: ["Others ", ' ', others
143
153
  .map((o) => `${o.name} [${stateLabel(o.state)}] ${truncate(o.currentAction || o.task, 40)}`)
144
- .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) })] })) : 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) => {
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) => {
145
155
  wire({ type: 'approve', id, approved: ok, always: !!always });
146
156
  setApproval(null);
147
157
  } })) : question ? (_jsx(QuestionPrompt, { question: { ...question, agentId: info?.id ?? '', resolve: noop }, pendingCount: 1, onAnswer: (id, answer) => {
148
158
  wire({ type: 'answer', id, text: answer });
149
159
  setQuestion(null);
150
- } }, question.id)) : null, _jsx(CommandInput, { active: !gone && !interacting, placeholder: t('attach.placeholder', { agent: info?.name ?? agentRef }), onSubmit: send, onEscape: () => exit() }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { color: "yellowBright", wrap: "truncate-end", children: formatAttachFooter(info) }) })] }));
160
+ } }, question.id)) : null, _jsx(CommandInput, { active: !gone && !interacting, placeholder: t('attach.placeholder', { agent: info?.name ?? agentRef }), context: "attach", targetAgent: info?.name ?? agentRef, modelLabel: info?.model, agentNames: [info?.alias, info?.name, ...others.flatMap((o) => [o.alias, o.name])].filter((n) => Boolean(n)), agents: info ? [info] : [], onSubmit: send, onEscape: () => exit() }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { color: "yellowBright", wrap: "truncate-end", children: formatAttachFooter(info) }) })] }));
151
161
  }
@@ -14,12 +14,23 @@ const GROUP_LABEL = {
14
14
  git: 'Git/session',
15
15
  other: 'Other',
16
16
  };
17
- function modeHint(value) {
17
+ const AGENT_ARG_COMMANDS = new Set(['/focus', '/send', '/attach', '/pause', '/resume', '/stop', '/restore', '/commit']);
18
+ function modeHint(value, context, targetAgent) {
18
19
  const v = value.trimStart().toLowerCase();
19
- if (!v)
20
+ if (!v) {
21
+ if (context === 'focus')
22
+ return `Message ${targetAgent ?? 'focused agent'} · / for hub commands · PgUp/PgDn scroll`;
23
+ if (context === 'attach')
24
+ return `Steer ${targetAgent ?? 'agent'} · /task spawns · @all broadcasts · /quit detaches`;
20
25
  return 'Default /task · Tab/→ autocomplete · / for commands';
21
- if (!v.startsWith('/'))
26
+ }
27
+ if (!v.startsWith('/')) {
28
+ if (context === 'focus')
29
+ return `Will message ${targetAgent ?? 'focused agent'}`;
30
+ if (context === 'attach')
31
+ return `Will steer ${targetAgent ?? 'attached agent'}`;
22
32
  return 'Will launch /task';
33
+ }
23
34
  if (v.startsWith('/ask') || v === '/a')
24
35
  return 'Ask mode · advice only · no edits';
25
36
  if (v.startsWith('/task') || v === '/t')
@@ -38,7 +49,25 @@ export function bestCommandCompletion(value) {
38
49
  const cmd = matchCommands(value)[0];
39
50
  return cmd ? `${cmd.name} ` : null;
40
51
  }
41
- export function CommandInput({ active, placeholder, mask, agentNames = [], agents = [], onSubmit, onEscape, notify }) {
52
+ export function commandNamesForContext(context) {
53
+ if (context !== 'attach')
54
+ return undefined;
55
+ return ['/ask', '/a', '/task', '/t', '/plan', '/p', '/send', '/raw', '/quit', '/exit', '/detach'];
56
+ }
57
+ export function agentArgCommand(value) {
58
+ const m = value.match(/^(\/\S+)\s+([^\s]*)$/);
59
+ if (!m)
60
+ return null;
61
+ const cmd = m[1].toLowerCase();
62
+ return AGENT_ARG_COMMANDS.has(cmd) ? cmd : null;
63
+ }
64
+ export function completeAgentArgument(value, agent) {
65
+ const cmd = agentArgCommand(value);
66
+ if (!cmd)
67
+ return value;
68
+ return `${cmd} ${agent} `;
69
+ }
70
+ export function CommandInput({ active, placeholder, mask, context = 'hub', targetAgent, modelLabel, commandNames, agentNames = [], agents = [], onSubmit, onEscape, notify, }) {
42
71
  const [value, setValue] = useState('');
43
72
  const [attachments, setAttachments] = useState([]);
44
73
  const [history, setHistory] = useState([]);
@@ -87,11 +116,26 @@ export function CommandInput({ active, placeholder, mask, agentNames = [], agent
87
116
  setAttachments((arr) => [...arr, { kind: 'image', n, dataUri: img.dataUri, label: img.label }]);
88
117
  notify?.(t('input.imageAdded'));
89
118
  };
90
- const cmdSuggestions = value.startsWith('/') && !value.includes(' ') ? matchCommands(value).slice(0, 10) : [];
119
+ const uniqueAgentNames = [...new Set(agentNames.filter(Boolean))];
120
+ const allowedCommands = commandNames ?? commandNamesForContext(context);
121
+ const commandAllowed = (c) => !allowedCommands || allowedCommands.includes(c.name) || c.aliases?.some((a) => allowedCommands.includes(a));
122
+ const cmdSuggestions = value.startsWith('/') && !value.includes(' ') ? matchCommands(value).filter(commandAllowed).slice(0, 10) : [];
91
123
  const agentSuggestions = value.startsWith('@') && !value.includes(' ')
92
- ? ['all', ...agentNames].filter((n) => n.toLowerCase().startsWith(value.slice(1).toLowerCase())).slice(0, 8)
124
+ ? ['all', ...uniqueAgentNames].filter((n) => n.toLowerCase().startsWith(value.slice(1).toLowerCase())).slice(0, 8)
93
125
  : [];
94
- const suggestionCount = cmdSuggestions.length > 0 ? cmdSuggestions.length : agentSuggestions.length;
126
+ const argCommand = agentArgCommand(value);
127
+ const argPrefix = argCommand ? value.split(/\s+/)[1] ?? '' : '';
128
+ const argSuggestions = argCommand
129
+ ? [
130
+ ...(argCommand === '/send' || argCommand === '/pause' || argCommand === '/resume' || argCommand === '/stop' || argCommand === '/commit'
131
+ ? ['all']
132
+ : []),
133
+ ...uniqueAgentNames,
134
+ ]
135
+ .filter((n) => n.toLowerCase().startsWith(argPrefix.toLowerCase()))
136
+ .slice(0, 8)
137
+ : [];
138
+ const suggestionCount = cmdSuggestions.length > 0 ? cmdSuggestions.length : agentSuggestions.length > 0 ? agentSuggestions.length : argSuggestions.length;
95
139
  const hasSuggestions = suggestionCount > 0;
96
140
  const exactCommand = cmdSuggestions.some((c) => c.name === value.toLowerCase() || c.aliases?.some((a) => a === value.toLowerCase()));
97
141
  useEffect(() => {
@@ -112,6 +156,11 @@ export function CommandInput({ active, placeholder, mask, agentNames = [], agent
112
156
  setValue('@' + agent + ' ');
113
157
  return true;
114
158
  }
159
+ if (argSuggestions.length > 0) {
160
+ const agent = argSuggestions[Math.min(selectedSuggestion, argSuggestions.length - 1)];
161
+ setValue(completeAgentArgument(value, agent));
162
+ return true;
163
+ }
115
164
  return false;
116
165
  };
117
166
  useInput((input, key) => {
@@ -211,10 +260,12 @@ export function CommandInput({ active, placeholder, mask, agentNames = [], agent
211
260
  const shown = mask ? '•'.repeat(value.length) : value;
212
261
  const byName = new Map(agents.flatMap((a) => [[a.name, a], [a.alias, a]]));
213
262
  const commandIndexes = new Map(cmdSuggestions.map((c, i) => [c.name, i]));
214
- 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) => ((() => {
263
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Text, { color: "gray", wrap: "truncate-end", children: modeHint(value, context, targetAgent) }), _jsxs(Text, { color: "cyan", children: ["[", context, "]"] }), targetAgent ? _jsxs(Text, { color: "magenta", children: ["[", targetAgent, "]"] }) : null, modelLabel ? _jsxs(Text, { color: "yellow", children: ["[", modelLabel, "]"] }) : null] }), cmdSuggestions.length > 0 && (_jsx(Box, { borderStyle: "single", borderColor: "gray", flexDirection: "column", paddingX: 1, children: groupedCommands(cmdSuggestions).map(([group, commands]) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "gray", bold: true, children: GROUP_LABEL[group] ?? group }), commands.map((c) => ((() => {
215
264
  const selected = commandIndexes.get(c.name) === selectedSuggestion;
216
265
  return (_jsxs(Text, { children: [_jsxs(Text, { color: selected ? 'cyanBright' : 'cyan', bold: true, children: [selected ? '› ' : ' ', c.name.padEnd(14)] }), _jsx(Text, { color: "yellow", children: c.args.padEnd(22) }), _jsx(Text, { color: "gray", children: t(c.descKey) })] }, c.name));
217
266
  })()))] }, group))) })), agentSuggestions.length > 0 && (_jsx(Box, { borderStyle: "single", borderColor: "gray", flexDirection: "column", paddingX: 1, children: agentSuggestions.map((n, i) => (_jsxs(Text, { children: [_jsxs(Text, { color: i === selectedSuggestion ? 'cyanBright' : 'cyan', bold: true, children: [i === selectedSuggestion ? '› ' : ' ', "@", n.padEnd(10)] }), _jsx(Text, { color: "gray", children: n === 'all'
218
267
  ? t('input.atAll')
219
- : `${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 })] }))] })] }));
268
+ : `${byName.get(n)?.state ?? ''} ${byName.get(n)?.mode ? `/${byName.get(n)?.mode}` : ''}` })] }, n))) })), argSuggestions.length > 0 && (_jsxs(Box, { borderStyle: "single", borderColor: "gray", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: "gray", bold: true, children: "Agents" }), argSuggestions.map((n, i) => (_jsxs(Text, { children: [_jsxs(Text, { color: i === selectedSuggestion ? 'cyanBright' : 'cyan', bold: true, children: [i === selectedSuggestion ? '› ' : ' ', n.padEnd(12)] }), _jsx(Text, { color: "gray", children: n === 'all'
269
+ ? t('input.atAll')
270
+ : `${byName.get(n)?.state ?? ''} ${byName.get(n)?.mode ? `/${byName.get(n)?.mode}` : ''}` })] }, n)))] })), attachments.length > 0 && (_jsx(Box, { flexDirection: "row", gap: 1, paddingX: 1, children: attachments.map((a) => (_jsxs(Text, { color: "cyan", backgroundColor: "gray", children: [' ', a.kind === 'paste' ? t('input.attPaste', { n: a.n, lines: a.lines }) : t('input.attImage', { n: a.n, file: a.label }), ' '] }, a.n))) })), _jsxs(Box, { borderStyle: "single", borderColor: active ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { color: "cyanBright", bold: true, children: ["\u203A", ' '] }), shown ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: shown }), active && _jsx(Text, { color: "cyanBright", children: "\u2588" })] })) : (_jsxs(_Fragment, { children: [active && _jsx(Text, { color: "cyanBright", children: "\u2588" }), _jsx(Text, { color: "gray", children: placeholder })] }))] })] }));
220
271
  }
@@ -26,20 +26,22 @@ function itemColor(item) {
26
26
  return UI.text;
27
27
  return UI.text;
28
28
  }
29
- function OutputLines({ item }) {
29
+ function OutputLines({ item, cols }) {
30
30
  if (!item.output || item.output.length === 0)
31
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] }));
32
+ const max = Math.max(40, cols - 8);
33
+ 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, max)] }, `${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
  }
34
- function TimelineRow({ item }) {
35
+ function TimelineRow({ item, cols }) {
36
+ const max = Math.max(40, cols - 8);
35
37
  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" });
38
+ return _jsx(Text, { color: UI.muted, children: '─'.repeat(Math.min(Math.max(20, cols - 4), 80)) });
37
39
  }
38
40
  if (item.kind === 'narration') {
39
41
  return (_jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { color: UI.text, wrap: "wrap", children: item.detail }) }));
40
42
  }
41
43
  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 })] }));
44
+ 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 })] }));
43
45
  }
44
46
  if (item.kind === 'files') {
45
47
  const files = item.files ?? [];
@@ -48,13 +50,13 @@ function TimelineRow({ item }) {
48
50
  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
51
  }
50
52
  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 })] }));
53
+ 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, cols: cols })] }));
52
54
  }
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)] }));
55
+ 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, max)] }));
54
56
  }
55
- export function Timeline({ logs, raw = false, emptyText }) {
57
+ export function Timeline({ logs, raw = false, emptyText, cols = 100 }) {
56
58
  const items = presentTimeline(logs, { raw, outputLines: raw ? 10 : 6 });
57
59
  if (items.length === 0)
58
60
  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}`))) }));
61
+ return (_jsx(Box, { flexDirection: "column", children: items.map((item, i) => item.kind === 'section' ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(TimelineRow, { item: item, cols: cols }), _jsx(Text, { color: UI.muted, children: sectionLabel(item.category) })] }, `${item.seq ?? i}-section`)) : (_jsx(TimelineRow, { item: item, cols: cols }, `${item.seq ?? i}-${i}`))) }));
60
62
  }
package/dist/ui/views.js CHANGED
@@ -60,7 +60,8 @@ export function BoardView({ board, bodyHeight }) {
60
60
  const sideRows = bodyHeight ? Math.max(1, Math.floor((bodyHeight - visibleAgents - 5) / 2)) : 8;
61
61
  const activities = [...board.fileActivity.values()].sort((a, b) => b.ts - a.ts).slice(0, sideRows);
62
62
  const notes = board.notes.slice(-sideRows);
63
- 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, 110)] })] }, 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)))] }));
63
+ const warnings = board.workMapWarnings.slice(-Math.max(2, Math.min(4, sideRows)));
64
+ 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)))] }));
64
65
  }
65
66
  export function NotesView({ board, bodyHeight }) {
66
67
  const fallbackVisible = useVisibleRows(7);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parallel-cli/parallel",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
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",