Add streaming terminal chat UX

This commit is contained in:
Adolfo Reyna
2026-05-15 14:20:08 -04:00
parent d8d79efe83
commit a04fc0a0b5
5 changed files with 127 additions and 15 deletions

View File

@@ -31,6 +31,9 @@ Type a message, "/help" for commands, or "/exit" to quit.
you>
```
Interactive responses stream into the terminal as they are generated, and the
session keeps conversation history until you reset or exit.
You can also keep using one-shot mode when scripting:
```bash
@@ -78,6 +81,7 @@ npm run dev -- "/tools"
The core provides:
- `/help` — show available commands
- `/history` — show how many conversation items are currently in memory
- `/reset` — clear the current conversation history
- `/exit` — leave the interactive session

View File

@@ -8,6 +8,10 @@ export type AgentOptions = {
events?: EventBus;
};
export type RunTurnOptions = {
onTextDelta?: (delta: string) => void;
};
export class Agent {
private readonly history: AgentMessage[] = [];
@@ -17,7 +21,7 @@ export class Agent {
private readonly options: AgentOptions
) {}
async runTurn(prompt: string): Promise<string> {
async runTurn(prompt: string, runOptions: RunTurnOptions = {}): Promise<string> {
const events = this.options.events;
const maxTurns = this.options.maxTurns ?? 20;
@@ -26,7 +30,14 @@ export class Agent {
for (let turn = 0; turn < maxTurns; turn += 1) {
await events?.emit("agent.turn.started", { turn: turn + 1 });
const result = await this.model.respond(this.history, this.tools.list());
const result =
runOptions.onTextDelta && this.model.respondStream
? await this.model.respondStream(
this.history,
this.tools.list(),
runOptions.onTextDelta
)
: await this.model.respond(this.history, this.tools.list());
this.history.push(...(result.output as AgentMessage[]));
if (result.toolCalls.length === 0) {

View File

@@ -72,6 +72,14 @@ await commands.register({
}
});
await commands.register({
name: "history",
description: "Show how many conversation items are in memory.",
execute() {
return `Conversation items in memory: ${currentAgent?.getHistory().length ?? 0}`;
}
});
await loadExtensions(resolve(cwd, "extensions"), registry, commands, events, cwd, promptFragments);
const systemPrompt = promptFragments.join("\n\n");
@@ -117,15 +125,39 @@ async function handleCommand(prompt: string): Promise<boolean> {
return true;
}
async function handlePrompt(prompt: string): Promise<void> {
async function handlePrompt(prompt: string, interactive = false): Promise<void> {
if (!prompt.trim()) return;
if (await handleCommand(prompt)) {
return;
}
const answer = await getAgent().runTurn(prompt);
console.log(`assistant> ${answer}`);
if (!interactive) {
const answer = await getAgent().runTurn(prompt);
console.log(answer);
return;
}
let wroteAssistantText = false;
let streamedText = "";
output.write("\x1b[36massistant>\x1b[0m ");
const answer = await getAgent().runTurn(prompt, {
onTextDelta(delta) {
wroteAssistantText = true;
streamedText += delta;
output.write(delta);
}
});
if (!wroteAssistantText) {
output.write(answer);
} else if (streamedText !== answer && !answer.startsWith(streamedText)) {
output.write(answer.slice(streamedText.length));
}
output.write("\n\n");
}
if (initialPrompt) {
@@ -137,15 +169,15 @@ const rl = createInterface({ input, output });
console.log("Simple Agent Harness");
console.log('Type a message, "/help" for commands, or "/exit" to quit.');
output.write("you> ");
output.write("\x1b[32myou>\x1b[0m ");
for await (const prompt of rl) {
if (prompt.trim() === "/exit") {
break;
}
await handlePrompt(prompt);
output.write("you> ");
await handlePrompt(prompt, true);
output.write("\x1b[32myou>\x1b[0m ");
}
rl.close();

View File

@@ -37,13 +37,73 @@ export class OpenAIResponsesClient implements ModelClient {
}))
});
const toolCalls: ToolCall[] = response.output
.filter((item): item is OpenAI.Responses.ResponseFunctionToolCall => item.type === "function_call")
.map((item) => ({
id: item.call_id,
name: item.name,
arguments: item.arguments
}));
const toolCalls: ToolCall[] = response.output.flatMap((item) =>
item.type === "function_call"
? [
{
id: item.call_id,
name: item.name,
arguments: item.arguments
}
]
: []
);
await this.events?.emit("model.response", {
outputItems: response.output.length,
toolCalls: toolCalls.map((toolCall) => toolCall.name),
hasText: response.output_text.length > 0
});
return {
output: response.output,
outputText: response.output_text,
toolCalls
};
}
async respondStream(
input: AgentMessage[],
tools: ToolDefinition[],
onTextDelta: (delta: string) => void
): Promise<ModelTurn> {
await this.events?.emit("model.request", {
model: this.model,
messages: input.length,
tools: tools.map((tool) => tool.name)
});
const stream = this.client.responses.stream({
model: this.model,
instructions: this.instructions,
input,
tools: tools.map((tool) => ({
type: "function",
name: tool.name,
description: tool.description,
parameters: tool.parameters,
strict: true
}))
});
for await (const event of stream) {
if (event.type === "response.output_text.delta") {
onTextDelta(event.delta);
}
}
const response = await stream.finalResponse();
const toolCalls: ToolCall[] = response.output.flatMap((item) =>
item.type === "function_call"
? [
{
id: item.call_id,
name: item.name,
arguments: item.arguments
}
]
: []
);
await this.events?.emit("model.response", {
outputItems: response.output.length,

View File

@@ -29,6 +29,11 @@ export type ModelTurn = {
export interface ModelClient {
respond(input: AgentMessage[], tools: ToolDefinition[]): Promise<ModelTurn>;
respondStream?(
input: AgentMessage[],
tools: ToolDefinition[],
onTextDelta: (delta: string) => void
): Promise<ModelTurn>;
}
export type CommandContext = {