feat(core,server,web,cli): mid-run steering messages and square-bracket markers (#63)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei ZhengandClaude Fable 5 authored and GitHub committed 2026-07-26 23:01:18 +08:00
1 parent abd5b13d52
commit 7584171482
81 files changed
+3139 -930

No files matched your search

+42 -2
View File
@@ -125,7 +125,7 @@ export function registerChatCommand(program: Command, t: Messages): void {
// regular input.
if (session.resumedHistory) {
out.write(`${t.resumedBanner(session.sessionId, session.resumedHistory.length)}\n`);
renderHistory(session.resumedHistory, out);
renderHistory(session.resumedHistory, out, t);
}
// TTY: raw mode + bracketed paste + PasteFilter; non-TTY (pipe/test): read stdin directly.
@@ -148,11 +148,26 @@ export function registerChatCommand(program: Command, t: Messages): void {
const rli = rl as unknown as RlInternals;
const composer = new LineComposer();
// Typing lock (owner directive): while the user is composing a line mid-run, streamed
// output must not scribble over it — the renderer holds (queues) output while the input
// buffer is non-empty and flushes once the line is submitted or cleared. Synced after
// readline has processed each chunk (setImmediate: listener order is not guaranteed);
// TTY only — non-TTY input (pipe/tests) has no echoed line to protect. The approval
// prompt has its own lock (beginUserPrompt), so the hold applies to "running" only.
const syncInputHold = (): void => {
renderer.setInputHold(state === "running" && rli.line.length > 0);
};
if (isTTY) {
inputStream.on("data", () => setImmediate(syncInputHold));
}
let state: ChatState = "idle";
let closed = false;
let taskAbort: AbortController | null = null;
let pendingLine: ((line: string | null) => void) | null = null;
let pendingApproval: ((decision: ApprovalDecision) => void) | null = null;
/** A line typed in the running->idle race window (steer refused): becomes the next prompt instead of being dropped. */
let queuedPrompt: string | null = null;
const cleanup = () => {
if (!isTTY) return;
@@ -221,8 +236,24 @@ export function registerChatCommand(program: Command, t: Messages): void {
pendingApproval = null;
// Tool approval defaults to allow: pressing Enter (empty input) is treated as allow.
resolve(parseApprovalAnswer(line, "allow"));
return;
}
// running: a non-empty line becomes a steering message for the running Task — core
// delivers it between turns as a standalone [user_steering] user message, so the
// model sees it without the loop being interrupted. Empty lines are still ignored.
// steer() returning false (race: the task just finished) must not drop the line:
// it is stashed and becomes the next normal prompt as soon as the loop asks again.
if (state === "running") {
const text = line.trim();
if (text.length === 0) return;
if (session.steer(text)) {
// Printed via the renderer while the typing hold is still engaged, so the ack
// lands before the held stream output flushes underneath it.
renderer.printLine(dim(t.steerQueued(text)));
} else {
queuedPrompt = text;
}
}
// running: ignore any line typed at this moment.
});
rl.on("SIGINT", () => {
@@ -272,6 +303,15 @@ export function registerChatCommand(program: Command, t: Messages): void {
resolve(null);
return;
}
// A line typed in the running->idle race window (steer refused): submit it as this
// prompt immediately — the text is already echoed on screen, no re-prompt needed.
if (queuedPrompt !== null) {
const line = queuedPrompt;
queuedPrompt = null;
state = "idle";
resolve(line);
return;
}
state = "idle";
pendingLine = resolve;
composer.reset();
+10 -2
View File
@@ -125,6 +125,10 @@ export interface Messages {
chatHints(): string;
confirmExit(): string;
taskInterrupted(): string;
/** Acknowledgment printed when a line typed mid-run is queued as steering (echoes the text; delivered between turns). */
steerQueued(text: string): string;
/** Prefix for a rendered [user_steering] line (a mid-run user message delivered between turns). */
steerLinePrefix(): string;
error(message: string): string;
/** Approval prompt text (the tool call is already streamed above and directly precedes this prompt, so no index and no re-rendering). */
approvePrompt(): string;
@@ -343,9 +347,11 @@ const en: Messages = {
header: headerEn,
chatHints: () =>
"Type a message to start a conversation; end a line with \\; /compact to compact the context; /exit to quit; and Ctrl-C interrupts the current conversation.",
"Type a message to start a conversation; end a line with \\; typing while a task runs steers the agent; /compact to compact the context; /exit to quit; and Ctrl-C interrupts the current conversation.",
confirmExit: () => "Exit penguin? [y/N] ",
taskInterrupted: () => "[current conversation interrupted]",
steerQueued: (text) => `» steering queued (delivered with the next turn): ${text}`,
steerLinePrefix: () => "↪ user: ",
error: (message) => `[error] ${message}`,
approvePrompt: () => "? Approve this tool call? [Y/n] ",
taskStats: (s) =>
@@ -508,9 +514,11 @@ const zh: Messages = {
header: headerZh,
chatHints: () =>
"输入消息发起对话;行尾 \\ 续行;/compact 压缩上下文;/exit 退出;Ctrl-C 中断对话。",
"输入消息发起对话;行尾 \\ 续行;运行中输入可插话引导;/compact 压缩上下文;/exit 退出;Ctrl-C 中断对话。",
confirmExit: () => "确认退出 penguin?[y/N] ",
taskInterrupted: () => "[已中断当前对话]",
steerQueued: (text) => `» 插话已排队(随下一轮送达):${text}`,
steerLinePrefix: () => "↪ 用户: ",
error: (message) => `[错误] ${message}`,
approvePrompt: () => "? 批准此工具调用?[Y/n] ",
taskStats: (s) =>
+71 -6
View File
@@ -40,7 +40,7 @@
*
* No third-party color library is used; only minimal ANSI escapes.
*/
import { isEventMessage, isModelMessage } from "@prismshadow/penguin-core";
import { isEventMessage, isModelMessage, parseUserSteeringText } from "@prismshadow/penguin-core";
import type {
AbortPayload,
ApprovalDecision,
@@ -55,6 +55,7 @@ import type {
PartialToolCallOutputPayload,
RequestEndPayload,
SessionMetaPayload,
TextPayload,
TokenUsagePayload,
ToolCallPayload,
ToolDefinition,
@@ -67,6 +68,7 @@ const DIM = "\x1b[2m";
const GREEN = "\x1b[32m";
const RED = "\x1b[31m";
const CYAN = "\x1b[36m";
const MAGENTA = "\x1b[35m";
const RESET = "\x1b[0m";
export function dim(text: string): string {
@@ -187,8 +189,18 @@ export function renderHistory(
const marker = p.stop_reason && p.stop_reason !== "completed" ? dim(` [${p.stop_reason}]`) : "";
switch (p.type) {
case "text":
if (p.role === "user") out.write(`\n> ${p.text ?? ""}\n`);
else out.write(`${p.text ?? ""}${marker}\n`);
if (p.role === "user") {
// Mid-run steering ([user_steering]-wrapped user text delivered between turns):
// rendered as distinct user-speech lines instead of a raw marker block or a prompt.
const steering = parseUserSteeringText(p.text ?? "");
if (steering !== null) {
writeSteeringLines(out, steering, t);
} else {
out.write(`\n> ${p.text ?? ""}\n`);
}
} else {
out.write(`${p.text ?? ""}${marker}\n`);
}
break;
case "image_url":
out.write(`\n> ${dim("[image]")}\n`);
@@ -231,6 +243,13 @@ export function renderHistory(
}
}
/** Writes a steering message's lines with the colored user prefix (shared by history rendering and the streaming renderer). */
function writeSteeringLines(out: NodeJS.WritableStream, text: string, t: Messages): void {
for (const line of text.split("\n")) {
out.write(`${MAGENTA}${t.steerLinePrefix()}${line}${RESET}\n`);
}
}
/**
* Streaming renderer: writes the OmniMessage stream to the output stream. The display
* text for tool calls is decided locally by `tool-render.ts`; it no longer accepts a
@@ -246,6 +265,13 @@ export class StreamRenderer {
private holder: string | null = null;
/** Awaiting user input (approval prompt): locks the screen, all messages queue up. */
private promptActive = false;
/**
* The user is composing an input line mid-run (chat REPL): streaming output must not
* scribble over the half-typed line, so rendering is held (messages queue up) until the
* line is submitted or cleared (see `setInputHold`). Independent of the approval-prompt
* lock — approval answers use `beginUserPrompt`/`endUserPrompt`.
*/
private inputHold = false;
/** Key of the call the current interactive prompt belongs to (the tool_call passed to beginUserPrompt); null = unattached. */
private promptKey: string | null = null;
/**
@@ -467,6 +493,29 @@ export class StreamRenderer {
this.lastLineKey = key;
}
/**
* Chat REPL typing hold: while the user is composing a line mid-run, hold rendering so
* streamed output doesn't scribble over the input; releasing flushes everything queued in
* the meantime. Idempotent; `endTask` force-releases it as a safety net.
*/
setInputHold(active: boolean): void {
if (this.inputHold === active) return;
this.inputHold = active;
if (!active) this.drain();
}
/**
* Writes one standalone line through the renderer at the current position (finishing any
* open streamed line first). Meant for host notices tied to the input flow — e.g. the chat
* REPL's steering acknowledgment — printed while the screen is held so they don't
* interleave with streamed output.
*/
printLine(text: string): void {
this.finishLine();
this.out.write(`${text}\n`);
this.lastLineKey = null;
}
/** User interaction ends: unlocks the screen, first renders approval results deferred during the lock, then drains the queue. */
endUserPrompt(): void {
this.promptActive = false;
@@ -512,7 +561,7 @@ export class StreamRenderer {
if (this.draining) return;
this.draining = true;
try {
while (!this.promptActive && this.pending.length > 0) {
while (!this.promptActive && !this.inputHold && this.pending.length > 0) {
if (this.holder === null) {
const msg = this.pending.shift()!;
const owner = this.streamOwner(msg);
@@ -527,7 +576,7 @@ export class StreamRenderer {
const keep: OmniMessage[] = [];
let progressed = false;
for (let i = 0; i < this.pending.length; i++) {
if (this.promptActive || this.holder === null) {
if (this.promptActive || this.inputHold || this.holder === null) {
keep.push(...this.pending.slice(i));
break;
}
@@ -585,6 +634,21 @@ export class StreamRenderer {
this.toolNames.set(p.tool_call_id, p.name);
return;
}
case "text": {
// Another exception: a mid-run steering message ([user_steering]-wrapped user text
// delivered between turns) has no streamed copy — it is rendered here, in the
// steering style.
const p = payload as TextPayload;
if (p.role === "user") {
const steering = parseUserSteeringText(p.text);
if (steering !== null) {
this.finishLine();
writeSteeringLines(this.out, steering, this.t);
this.lastLineKey = null;
}
}
return;
}
default:
return;
}
@@ -628,7 +692,7 @@ export class StreamRenderer {
this.lastLineKey = null;
} else if (payload.type === "request_begin") {
// The previous request ended in timeout/malformed -> this request is a retry
// carrying <turn_retried>: printed when the retry **actually starts** (when
// carrying [turn_retried]: printed when the retry **actually starts** (when
// retries are exhausted, there's no retry after the last failure, only an abort
// explaining why).
if (this.pendingRetry) {
@@ -935,6 +999,7 @@ export class StreamRenderer {
endTask(elapsedMs = 0): void {
this.promptActive = false;
this.promptKey = null;
this.inputHold = false;
this.flushDeferredDecisions();
this.holder = null;
this.drain();