mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-20 16:05:11 +00:00
Fix markdown streaming duplication by splitting newlines first
- Added string-width library for proper terminal column width calculation - Fixed wrapLine() to split by newlines before wrapping (like Text component) - Fixed Loader interval leak by stopping before container removal - Changed loader message from 'Loading...' to 'Working...'
This commit is contained in:
parent
985f955ea0
commit
c5083bb7cb
16 changed files with 429 additions and 372 deletions
58
package-lock.json
generated
58
package-lock.json
generated
|
|
@ -3132,6 +3132,18 @@
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-east-asian-width": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-tsconfig": {
|
"node_modules/get-tsconfig": {
|
||||||
"version": "4.10.1",
|
"version": "4.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz",
|
||||||
|
|
@ -5513,7 +5525,8 @@
|
||||||
"@types/mime-types": "^2.1.4",
|
"@types/mime-types": "^2.1.4",
|
||||||
"chalk": "^5.5.0",
|
"chalk": "^5.5.0",
|
||||||
"marked": "^15.0.12",
|
"marked": "^15.0.12",
|
||||||
"mime-types": "^3.0.1"
|
"mime-types": "^3.0.1",
|
||||||
|
"string-width": "^8.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@xterm/headless": "^5.5.0",
|
"@xterm/headless": "^5.5.0",
|
||||||
|
|
@ -5523,6 +5536,18 @@
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/tui/node_modules/ansi-regex": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/tui/node_modules/chalk": {
|
"packages/tui/node_modules/chalk": {
|
||||||
"version": "5.6.2",
|
"version": "5.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||||
|
|
@ -5545,6 +5570,37 @@
|
||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/tui/node_modules/string-width": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"get-east-asian-width": "^1.3.0",
|
||||||
|
"strip-ansi": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages/tui/node_modules/strip-ansi": {
|
||||||
|
"version": "7.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||||
|
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/web-ui": {
|
"packages/web-ui": {
|
||||||
"name": "@mariozechner/pi-web-ui",
|
"name": "@mariozechner/pi-web-ui",
|
||||||
"version": "0.5.48",
|
"version": "0.5.48",
|
||||||
|
|
|
||||||
|
|
@ -87,33 +87,32 @@ export class Agent {
|
||||||
|
|
||||||
subscribe(fn: (e: AgentEvent) => void): () => void {
|
subscribe(fn: (e: AgentEvent) => void): () => void {
|
||||||
this.listeners.add(fn);
|
this.listeners.add(fn);
|
||||||
fn({ type: "state-update", state: this._state });
|
|
||||||
return () => this.listeners.delete(fn);
|
return () => this.listeners.delete(fn);
|
||||||
}
|
}
|
||||||
|
|
||||||
// State mutators
|
// State mutators - update internal state without emitting events
|
||||||
setSystemPrompt(v: string) {
|
setSystemPrompt(v: string) {
|
||||||
this.patch({ systemPrompt: v });
|
this._state.systemPrompt = v;
|
||||||
}
|
}
|
||||||
|
|
||||||
setModel(m: typeof this._state.model) {
|
setModel(m: typeof this._state.model) {
|
||||||
this.patch({ model: m });
|
this._state.model = m;
|
||||||
}
|
}
|
||||||
|
|
||||||
setThinkingLevel(l: ThinkingLevel) {
|
setThinkingLevel(l: ThinkingLevel) {
|
||||||
this.patch({ thinkingLevel: l });
|
this._state.thinkingLevel = l;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTools(t: typeof this._state.tools) {
|
setTools(t: typeof this._state.tools) {
|
||||||
this.patch({ tools: t });
|
this._state.tools = t;
|
||||||
}
|
}
|
||||||
|
|
||||||
replaceMessages(ms: AppMessage[]) {
|
replaceMessages(ms: AppMessage[]) {
|
||||||
this.patch({ messages: ms.slice() });
|
this._state.messages = ms.slice();
|
||||||
}
|
}
|
||||||
|
|
||||||
appendMessage(m: AppMessage) {
|
appendMessage(m: AppMessage) {
|
||||||
this.patch({ messages: [...this._state.messages, m] });
|
this._state.messages = [...this._state.messages, m];
|
||||||
}
|
}
|
||||||
|
|
||||||
async queueMessage(m: AppMessage) {
|
async queueMessage(m: AppMessage) {
|
||||||
|
|
@ -126,7 +125,7 @@ export class Agent {
|
||||||
}
|
}
|
||||||
|
|
||||||
clearMessages() {
|
clearMessages() {
|
||||||
this.patch({ messages: [] });
|
this._state.messages = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
abort() {
|
abort() {
|
||||||
|
|
@ -163,8 +162,12 @@ export class Agent {
|
||||||
};
|
};
|
||||||
|
|
||||||
this.abortController = new AbortController();
|
this.abortController = new AbortController();
|
||||||
this.patch({ isStreaming: true, streamMessage: null, error: undefined });
|
this._state.isStreaming = true;
|
||||||
this.emit({ type: "started" });
|
this._state.streamMessage = null;
|
||||||
|
this._state.error = undefined;
|
||||||
|
|
||||||
|
// Emit agent_start
|
||||||
|
this.emit({ type: "agent_start" });
|
||||||
|
|
||||||
const reasoning =
|
const reasoning =
|
||||||
this._state.thinkingLevel === "off"
|
this._state.thinkingLevel === "off"
|
||||||
|
|
@ -186,6 +189,9 @@ export class Agent {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Track all messages generated in this prompt
|
||||||
|
const generatedMessages: AppMessage[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let partial: Message | null = null;
|
let partial: Message | null = null;
|
||||||
|
|
||||||
|
|
@ -198,38 +204,51 @@ export class Agent {
|
||||||
cfg,
|
cfg,
|
||||||
this.abortController.signal,
|
this.abortController.signal,
|
||||||
)) {
|
)) {
|
||||||
|
// Pass through all events directly
|
||||||
|
this.emit(ev as AgentEvent);
|
||||||
|
|
||||||
|
// Update internal state as needed
|
||||||
switch (ev.type) {
|
switch (ev.type) {
|
||||||
case "message_start":
|
case "message_start": {
|
||||||
case "message_update": {
|
// Track streaming message
|
||||||
partial = ev.message;
|
partial = ev.message;
|
||||||
this.patch({ streamMessage: ev.message });
|
this._state.streamMessage = ev.message;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "message_update": {
|
||||||
|
// Update streaming message
|
||||||
|
partial = ev.message;
|
||||||
|
this._state.streamMessage = ev.message;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "message_end": {
|
case "message_end": {
|
||||||
|
// Add completed message to state
|
||||||
partial = null;
|
partial = null;
|
||||||
|
this._state.streamMessage = null;
|
||||||
this.appendMessage(ev.message as AppMessage);
|
this.appendMessage(ev.message as AppMessage);
|
||||||
this.patch({ streamMessage: null });
|
generatedMessages.push(ev.message as AppMessage);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "tool_execution_start": {
|
case "tool_execution_start": {
|
||||||
const s = new Set(this._state.pendingToolCalls);
|
const s = new Set(this._state.pendingToolCalls);
|
||||||
s.add(ev.toolCallId);
|
s.add(ev.toolCallId);
|
||||||
this.patch({ pendingToolCalls: s });
|
this._state.pendingToolCalls = s;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "tool_execution_end": {
|
case "tool_execution_end": {
|
||||||
const s = new Set(this._state.pendingToolCalls);
|
const s = new Set(this._state.pendingToolCalls);
|
||||||
s.delete(ev.toolCallId);
|
s.delete(ev.toolCallId);
|
||||||
this.patch({ pendingToolCalls: s });
|
this._state.pendingToolCalls = s;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "agent_end": {
|
case "agent_end": {
|
||||||
this.patch({ streamMessage: null });
|
this._state.streamMessage = null;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle any remaining partial message
|
||||||
if (partial && partial.role === "assistant" && partial.content.length > 0) {
|
if (partial && partial.role === "assistant" && partial.content.length > 0) {
|
||||||
const onlyEmpty = !partial.content.some(
|
const onlyEmpty = !partial.content.some(
|
||||||
(c) =>
|
(c) =>
|
||||||
|
|
@ -239,6 +258,7 @@ export class Agent {
|
||||||
);
|
);
|
||||||
if (!onlyEmpty) {
|
if (!onlyEmpty) {
|
||||||
this.appendMessage(partial as AppMessage);
|
this.appendMessage(partial as AppMessage);
|
||||||
|
generatedMessages.push(partial as AppMessage);
|
||||||
} else {
|
} else {
|
||||||
if (this.abortController?.signal.aborted) {
|
if (this.abortController?.signal.aborted) {
|
||||||
throw new Error("Request was aborted");
|
throw new Error("Request was aborted");
|
||||||
|
|
@ -264,17 +284,17 @@ export class Agent {
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
this.appendMessage(msg as AppMessage);
|
this.appendMessage(msg as AppMessage);
|
||||||
this.patch({ error: err?.message || String(err) });
|
generatedMessages.push(msg as AppMessage);
|
||||||
|
this._state.error = err?.message || String(err);
|
||||||
} finally {
|
} finally {
|
||||||
this.patch({ isStreaming: false, streamMessage: null, pendingToolCalls: new Set<string>() });
|
this._state.isStreaming = false;
|
||||||
|
this._state.streamMessage = null;
|
||||||
|
this._state.pendingToolCalls = new Set<string>();
|
||||||
this.abortController = undefined;
|
this.abortController = undefined;
|
||||||
this.emit({ type: "completed" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private patch(p: Partial<AgentState>): void {
|
// Emit agent_end with all generated messages
|
||||||
this._state = { ...this._state, ...p };
|
this.emit({ type: "agent_end", messages: generatedMessages });
|
||||||
this.emit({ type: "state-update", state: this._state });
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private emit(e: AgentEvent) {
|
private emit(e: AgentEvent) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
import type { AgentTool, AssistantMessage, Message, Model, UserMessage } from "@mariozechner/pi-ai";
|
import type {
|
||||||
|
AgentTool,
|
||||||
|
AssistantMessage,
|
||||||
|
AssistantMessageEvent,
|
||||||
|
Message,
|
||||||
|
Model,
|
||||||
|
UserMessage,
|
||||||
|
} from "@mariozechner/pi-ai";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attachment type definition.
|
* Attachment type definition.
|
||||||
|
|
@ -71,5 +78,20 @@ export interface AgentState {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Events emitted by the Agent for UI updates.
|
* Events emitted by the Agent for UI updates.
|
||||||
|
* These events provide fine-grained lifecycle information for messages, turns, and tool executions.
|
||||||
*/
|
*/
|
||||||
export type AgentEvent = { type: "state-update"; state: AgentState } | { type: "started" } | { type: "completed" };
|
export type AgentEvent =
|
||||||
|
// Agent lifecycle
|
||||||
|
| { type: "agent_start" }
|
||||||
|
| { type: "agent_end"; messages: AppMessage[] }
|
||||||
|
// Turn lifecycle - a turn is one assistant response + any tool calls/results
|
||||||
|
| { type: "turn_start" }
|
||||||
|
| { type: "turn_end"; message: AppMessage; toolResults: AppMessage[] }
|
||||||
|
// Message lifecycle - emitted for user, assistant, and toolResult messages
|
||||||
|
| { type: "message_start"; message: AppMessage }
|
||||||
|
// Only emitted for assistant messages during streaming
|
||||||
|
| { type: "message_update"; message: AppMessage; assistantMessageEvent: AssistantMessageEvent }
|
||||||
|
| { type: "message_end"; message: AppMessage }
|
||||||
|
// Tool execution lifecycle
|
||||||
|
| { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any }
|
||||||
|
| { type: "tool_execution_end"; toolCallId: string; toolName: string; result: any; isError: boolean };
|
||||||
|
|
|
||||||
|
|
@ -36,30 +36,28 @@ describe("Agent", () => {
|
||||||
expect(agent.state.thinkingLevel).toBe("low");
|
expect(agent.state.thinkingLevel).toBe("low");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should subscribe to state updates", () => {
|
it("should subscribe to events", () => {
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
transport: new ProviderTransport(),
|
transport: new ProviderTransport(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let updateCount = 0;
|
let eventCount = 0;
|
||||||
const unsubscribe = agent.subscribe((event) => {
|
const unsubscribe = agent.subscribe((_event) => {
|
||||||
if (event.type === "state-update") {
|
eventCount++;
|
||||||
updateCount++;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initial state update on subscribe
|
// No initial event on subscribe
|
||||||
expect(updateCount).toBe(1);
|
expect(eventCount).toBe(0);
|
||||||
|
|
||||||
// Update state
|
// State mutators don't emit events
|
||||||
agent.setSystemPrompt("Test prompt");
|
agent.setSystemPrompt("Test prompt");
|
||||||
expect(updateCount).toBe(2);
|
expect(eventCount).toBe(0);
|
||||||
expect(agent.state.systemPrompt).toBe("Test prompt");
|
expect(agent.state.systemPrompt).toBe("Test prompt");
|
||||||
|
|
||||||
// Unsubscribe should work
|
// Unsubscribe should work
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
agent.setSystemPrompt("Another prompt");
|
agent.setSystemPrompt("Another prompt");
|
||||||
expect(updateCount).toBe(2); // Should not increase
|
expect(eventCount).toBe(0); // Should not increase
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should update state with mutators", () => {
|
it("should update state with mutators", () => {
|
||||||
|
|
|
||||||
|
|
@ -167,29 +167,26 @@ async function stateUpdates(model: Model<any>) {
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const stateSnapshots: Array<{ isStreaming: boolean; messageCount: number; hasStreamMessage: boolean }> = [];
|
const events: Array<string> = [];
|
||||||
|
|
||||||
agent.subscribe((event) => {
|
agent.subscribe((event) => {
|
||||||
if (event.type === "state-update") {
|
events.push(event.type);
|
||||||
stateSnapshots.push({
|
|
||||||
isStreaming: event.state.isStreaming,
|
|
||||||
messageCount: event.state.messages.length,
|
|
||||||
hasStreamMessage: event.state.streamMessage !== null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await agent.prompt("Count from 1 to 5.");
|
await agent.prompt("Count from 1 to 5.");
|
||||||
|
|
||||||
const streamingStates = stateSnapshots.filter((s) => s.isStreaming);
|
// Should have received lifecycle events
|
||||||
const nonStreamingStates = stateSnapshots.filter((s) => !s.isStreaming);
|
expect(events).toContain("agent_start");
|
||||||
|
expect(events).toContain("agent_end");
|
||||||
|
expect(events).toContain("message_start");
|
||||||
|
expect(events).toContain("message_end");
|
||||||
|
// May have message_update events during streaming
|
||||||
|
const hasMessageUpdates = events.some((e) => e === "message_update");
|
||||||
|
expect(hasMessageUpdates).toBe(true);
|
||||||
|
|
||||||
expect(streamingStates.length).toBeGreaterThan(0);
|
// Check final state
|
||||||
expect(nonStreamingStates.length).toBeGreaterThan(0);
|
expect(agent.state.isStreaming).toBe(false);
|
||||||
|
expect(agent.state.messages.length).toBe(2); // User message + assistant response
|
||||||
const finalState = stateSnapshots[stateSnapshots.length - 1];
|
|
||||||
expect(finalState.isStreaming).toBe(false);
|
|
||||||
expect(finalState.messageCount).toBe(2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function multiTurnConversation(model: Model<any>) {
|
async function multiTurnConversation(model: Model<any>) {
|
||||||
|
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
# Debug Mode Guide
|
|
||||||
|
|
||||||
## Enabling Debug Output
|
|
||||||
|
|
||||||
Debug logs are written to files in `/tmp/` to avoid interfering with TUI rendering.
|
|
||||||
|
|
||||||
There are three ways to enable debug output:
|
|
||||||
|
|
||||||
1. **CLI flag**: `--debug` or `-d`
|
|
||||||
```bash
|
|
||||||
coding-agent --debug --script "Hello"
|
|
||||||
```
|
|
||||||
This will print log file locations:
|
|
||||||
```
|
|
||||||
[TUI] Debug logging to: /tmp/tui-debug-1234567890.log
|
|
||||||
[RENDERER] Debug logging to: /tmp/agent-debug-1234567890.log
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Environment variables**:
|
|
||||||
```bash
|
|
||||||
TUI_DEBUG=1 AGENT_DEBUG=1 coding-agent
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Individual components**:
|
|
||||||
```bash
|
|
||||||
TUI_DEBUG=1 coding-agent # Only TUI debug
|
|
||||||
AGENT_DEBUG=1 coding-agent # Only agent/renderer debug
|
|
||||||
```
|
|
||||||
|
|
||||||
## Viewing Debug Logs
|
|
||||||
|
|
||||||
Debug logs are written to `/tmp/` with timestamps:
|
|
||||||
- `/tmp/tui-debug-<timestamp>.log` - TUI rendering events
|
|
||||||
- `/tmp/agent-debug-<timestamp>.log` - Agent/renderer events
|
|
||||||
|
|
||||||
To tail the logs while the agent runs:
|
|
||||||
```bash
|
|
||||||
# In one terminal
|
|
||||||
coding-agent --debug --script "Hello"
|
|
||||||
|
|
||||||
# In another terminal (use the path printed above)
|
|
||||||
tail -f /tmp/tui-debug-*.log
|
|
||||||
tail -f /tmp/agent-debug-*.log
|
|
||||||
```
|
|
||||||
|
|
||||||
## Scripted Messages for Testing
|
|
||||||
|
|
||||||
Use `--script` to replay messages automatically in interactive mode:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Single scripted message
|
|
||||||
coding-agent --debug --script "What files are in this directory?"
|
|
||||||
|
|
||||||
# Multiple scripted messages
|
|
||||||
coding-agent --debug --script "Hello" --script "List the files" --script "Read package.json"
|
|
||||||
```
|
|
||||||
|
|
||||||
The agent will:
|
|
||||||
1. Type the message into the editor
|
|
||||||
2. Submit it
|
|
||||||
3. Wait for the agent to complete its response
|
|
||||||
4. Move to the next message
|
|
||||||
5. Exit after all messages are processed
|
|
||||||
|
|
||||||
## Debug Output Reference
|
|
||||||
|
|
||||||
### TUI Debug Messages
|
|
||||||
|
|
||||||
**`[TUI DEBUG]`** - Low-level terminal UI rendering events
|
|
||||||
|
|
||||||
- **`requestRender() called but TUI not started`** - Render requested before TUI initialization (usually benign)
|
|
||||||
- **`Render queued`** - A render has been scheduled for next tick
|
|
||||||
- **`Executing queued render`** - About to perform the actual render
|
|
||||||
- **`renderToScreen() called: resize=X, termWidth=Y, termHeight=Z`** - Starting render cycle
|
|
||||||
- **`Reset for resize`** - Terminal was resized, clearing buffers
|
|
||||||
- **`Collected N render commands, total lines: M`** - Gathered all component output (N components, M total lines)
|
|
||||||
- **`Performing initial render`** - First render (full screen write)
|
|
||||||
- **`Performing line-based render`** - Differential render (only changed lines)
|
|
||||||
- **`Render complete. Total renders: X, avg lines redrawn: Y`** - Render finished with performance stats
|
|
||||||
|
|
||||||
### Renderer Debug Messages
|
|
||||||
|
|
||||||
**`[RENDERER DEBUG]`** - Agent renderer (TuiRenderer) events
|
|
||||||
|
|
||||||
- **`handleStateUpdate: isStreaming=X, messages=N, pendingToolCalls=M`** - Agent state changed
|
|
||||||
- `isStreaming=true` - Agent is currently responding
|
|
||||||
- `messages=N` - Total messages in conversation
|
|
||||||
- `pendingToolCalls=M` - Number of tool calls waiting to execute
|
|
||||||
|
|
||||||
- **`Adding N new stable messages`** - N messages were finalized and added to chat history
|
|
||||||
- **`Streaming message role=X`** - Currently streaming a message with role X (user/assistant/toolResult)
|
|
||||||
- **`Starting loading animation`** - Spinner started because agent is thinking
|
|
||||||
- **`Creating streaming component`** - Creating UI component to show live message updates
|
|
||||||
- **`Streaming stopped`** - Agent finished responding
|
|
||||||
- **`Requesting render`** - Asking TUI to redraw the screen
|
|
||||||
- **`simulateInput: "text"`** - Scripted message being typed
|
|
||||||
- **`Triggering onInputCallback`** - Submitting the scripted message
|
|
||||||
|
|
||||||
### Script Debug Messages
|
|
||||||
|
|
||||||
**`[SCRIPT]`** - Scripted message playback
|
|
||||||
|
|
||||||
- **`Sending message N/M: text`** - Sending message N out of M total
|
|
||||||
- **`All N messages completed. Exiting.`** - Finished all scripted messages
|
|
||||||
|
|
||||||
**`[AGENT]`** - Agent execution
|
|
||||||
|
|
||||||
- **`Completed response to: "text"`** - Agent finished processing this message
|
|
||||||
|
|
||||||
## Interpreting Debug Output
|
|
||||||
|
|
||||||
### Normal Message Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
[RENDERER DEBUG] handleStateUpdate: isStreaming=false, messages=0, pendingToolCalls=0
|
|
||||||
[SCRIPT] Sending message 1/1: Hello
|
|
||||||
[RENDERER DEBUG] simulateInput: "Hello"
|
|
||||||
[RENDERER DEBUG] Triggering onInputCallback
|
|
||||||
[RENDERER DEBUG] handleStateUpdate: isStreaming=true, messages=1, pendingToolCalls=0
|
|
||||||
[RENDERER DEBUG] Streaming message role=user
|
|
||||||
[RENDERER DEBUG] Starting loading animation
|
|
||||||
[RENDERER DEBUG] Requesting render
|
|
||||||
[TUI DEBUG] Render queued
|
|
||||||
[TUI DEBUG] Executing queued render
|
|
||||||
[TUI DEBUG] renderToScreen() called: resize=false, termWidth=120, termHeight=40
|
|
||||||
[TUI DEBUG] Collected 4 render commands, total lines: 8
|
|
||||||
[TUI DEBUG] Performing line-based render
|
|
||||||
[TUI DEBUG] Render complete. Total renders: 5, avg lines redrawn: 12.4
|
|
||||||
[RENDERER DEBUG] handleStateUpdate: isStreaming=true, messages=1, pendingToolCalls=0
|
|
||||||
[RENDERER DEBUG] Streaming message role=assistant
|
|
||||||
...
|
|
||||||
[RENDERER DEBUG] handleStateUpdate: isStreaming=false, messages=2, pendingToolCalls=0
|
|
||||||
[RENDERER DEBUG] Streaming stopped
|
|
||||||
[RENDERER DEBUG] Adding 1 new stable messages
|
|
||||||
[AGENT] Completed response to: "Hello"
|
|
||||||
```
|
|
||||||
|
|
||||||
### What to Look For
|
|
||||||
|
|
||||||
**Rendering Issues:**
|
|
||||||
- If `Render queued` appears but no `Executing queued render` → render loop broken
|
|
||||||
- If `total lines` is 0 or unexpectedly small → components not rendering
|
|
||||||
- If `avg lines redrawn` is huge → too many full redraws (performance issue)
|
|
||||||
- If no `[TUI DEBUG]` messages → TUI debug not enabled or TUI not starting
|
|
||||||
|
|
||||||
**Message Flow Issues:**
|
|
||||||
- If messages increase but no "Adding N new stable messages" → renderer not detecting changes
|
|
||||||
- If `isStreaming=true` never becomes `false` → agent hanging
|
|
||||||
- If `pendingToolCalls` stays > 0 → tool execution stuck
|
|
||||||
- If `Streaming stopped` never appears → streaming never completes
|
|
||||||
|
|
||||||
**Scripted Message Issues:**
|
|
||||||
- If `simulateInput` appears but no `Triggering onInputCallback` → callback not registered yet
|
|
||||||
- If `Sending message` appears but no `Completed response` → agent not responding
|
|
||||||
- If no `[SCRIPT]` messages → script messages not being processed
|
|
||||||
|
|
||||||
## Example Debug Session
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Test basic rendering with a simple scripted message
|
|
||||||
coding-agent --debug --script "Hello"
|
|
||||||
|
|
||||||
# Test multi-turn conversation
|
|
||||||
coding-agent --debug --script "Hi" --script "What files are here?" --script "Thanks"
|
|
||||||
|
|
||||||
# Test tool execution
|
|
||||||
coding-agent --debug --script "List all TypeScript files"
|
|
||||||
```
|
|
||||||
|
|
||||||
Look for the flow: script → simulateInput → handleStateUpdate → render → completed
|
|
||||||
|
|
@ -116,11 +116,10 @@ async function runInteractiveMode(agent: Agent, _sessionManager: SessionManager)
|
||||||
agent.abort();
|
agent.abort();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Subscribe to agent state updates
|
// Subscribe to agent events
|
||||||
agent.subscribe(async (event) => {
|
agent.subscribe(async (event) => {
|
||||||
if (event.type === "state-update") {
|
// Pass all events to the renderer
|
||||||
await renderer.handleStateUpdate(event.state);
|
await renderer.handleEvent(event, agent.state);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Interactive loop
|
// Interactive loop
|
||||||
|
|
@ -168,8 +167,8 @@ export async function main(args: string[]) {
|
||||||
const sessionManager = new SessionManager(parsed.continue);
|
const sessionManager = new SessionManager(parsed.continue);
|
||||||
|
|
||||||
// Determine provider and model
|
// Determine provider and model
|
||||||
const provider = (parsed.provider || "google") as any;
|
const provider = (parsed.provider || "anthropic") as any;
|
||||||
const modelId = parsed.model || "gemini-2.5-flash";
|
const modelId = parsed.model || "claude-sonnet-4-5";
|
||||||
|
|
||||||
// Get API key
|
// Get API key
|
||||||
let apiKey = parsed.apiKey;
|
let apiKey = parsed.apiKey;
|
||||||
|
|
@ -177,7 +176,7 @@ export async function main(args: string[]) {
|
||||||
const envVarMap: Record<string, string> = {
|
const envVarMap: Record<string, string> = {
|
||||||
google: "GEMINI_API_KEY",
|
google: "GEMINI_API_KEY",
|
||||||
openai: "OPENAI_API_KEY",
|
openai: "OPENAI_API_KEY",
|
||||||
anthropic: "ANTHROPIC_API_KEY",
|
anthropic: "ANTHROPIC_OAUTH_TOKEN",
|
||||||
xai: "XAI_API_KEY",
|
xai: "XAI_API_KEY",
|
||||||
groq: "GROQ_API_KEY",
|
groq: "GROQ_API_KEY",
|
||||||
cerebras: "CEREBRAS_API_KEY",
|
cerebras: "CEREBRAS_API_KEY",
|
||||||
|
|
@ -221,20 +220,14 @@ export async function main(args: string[]) {
|
||||||
// Start session
|
// Start session
|
||||||
sessionManager.startSession(agent.state);
|
sessionManager.startSession(agent.state);
|
||||||
|
|
||||||
// Subscribe to state updates to save messages
|
// Subscribe to agent events to save messages and log events
|
||||||
agent.subscribe((event) => {
|
agent.subscribe((event) => {
|
||||||
if (event.type === "state-update") {
|
// Save messages on completion
|
||||||
// Save any new messages
|
if (event.type === "message_end") {
|
||||||
const currentMessages = event.state.messages;
|
sessionManager.saveMessage(event.message);
|
||||||
const loadedMessages = sessionManager.loadMessages();
|
|
||||||
|
|
||||||
if (currentMessages.length > loadedMessages.length) {
|
|
||||||
for (let i = loadedMessages.length; i < currentMessages.length; i++) {
|
|
||||||
sessionManager.saveMessage(currentMessages[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log all events
|
||||||
sessionManager.saveEvent(event);
|
sessionManager.saveEvent(event);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ export class SessionManager {
|
||||||
const cwd = process.cwd();
|
const cwd = process.cwd();
|
||||||
const safePath = "--" + cwd.replace(/^\//, "").replace(/\//g, "-") + "--";
|
const safePath = "--" + cwd.replace(/^\//, "").replace(/\//g, "-") + "--";
|
||||||
|
|
||||||
const configDir = resolve(process.env.CODING_AGENT_DIR || join(homedir(), ".coding-agent"));
|
const configDir = resolve(process.env.CODING_AGENT_DIR || join(homedir(), ".pi/agent/"));
|
||||||
const sessionDir = join(configDir, "sessions", safePath);
|
const sessionDir = join(configDir, "sessions", safePath);
|
||||||
if (!existsSync(sessionDir)) {
|
if (!existsSync(sessionDir)) {
|
||||||
mkdirSync(sessionDir, { recursive: true });
|
mkdirSync(sessionDir, { recursive: true });
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import {
|
||||||
Loader,
|
Loader,
|
||||||
Markdown,
|
Markdown,
|
||||||
ProcessTerminal,
|
ProcessTerminal,
|
||||||
Spacer,
|
|
||||||
Text,
|
Text,
|
||||||
TUI,
|
TUI,
|
||||||
} from "@mariozechner/pi-tui";
|
} from "@mariozechner/pi-tui";
|
||||||
|
|
@ -42,14 +41,17 @@ class CustomEditor extends Editor {
|
||||||
* Component that renders a streaming message with live updates
|
* Component that renders a streaming message with live updates
|
||||||
*/
|
*/
|
||||||
class StreamingMessageComponent extends Container {
|
class StreamingMessageComponent extends Container {
|
||||||
private textComponent: Markdown | null = null;
|
private markdown: Markdown;
|
||||||
private toolCallsContainer: Container | null = null;
|
|
||||||
private currentContent = "";
|
constructor() {
|
||||||
private currentToolCalls: any[] = [];
|
super();
|
||||||
|
this.markdown = new Markdown("");
|
||||||
|
this.addChild(this.markdown);
|
||||||
|
}
|
||||||
|
|
||||||
updateContent(message: Message | null) {
|
updateContent(message: Message | null) {
|
||||||
if (!message) {
|
if (!message) {
|
||||||
this.clear();
|
this.markdown.setText("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,35 +63,95 @@ class StreamingMessageComponent extends Container {
|
||||||
.filter((c) => c.type === "text")
|
.filter((c) => c.type === "text")
|
||||||
.map((c) => c.text)
|
.map((c) => c.text)
|
||||||
.join("");
|
.join("");
|
||||||
if (textContent !== this.currentContent) {
|
|
||||||
this.currentContent = textContent;
|
this.markdown.setText(textContent);
|
||||||
if (this.textComponent) {
|
}
|
||||||
this.removeChild(this.textComponent);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that renders a tool call with its result
|
||||||
|
*/
|
||||||
|
class ToolExecutionComponent extends Container {
|
||||||
|
private markdown: Markdown;
|
||||||
|
|
||||||
|
constructor(toolName: string, args: any, result?: { output: string; isError: boolean }) {
|
||||||
|
super();
|
||||||
|
const bgColor = result
|
||||||
|
? result.isError
|
||||||
|
? { r: 60, g: 40, b: 40 }
|
||||||
|
: { r: 40, g: 50, b: 40 }
|
||||||
|
: { r: 40, g: 40, b: 50 };
|
||||||
|
this.markdown = new Markdown(this.formatToolExecution(toolName, args, result), undefined, undefined, bgColor);
|
||||||
|
this.addChild(this.markdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatToolExecution(toolName: string, args: any, result?: { output: string; isError: boolean }): string {
|
||||||
|
let text = "";
|
||||||
|
|
||||||
|
// Format based on tool type
|
||||||
|
if (toolName === "bash") {
|
||||||
|
const command = args.command || "";
|
||||||
|
text = `**$ ${command}**`;
|
||||||
|
if (result) {
|
||||||
|
const lines = result.output.split("\n");
|
||||||
|
const maxLines = 5;
|
||||||
|
const displayLines = lines.slice(0, maxLines);
|
||||||
|
const remaining = lines.length - maxLines;
|
||||||
|
|
||||||
|
text += "\n```\n" + displayLines.join("\n");
|
||||||
|
if (remaining > 0) {
|
||||||
|
text += `\n... (${remaining} more lines)`;
|
||||||
}
|
}
|
||||||
if (textContent) {
|
text += "\n```";
|
||||||
this.textComponent = new Markdown(textContent);
|
|
||||||
this.addChild(this.textComponent);
|
if (result.isError) {
|
||||||
|
text += " ❌";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (toolName === "read") {
|
||||||
|
const path = args.path || "";
|
||||||
|
text = `**read** \`${path}\``;
|
||||||
|
if (result) {
|
||||||
|
const lines = result.output.split("\n");
|
||||||
|
const maxLines = 5;
|
||||||
|
const displayLines = lines.slice(0, maxLines);
|
||||||
|
const remaining = lines.length - maxLines;
|
||||||
|
|
||||||
// Update tool calls
|
text += "\n```\n" + displayLines.join("\n");
|
||||||
const toolCalls = assistantMsg.content.filter((c) => c.type === "toolCall");
|
if (remaining > 0) {
|
||||||
if (JSON.stringify(toolCalls) !== JSON.stringify(this.currentToolCalls)) {
|
text += `\n... (${remaining} more lines)`;
|
||||||
this.currentToolCalls = toolCalls;
|
|
||||||
if (this.toolCallsContainer) {
|
|
||||||
this.removeChild(this.toolCallsContainer);
|
|
||||||
}
|
}
|
||||||
if (toolCalls.length > 0) {
|
text += "\n```";
|
||||||
this.toolCallsContainer = new Container();
|
|
||||||
for (const toolCall of toolCalls) {
|
if (result.isError) {
|
||||||
const argsStr =
|
text += " ❌";
|
||||||
typeof toolCall.arguments === "string" ? toolCall.arguments : JSON.stringify(toolCall.arguments);
|
|
||||||
this.toolCallsContainer.addChild(new Text(chalk.yellow(`[tool] ${toolCall.name}(${argsStr})`)));
|
|
||||||
}
|
|
||||||
this.addChild(this.toolCallsContainer);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (toolName === "write") {
|
||||||
|
const path = args.path || "";
|
||||||
|
const content = args.content || "";
|
||||||
|
const lines = content.split("\n");
|
||||||
|
text = `**write** \`${path}\` (${lines.length} lines)`;
|
||||||
|
if (result) {
|
||||||
|
text += result.isError ? " ❌" : " ✓";
|
||||||
|
}
|
||||||
|
} else if (toolName === "edit") {
|
||||||
|
const path = args.path || "";
|
||||||
|
text = `**edit** \`${path}\``;
|
||||||
|
if (result) {
|
||||||
|
text += result.isError ? " ❌" : " ✓";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Generic tool
|
||||||
|
text = `**${toolName}**\n\`\`\`json\n${JSON.stringify(args, null, 2)}\n\`\`\``;
|
||||||
|
if (result) {
|
||||||
|
text += `\n\`\`\`\n${result.output}\n\`\`\``;
|
||||||
|
text += result.isError ? " ❌" : " ✓";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,10 +169,12 @@ export class TuiRenderer {
|
||||||
private onInterruptCallback?: () => void;
|
private onInterruptCallback?: () => void;
|
||||||
private lastSigintTime = 0;
|
private lastSigintTime = 0;
|
||||||
|
|
||||||
// Message tracking
|
// Streaming message tracking
|
||||||
private lastStableMessageCount = 0;
|
|
||||||
private streamingComponent: StreamingMessageComponent | null = null;
|
private streamingComponent: StreamingMessageComponent | null = null;
|
||||||
|
|
||||||
|
// Tool execution tracking: toolCallId -> { component, toolName, args }
|
||||||
|
private pendingTools = new Map<string, { component: ToolExecutionComponent; toolName: string; args: any }>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.ui = new TUI(new ProcessTerminal());
|
this.ui = new TUI(new ProcessTerminal());
|
||||||
this.chatContainer = new Container();
|
this.chatContainer = new Container();
|
||||||
|
|
@ -127,20 +191,16 @@ export class TuiRenderer {
|
||||||
|
|
||||||
// Add header with instructions
|
// Add header with instructions
|
||||||
const header = new Text(
|
const header = new Text(
|
||||||
chalk.blueBright(">> coding-agent interactive <<") +
|
">> coding-agent interactive <<\n" +
|
||||||
"\n" +
|
"Press Escape to interrupt while processing\n" +
|
||||||
chalk.dim("Press Escape to interrupt while processing") +
|
"Press CTRL+C to clear the text editor\n" +
|
||||||
"\n" +
|
"Press CTRL+C twice quickly to exit\n",
|
||||||
chalk.dim("Press CTRL+C to clear the text editor") +
|
|
||||||
"\n" +
|
|
||||||
chalk.dim("Press CTRL+C twice quickly to exit"),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Setup UI layout
|
// Setup UI layout
|
||||||
this.ui.addChild(header);
|
this.ui.addChild(header);
|
||||||
this.ui.addChild(this.chatContainer);
|
this.ui.addChild(this.chatContainer);
|
||||||
this.ui.addChild(this.statusContainer);
|
this.ui.addChild(this.statusContainer);
|
||||||
this.ui.addChild(new Spacer(1));
|
|
||||||
this.ui.addChild(this.editor);
|
this.ui.addChild(this.editor);
|
||||||
this.ui.setFocus(this.editor);
|
this.ui.setFocus(this.editor);
|
||||||
|
|
||||||
|
|
@ -183,108 +243,146 @@ export class TuiRenderer {
|
||||||
this.isInitialized = true;
|
this.isInitialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleStateUpdate(state: AgentState): Promise<void> {
|
async handleEvent(event: import("@mariozechner/pi-agent").AgentEvent, _state: AgentState): Promise<void> {
|
||||||
if (!this.isInitialized) {
|
if (!this.isInitialized) {
|
||||||
await this.init();
|
await this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count stable messages (exclude the streaming one if streaming)
|
switch (event.type) {
|
||||||
const stableMessageCount = state.isStreaming ? state.messages.length - 1 : state.messages.length;
|
case "agent_start":
|
||||||
|
// Show loading animation
|
||||||
// Add any NEW stable messages
|
|
||||||
if (stableMessageCount > this.lastStableMessageCount) {
|
|
||||||
for (let i = this.lastStableMessageCount; i < stableMessageCount; i++) {
|
|
||||||
const message = state.messages[i];
|
|
||||||
this.addMessageToChat(message);
|
|
||||||
}
|
|
||||||
this.lastStableMessageCount = stableMessageCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle streaming message
|
|
||||||
if (state.isStreaming) {
|
|
||||||
const streamingMessage = state.messages[state.messages.length - 1];
|
|
||||||
|
|
||||||
// Show loading animation if we just started streaming
|
|
||||||
if (!this.loadingAnimation) {
|
|
||||||
this.editor.disableSubmit = true;
|
this.editor.disableSubmit = true;
|
||||||
|
// Stop old loader before clearing
|
||||||
|
if (this.loadingAnimation) {
|
||||||
|
this.loadingAnimation.stop();
|
||||||
|
}
|
||||||
this.statusContainer.clear();
|
this.statusContainer.clear();
|
||||||
this.loadingAnimation = new Loader(this.ui);
|
this.loadingAnimation = new Loader(this.ui, "Working...");
|
||||||
this.statusContainer.addChild(this.loadingAnimation);
|
this.statusContainer.addChild(this.loadingAnimation);
|
||||||
|
this.ui.requestRender();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "message_start":
|
||||||
|
if (event.message.role === "user") {
|
||||||
|
// Show user message immediately and clear editor
|
||||||
|
this.addMessageToChat(event.message);
|
||||||
|
this.editor.setText("");
|
||||||
|
this.ui.requestRender();
|
||||||
|
} else if (event.message.role === "assistant") {
|
||||||
|
// Create streaming component for assistant messages
|
||||||
|
this.streamingComponent = new StreamingMessageComponent();
|
||||||
|
this.chatContainer.addChild(this.streamingComponent);
|
||||||
|
this.streamingComponent.updateContent(event.message);
|
||||||
|
this.ui.requestRender();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "message_update":
|
||||||
|
// Update streaming component
|
||||||
|
if (this.streamingComponent && event.message.role === "assistant") {
|
||||||
|
this.streamingComponent.updateContent(event.message);
|
||||||
|
this.ui.requestRender();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "message_end":
|
||||||
|
// Skip user messages (already shown in message_start)
|
||||||
|
if (event.message.role === "user") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (this.streamingComponent && event.message.role === "assistant") {
|
||||||
|
this.chatContainer.removeChild(this.streamingComponent);
|
||||||
|
this.streamingComponent = null;
|
||||||
|
}
|
||||||
|
// Show final assistant message
|
||||||
|
this.addMessageToChat(event.message);
|
||||||
|
this.ui.requestRender();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "tool_execution_start": {
|
||||||
|
// Create tool execution component and add it
|
||||||
|
const component = new ToolExecutionComponent(event.toolName, event.args);
|
||||||
|
this.chatContainer.addChild(component);
|
||||||
|
this.pendingTools.set(event.toolCallId, { component, toolName: event.toolName, args: event.args });
|
||||||
|
this.ui.requestRender();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create or update streaming component
|
case "tool_execution_end": {
|
||||||
if (!this.streamingComponent) {
|
// Update the existing tool component with the result
|
||||||
this.streamingComponent = new StreamingMessageComponent();
|
const pending = this.pendingTools.get(event.toolCallId);
|
||||||
this.chatContainer.addChild(this.streamingComponent);
|
if (pending) {
|
||||||
}
|
// Re-render the component with result
|
||||||
this.streamingComponent.updateContent(streamingMessage);
|
this.chatContainer.removeChild(pending.component);
|
||||||
} else {
|
const updatedComponent = new ToolExecutionComponent(pending.toolName, pending.args, {
|
||||||
// Streaming stopped
|
output: typeof event.result === "string" ? event.result : event.result.output,
|
||||||
if (this.loadingAnimation) {
|
isError: event.isError,
|
||||||
this.loadingAnimation.stop();
|
});
|
||||||
this.loadingAnimation = null;
|
this.chatContainer.addChild(updatedComponent);
|
||||||
this.statusContainer.clear();
|
this.pendingTools.delete(event.toolCallId);
|
||||||
|
this.ui.requestRender();
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.streamingComponent) {
|
case "agent_end":
|
||||||
this.chatContainer.removeChild(this.streamingComponent);
|
// Stop loading animation
|
||||||
this.streamingComponent = null;
|
if (this.loadingAnimation) {
|
||||||
}
|
this.loadingAnimation.stop();
|
||||||
|
this.loadingAnimation = null;
|
||||||
this.editor.disableSubmit = false;
|
this.statusContainer.clear();
|
||||||
|
}
|
||||||
|
if (this.streamingComponent) {
|
||||||
|
this.chatContainer.removeChild(this.streamingComponent);
|
||||||
|
this.streamingComponent = null;
|
||||||
|
}
|
||||||
|
this.pendingTools.clear();
|
||||||
|
this.editor.disableSubmit = false;
|
||||||
|
this.ui.requestRender();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.ui.requestRender();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private addMessageToChat(message: Message): void {
|
private addMessageToChat(message: Message): void {
|
||||||
if (message.role === "user") {
|
if (message.role === "user") {
|
||||||
this.chatContainer.addChild(new Text(chalk.green("[user]")));
|
|
||||||
const userMsg = message as any;
|
const userMsg = message as any;
|
||||||
const textContent = userMsg.content?.map((c: any) => c.text || "").join("") || message.content || "";
|
// Extract text content from content blocks
|
||||||
this.chatContainer.addChild(new Text(textContent));
|
const textBlocks = userMsg.content.filter((c: any) => c.type === "text");
|
||||||
this.chatContainer.addChild(new Spacer(1));
|
const textContent = textBlocks.map((c: any) => c.text).join("");
|
||||||
|
if (textContent) {
|
||||||
|
// User messages with dark gray background
|
||||||
|
this.chatContainer.addChild(new Markdown(textContent, undefined, undefined, { r: 52, g: 53, b: 65 }));
|
||||||
|
}
|
||||||
} else if (message.role === "assistant") {
|
} else if (message.role === "assistant") {
|
||||||
this.chatContainer.addChild(new Text(chalk.hex("#FFA500")("[assistant]")));
|
|
||||||
const assistantMsg = message as AssistantMessage;
|
const assistantMsg = message as AssistantMessage;
|
||||||
|
|
||||||
// Render text content
|
// Render text content first (tool calls handled by events)
|
||||||
const textContent = assistantMsg.content
|
const textContent = assistantMsg.content
|
||||||
.filter((c) => c.type === "text")
|
.filter((c) => c.type === "text")
|
||||||
.map((c) => c.text)
|
.map((c) => c.text)
|
||||||
.join("");
|
.join("");
|
||||||
if (textContent) {
|
if (textContent) {
|
||||||
|
// Assistant messages with no background
|
||||||
this.chatContainer.addChild(new Markdown(textContent));
|
this.chatContainer.addChild(new Markdown(textContent));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render tool calls
|
// Check if aborted - show after partial content
|
||||||
const toolCalls = assistantMsg.content.filter((c) => c.type === "toolCall");
|
if (assistantMsg.stopReason === "aborted") {
|
||||||
for (const toolCall of toolCalls) {
|
// Show red "Aborted" message after partial content
|
||||||
const argsStr =
|
const abortedText = new Text(chalk.red("Aborted"));
|
||||||
typeof toolCall.arguments === "string" ? toolCall.arguments : JSON.stringify(toolCall.arguments);
|
this.chatContainer.addChild(abortedText);
|
||||||
this.chatContainer.addChild(new Text(chalk.yellow(`[tool] ${toolCall.name}(${argsStr})`)));
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.chatContainer.addChild(new Spacer(1));
|
if (assistantMsg.stopReason === "error") {
|
||||||
} else if (message.role === "toolResult") {
|
// Show red error message after partial content
|
||||||
const toolResultMsg = message as any;
|
const errorMsg = assistantMsg.errorMessage || "Unknown error";
|
||||||
const output = toolResultMsg.result?.output || toolResultMsg.result || "";
|
const errorText = new Text(chalk.red(`Error: ${errorMsg}`));
|
||||||
|
this.chatContainer.addChild(errorText);
|
||||||
// Truncate long outputs
|
return;
|
||||||
const lines = output.split("\n");
|
|
||||||
const maxLines = 10;
|
|
||||||
const truncated = lines.length > maxLines;
|
|
||||||
const toShow = truncated ? lines.slice(0, maxLines) : lines;
|
|
||||||
|
|
||||||
for (const line of toShow) {
|
|
||||||
this.chatContainer.addChild(new Text(chalk.gray(line)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (truncated) {
|
|
||||||
this.chatContainer.addChild(new Text(chalk.dim(`... (${lines.length - maxLines} more lines)`)));
|
|
||||||
}
|
|
||||||
this.chatContainer.addChild(new Spacer(1));
|
|
||||||
}
|
}
|
||||||
|
// Note: tool calls and results are now handled via tool_execution_start/end events
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserInput(): Promise<string> {
|
async getUserInput(): Promise<string> {
|
||||||
|
|
@ -303,7 +401,7 @@ export class TuiRenderer {
|
||||||
clearEditor(): void {
|
clearEditor(): void {
|
||||||
this.editor.setText("");
|
this.editor.setText("");
|
||||||
this.statusContainer.clear();
|
this.statusContainer.clear();
|
||||||
const hint = new Text(chalk.dim("Press Ctrl+C again to exit"));
|
const hint = new Text("Press Ctrl+C again to exit");
|
||||||
this.statusContainer.addChild(hint);
|
this.statusContainer.addChild(hint);
|
||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,8 @@
|
||||||
"@types/mime-types": "^2.1.4",
|
"@types/mime-types": "^2.1.4",
|
||||||
"chalk": "^5.5.0",
|
"chalk": "^5.5.0",
|
||||||
"marked": "^15.0.12",
|
"marked": "^15.0.12",
|
||||||
"mime-types": "^3.0.1"
|
"mime-types": "^3.0.1",
|
||||||
|
"string-width": "^8.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@xterm/headless": "^5.5.0",
|
"@xterm/headless": "^5.5.0",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { stripVTControlCharacters } from "node:util";
|
|
||||||
import type { Component } from "../tui.js";
|
import type { Component } from "../tui.js";
|
||||||
|
import { visibleWidth } from "../utils.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Input component - single-line text input with horizontal scrolling
|
* Input component - single-line text input with horizontal scrolling
|
||||||
|
|
@ -127,8 +127,8 @@ export class Input implements Component {
|
||||||
const cursorChar = `\x1b[7m${atCursor}\x1b[27m`; // ESC[7m = reverse video, ESC[27m = normal
|
const cursorChar = `\x1b[7m${atCursor}\x1b[27m`; // ESC[7m = reverse video, ESC[27m = normal
|
||||||
const textWithCursor = beforeCursor + cursorChar + afterCursor;
|
const textWithCursor = beforeCursor + cursorChar + afterCursor;
|
||||||
|
|
||||||
// Calculate visual width (strip ANSI codes to measure actual displayed characters)
|
// Calculate visual width
|
||||||
const visualLength = stripVTControlCharacters(textWithCursor).length;
|
const visualLength = visibleWidth(textWithCursor);
|
||||||
const padding = " ".repeat(Math.max(0, availableWidth - visualLength));
|
const padding = " ".repeat(Math.max(0, availableWidth - visualLength));
|
||||||
const line = prompt + textWithCursor + padding;
|
const line = prompt + textWithCursor + padding;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { stripVTControlCharacters } from "node:util";
|
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { marked, type Token } from "marked";
|
import { marked, type Token } from "marked";
|
||||||
import type { Component } from "../tui.js";
|
import type { Component } from "../tui.js";
|
||||||
|
import { visibleWidth } from "../utils.js";
|
||||||
|
|
||||||
type Color =
|
type Color =
|
||||||
| "black"
|
| "black"
|
||||||
|
|
@ -109,8 +109,8 @@ export class Markdown implements Component {
|
||||||
const paddedLines: string[] = [];
|
const paddedLines: string[] = [];
|
||||||
|
|
||||||
for (const line of wrappedLines) {
|
for (const line of wrappedLines) {
|
||||||
// Calculate visible length (strip ANSI codes)
|
// Calculate visible length
|
||||||
const visibleLength = stripVTControlCharacters(line).length;
|
const visibleLength = visibleWidth(line);
|
||||||
// Right padding to fill to width (accounting for left padding and content)
|
// Right padding to fill to width (accounting for left padding and content)
|
||||||
const rightPadLength = Math.max(0, width - this.paddingX - visibleLength);
|
const rightPadLength = Math.max(0, width - this.paddingX - visibleLength);
|
||||||
const rightPad = " ".repeat(rightPadLength);
|
const rightPad = " ".repeat(rightPadLength);
|
||||||
|
|
@ -328,12 +328,26 @@ export class Markdown implements Component {
|
||||||
return [""];
|
return [""];
|
||||||
}
|
}
|
||||||
|
|
||||||
// If line fits within width, return as-is
|
// Split by newlines first - wrap each line individually
|
||||||
const visibleLength = stripVTControlCharacters(line).length;
|
const splitLines = line.split("\n");
|
||||||
if (visibleLength <= width) {
|
for (const splitLine of splitLines) {
|
||||||
return [line];
|
const visibleLength = visibleWidth(splitLine);
|
||||||
|
|
||||||
|
if (visibleLength <= width) {
|
||||||
|
wrapped.push(splitLine);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This line needs wrapping
|
||||||
|
wrapped.push(...this.wrapSingleLine(splitLine, width));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return wrapped.length > 0 ? wrapped : [""];
|
||||||
|
}
|
||||||
|
|
||||||
|
private wrapSingleLine(line: string, width: number): string[] {
|
||||||
|
const wrapped: string[] = [];
|
||||||
|
|
||||||
// Track active ANSI codes to preserve them across wrapped lines
|
// Track active ANSI codes to preserve them across wrapped lines
|
||||||
const activeAnsiCodes: string[] = [];
|
const activeAnsiCodes: string[] = [];
|
||||||
let currentLine = "";
|
let currentLine = "";
|
||||||
|
|
@ -381,8 +395,10 @@ export class Markdown implements Component {
|
||||||
}
|
}
|
||||||
currentLength = 0;
|
currentLength = 0;
|
||||||
}
|
}
|
||||||
currentLine += line[i];
|
const char = line[i];
|
||||||
currentLength++;
|
currentLine += char;
|
||||||
|
// Count actual terminal column width, not string length
|
||||||
|
currentLength += visibleWidth(char);
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { stripVTControlCharacters } from "node:util";
|
|
||||||
import type { Component } from "../tui.js";
|
import type { Component } from "../tui.js";
|
||||||
|
import { visibleWidth } from "../utils.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Text component - displays multi-line text with word wrapping
|
* Text component - displays multi-line text with word wrapping
|
||||||
|
|
@ -50,7 +50,10 @@ export class Text implements Component {
|
||||||
const textLines = this.text.split("\n");
|
const textLines = this.text.split("\n");
|
||||||
|
|
||||||
for (const line of textLines) {
|
for (const line of textLines) {
|
||||||
if (line.length <= contentWidth) {
|
// Measure visible length (strip ANSI codes)
|
||||||
|
const visibleLineLength = visibleWidth(line);
|
||||||
|
|
||||||
|
if (visibleLineLength <= contentWidth) {
|
||||||
lines.push(line);
|
lines.push(line);
|
||||||
} else {
|
} else {
|
||||||
// Word wrap
|
// Word wrap
|
||||||
|
|
@ -58,9 +61,12 @@ export class Text implements Component {
|
||||||
let currentLine = "";
|
let currentLine = "";
|
||||||
|
|
||||||
for (const word of words) {
|
for (const word of words) {
|
||||||
if (currentLine.length === 0) {
|
const currentVisible = visibleWidth(currentLine);
|
||||||
|
const wordVisible = visibleWidth(word);
|
||||||
|
|
||||||
|
if (currentVisible === 0) {
|
||||||
currentLine = word;
|
currentLine = word;
|
||||||
} else if (currentLine.length + 1 + word.length <= contentWidth) {
|
} else if (currentVisible + 1 + wordVisible <= contentWidth) {
|
||||||
currentLine += " " + word;
|
currentLine += " " + word;
|
||||||
} else {
|
} else {
|
||||||
lines.push(currentLine);
|
lines.push(currentLine);
|
||||||
|
|
@ -80,7 +86,7 @@ export class Text implements Component {
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
// Calculate visible length (strip ANSI codes)
|
// Calculate visible length (strip ANSI codes)
|
||||||
const visibleLength = stripVTControlCharacters(line).length;
|
const visibleLength = visibleWidth(line);
|
||||||
// Right padding to fill to width (accounting for left padding and content)
|
// Right padding to fill to width (accounting for left padding and content)
|
||||||
const rightPadLength = Math.max(0, width - this.paddingX - visibleLength);
|
const rightPadLength = Math.max(0, width - this.paddingX - visibleLength);
|
||||||
const rightPad = " ".repeat(rightPadLength);
|
const rightPad = " ".repeat(rightPadLength);
|
||||||
|
|
|
||||||
|
|
@ -17,4 +17,6 @@ export { Spacer } from "./components/spacer.js";
|
||||||
export { Text } from "./components/text.js";
|
export { Text } from "./components/text.js";
|
||||||
// Terminal interface and implementations
|
// Terminal interface and implementations
|
||||||
export { ProcessTerminal, type Terminal } from "./terminal.js";
|
export { ProcessTerminal, type Terminal } from "./terminal.js";
|
||||||
export { Component, Container, TUI } from "./tui.js";
|
export { type Component, Container, TUI } from "./tui.js";
|
||||||
|
// Utilities
|
||||||
|
export { visibleWidth } from "./utils.js";
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Terminal } from "./terminal.js";
|
import type { Terminal } from "./terminal.js";
|
||||||
|
import { visibleWidth } from "./utils.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component interface - all components must implement this
|
* Component interface - all components must implement this
|
||||||
|
|
@ -21,6 +22,8 @@ export interface Component {
|
||||||
handleInput?(data: string): void;
|
handleInput?(data: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { visibleWidth };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Container - a component that contains other components
|
* Container - a component that contains other components
|
||||||
*/
|
*/
|
||||||
|
|
@ -211,6 +214,9 @@ export class TUI extends Container {
|
||||||
// Render from first changed line to end
|
// Render from first changed line to end
|
||||||
for (let i = firstChanged; i < newLines.length; i++) {
|
for (let i = firstChanged; i < newLines.length; i++) {
|
||||||
if (i > firstChanged) buffer += "\r\n";
|
if (i > firstChanged) buffer += "\r\n";
|
||||||
|
if (visibleWidth(newLines[i]) > width) {
|
||||||
|
throw new Error("Rendered line exceeds terminal width");
|
||||||
|
}
|
||||||
buffer += newLines[i];
|
buffer += newLines[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
12
packages/tui/src/utils.ts
Normal file
12
packages/tui/src/utils.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import stringWidth from "string-width";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the visible width of a string in terminal columns.
|
||||||
|
* This correctly handles:
|
||||||
|
* - ANSI escape codes (ignored)
|
||||||
|
* - Emojis and wide characters (counted as 2 columns)
|
||||||
|
* - Combining characters (counted correctly)
|
||||||
|
*/
|
||||||
|
export function visibleWidth(str: string): number {
|
||||||
|
return stringWidth(str);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue