@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/package.json +2 -2
- package/src/agent.js +172 -19
- package/src/cache.js +175 -130
- package/src/clipboard.js +69 -2
- package/src/index.js +29 -5
- package/src/keybindings.js +25 -3
- package/src/llm.js +54 -6
- package/src/mcp.js +419 -384
- package/src/plugin.js +95 -10
- package/src/remote.js +307 -289
- package/src/shell-safety.js +610 -562
- package/src/swarm-progress.js +1 -1
- package/src/team/mailbox.js +216 -139
- package/src/team/permission.js +121 -0
- package/src/team/protocol.js +167 -0
- package/src/team/runner.js +127 -12
- package/src/team/spawn.js +63 -5
- package/src/team/store.js +31 -4
- package/src/team/tasks.js +15 -2
- package/src/team/team.js +51 -16
- package/src/team-panel.js +418 -20
- package/src/term-caps.js +457 -410
- package/src/term.js +142 -26
- package/src/tool-result.js +50 -2
- package/src/tools/agent.js +464 -436
- package/src/tools/bash.js +53 -14
- package/src/tools/diagnostics.js +13 -0
- package/src/tools/edit.js +813 -590
- package/src/tools/glob.js +5 -3
- package/src/tools/grep.js +19 -5
- package/src/tools/ignore.js +17 -3
- package/src/tools/index.js +17 -3
- package/src/tools/read.js +2 -1
- package/src/tools/shell.js +145 -134
- package/src/tools/tasks.js +214 -141
- package/src/tools/team.js +13 -0
- package/src/tools/utils.js +12 -3
- package/src/tools/web-search.js +88 -88
- package/src/tui.js +1815 -628
- package/src/update.js +38 -3
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
|
-
/**
|
|
37
|
-
|
|
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
|
-
/**
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 || '')
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|