mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 11:02:17 +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_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": {
|
||||
"version": "4.10.1",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz",
|
||||
|
|
@ -5513,7 +5525,8 @@
|
|||
"@types/mime-types": "^2.1.4",
|
||||
"chalk": "^5.5.0",
|
||||
"marked": "^15.0.12",
|
||||
"mime-types": "^3.0.1"
|
||||
"mime-types": "^3.0.1",
|
||||
"string-width": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@xterm/headless": "^5.5.0",
|
||||
|
|
@ -5523,6 +5536,18 @@
|
|||
"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": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
|
|
@ -5545,6 +5570,37 @@
|
|||
"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": {
|
||||
"name": "@mariozechner/pi-web-ui",
|
||||
"version": "0.5.48",
|
||||
|
|
|
|||
|
|
@ -87,33 +87,32 @@ export class Agent {
|
|||
|
||||
subscribe(fn: (e: AgentEvent) => void): () => void {
|
||||
this.listeners.add(fn);
|
||||
fn({ type: "state-update", state: this._state });
|
||||
return () => this.listeners.delete(fn);
|
||||
}
|
||||
|
||||
// State mutators
|
||||
// State mutators - update internal state without emitting events
|
||||
setSystemPrompt(v: string) {
|
||||
this.patch({ systemPrompt: v });
|
||||
this._state.systemPrompt = v;
|
||||
}
|
||||
|
||||
setModel(m: typeof this._state.model) {
|
||||
this.patch({ model: m });
|
||||
this._state.model = m;
|
||||
}
|
||||
|
||||
setThinkingLevel(l: ThinkingLevel) {
|
||||
this.patch({ thinkingLevel: l });
|
||||
this._state.thinkingLevel = l;
|
||||
}
|
||||
|
||||
setTools(t: typeof this._state.tools) {
|
||||
this.patch({ tools: t });
|
||||
this._state.tools = t;
|
||||
}
|
||||
|
||||
replaceMessages(ms: AppMessage[]) {
|
||||
this.patch({ messages: ms.slice() });
|
||||
this._state.messages = ms.slice();
|
||||
}
|
||||
|
||||
appendMessage(m: AppMessage) {
|
||||
this.patch({ messages: [...this._state.messages, m] });
|
||||
this._state.messages = [...this._state.messages, m];
|
||||
}
|
||||
|
||||
async queueMessage(m: AppMessage) {
|
||||
|
|
@ -126,7 +125,7 @@ export class Agent {
|
|||
}
|
||||
|
||||
clearMessages() {
|
||||
this.patch({ messages: [] });
|
||||
this._state.messages = [];
|
||||
}
|
||||
|
||||
abort() {
|
||||
|
|
@ -163,8 +162,12 @@ export class Agent {
|
|||
};
|
||||
|
||||
this.abortController = new AbortController();
|
||||
this.patch({ isStreaming: true, streamMessage: null, error: undefined });
|
||||
this.emit({ type: "started" });
|
||||
this._state.isStreaming = true;
|
||||
this._state.streamMessage = null;
|
||||
this._state.error = undefined;
|
||||
|
||||
// Emit agent_start
|
||||
this.emit({ type: "agent_start" });
|
||||
|
||||
const reasoning =
|
||||
this._state.thinkingLevel === "off"
|
||||
|
|
@ -186,6 +189,9 @@ export class Agent {
|
|||
},
|
||||
};
|
||||
|
||||
// Track all messages generated in this prompt
|
||||
const generatedMessages: AppMessage[] = [];
|
||||
|
||||
try {
|
||||
let partial: Message | null = null;
|
||||
|
||||
|
|
@ -198,38 +204,51 @@ export class Agent {
|
|||
cfg,
|
||||
this.abortController.signal,
|
||||
)) {
|
||||
// Pass through all events directly
|
||||
this.emit(ev as AgentEvent);
|
||||
|
||||
// Update internal state as needed
|
||||
switch (ev.type) {
|
||||
case "message_start":
|
||||
case "message_update": {
|
||||
case "message_start": {
|
||||
// Track streaming 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;
|
||||
}
|
||||
case "message_end": {
|
||||
// Add completed message to state
|
||||
partial = null;
|
||||
this._state.streamMessage = null;
|
||||
this.appendMessage(ev.message as AppMessage);
|
||||
this.patch({ streamMessage: null });
|
||||
generatedMessages.push(ev.message as AppMessage);
|
||||
break;
|
||||
}
|
||||
case "tool_execution_start": {
|
||||
const s = new Set(this._state.pendingToolCalls);
|
||||
s.add(ev.toolCallId);
|
||||
this.patch({ pendingToolCalls: s });
|
||||
this._state.pendingToolCalls = s;
|
||||
break;
|
||||
}
|
||||
case "tool_execution_end": {
|
||||
const s = new Set(this._state.pendingToolCalls);
|
||||
s.delete(ev.toolCallId);
|
||||
this.patch({ pendingToolCalls: s });
|
||||
this._state.pendingToolCalls = s;
|
||||
break;
|
||||
}
|
||||
case "agent_end": {
|
||||
this.patch({ streamMessage: null });
|
||||
this._state.streamMessage = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle any remaining partial message
|
||||
if (partial && partial.role === "assistant" && partial.content.length > 0) {
|
||||
const onlyEmpty = !partial.content.some(
|
||||
(c) =>
|
||||
|
|
@ -239,6 +258,7 @@ export class Agent {
|
|||
);
|
||||
if (!onlyEmpty) {
|
||||
this.appendMessage(partial as AppMessage);
|
||||
generatedMessages.push(partial as AppMessage);
|
||||
} else {
|
||||
if (this.abortController?.signal.aborted) {
|
||||
throw new Error("Request was aborted");
|
||||
|
|
@ -264,17 +284,17 @@ export class Agent {
|
|||
timestamp: Date.now(),
|
||||
};
|
||||
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 {
|
||||
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.emit({ type: "completed" });
|
||||
}
|
||||
}
|
||||
|
||||
private patch(p: Partial<AgentState>): void {
|
||||
this._state = { ...this._state, ...p };
|
||||
this.emit({ type: "state-update", state: this._state });
|
||||
// Emit agent_end with all generated messages
|
||||
this.emit({ type: "agent_end", messages: generatedMessages });
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
|
|
@ -71,5 +78,20 @@ export interface AgentState {
|
|||
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
|
||||
it("should subscribe to state updates", () => {
|
||||
it("should subscribe to events", () => {
|
||||
const agent = new Agent({
|
||||
transport: new ProviderTransport(),
|
||||
});
|
||||
|
||||
let updateCount = 0;
|
||||
const unsubscribe = agent.subscribe((event) => {
|
||||
if (event.type === "state-update") {
|
||||
updateCount++;
|
||||
}
|
||||
let eventCount = 0;
|
||||
const unsubscribe = agent.subscribe((_event) => {
|
||||
eventCount++;
|
||||
});
|
||||
|
||||
// Initial state update on subscribe
|
||||
expect(updateCount).toBe(1);
|
||||
// No initial event on subscribe
|
||||
expect(eventCount).toBe(0);
|
||||
|
||||
// Update state
|
||||
// State mutators don't emit events
|
||||
agent.setSystemPrompt("Test prompt");
|
||||
expect(updateCount).toBe(2);
|
||||
expect(eventCount).toBe(0);
|
||||
expect(agent.state.systemPrompt).toBe("Test prompt");
|
||||
|
||||
// Unsubscribe should work
|
||||
unsubscribe();
|
||||
agent.setSystemPrompt("Another prompt");
|
||||
expect(updateCount).toBe(2); // Should not increase
|
||||
expect(eventCount).toBe(0); // Should not increase
|
||||
});
|
||||
|
||||
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) => {
|
||||
if (event.type === "state-update") {
|
||||
stateSnapshots.push({
|
||||
isStreaming: event.state.isStreaming,
|
||||
messageCount: event.state.messages.length,
|
||||
hasStreamMessage: event.state.streamMessage !== null,
|
||||
});
|
||||
}
|
||||
events.push(event.type);
|
||||
});
|
||||
|
||||
await agent.prompt("Count from 1 to 5.");
|
||||
|
||||
const streamingStates = stateSnapshots.filter((s) => s.isStreaming);
|
||||
const nonStreamingStates = stateSnapshots.filter((s) => !s.isStreaming);
|
||||
// Should have received lifecycle events
|
||||
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);
|
||||
expect(nonStreamingStates.length).toBeGreaterThan(0);
|
||||
|
||||
const finalState = stateSnapshots[stateSnapshots.length - 1];
|
||||
expect(finalState.isStreaming).toBe(false);
|
||||
expect(finalState.messageCount).toBe(2);
|
||||
// Check final state
|
||||
expect(agent.state.isStreaming).toBe(false);
|
||||
expect(agent.state.messages.length).toBe(2); // User message + assistant response
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
// Subscribe to agent state updates
|
||||
// Subscribe to agent events
|
||||
agent.subscribe(async (event) => {
|
||||
if (event.type === "state-update") {
|
||||
await renderer.handleStateUpdate(event.state);
|
||||
}
|
||||
// Pass all events to the renderer
|
||||
await renderer.handleEvent(event, agent.state);
|
||||
});
|
||||
|
||||
// Interactive loop
|
||||
|
|
@ -168,8 +167,8 @@ export async function main(args: string[]) {
|
|||
const sessionManager = new SessionManager(parsed.continue);
|
||||
|
||||
// Determine provider and model
|
||||
const provider = (parsed.provider || "google") as any;
|
||||
const modelId = parsed.model || "gemini-2.5-flash";
|
||||
const provider = (parsed.provider || "anthropic") as any;
|
||||
const modelId = parsed.model || "claude-sonnet-4-5";
|
||||
|
||||
// Get API key
|
||||
let apiKey = parsed.apiKey;
|
||||
|
|
@ -177,7 +176,7 @@ export async function main(args: string[]) {
|
|||
const envVarMap: Record<string, string> = {
|
||||
google: "GEMINI_API_KEY",
|
||||
openai: "OPENAI_API_KEY",
|
||||
anthropic: "ANTHROPIC_API_KEY",
|
||||
anthropic: "ANTHROPIC_OAUTH_TOKEN",
|
||||
xai: "XAI_API_KEY",
|
||||
groq: "GROQ_API_KEY",
|
||||
cerebras: "CEREBRAS_API_KEY",
|
||||
|
|
@ -221,20 +220,14 @@ export async function main(args: string[]) {
|
|||
// Start session
|
||||
sessionManager.startSession(agent.state);
|
||||
|
||||
// Subscribe to state updates to save messages
|
||||
// Subscribe to agent events to save messages and log events
|
||||
agent.subscribe((event) => {
|
||||
if (event.type === "state-update") {
|
||||
// Save any new messages
|
||||
const currentMessages = event.state.messages;
|
||||
const loadedMessages = sessionManager.loadMessages();
|
||||
|
||||
if (currentMessages.length > loadedMessages.length) {
|
||||
for (let i = loadedMessages.length; i < currentMessages.length; i++) {
|
||||
sessionManager.saveMessage(currentMessages[i]);
|
||||
}
|
||||
}
|
||||
// Save messages on completion
|
||||
if (event.type === "message_end") {
|
||||
sessionManager.saveMessage(event.message);
|
||||
}
|
||||
|
||||
// Log all events
|
||||
sessionManager.saveEvent(event);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ export class SessionManager {
|
|||
const cwd = process.cwd();
|
||||
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);
|
||||
if (!existsSync(sessionDir)) {
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import {
|
|||
Loader,
|
||||
Markdown,
|
||||
ProcessTerminal,
|
||||
Spacer,
|
||||
Text,
|
||||
TUI,
|
||||
} from "@mariozechner/pi-tui";
|
||||
|
|
@ -42,14 +41,17 @@ class CustomEditor extends Editor {
|
|||
* Component that renders a streaming message with live updates
|
||||
*/
|
||||
class StreamingMessageComponent extends Container {
|
||||
private textComponent: Markdown | null = null;
|
||||
private toolCallsContainer: Container | null = null;
|
||||
private currentContent = "";
|
||||
private currentToolCalls: any[] = [];
|
||||
private markdown: Markdown;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.markdown = new Markdown("");
|
||||
this.addChild(this.markdown);
|
||||
}
|
||||
|
||||
updateContent(message: Message | null) {
|
||||
if (!message) {
|
||||
this.clear();
|
||||
this.markdown.setText("");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -61,35 +63,95 @@ class StreamingMessageComponent extends Container {
|
|||
.filter((c) => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("");
|
||||
if (textContent !== this.currentContent) {
|
||||
this.currentContent = textContent;
|
||||
if (this.textComponent) {
|
||||
this.removeChild(this.textComponent);
|
||||
|
||||
this.markdown.setText(textContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
this.textComponent = new Markdown(textContent);
|
||||
this.addChild(this.textComponent);
|
||||
text += "\n```";
|
||||
|
||||
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
|
||||
const toolCalls = assistantMsg.content.filter((c) => c.type === "toolCall");
|
||||
if (JSON.stringify(toolCalls) !== JSON.stringify(this.currentToolCalls)) {
|
||||
this.currentToolCalls = toolCalls;
|
||||
if (this.toolCallsContainer) {
|
||||
this.removeChild(this.toolCallsContainer);
|
||||
text += "\n```\n" + displayLines.join("\n");
|
||||
if (remaining > 0) {
|
||||
text += `\n... (${remaining} more lines)`;
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
this.toolCallsContainer = new Container();
|
||||
for (const toolCall of toolCalls) {
|
||||
const argsStr =
|
||||
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);
|
||||
text += "\n```";
|
||||
|
||||
if (result.isError) {
|
||||
text += " ❌";
|
||||
}
|
||||
}
|
||||
} 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 lastSigintTime = 0;
|
||||
|
||||
// Message tracking
|
||||
private lastStableMessageCount = 0;
|
||||
// Streaming message tracking
|
||||
private streamingComponent: StreamingMessageComponent | null = null;
|
||||
|
||||
// Tool execution tracking: toolCallId -> { component, toolName, args }
|
||||
private pendingTools = new Map<string, { component: ToolExecutionComponent; toolName: string; args: any }>();
|
||||
|
||||
constructor() {
|
||||
this.ui = new TUI(new ProcessTerminal());
|
||||
this.chatContainer = new Container();
|
||||
|
|
@ -127,20 +191,16 @@ export class TuiRenderer {
|
|||
|
||||
// Add header with instructions
|
||||
const header = new Text(
|
||||
chalk.blueBright(">> coding-agent interactive <<") +
|
||||
"\n" +
|
||||
chalk.dim("Press Escape to interrupt while processing") +
|
||||
"\n" +
|
||||
chalk.dim("Press CTRL+C to clear the text editor") +
|
||||
"\n" +
|
||||
chalk.dim("Press CTRL+C twice quickly to exit"),
|
||||
">> coding-agent interactive <<\n" +
|
||||
"Press Escape to interrupt while processing\n" +
|
||||
"Press CTRL+C to clear the text editor\n" +
|
||||
"Press CTRL+C twice quickly to exit\n",
|
||||
);
|
||||
|
||||
// Setup UI layout
|
||||
this.ui.addChild(header);
|
||||
this.ui.addChild(this.chatContainer);
|
||||
this.ui.addChild(this.statusContainer);
|
||||
this.ui.addChild(new Spacer(1));
|
||||
this.ui.addChild(this.editor);
|
||||
this.ui.setFocus(this.editor);
|
||||
|
||||
|
|
@ -183,108 +243,146 @@ export class TuiRenderer {
|
|||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
async handleStateUpdate(state: AgentState): Promise<void> {
|
||||
async handleEvent(event: import("@mariozechner/pi-agent").AgentEvent, _state: AgentState): Promise<void> {
|
||||
if (!this.isInitialized) {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
// Count stable messages (exclude the streaming one if streaming)
|
||||
const stableMessageCount = state.isStreaming ? state.messages.length - 1 : state.messages.length;
|
||||
|
||||
// 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) {
|
||||
switch (event.type) {
|
||||
case "agent_start":
|
||||
// Show loading animation
|
||||
this.editor.disableSubmit = true;
|
||||
// Stop old loader before clearing
|
||||
if (this.loadingAnimation) {
|
||||
this.loadingAnimation.stop();
|
||||
}
|
||||
this.statusContainer.clear();
|
||||
this.loadingAnimation = new Loader(this.ui);
|
||||
this.loadingAnimation = new Loader(this.ui, "Working...");
|
||||
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
|
||||
if (!this.streamingComponent) {
|
||||
this.streamingComponent = new StreamingMessageComponent();
|
||||
this.chatContainer.addChild(this.streamingComponent);
|
||||
}
|
||||
this.streamingComponent.updateContent(streamingMessage);
|
||||
} else {
|
||||
// Streaming stopped
|
||||
if (this.loadingAnimation) {
|
||||
this.loadingAnimation.stop();
|
||||
this.loadingAnimation = null;
|
||||
this.statusContainer.clear();
|
||||
case "tool_execution_end": {
|
||||
// Update the existing tool component with the result
|
||||
const pending = this.pendingTools.get(event.toolCallId);
|
||||
if (pending) {
|
||||
// Re-render the component with result
|
||||
this.chatContainer.removeChild(pending.component);
|
||||
const updatedComponent = new ToolExecutionComponent(pending.toolName, pending.args, {
|
||||
output: typeof event.result === "string" ? event.result : event.result.output,
|
||||
isError: event.isError,
|
||||
});
|
||||
this.chatContainer.addChild(updatedComponent);
|
||||
this.pendingTools.delete(event.toolCallId);
|
||||
this.ui.requestRender();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (this.streamingComponent) {
|
||||
this.chatContainer.removeChild(this.streamingComponent);
|
||||
this.streamingComponent = null;
|
||||
}
|
||||
|
||||
this.editor.disableSubmit = false;
|
||||
case "agent_end":
|
||||
// Stop loading animation
|
||||
if (this.loadingAnimation) {
|
||||
this.loadingAnimation.stop();
|
||||
this.loadingAnimation = null;
|
||||
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 {
|
||||
if (message.role === "user") {
|
||||
this.chatContainer.addChild(new Text(chalk.green("[user]")));
|
||||
const userMsg = message as any;
|
||||
const textContent = userMsg.content?.map((c: any) => c.text || "").join("") || message.content || "";
|
||||
this.chatContainer.addChild(new Text(textContent));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
// Extract text content from content blocks
|
||||
const textBlocks = userMsg.content.filter((c: any) => c.type === "text");
|
||||
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") {
|
||||
this.chatContainer.addChild(new Text(chalk.hex("#FFA500")("[assistant]")));
|
||||
const assistantMsg = message as AssistantMessage;
|
||||
|
||||
// Render text content
|
||||
// Render text content first (tool calls handled by events)
|
||||
const textContent = assistantMsg.content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("");
|
||||
if (textContent) {
|
||||
// Assistant messages with no background
|
||||
this.chatContainer.addChild(new Markdown(textContent));
|
||||
}
|
||||
|
||||
// Render tool calls
|
||||
const toolCalls = assistantMsg.content.filter((c) => c.type === "toolCall");
|
||||
for (const toolCall of toolCalls) {
|
||||
const argsStr =
|
||||
typeof toolCall.arguments === "string" ? toolCall.arguments : JSON.stringify(toolCall.arguments);
|
||||
this.chatContainer.addChild(new Text(chalk.yellow(`[tool] ${toolCall.name}(${argsStr})`)));
|
||||
// Check if aborted - show after partial content
|
||||
if (assistantMsg.stopReason === "aborted") {
|
||||
// Show red "Aborted" message after partial content
|
||||
const abortedText = new Text(chalk.red("Aborted"));
|
||||
this.chatContainer.addChild(abortedText);
|
||||
return;
|
||||
}
|
||||
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
} else if (message.role === "toolResult") {
|
||||
const toolResultMsg = message as any;
|
||||
const output = toolResultMsg.result?.output || toolResultMsg.result || "";
|
||||
|
||||
// Truncate long outputs
|
||||
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 (assistantMsg.stopReason === "error") {
|
||||
// Show red error message after partial content
|
||||
const errorMsg = assistantMsg.errorMessage || "Unknown error";
|
||||
const errorText = new Text(chalk.red(`Error: ${errorMsg}`));
|
||||
this.chatContainer.addChild(errorText);
|
||||
return;
|
||||
}
|
||||
|
||||
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> {
|
||||
|
|
@ -303,7 +401,7 @@ export class TuiRenderer {
|
|||
clearEditor(): void {
|
||||
this.editor.setText("");
|
||||
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.ui.requestRender();
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@
|
|||
"@types/mime-types": "^2.1.4",
|
||||
"chalk": "^5.5.0",
|
||||
"marked": "^15.0.12",
|
||||
"mime-types": "^3.0.1"
|
||||
"mime-types": "^3.0.1",
|
||||
"string-width": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@xterm/headless": "^5.5.0",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { stripVTControlCharacters } from "node:util";
|
||||
import type { Component } from "../tui.js";
|
||||
import { visibleWidth } from "../utils.js";
|
||||
|
||||
/**
|
||||
* 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 textWithCursor = beforeCursor + cursorChar + afterCursor;
|
||||
|
||||
// Calculate visual width (strip ANSI codes to measure actual displayed characters)
|
||||
const visualLength = stripVTControlCharacters(textWithCursor).length;
|
||||
// Calculate visual width
|
||||
const visualLength = visibleWidth(textWithCursor);
|
||||
const padding = " ".repeat(Math.max(0, availableWidth - visualLength));
|
||||
const line = prompt + textWithCursor + padding;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { stripVTControlCharacters } from "node:util";
|
||||
import chalk from "chalk";
|
||||
import { marked, type Token } from "marked";
|
||||
import type { Component } from "../tui.js";
|
||||
import { visibleWidth } from "../utils.js";
|
||||
|
||||
type Color =
|
||||
| "black"
|
||||
|
|
@ -109,8 +109,8 @@ export class Markdown implements Component {
|
|||
const paddedLines: string[] = [];
|
||||
|
||||
for (const line of wrappedLines) {
|
||||
// Calculate visible length (strip ANSI codes)
|
||||
const visibleLength = stripVTControlCharacters(line).length;
|
||||
// Calculate visible length
|
||||
const visibleLength = visibleWidth(line);
|
||||
// Right padding to fill to width (accounting for left padding and content)
|
||||
const rightPadLength = Math.max(0, width - this.paddingX - visibleLength);
|
||||
const rightPad = " ".repeat(rightPadLength);
|
||||
|
|
@ -328,12 +328,26 @@ export class Markdown implements Component {
|
|||
return [""];
|
||||
}
|
||||
|
||||
// If line fits within width, return as-is
|
||||
const visibleLength = stripVTControlCharacters(line).length;
|
||||
if (visibleLength <= width) {
|
||||
return [line];
|
||||
// Split by newlines first - wrap each line individually
|
||||
const splitLines = line.split("\n");
|
||||
for (const splitLine of splitLines) {
|
||||
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
|
||||
const activeAnsiCodes: string[] = [];
|
||||
let currentLine = "";
|
||||
|
|
@ -381,8 +395,10 @@ export class Markdown implements Component {
|
|||
}
|
||||
currentLength = 0;
|
||||
}
|
||||
currentLine += line[i];
|
||||
currentLength++;
|
||||
const char = line[i];
|
||||
currentLine += char;
|
||||
// Count actual terminal column width, not string length
|
||||
currentLength += visibleWidth(char);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { stripVTControlCharacters } from "node:util";
|
||||
import type { Component } from "../tui.js";
|
||||
import { visibleWidth } from "../utils.js";
|
||||
|
||||
/**
|
||||
* Text component - displays multi-line text with word wrapping
|
||||
|
|
@ -50,7 +50,10 @@ export class Text implements Component {
|
|||
const textLines = this.text.split("\n");
|
||||
|
||||
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);
|
||||
} else {
|
||||
// Word wrap
|
||||
|
|
@ -58,9 +61,12 @@ export class Text implements Component {
|
|||
let currentLine = "";
|
||||
|
||||
for (const word of words) {
|
||||
if (currentLine.length === 0) {
|
||||
const currentVisible = visibleWidth(currentLine);
|
||||
const wordVisible = visibleWidth(word);
|
||||
|
||||
if (currentVisible === 0) {
|
||||
currentLine = word;
|
||||
} else if (currentLine.length + 1 + word.length <= contentWidth) {
|
||||
} else if (currentVisible + 1 + wordVisible <= contentWidth) {
|
||||
currentLine += " " + word;
|
||||
} else {
|
||||
lines.push(currentLine);
|
||||
|
|
@ -80,7 +86,7 @@ export class Text implements Component {
|
|||
|
||||
for (const line of lines) {
|
||||
// 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)
|
||||
const rightPadLength = Math.max(0, width - this.paddingX - visibleLength);
|
||||
const rightPad = " ".repeat(rightPadLength);
|
||||
|
|
|
|||
|
|
@ -17,4 +17,6 @@ export { Spacer } from "./components/spacer.js";
|
|||
export { Text } from "./components/text.js";
|
||||
// Terminal interface and implementations
|
||||
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 { visibleWidth } from "./utils.js";
|
||||
|
||||
/**
|
||||
* Component interface - all components must implement this
|
||||
|
|
@ -21,6 +22,8 @@ export interface Component {
|
|||
handleInput?(data: string): void;
|
||||
}
|
||||
|
||||
export { visibleWidth };
|
||||
|
||||
/**
|
||||
* Container - a component that contains other components
|
||||
*/
|
||||
|
|
@ -211,6 +214,9 @@ export class TUI extends Container {
|
|||
// Render from first changed line to end
|
||||
for (let i = firstChanged; i < newLines.length; i++) {
|
||||
if (i > firstChanged) buffer += "\r\n";
|
||||
if (visibleWidth(newLines[i]) > width) {
|
||||
throw new Error("Rendered line exceeds terminal width");
|
||||
}
|
||||
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