Skip to content

Commit 4d3c87f

Browse files
committed
Keep terminal status and input prompt visible
1 parent c84e5de commit 4d3c87f

3 files changed

Lines changed: 146 additions & 18 deletions

File tree

client/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,10 @@ is open, the client maintains an authenticated WebSocket to receive conversation
2626
events, thread state, heartbeats, and bounded subagent status. A separate
2727
session WebSocket carries live orchestrator terminal output only while an
2828
execution is active. The interactive TTY reserves a small bottom pane for each
29-
subagent's state, role, and current work; HTTP event replay repairs gaps after a
30-
disconnect.
29+
subagent's state, role, and current work. The pane also keeps the open thread and
30+
orchestrator state visible, while asynchronous output redraws the active
31+
`THREAD_ID> ` prompt without discarding partially typed follow-up text. HTTP
32+
event replay repairs gaps after a disconnect.
3133

3234
The first command securely prompts for the password. For a non-interactive
3335
caller, provide the password on stdin. Do not put it in a command argument:

client/src/client.mjs

Lines changed: 69 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,22 @@ export async function runInteractive({
177177
let cursor = 0;
178178
let monitor = null;
179179
let threadConnection = null;
180-
const agentPane = createAgentPane(stdout);
180+
let promptActive = false;
181+
let promptLabel = "multiagent> ";
182+
const refreshPrompt = () => {
183+
if (!promptActive) return;
184+
if (typeof terminal.setPrompt !== "function" || typeof terminal.prompt !== "function") return;
185+
terminal.setPrompt(promptLabel);
186+
terminal.prompt(true);
187+
};
188+
const interactiveOutput = {
189+
write(value) {
190+
if (promptActive && stdout.isTTY) stdout.write("\r\u001b[2K");
191+
stdout.write(value);
192+
refreshPrompt();
193+
},
194+
};
195+
const agentPane = createAgentPane(stdout, { onDraw: refreshPrompt });
181196

182197
const listThreads = async () => {
183198
threads = (await client.request("/api/threads")).value.threads || [];
@@ -202,7 +217,7 @@ export async function runInteractive({
202217
const sequence = Number(event.sequence) || 0;
203218
if (sequence <= cursor) continue;
204219
cursor = sequence;
205-
renderInteractiveEvent(stdout, event);
220+
renderInteractiveEvent(interactiveOutput, event);
206221
}
207222
return events;
208223
};
@@ -238,15 +253,18 @@ export async function runInteractive({
238253
createWebSocketImpl,
239254
onState: (state) => agentPane.setConnectionState(state),
240255
onThread: (thread) => {
241-
if (current?.id === threadId) current = thread;
256+
if (current?.id === threadId) {
257+
current = thread;
258+
agentPane.setThread(thread);
259+
}
242260
},
243261
onAgents: (agents) => agentPane.render(agents),
244262
onEvent: (event) => {
245263
if (current?.id !== threadId) return;
246264
const sequence = Number(event.sequence) || 0;
247265
if (sequence <= cursor) return;
248266
cursor = sequence;
249-
renderInteractiveEvent(stdout, event);
267+
renderInteractiveEvent(interactiveOutput, event);
250268
if (new Set(["assistant_message", "question", "session_interrupted"]).has(event.type)) {
251269
monitor?.controller.abort();
252270
}
@@ -263,7 +281,7 @@ export async function runInteractive({
263281
const active = { sessionId, controller, promise: null };
264282
monitor = active;
265283
active.promise = (async () => {
266-
const stream = streamSessionTerminal({ client, sessionId, stdout, sleep, signal: controller.signal, createWebSocketImpl });
284+
const stream = streamSessionTerminal({ client, sessionId, stdout: interactiveOutput, sleep, signal: controller.signal, createWebSocketImpl });
267285
try {
268286
while (!controller.signal.aborted) {
269287
const events = await replay();
@@ -286,6 +304,7 @@ export async function runInteractive({
286304
await stopMonitor();
287305
await stopThreadConnection();
288306
current = response.value.thread;
307+
agentPane.setThread(current);
289308
cursor = 0;
290309
stdout.write(`\nOpened ${current.id} [${current.state}] — ${current.repository}\n`);
291310
await replay({ all: true });
@@ -302,7 +321,15 @@ export async function runInteractive({
302321
await listThreads();
303322
if (initialThreadId) await openThread(initialThreadId);
304323
while (true) {
305-
const line = String(await terminal.question(current ? `${current.id}> ` : "multiagent> ")).trim();
324+
promptLabel = current ? `${current.id}> ` : "multiagent> ";
325+
promptActive = true;
326+
let answer;
327+
try {
328+
answer = await terminal.question(promptLabel);
329+
} finally {
330+
promptActive = false;
331+
}
332+
const line = String(answer).trim();
306333
if (!line) continue;
307334
try {
308335
if (line === "/quit" || line === "/exit") return 0;
@@ -332,6 +359,7 @@ export async function runInteractive({
332359
await stopMonitor();
333360
await stopThreadConnection();
334361
current = created.value.thread;
362+
agentPane.setThread(current);
335363
cursor = 0;
336364
startThreadConnection(current.id);
337365
await listThreads();
@@ -349,11 +377,14 @@ export async function runInteractive({
349377
const sequence = Number(routed.event.sequence) || 0;
350378
if (sequence > cursor) {
351379
cursor = sequence;
352-
renderInteractiveEvent(stdout, routed.event);
380+
renderInteractiveEvent(interactiveOutput, routed.event);
353381
}
354382
}
355383
const session = routed.session;
356-
if (session) stdout.write(`[execution ${session.status}] ${session.id}\n`);
384+
if (session) {
385+
agentPane.setThread(current, session.status);
386+
stdout.write(`[execution ${session.status}] ${session.id}\n`);
387+
}
357388
await startMonitor(session?.id || "");
358389
} catch (error) {
359390
if (!(error instanceof ClientError)) throw error;
@@ -634,19 +665,27 @@ function claudeStreamProgress(lines) {
634665

635666
const inactiveAgentStatuses = new Set(["done", "completed", "closed", "cancelled", "canceled", "failed", "released", "skipped", "finalized", "killed", "missing"]);
636667

637-
export function renderAgentPane(agents, { columns = 80, maxRows = 6, connectionState = "connected" } = {}) {
668+
export function renderAgentPane(agents, {
669+
columns = 80,
670+
maxRows = 6,
671+
connectionState = "connected",
672+
thread = null,
673+
executionStatus = "",
674+
} = {}) {
638675
const values = Array.isArray(agents) ? agents : [];
639676
const active = values.filter((agent) => !inactiveAgentStatuses.has(String(agent.status || "").toLowerCase())).length;
640-
const header = `Subagents | ${connectionState} | ${active} active, ${values.length} total`;
641-
const rows = values.slice(0, Math.max(0, maxRows - 1)).map((agent) => {
677+
const headers = [];
678+
if (thread?.id) headers.push(`Thread ${thread.id} | orchestrator ${executionStatus || thread.state || "idle"}`);
679+
headers.push(`Subagents | ${connectionState} | ${active} active, ${values.length} total`);
680+
const rows = values.slice(0, Math.max(0, maxRows - headers.length)).map((agent) => {
642681
const status = String(agent.status || "unknown");
643682
const role = agent.role ? ` (${agent.role})` : "";
644683
const work = String(agent.workingOn || agent.assignment || "waiting");
645684
return `${inactiveAgentStatuses.has(status.toLowerCase()) ? "-" : ">"} ${agent.name || "agent"} [${status}]${role}: ${work}`;
646685
});
647-
if (values.length > rows.length) rows.push(`... ${values.length - rows.length} more`);
648-
if (!rows.length && maxRows > 1) rows.push(" No subagents reported yet");
649-
return [header, ...rows].slice(0, maxRows).map((line) => truncateTerminalLine(line, columns));
686+
if (values.length > rows.length && headers.length + rows.length < maxRows) rows.push(`... ${values.length - rows.length} more`);
687+
if (!rows.length && maxRows > headers.length) rows.push(" No subagents reported yet");
688+
return [...headers, ...rows].slice(0, maxRows).map((line) => truncateTerminalLine(line, columns));
650689
}
651690

652691
function truncateTerminalLine(value, columns) {
@@ -656,10 +695,12 @@ function truncateTerminalLine(value, columns) {
656695
return width <= 3 ? line.slice(0, width) : `${line.slice(0, width - 3)}...`;
657696
}
658697

659-
function createAgentPane(stdout) {
698+
function createAgentPane(stdout, { onDraw = () => {} } = {}) {
660699
const enabled = Boolean(stdout?.isTTY && Number(stdout.rows) >= 8);
661700
let agents = [];
662701
let connectionState = "disconnected";
702+
let thread = null;
703+
let executionStatus = "";
663704
let panel = null;
664705
let lastFrame = "";
665706

@@ -682,7 +723,13 @@ function createAgentPane(stdout) {
682723
const mainBottom = start - 1;
683724
if (panel && (panel.rows !== rows || panel.start !== start)) clear();
684725
panel = { rows, start };
685-
const lines = renderAgentPane(agents, { columns, maxRows: height, connectionState });
726+
const lines = renderAgentPane(agents, {
727+
columns,
728+
maxRows: height,
729+
connectionState,
730+
thread,
731+
executionStatus,
732+
});
686733
const frame = JSON.stringify({ rows, columns, height, lines });
687734
if (frame === lastFrame) return;
688735
lastFrame = frame;
@@ -692,6 +739,7 @@ function createAgentPane(stdout) {
692739
}
693740
output += "\u001b8";
694741
stdout.write(output);
742+
onDraw();
695743
};
696744

697745
return {
@@ -704,6 +752,11 @@ function createAgentPane(stdout) {
704752
connectionState = next;
705753
draw();
706754
},
755+
setThread(nextThread, nextExecutionStatus = "") {
756+
thread = nextThread || null;
757+
executionStatus = nextExecutionStatus || "";
758+
draw();
759+
},
707760
close: clear,
708761
};
709762
}

client/test/client.test.mjs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ function writer() {
1010
return { output: "", write(value) { this.output += String(value); } };
1111
}
1212

13+
function ttyWriter({ rows = 24, columns = 100 } = {}) {
14+
return { ...writer(), isTTY: true, rows, columns };
15+
}
16+
1317
function jsonResponse(value, init = {}) {
1418
return new Response(JSON.stringify(value), {
1519
status: init.status || 200,
@@ -456,3 +460,72 @@ test("subagent pane includes status, role, and current work within its width", (
456460
assert.match(lines[2], /- tester \[done\] \(verification\): Ran the client tests/);
457461
assert.ok(lines.every((line) => line.length <= 72));
458462
});
463+
464+
test("subagent pane keeps the open thread and orchestrator status visible", () => {
465+
const lines = renderAgentPane([
466+
{ name: "reader", status: "running", role: "investigator", workingOn: "Inspecting the runtime" },
467+
], {
468+
columns: 100,
469+
maxRows: 5,
470+
connectionState: "connected",
471+
thread: { id: "thread-123", state: "running" },
472+
});
473+
assert.equal(lines[0], "Thread thread-123 | orchestrator running");
474+
assert.equal(lines[1], "Subagents | connected | 1 active, 1 total");
475+
});
476+
477+
test("asynchronous status redraw restores the active input prompt", async () => {
478+
const sessionFile = await sessionFixture();
479+
const output = ttyWriter();
480+
let questionCount = 0;
481+
let threadSocket = null;
482+
const prompts = [];
483+
const terminal = {
484+
async question() {
485+
questionCount += 1;
486+
if (questionCount === 1) return "/open 1";
487+
return new Promise((resolve) => {
488+
setImmediate(() => {
489+
threadSocket.emit("message", Buffer.from(JSON.stringify({
490+
type: "agents",
491+
agents: [{ name: "reader", status: "running", role: "investigator", workingOn: "Checking status" }],
492+
})));
493+
threadSocket.emit("message", Buffer.from(JSON.stringify({
494+
type: "event",
495+
event: { sequence: 1, type: "assistant_message", payload: { text: "Still working" } },
496+
})));
497+
setImmediate(() => resolve("/quit"));
498+
});
499+
});
500+
},
501+
setPrompt(value) { this.label = value; },
502+
prompt(preserveCursor) { prompts.push({ label: this.label, preserveCursor }); },
503+
close() {},
504+
};
505+
506+
await main([
507+
"--server", "https://control.example", "--session-file", sessionFile,
508+
], {
509+
stdin: { isTTY: true },
510+
stdout: output,
511+
createInterface: () => terminal,
512+
sleep: async () => {},
513+
createWebSocket: (url) => {
514+
const socket = new EventEmitter();
515+
socket.close = () => queueMicrotask(() => socket.emit("close"));
516+
if (String(url).includes("/stream")) threadSocket = socket;
517+
return socket;
518+
},
519+
fetchImpl: async (url) => {
520+
const value = String(url);
521+
if (value.endsWith("/api/threads")) return jsonResponse({ threads: [{ id: "thread-1", state: "running", repository: "multiagent" }] });
522+
if (value.endsWith("/api/threads/thread-1")) return jsonResponse({ thread: { id: "thread-1", state: "running", repository: "multiagent" } });
523+
if (value.includes("/events?after_sequence=0")) return jsonResponse({ events: [] });
524+
throw new Error(`unexpected request: ${value}`);
525+
},
526+
});
527+
528+
assert.match(output.output, /Thread thread-1 \| orchestrator running/);
529+
assert.match(output.output, /\r\u001b\[2K\nassistant> Still working/);
530+
assert.ok(prompts.some((prompt) => prompt.label === "thread-1> " && prompt.preserveCursor === true));
531+
});

0 commit comments

Comments
 (0)