@hncode/hncode 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/team-panel.js CHANGED
@@ -30,14 +30,151 @@ export function makeTeamPanel(teamName) {
30
30
  selectedIndex: 0,
31
31
  /** 'roster' or 'compose', so a keystroke means one thing at a time. */
32
32
  mode: 'roster',
33
+ /**
34
+ * Which VIEW: 'list' shows the roster, 'detail' zooms into one member.
35
+ *
36
+ * Two levels, like Claude Code's TeamsDialog (`teammateList` → `teammateDetail`). The roster is for
37
+ * scanning many members; the detail is for one — its prompt, its mode, its tasks, its model. A single
38
+ * flat screen cannot do both: the roster wants its rows, and the detail wants the space.
39
+ *
40
+ * `←` goes back, `Enter` goes in.
41
+ */
42
+ level: 'list',
33
43
  composing: '',
34
44
  caret: 0,
35
45
  flash: '',
36
- /** How much of each member's recent mail to show. */
37
- mailLines: 4,
46
+ /**
47
+ * How many ROWS of the conversation to show.
48
+ *
49
+ * Rows, not messages: messages are wrapped now, so how many fit depends on how long they are. Four
50
+ * was chosen when each message was forced onto a single line — a multi-line answer showed only its
51
+ * opening line and the panel reported a conversation nobody could read. The layout caps this against
52
+ * the space actually left in the panel, so raising it cannot overflow.
53
+ */
54
+ mailLines: 12,
55
+ /**
56
+ * Each member's CONVERSATION — the entries the chat pane draws when that member is selected.
57
+ *
58
+ * This is the shape Claude Code uses: it renders a teammate by handing
59
+ * `viewedAgentTask.messages` to the same `<Messages>` component the main transcript uses
60
+ * (REPL.tsx: `displayedMessages = viewedAgentTask.messages`). One conversation per member, and the
61
+ * view switches which one is shown — not a status line appended under a roster row.
62
+ *
63
+ * Entries: `{ kind: 'text'|'think'|'tool'|'tool_result', text, tool, detail }`. A `text` entry grows
64
+ * in place while the answer streams, so the pane shows one bubble rather than a fragment per delta.
65
+ */
66
+ chat: {},
67
+ /**
68
+ * A member's outstanding permission request, if any:
69
+ * `{ requestId, agentId, toolName, description, input }`.
70
+ *
71
+ * Only one at a time. Two members can block at once, and the second request waits in the inbox rather
72
+ * than overwriting this — a prompt that changed under the user's fingers would authorise the wrong
73
+ * call.
74
+ */
75
+ pendingPermission: null,
38
76
  };
39
77
  }
40
78
 
