@hncode/hncode 0.5.1 → 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/bin/install.cjs +24 -44
- package/bin/preinstall.cjs +0 -14
- package/package.json +2 -2
- package/src/agent-task.js +12 -1
- package/src/agent.js +279 -48
- package/src/cache.js +175 -130
- package/src/cell-buffer.js +5 -0
- package/src/clipboard.js +111 -8
- package/src/context-bar.js +46 -5
- package/src/doctor.js +7 -2
- package/src/git.js +35 -4
- package/src/index.js +29 -5
- package/src/keybindings.js +25 -3
- package/src/llm.js +60 -6
- package/src/mcp.js +419 -384
- package/src/permissions.js +29 -7
- package/src/plugin.js +95 -10
- package/src/registry-browser.js +21 -6
- package/src/remote.js +307 -289
- package/src/session-core.js +203 -0
- package/src/session-host.js +213 -0
- package/src/session-view.js +110 -0
- package/src/settings-schema.js +12 -9
- package/src/shell-safety.js +610 -473
- package/src/swarm-progress.js +348 -293
- package/src/tasks-browser.js +4 -1
- package/src/team/constants.js +20 -0
- package/src/team/context.js +89 -0
- package/src/team/mailbox.js +216 -0
- package/src/team/permission.js +121 -0
- package/src/team/protocol.js +167 -0
- package/src/team/runner.js +462 -0
- package/src/team/spawn.js +305 -0
- package/src/team/store.js +172 -0
- package/src/team/tasks.js +177 -0
- package/src/team/team.js +346 -0
- package/src/team-panel.js +624 -0
- package/src/term-caps.js +457 -410
- package/src/term.js +162 -19
- package/src/tool-result.js +70 -7
- package/src/tools/agent-swarm.js +114 -11
- package/src/tools/agent.js +464 -386
- package/src/tools/bash.js +53 -14
- package/src/tools/diagnostics.js +13 -0
- package/src/tools/edit.js +428 -50
- package/src/tools/git.js +15 -3
- 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 +19 -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 +411 -0
- package/src/tools/utils.js +12 -3
- package/src/tools/web-search.js +53 -40
- package/src/tui.js +7496 -5366
- package/src/update.js +320 -115
- package/src/web-daemon.js +27 -4
- package/src/web-live.js +250 -0
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
// The team panel: who is on the team, what each is doing, and a composer to message them.
|
|
2
|
+
//
|
|
3
|
+
// WHY A PANEL AND NOT ONLY THE COMMAND
|
|
4
|
+
// ------------------------------------
|
|
5
|
+
// `/team msg alice ...` works, but a team is a thing you WATCH. The question a user has is "is
|
|
6
|
+
// anyone stuck, and who do I need to nudge" — and answering it from the command line means running
|
|
7
|
+
// `/team list`, reading four lines, and remembering them. The panel keeps the roster and each
|
|
8
|
+
// member's recent traffic on screen while you type.
|
|
9
|
+
//
|
|
10
|
+
// SHAPE
|
|
11
|
+
// -----
|
|
12
|
+
// Left: the roster, one line per member — name, live state, unread count. Below it a one-line
|
|
13
|
+
// composer. Enter sends to the selected member; Tab moves between members; Esc leaves.
|
|
14
|
+
//
|
|
15
|
+
// It follows tasks-browser.js: a pure renderer plus a key handler, so both are testable without a
|
|
16
|
+
// terminal.
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import { C } from './colors.js';
|
|
19
|
+
import { visualWidth, fit } from './term.js';
|
|
20
|
+
import { readTeamFile } from './team/store.js';
|
|
21
|
+
import { readMailbox, writeToMailbox } from './team/mailbox.js';
|
|
22
|
+
import { readTasks, formatTasks } from './team/tasks.js';
|
|
23
|
+
import { TEAM_LEAD_NAME } from './team/constants.js';
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
/** The state a panel needs, created once when it opens. */
|
|
27
|
+
export function makeTeamPanel(teamName) {
|
|
28
|
+
return {
|
|
29
|
+
teamName,
|
|
30
|
+
selectedIndex: 0,
|
|
31
|
+
/** 'roster' or 'compose', so a keystroke means one thing at a time. */
|
|
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',
|
|
43
|
+
composing: '',
|
|
44
|
+
caret: 0,
|
|
45
|
+
flash: '',
|
|
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,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
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
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The members as rows, in roster order.
|
|
180
|
+
*
|
|
181
|
+
* The LEAD is included and is selectable: the user is the boss, and talking to the lead is how
|
|
182
|
+
* they give the team its work.
|
|
183
|
+
*/
|
|
184
|
+
export function teamRows(panel) {
|
|
185
|
+
const team = readTeamFile(panel.teamName);
|
|
186
|
+
if (!team || !Array.isArray(team.members)) return [];
|
|
187
|
+
return team.members.map((m) => {
|
|
188
|
+
// Unread mail TO this member from someone ELSE. The user's own sends are stamped
|
|
189
|
+
// `from: TEAM_LEAD_NAME` and sit in the recipient's box until it reads them, so counting
|
|
190
|
+
// them made the roster show `1 new` for a message the user had just typed themselves —
|
|
191
|
+
// a badge nothing could ever clear and nothing ever delivered.
|
|
192
|
+
const unread = readMailbox(panel.teamName, m.name)
|
|
193
|
+
.filter((x) => !x.read && x.from !== TEAM_LEAD_NAME).length;
|
|
194
|
+
return {
|
|
195
|
+
name: m.name,
|
|
196
|
+
model: m.model || '',
|
|
197
|
+
color: m.color || '',
|
|
198
|
+
// By ROLE: a team that names its own leader still shows it as the lead.
|
|
199
|
+
isLead: m.role === 'lead',
|
|
200
|
+
unread,
|
|
201
|
+
// The member's own PHASE — what it is doing, not whether a flag is set. `starting` covers
|
|
202
|
+
// one written before the phase existed; `working` means a tool is actually running, and
|
|
203
|
+
// `thinking` means it is blocked on the model, which is most of a round.
|
|
204
|
+
//
|
|
205
|
+
// `lead` is decided by ROLE, not by the name matching the wire alias. The alias test never
|
|
206
|
+
// matched a real lead (`addMember` gives the role to the first member added, whose name is
|
|
207
|
+
// its own — `NiceHello_01`, not `team-lead`), so the branch was dead and every lead fell
|
|
208
|
+
// through to its phase.
|
|
209
|
+
state: (m.role === 'lead') ? 'lead'
|
|
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 || '',
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
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) => ({
|
|
241
|
+
from: m.from,
|
|
242
|
+
text: String(m.text || ''),
|
|
243
|
+
at: m.timestamp,
|
|
244
|
+
order: Number.isFinite(m.seq) ? m.seq : null,
|
|
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);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Draw the panel. Exactly `rows` lines, each `cols` wide. */
|
|
277
|
+
export function renderTeamPanel(panel, cols, rows) {
|
|
278
|
+
const out = [];
|
|
279
|
+
const push = (s) => { out.push(fit(s, cols)); };
|
|
280
|
+
|
|
281
|
+
const team = readTeamFile(panel.teamName);
|
|
282
|
+
const memberCount = (team && team.members) ? team.members.length : 0;
|
|
283
|
+
const title = team
|
|
284
|
+
? ` Team "${panel.teamName}" — ${memberCount} member(s)`
|
|
285
|
+
: ` Team "${panel.teamName}" — the roster is gone`;
|
|
286
|
+
|
|
287
|
+
push(C.border + '─'.repeat(cols) + C.reset);
|
|
288
|
+
push(C.cyan + C.bold + title + C.reset
|
|
289
|
+
+ (panel.flash ? ` ${C.yellow}${panel.flash}${C.reset}` : ''));
|
|
290
|
+
|
|
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
|
+
|
|
324
|
+
if (!roster.length) {
|
|
325
|
+
push(' ' + C.gray + 'No members. Add one with /team add <name>.' + C.reset);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// The roster, then the selected member's traffic, then the composer.
|
|
329
|
+
const composerRows = 3;
|
|
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));
|
|
339
|
+
|
|
340
|
+
for (let i = 0; i < roster.length && out.length < rows - composerRows - trafficRoom - 2; i++) {
|
|
341
|
+
const m = roster[i];
|
|
342
|
+
const sel = i === panel.selectedIndex;
|
|
343
|
+
const ptr = sel ? `${C.cyan}❯${C.reset} ` : ' ';
|
|
344
|
+
const name = (m.isLead ? `${m.name} (lead)` : m.name).padEnd(20);
|
|
345
|
+
// The states a member can be in, each with its own colour: green is a tool actually running,
|
|
346
|
+
// cyan is blocked on the model, grey is waiting for work, yellow is an agent that has not
|
|
347
|
+
// begun. `starting` used to fall through to `not started`, so a lead mid-launch claimed the
|
|
348
|
+
// one thing that was certainly false about it — this is the row the user read as "my lead
|
|
349
|
+
// never came up", and the correct reading was "it is on its way".
|
|
350
|
+
const state = m.state === 'lead' ? `${C.gray}lead${C.reset}`
|
|
351
|
+
: m.state === 'working' ? `${C.green}working${C.reset}`
|
|
352
|
+
: m.state === 'thinking' ? `${C.cyan}thinking${C.reset}`
|
|
353
|
+
: m.state === 'idle' ? `${C.gray}idle${C.reset}`
|
|
354
|
+
: m.state === 'starting' ? `${C.yellow}starting${C.reset}`
|
|
355
|
+
: `${C.yellow}not started${C.reset}`;
|
|
356
|
+
const mail = m.unread ? `${C.yellow} ${m.unread} new${C.reset}` : '';
|
|
357
|
+
const model = m.model ? `${C.gray} ${m.model}${C.reset}` : '';
|
|
358
|
+
push(sel
|
|
359
|
+
? `${ptr}${C.cyan}${name}${C.reset}${state}${mail}${model}`
|
|
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
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// What the selected member has been saying.
|
|
379
|
+
const sel = roster[panel.selectedIndex];
|
|
380
|
+
if (sel && trafficRoom > 0) {
|
|
381
|
+
push('');
|
|
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 = [];
|
|
460
|
+
for (const t of traffic) {
|
|
461
|
+
if (t.from === sel.name) continue;
|
|
462
|
+
renderMail(t, mail);
|
|
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`);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// The shared task list, one line, so "is the team moving" needs no second screen.
|
|
493
|
+
const tasks = readTasks(panel.teamName);
|
|
494
|
+
if (tasks.length && out.length < rows - composerRows - 1) {
|
|
495
|
+
const open = tasks.filter((t) => t.status !== 'completed' && t.status !== 'cancelled').length;
|
|
496
|
+
push(' ' + C.gray + `${tasks.length - open}/${tasks.length} tasks done${C.reset}`);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
while (out.length < rows - composerRows) out.push(' '.repeat(cols));
|
|
500
|
+
|
|
501
|
+
// The composer. One line, always visible, and it SAYS who it will go to — the failure mode of a
|
|
502
|
+
// chat box is sending to the wrong person and only finding out afterwards.
|
|
503
|
+
push(C.border + '─'.repeat(cols) + C.reset);
|
|
504
|
+
if (panel.mode === 'compose') {
|
|
505
|
+
push(` ${C.cyan}→ ${sel ? sel.name : '?'}${C.reset} ${panel.composing}${C.cyan}█${C.reset}`);
|
|
506
|
+
push(' ' + C.gray + 'Enter send · Esc cancel' + C.reset);
|
|
507
|
+
} else {
|
|
508
|
+
push(' ' + C.gray + 'Enter write · Tab next · ↑↓ select · Esc close' + C.reset);
|
|
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);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return out.slice(0, rows);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Keys for the panel.
|
|
519
|
+
*
|
|
520
|
+
* Returns an action for the caller to perform, rather than performing it: the panel does not own
|
|
521
|
+
* the frame, the agent or the clipboard, and a renderer that acts is a renderer that cannot be
|
|
522
|
+
* tested.
|
|
523
|
+
*/
|
|
524
|
+
export function handleTeamPanelKey(panel, t) {
|
|
525
|
+
if (!t) return { handled: false };
|
|
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
|
+
|
|
539
|
+
if (panel.mode === 'compose') {
|
|
540
|
+
if (t.key === 'escape') { panel.mode = 'roster'; panel.composing = ''; panel.caret = 0; return { handled: true }; }
|
|
541
|
+
if (t.key === 'enter') {
|
|
542
|
+
const text = panel.composing.trim();
|
|
543
|
+
const roster = teamRows(panel);
|
|
544
|
+
const to = roster[panel.selectedIndex];
|
|
545
|
+
panel.composing = '';
|
|
546
|
+
panel.caret = 0;
|
|
547
|
+
panel.mode = 'roster';
|
|
548
|
+
if (!text || !to) return { handled: true };
|
|
549
|
+
return { handled: true, action: 'send', to: to.name, text };
|
|
550
|
+
}
|
|
551
|
+
if (t.key === 'backspace') {
|
|
552
|
+
panel.composing = panel.composing.slice(0, -1);
|
|
553
|
+
panel.caret = panel.composing.length;
|
|
554
|
+
return { handled: true };
|
|
555
|
+
}
|
|
556
|
+
if (t.ch && !t.key?.startsWith('c-')) { panel.composing += t.ch; panel.caret = panel.composing.length; return { handled: true }; }
|
|
557
|
+
return { handled: true };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const roster = teamRows(panel);
|
|
561
|
+
const n = roster.length;
|
|
562
|
+
|
|
563
|
+
if (t.key === 'escape' || t.ch === 'q') return { handled: true, action: 'close' };
|
|
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
|
+
|
|
584
|
+
if (t.key === 'tab' || t.key === 'down') { if (n) panel.selectedIndex = (panel.selectedIndex + 1) % n; return { handled: true }; }
|
|
585
|
+
if (t.key === 's-tab' || t.key === 'up') { if (n) panel.selectedIndex = (panel.selectedIndex - 1 + n) % n; return { handled: true }; }
|
|
586
|
+
if (t.key === 'home') { panel.selectedIndex = 0; return { handled: true }; }
|
|
587
|
+
if (t.key === 'end') { if (n) panel.selectedIndex = n - 1; return { handled: true }; }
|
|
588
|
+
// already looked at in the traffic pane.
|
|
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
|
+
|
|
616
|
+
return { handled: false };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Deliver a message from the panel. Separated so the key handler stays pure. */
|
|
620
|
+
export function sendFromPanel(teamName, to, text) {
|
|
621
|
+
if (!to || !text) return false;
|
|
622
|
+
writeToMailbox(teamName, to, { from: TEAM_LEAD_NAME, text: String(text) });
|
|
623
|
+
return true;
|
|
624
|
+
}
|