79
+
80
+ /**
81
+ * Whether the panel should close itself.
82
+ *
83
+ * The rule mirrors `useTeammateViewAutoExit`: a member that was KILLED or FAILED takes its view with it,
84
+ * because there is nothing left to watch — but a COMPLETED one stays open, since that is exactly when a
85
+ * person wants to read what it did. The distinction is the whole point: exiting on completion would slam
86
+ * the transcript shut at the moment it became worth reading.
87
+ *
88
+ * `status` is what the roster records, not a live probe: this is called from the render path and must not
89
+ * touch disk.
90
+ */
91
+ export function shouldAutoExit(status) {
92
+ if (!status) return false;
93
+ return status === 'killed' || status === 'failed' || status === 'stopped';
94
+ }
95
+ /** How many entries a member's conversation keeps before the oldest are dropped. */
96
+ const CHAT_MAX = 400;
97
+
98
+ /**
99
+ * The last few lines of a member's conversation, oldest of them first.
100
+ *
101
+ * Mirrors `getMessagePreview` in Claude Code's TeammateSpinnerLine: walk the conversation BACKWARDS, take
102
+ * up to `n` lines, then reverse so they read in order. Text contributes its own lines; a tool call
103
+ * contributes ONE line, preferring something descriptive over the tool's name.
104
+ *
105
+ * That preference is what makes the preview useful — `Bash` says nothing, `npm test` says what the member
106
+ * is actually doing.
107
+ */
108
+ export function previewLines(panel, name, n = 3, cols = 80) {
109
+ const log = (panel && panel.chat && panel.chat[name]) || [];
110
+ const out = [];
111
+ const width = Math.max(8, cols - 10);
112
+ const cut = (s) => {
113
+ const one = String(s || '').replace(/\s+/g, ' ').trim();
114
+ return one.length > width ? `${one.slice(0, width - 1)}…` : one;
115
+ };
116
+
117
+ for (let i = log.length - 1; i >= 0 && out.length < n; i--) {
118
+ const e = log[i];
119
+ if (e.kind === 'tool') {
120
+ // A result's first line often says more than the tool's name — "npm test" over "Bash".
121
+ const detail = String(e.detail || '').split('\n')[0];
122
+ out.push(cut(detail || `Using ${e.tool}…`));
123
+ } else if (e.kind === 'tool_result') {
124
+ const first = String(e.detail || '').split('\n')[0];
125
+ if (first) out.push(cut(first));
126
+ } else if (e.kind === 'text' || e.kind === 'think') {
127
+ const lines = String(e.text || '').split('\n').filter((l) => l.trim());
128
+ for (let j = lines.length - 1; j >= 0 && out.length < n; j--) out.push(cut(lines[j]));
129
+ }
130
+ }
131
+ return out.reverse();
132
+ }
133
+
134
+ /**
135
+ * Append a live event to a member's conversation.
136
+ *
137
+ * THE CHAT PANE, not the roster row. The roster said `working` and nothing else, and a single activity
138
+ * line under the name gave no history: switching to a member who was busy showed nothing at all, which
139
+ * is the reported fault. Streaming text, tool calls and their results are appended here as entries, and
140
+ * the pane renders them in order.
141
+ *
142
+ * A streaming answer is ONE entry that grows: every `text` delta appends to the last entry when it is
143
+ * also text. Creating an entry per delta would put a bubble on every few characters and make the pane
144
+ * unreadable for the very reason it exists.
145
+ *
146
+ * Bounded, because a member can produce megabytes and the pane draws a screenful.
147
+ */
148
+ export function noteActivity(panel, stream) {
149
+ if (!panel || !stream || !stream.name) return;
150
+ if (!panel.chat) panel.chat = {};
151
+ const log = panel.chat[stream.name] || (panel.chat[stream.name] = []);
152
+ const kind = stream.kind;
153
+ const last = log[log.length - 1];
154
+
155
+ if (kind === 'text' || kind === 'think') {
156
+ const chunk = String(stream.text || '');
157
+ if (!chunk) return;
158
+ // Consecutive deltas of the same stream merge into the entry already being written.
159
+ if (last && last.kind === kind) last.text += chunk;
160
+ else log.push({ kind, text: chunk });
161
+ } else if (kind === 'tool') {
162
+ log.push({ kind: 'tool', tool: String(stream.tool || ''), text: '' });
163
+ } else if (kind === 'tool_result') {
164
+ // Paired with the call it finished, by tool name: the id is not always present, and a result with
165
+ // no call above it would read as output from nowhere.
166
+ const call = [...log].reverse().find((e) => e.kind === 'tool' && e.tool === stream.tool && !e.done);
167
+ if (call) {
168
+ call.done = true;
169
+ call.detail = String(stream.detail || '');
170
+ } else {
171
+ log.push({ kind: 'tool_result', tool: String(stream.tool || ''), detail: String(stream.detail || '') });
172
+ }
173
+ }
174
+
175
+ if (log.length > CHAT_MAX) log.splice(0, log.length - CHAT_MAX);
176
+ }
177
+
41
178
  /**
42
179
  * The members as rows, in roster order.
43
180
  *
@@ -71,18 +208,69 @@ export function teamRows(panel) {
71
208
  // through to its phase.
72
209
  state: (m.role === 'lead') ? 'lead'
73
210
  : (m.phase || (m.isActive === false ? 'idle' : 'starting')),
211
+ // Carried for the DETAIL view: what it runs as, what it was assigned, where it works. The roster row
212
+ // does not draw these, but opening a member should not need a second read of the team file.
213
+ permissionMode: m.permissionMode || 'ask',
214
+ taskId: m.taskId || '',
215
+ cwd: m.cwd || '',
74
216
  };
75
217
  });
76
218
  }
77
219
 
78
- /** The traffic for one member: what they said, and what was said to them, newest last. */
79
- export function memberTraffic(teamName, name, limit = 4) {
80
- const box = readMailbox(teamName, name);
81
- return box.slice(-limit).map((m) => ({
220
+ /**
221
+ * The traffic for one member, newest last: what was said TO them and what THEY said.
222
+ *
223
+ * TWO MAILBOXES, not one. A member's own inbox holds only what others sent it — everything it SAID
224
+ * lives in the recipient's box. Reading one side meant the panel showed the user's own messages back to
225
+ * them and nothing the member ever answered, which is why a working team looked like a silent one: the
226
+ * member replied, the reply went to the lead's inbox, and the panel never looked there.
227
+ *
228
+ * Both sides are merged and ordered by time so a conversation reads in the order it happened.
229
+ */
230
+ export function memberTraffic(teamName, name, limit = 40) {
231
+ // THE WHOLE TEXT IS KEPT. It used to be `split('\n')[0].slice(0, 100)`: the first line only, cut at a
232
+ // hundred characters. A member answering with a list — a capability summary, a plan, a stack trace —
233
+ // was reduced to its opening line, so the panel showed a message that said nothing. Truncation belongs
234
+ // in the ROW LAYOUT, where the available height is known, not here.
235
+ //
236
+ // ORDERED BY THE GLOBAL `seq`, not by `timestamp` and not by file position. Position is meaningful
237
+ // only WITHIN one file, and a millisecond timestamp ties messages written in two files at once — so
238
+ // merging by either put the user's two consecutive messages above the answer to the first one, which
239
+ // is exactly what was reported.
240
+ const toThem = readMailbox(teamName, name).map((m) => ({
82
241
  from: m.from,
83
- text: String(m.text || '').split('\n')[0].slice(0, 100),
242
+ text: String(m.text || ''),
84
243
  at: m.timestamp,
244
+ order: Number.isFinite(m.seq) ? m.seq : null,
85
245
  }));
246
+ // What this member sent to the LEAD. Its other conversations (peer to peer, and on a shared task) go
247
+ // to those recipients' boxes; the lead is the reader this panel is drawn for.
248
+ const fromThem = readMailbox(teamName, TEAM_LEAD_NAME)
249
+ .filter((m) => m.from === name)
250
+ .map((m) => ({
251
+ from: m.from,
252
+ text: String(m.text || ''),
253
+ at: m.timestamp,
254
+ order: Number.isFinite(m.seq) ? m.seq : null,
255
+ }));
256
+ const seen = new Set();
257
+ return [...toThem, ...fromThem]
258
+ // A message can appear in both only if the member messaged itself; drop the copy rather than
259
+ // showing it twice.
260
+ .filter((m) => {
261
+ const key = `${m.from}\u0000${m.text}\u0000${m.at || ''}`;
262
+ if (seen.has(key)) return false;
263
+ seen.add(key);
264
+ return true;
265
+ })
266
+ // By the GLOBAL sequence number, which is the only key that orders messages written to two different
267
+ // files. Falls back to the timestamp for a message written before `seq` existed.
268
+ .sort((a, b) => {
269
+ if (a.order != null && b.order != null) return a.order - b.order;
270
+ return Number(a.at || 0) - Number(b.at || 0);
271
+ })
272
+ .map(({ order, ...rest }) => rest)
273
+ .slice(-limit);
86
274
  }
87
275
 
88
276
  /** Draw the panel. Exactly `rows` lines, each `cols` wide. */
@@ -101,13 +289,53 @@ export function renderTeamPanel(panel, cols, rows) {
101
289
  + (panel.flash ? ` ${C.yellow}${panel.flash}${C.reset}` : ''));
102
290
 
103
291
  const roster = teamRows(panel);
292
+ // The roster, then the selected member's traffic, then the composer.
293
+
294
+ // THE DETAIL VIEW: one member, filling the panel.
295
+ //
296
+ // Claude Code's `teammateDetail` does the same thing — the roster is for scanning many members, the
297
+ // detail is for one, and a single flat screen cannot serve both. What matters when you are looking at
298
+ // ONE member is not the roster row it came from: it is what it was asked to do, what mode it runs in,
299
+ // how long it has been going, and what it has been saying.
300
+ //
301
+ // `←` returns, so everything below this block is the list view.
302
+ if (panel.level === 'detail' && roster.length) {
303
+ const m = roster[Math.min(panel.selectedIndex, roster.length - 1)];
304
+ push('');
305
+ push(` ${C.cyan}${C.bold}@${m.name}${C.reset} ${C.gray}${m.state || ''}${C.reset}`);
306
+ push(` ${C.gray}${m.model || '(no model set)'}${m.model ? '' : ''}${C.reset}`);
307
+ push(` ${C.gray}mode: ${m.permissionMode || 'ask'}${C.reset}`);
308
+ if (m.taskId) push(` ${C.gray}task: ${m.taskId}${C.reset}`);
309
+ if (m.cwd) push(` ${C.gray}cwd: ${m.cwd}${C.reset}`);
310
+ push('');
311
+ push(` ${C.gray}── what it is doing ──${C.reset}`);
312
+ const live = (panel.chat && panel.chat[m.name]) || [];
313
+ const preview = previewLines(panel, m.name, Math.max(3, rows - 14), cols);
314
+ if (!preview.length) push(` ${C.gray}(nothing yet)${C.reset}`);
315
+ for (const line of preview) push(` ${C.gray}${line}${C.reset}`);
316
+ void live;
317
+ while (out.length < rows - 3) push('');
318
+ push(C.border + '─'.repeat(cols) + C.reset);
319
+ push(` ${C.gray}← back · Enter message · k kill · s stop · m mode${C.reset}`);
320
+ push('');
321
+ return out.slice(0, rows);
322
+ }
323
+
104
324
  if (!roster.length) {
105
325
  push(' ' + C.gray + 'No members. Add one with /team add <name>.' + C.reset);
106
326
  }
107
327
 
108
328
  // The roster, then the selected member's traffic, then the composer.
109
329
  const composerRows = 3;
110
- const trafficRoom = Math.max(0, Math.min(panel.mailLines + 2, rows - roster.length - composerRows - 6));
330
+ // THE TRAFFIC TAKES WHATEVER THE ROSTER LEAVES.
331
+ //
332
+ // It used to be capped at `panel.mailLines + 2`, a fixed number that had no relation to the panel's
333
+ // height: on a tall panel most of the space sat empty below a two-message conversation, and a member's
334
+ // long answer was cut off while there was room to draw it. `mailLines` is now a FLOOR rather than a
335
+ // ceiling — enough rows are always offered that a message is unlikely to be sliced — and the caller's
336
+ // height does the rest.
337
+ const reserved = roster.length + composerRows + 6; // title, rules, hint, the `── name ──` header
338
+ const trafficRoom = Math.max(0, Math.max(panel.mailLines, rows - reserved));
111
339
 
112
340
  for (let i = 0; i < roster.length && out.length < rows - composerRows - trafficRoom - 2; i++) {
113
341
  const m = roster[i];
@@ -130,23 +358,135 @@ export function renderTeamPanel(panel, cols, rows) {
130
358
  push(sel
131
359
  ? `${ptr}${C.cyan}${name}${C.reset}${state}${mail}${model}`
132
360
  : `${ptr}${name}${state}${mail}${model}`);
361
+
362
+ // A THREE-LINE PREVIEW under the name, drawn from that member's own conversation.
363
+ //
364
+ // This is Claude Code's arrangement (`getMessagePreview` in TeammateSpinnerLine): the roster says what
365
+ // a member is DOING without opening it, and the full transcript is one keystroke away. Removing the
366
+ // preview entirely — which I did — left the roster claiming `working` with no evidence, so a member
367
+ // running a long test and one that had hung looked identical.
368
+ //
369
+ // Newest first, then reversed for reading order. A tool call shows a DESCRIPTION rather than its name
370
+ // where one exists (`command`, `description`, `query`, `pattern`): "npm test" says more than "Bash".
371
+ const preview = previewLines(panel, m.name, 3, cols);
372
+ for (const line of preview) {
373
+ if (out.length >= rows - composerRows - 4) break;
374
+ push(` ${C.gray}${'│'} ${String(line).slice(0, Math.max(8, cols - 10))}${C.reset}`);
375
+ }
133
376
  }
134
377
 
135
378
  // What the selected member has been saying.
136
379
  const sel = roster[panel.selectedIndex];
137
380
  if (sel && trafficRoom > 0) {
138
381
  push('');
139
- push(` ${C.gray}── ${sel.name} ──${C.reset}`);
140
- const traffic = memberTraffic(panel.teamName, sel.name, trafficRoom);
141
- if (!traffic.length) push(' ' + C.gray + '(no messages yet)' + C.reset);
382
+ const rendered = [];
383
+ const bodyW = Math.max(8, cols - 6);
384
+
385
+ /**
386
+ * Wrap one line of text to `bodyW`, on spaces. Panel-local rather than `wrapWords` from tui.js,
387
+ * which is module-private; a word longer than the row is cut, since leaving it would push the line
388
+ * past the panel's edge.
389
+ */
390
+ const wrapOne = (ln) => {
391
+ if (ln === '') return [''];
392
+ const out2 = [];
393
+ let cur = '';
394
+ for (const word of String(ln).split(' ')) {
395
+ if (!cur) { cur = word; continue; }
396
+ if (visualWidth(cur) + 1 + visualWidth(word) <= bodyW) cur += ' ' + word;
397
+ else { out2.push(cur); cur = word; }
398
+ }
399
+ if (cur) out2.push(cur);
400
+ return out2;
401
+ };
402
+
403
+ /** A message from the mailboxes, drawn as `<who>: text`, appended to `into`. */
404
+ const renderMail = (t, into) => {
405
+ const mine = t.from === TEAM_LEAD_NAME;
406
+ const who = mine ? 'you' : t.from;
407
+ const colour = mine ? C.gray : C.cyan;
408
+ String(t.text || '').replace(/\r\n/g, '\n').split('\n').forEach((ln, i) => {
409
+ const segs = wrapOne(ln);
410
+ segs.forEach((seg, j) => {
411
+ into.push(i === 0 && j === 0
412
+ ? ` ${colour}${who}:${C.reset} ${seg}`
413
+ : ` ${' '.repeat(who.length + 2)}${seg}`);
414
+ });
415
+ });
416
+ };
417
+
418
+ // THE MEMBER'S OWN WORK, drawn in the chat pane — its answer, the tools it called and what they
419
+ // returned. This is the piece that was missing: the mailbox holds only what was sent to it and what
420
+ // it chose to send back, so a member thinking and running commands showed nothing here at all, and
421
+ // switching to it looked like an empty channel while the roster said `working`.
422
+ //
423
+ // Claude Code draws a teammate exactly this way — the same transcript component, switched to that
424
+ // member's messages — so the two kinds of entry belong in one stream rather than the live stream
425
+ // being bolted under the roster.
426
+ const live = (panel.chat && panel.chat[sel.name]) || [];
427
+ for (const e of live) {
428
+ if (e.kind === 'think') {
429
+ for (const seg of wrapOne(String(e.text || '').replace(/\s+/g, ' ').trim())) {
430
+ if (seg) rendered.push(` ${C.gray}${C.italic || ''}${seg}${C.reset}`);
431
+ }
432
+ } else if (e.kind === 'text') {
433
+ String(e.text || '').replace(/\r\n/g, '\n').split('\n').forEach((ln, i) => {
434
+ wrapOne(ln).forEach((seg, j) => {
435
+ rendered.push(i === 0 && j === 0
436
+ ? ` ${C.cyan}${sel.name}:${C.reset} ${seg}`
437
+ : ` ${' '.repeat(sel.name.length + 2)}${seg}`);
438
+ });
439
+ });
440
+ } else if (e.kind === 'tool') {
441
+ // A tool call reads as one line: the name, then its result's first line when it has one.
442
+ const head = String(e.tool || 'tool');
443
+ const detail = String(e.detail || '').split('\n')[0].trim();
444
+ rendered.push(` ${C.green}${'⏺'}${C.reset} ${head}${detail ? C.gray + ': ' + detail + C.reset : ''}`);
445
+ } else if (e.kind === 'tool_result') {
446
+ for (const seg of wrapOne(`${e.tool}: ${String(e.detail || '').split('\n')[0]}`)) {
447
+ rendered.push(` ${C.gray}${seg}${C.reset}`);
448
+ }
449
+ }
450
+ }
451
+
452
+ // What was SAID TO this member, before its work. Those messages are the instructions it acted on,
453
+ // and leaving them at the end put the cause after the effect — the pane read as though the member
454
+ // had worked unprompted and then been asked.
455
+ //
456
+ // What the member SENT is not repeated here: its answer is already a `text` entry above, and the
457
+ // mailbox copy would show the same words twice.
458
+ const traffic = memberTraffic(panel.teamName, sel.name, 40);
459
+ const mail = [];
142
460
  for (const t of traffic) {
143
- // `team-lead` is the alias the USER'S own sends carry, so this line reads "you wrote this".
144
- // It is NOT a statement that the lead is the user — the lead is a rostered agent under its
145
- // own name. That misreading once removed this very alias check from the inbox reader, which
146
- // made a message you sent to your lead come back to you as though an agent had sent it.
147
- const who = t.from === TEAM_LEAD_NAME ? 'you' : t.from;
148
- push(` ${C.gray}${who}:${C.reset} ${t.text}`);
461
+ if (t.from === sel.name) continue;
462
+ renderMail(t, mail);
149
463
  }
464
+ rendered.unshift(...mail);
465
+
466
+ if (!rendered.length) push(' ' + C.gray + '(nothing yet)' + C.reset);
467
+ const room = Math.max(1, trafficRoom);
468
+ for (const line of rendered.slice(-room)) push(line);
469
+ }
470
+
471
+ // A MEMBER IS BLOCKED ON A PERMISSION, and only the person here can unblock it.
472
+ //
473
+ // Drawn at the bottom of the panel, just above the composer, because that is where the answer is
474
+ // given. A pending request is the one state where doing nothing costs time: the member polls until the
475
+ // timeout, up to ten minutes.
476
+ //
477
+ // `y` / `n` are the whole interaction — the decision is binary, and anything more elaborate would need
478
+ // a focus model this panel does not have.
479
+ const req = panel.pendingPermission;
480
+ if (req && out.length < rows - composerRows - 3) {
481
+ const cut2 = (s) => String(s || '').replace(/\s+/g, ' ').trim().slice(0, Math.max(8, cols - 4));
482
+ const who = String(req.agentId || 'a member');
483
+ const what = String(req.description || req.toolName || 'a tool call');
484
+ push(` ${C.yellow}${cut2(`${who} wants ${req.toolName}: ${what}`)}${C.reset}`);
485
+ const inputLine = req.input && typeof req.input === 'object'
486
+ ? Object.entries(req.input).slice(0, 2).map(([k, v]) => `${k}=${String(v).slice(0, 40)}`).join(' ')
487
+ : '';
488
+ if (inputLine) push(` ${C.gray}${cut2(inputLine)}${C.reset}`);
489
+ push(` ${C.yellow}y${C.reset} allow ${C.yellow}n${C.reset} deny`);
150
490
  }
151
491
 
152
492
  // The shared task list, one line, so "is the team moving" needs no second screen.
@@ -166,7 +506,9 @@ export function renderTeamPanel(panel, cols, rows) {
166
506
  push(' ' + C.gray + 'Enter send · Esc cancel' + C.reset);
167
507
  } else {
168
508
  push(' ' + C.gray + 'Enter write · Tab next · ↑↓ select · Esc close' + C.reset);
169
- push('');
509
+ // The roster actions, named so they are discoverable rather than folklore. A second line because
510
+ // squeezing six shortcuts onto one makes all of them unreadable.
511
+ push(' ' + C.gray + 'k kill · s stop · p prune idle · m mode · r read' + C.reset);
170
512
  }
171
513
 
172
514
  return out.slice(0, rows);
@@ -182,6 +524,18 @@ export function renderTeamPanel(panel, cols, rows) {
182
524
  export function handleTeamPanelKey(panel, t) {
183
525
  if (!t) return { handled: false };
184
526
 
527
+ // A PENDING PERMISSION OWNS THE KEYS, ahead of composing.
528
+ //
529
+ // It is asked at the bottom of the panel and must be answered before anything else is typed: otherwise
530
+ // `y` while composing would send the letter instead of approving, and the member would keep blocking.
531
+ if (panel.pendingPermission) {
532
+ if (t.ch === 'y') return { handled: true, action: 'permission', approved: true };
533
+ if (t.ch === 'n' || t.key === 'escape') return { handled: true, action: 'permission', approved: false };
534
+ // Every other key is swallowed: an unrelated keystroke must not move the roster out from under a
535
+ // decision that is waiting.
536
+ return { handled: true };
537
+ }
538
+
185
539
  if (panel.mode === 'compose') {
186
540
  if (t.key === 'escape') { panel.mode = 'roster'; panel.composing = ''; panel.caret = 0; return { handled: true }; }
187
541
  if (t.key === 'enter') {
@@ -207,14 +561,58 @@ export function handleTeamPanelKey(panel, t) {
207
561
  const n = roster.length;
208
562
 
209
563
  if (t.key === 'escape' || t.ch === 'q') return { handled: true, action: 'close' };
210
- if (t.key === 'enter') { if (n) { panel.mode = 'compose'; panel.composing = ''; } return { handled: true }; }
564
+
565
+ // ---- two levels, like Claude Code's TeamsDialog ----
566
+ //
567
+ // `←` goes back. In the detail view it returns to the roster; in the list it closes the panel, which is
568
+ // what a person pressing "back" at the top level means.
569
+ if (t.key === 'left') {
570
+ if (panel.level === 'detail') { panel.level = 'list'; return { handled: true }; }
571
+ return { handled: true, action: 'close' };
572
+ }
573
+
574
+ // `Enter` goes IN. In the detail view it starts composing a message to that member, because writing to
575
+ // them is the one thing you reach for once you are looking at them.
576
+ if (t.key === 'enter') {
577
+ if (!n) return { handled: true };
578
+ if (panel.level === 'list') { panel.level = 'detail'; return { handled: true }; }
579
+ panel.mode = 'compose';
580
+ panel.composing = '';
581
+ return { handled: true };
582
+ }
583
+
211
584
  if (t.key === 'tab' || t.key === 'down') { if (n) panel.selectedIndex = (panel.selectedIndex + 1) % n; return { handled: true }; }
212
585
  if (t.key === 's-tab' || t.key === 'up') { if (n) panel.selectedIndex = (panel.selectedIndex - 1 + n) % n; return { handled: true }; }
213
586
  if (t.key === 'home') { panel.selectedIndex = 0; return { handled: true }; }
214
587
  if (t.key === 'end') { if (n) panel.selectedIndex = n - 1; return { handled: true }; }
215
- // `r` marks everything read for the selected member — the way to clear a "3 new" badge you have
216
588
  // already looked at in the traffic pane.
217
589
  if (t.ch === 'r' && n) return { handled: true, action: 'markRead', name: roster[panel.selectedIndex].name };
590
+
591
+ // ---- roster actions, matching Claude Code's TeamsDialog ----
592
+ //
593
+ // `k` KILLS the selected member: the abort controller fires and the run unwinds. Until this existed a
594
+ // member could not be stopped at all — closing the panel left it working in the background, which is
595
+ // the one thing a person watching a runaway agent most needs.
596
+ if (t.ch === 'k' && n) return { handled: true, action: 'kill', name: roster[panel.selectedIndex].name };
597
+
598
+ // `s` asks it to STOP GRACEFULLY: a `shutdown_request` on its mailbox, which it may refuse if it is
599
+ // mid-task. A kill takes no answer; a shutdown does. Both are needed — killing a member that is
600
+ // halfway through a write leaves the file half-written.
601
+ if (t.ch === 's' && n) return { handled: true, action: 'shutdown', name: roster[panel.selectedIndex].name };
602
+
603
+ // `p` PRUNES every idle member at once. With five idle teammates the roster is mostly noise, and
604
+ // killing them one at a time is five round trips.
605
+ if (t.ch === 'p' && n) {
606
+ const idle = roster.filter((m) => !m.isLead && m.state === 'idle').map((m) => m.name);
607
+ return { handled: true, action: 'prune', names: idle };
608
+ }
609
+
610
+ // `m` cycles the selected member's permission mode: ask → auto → yolo → ask.
611
+ //
612
+ // Per member, not per team: one member doing desktop work may need `yolo` while another editing the
613
+ // repository should still ask. Claude Code exposes the same control as shift+tab in its detail view.
614
+ if (t.ch === 'm' && n) return { handled: true, action: 'cycleMode', name: roster[panel.selectedIndex].name };
615
+
218
616
  return { handled: false };
219
617
  }
220
618