mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-22 01:02:16 +00:00
Merge branch 'bash-mode'
This commit is contained in:
commit
21800035cd
15 changed files with 1130 additions and 169 deletions
|
|
@ -14,6 +14,8 @@
|
||||||
|
|
||||||
- **Collapse changelog setting**: Add `"collapseChangelog": true` to `~/.pi/agent/settings.json` to show a condensed "Updated to vX.Y.Z" message instead of the full changelog after updates. Use `/changelog` to view the full changelog. ([#148](https://github.com/badlogic/pi-mono/issues/148))
|
- **Collapse changelog setting**: Add `"collapseChangelog": true` to `~/.pi/agent/settings.json` to show a condensed "Updated to vX.Y.Z" message instead of the full changelog after updates. Use `/changelog` to view the full changelog. ([#148](https://github.com/badlogic/pi-mono/issues/148))
|
||||||
|
|
||||||
|
- **Bash mode**: Execute shell commands directly from the editor by prefixing with `!` (e.g., `!ls -la`). Output streams in real-time, is added to the LLM context, and persists in session history. Supports multiline commands, cancellation (Escape), truncation for large outputs, and preview/expand toggle (Ctrl+O). Also available in RPC mode via `{"type":"bash","command":"..."}`. ([#112](https://github.com/badlogic/pi-mono/pull/112), original implementation by [@markusylisiurunen](https://github.com/markusylisiurunen))
|
||||||
|
|
||||||
## [0.13.2] - 2025-12-07
|
## [0.13.2] - 2025-12-07
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
||||||
|
|
@ -762,6 +762,27 @@ You can submit multiple messages while the agent is processing without waiting f
|
||||||
|
|
||||||
Change queue mode with `/queue` command. Setting is saved in `~/.pi/agent/settings.json`.
|
Change queue mode with `/queue` command. Setting is saved in `~/.pi/agent/settings.json`.
|
||||||
|
|
||||||
|
### Bash Mode (`!`)
|
||||||
|
|
||||||
|
Execute shell commands directly and add output to the LLM context by prefixing with `!`:
|
||||||
|
|
||||||
|
```
|
||||||
|
!ls -la
|
||||||
|
!git status
|
||||||
|
!cat package.json | jq '.dependencies'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- **Streaming output**: Command output streams in real-time as it executes
|
||||||
|
- **Multiline commands**: Write complex commands across multiple lines
|
||||||
|
- **Cancellation**: Press **Escape** to cancel a running command
|
||||||
|
- **Truncation**: Large outputs are truncated (2000 lines / 50KB) with full output saved to a temp file
|
||||||
|
- **Preview mode**: Shows last 20 lines by default; press **Ctrl+O** to expand
|
||||||
|
- **History**: Commands are added to editor history (navigate with Up/Down arrows)
|
||||||
|
- **Visual feedback**: Editor border turns green in bash mode; cancelled commands show yellow warning
|
||||||
|
|
||||||
|
Output is automatically added to the conversation context, allowing the LLM to see command results without manual copy-paste.
|
||||||
|
|
||||||
### Keyboard Shortcuts
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
**Navigation:**
|
**Navigation:**
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,19 @@ Compact the conversation context to reduce token usage:
|
||||||
|
|
||||||
The `customInstructions` field is optional and allows you to guide what the summary should focus on.
|
The `customInstructions` field is optional and allows you to guide what the summary should focus on.
|
||||||
|
|
||||||
|
#### Bash Message
|
||||||
|
|
||||||
|
Execute a shell command and add output to the LLM context (without triggering a prompt):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "bash",
|
||||||
|
"command": "ls -la"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
On success, emits a `bash_end` event with the `BashExecutionMessage`. The command output is automatically added to the conversation context, allowing subsequent prompts to reference it.
|
||||||
|
|
||||||
## Output Protocol
|
## Output Protocol
|
||||||
|
|
||||||
The agent emits JSON events to stdout, one per line. Events follow the `AgentEvent` type hierarchy.
|
The agent emits JSON events to stdout, one per line. Events follow the `AgentEvent` type hierarchy.
|
||||||
|
|
@ -72,6 +85,7 @@ The agent emits JSON events to stdout, one per line. Events follow the `AgentEve
|
||||||
| `tool_execution_start` | Tool execution begins |
|
| `tool_execution_start` | Tool execution begins |
|
||||||
| `tool_execution_end` | Tool execution completes |
|
| `tool_execution_end` | Tool execution completes |
|
||||||
| `compaction` | Context was compacted (manual or auto) |
|
| `compaction` | Context was compacted (manual or auto) |
|
||||||
|
| `bash_end` | User-initiated bash command completed |
|
||||||
| `error` | An error occurred |
|
| `error` | An error occurred |
|
||||||
|
|
||||||
### Event Schemas
|
### Event Schemas
|
||||||
|
|
@ -192,6 +206,28 @@ The `result` field contains either:
|
||||||
- An `AgentToolResult` object with `content` and `details` fields
|
- An `AgentToolResult` object with `content` and `details` fields
|
||||||
- A string error message if `isError` is true
|
- A string error message if `isError` is true
|
||||||
|
|
||||||
|
#### bash_end
|
||||||
|
|
||||||
|
Emitted when a user-initiated bash command (via `bash` input message) completes.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "bash_end",
|
||||||
|
"message": {
|
||||||
|
"role": "bashExecution",
|
||||||
|
"command": "ls -la",
|
||||||
|
"output": "total 48\ndrwxr-xr-x ...",
|
||||||
|
"exitCode": 0,
|
||||||
|
"cancelled": false,
|
||||||
|
"truncated": false,
|
||||||
|
"fullOutputPath": "/tmp/pi-bash-abc123.log", // Only present if output was truncated
|
||||||
|
"timestamp": 1733234567890
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `message` is a `BashExecutionMessage` that has been added to the conversation context. See [BashExecutionMessage](#bashexecutionmessage) for the full schema.
|
||||||
|
|
||||||
#### error
|
#### error
|
||||||
|
|
||||||
Emitted when an error occurs during input processing.
|
Emitted when an error occurs during input processing.
|
||||||
|
|
@ -307,6 +343,33 @@ type AppMessage =
|
||||||
| CustomMessages[keyof CustomMessages];
|
| CustomMessages[keyof CustomMessages];
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### BashExecutionMessage
|
||||||
|
|
||||||
|
Defined in [`packages/coding-agent/src/messages.ts`](../src/messages.ts)
|
||||||
|
|
||||||
|
Custom message type for user-executed bash commands (via `!` in TUI or `bash` RPC command):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface BashExecutionMessage {
|
||||||
|
role: "bashExecution";
|
||||||
|
command: string; // The command that was executed
|
||||||
|
output: string; // Command output (truncated if large)
|
||||||
|
exitCode: number | null; // Exit code, null if killed
|
||||||
|
cancelled: boolean; // True if user cancelled with Escape
|
||||||
|
truncated: boolean; // True if output was truncated
|
||||||
|
fullOutputPath?: string; // Path to temp file with full output (if truncated)
|
||||||
|
timestamp: number; // Unix timestamp in milliseconds
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When sent to the LLM, this message is transformed into a user message with the format:
|
||||||
|
```
|
||||||
|
Ran `<command>`
|
||||||
|
\`\`\`
|
||||||
|
<output>
|
||||||
|
\`\`\`
|
||||||
|
```
|
||||||
|
|
||||||
### Content Types
|
### Content Types
|
||||||
|
|
||||||
#### TextContent
|
#### TextContent
|
||||||
|
|
@ -456,7 +519,7 @@ function handleEvent(event: any) {
|
||||||
args: event.args
|
args: event.args
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "tool_execution_end") {
|
if (event.type === "tool_execution_end") {
|
||||||
const toolCall = pendingTools.get(event.toolCallId);
|
const toolCall = pendingTools.get(event.toolCallId);
|
||||||
if (toolCall) {
|
if (toolCall) {
|
||||||
|
|
@ -467,7 +530,7 @@ function handleEvent(event: any) {
|
||||||
result: event.result,
|
result: event.result,
|
||||||
isError: event.isError
|
isError: event.isError
|
||||||
};
|
};
|
||||||
|
|
||||||
// Format for display
|
// Format for display
|
||||||
displayToolExecution(merged);
|
displayToolExecution(merged);
|
||||||
pendingTools.delete(event.toolCallId);
|
pendingTools.delete(event.toolCallId);
|
||||||
|
|
@ -497,16 +560,16 @@ function displayToolExecution(tool: {
|
||||||
switch (tool.name) {
|
switch (tool.name) {
|
||||||
case "bash":
|
case "bash":
|
||||||
return `$ ${tool.args.command}\n${resultText}`;
|
return `$ ${tool.args.command}\n${resultText}`;
|
||||||
|
|
||||||
case "read":
|
case "read":
|
||||||
return `📄 ${tool.args.path}\n${resultText.slice(0, 500)}...`;
|
return `📄 ${tool.args.path}\n${resultText.slice(0, 500)}...`;
|
||||||
|
|
||||||
case "write":
|
case "write":
|
||||||
return `✏️ Wrote ${tool.args.path}`;
|
return `✏️ Wrote ${tool.args.path}`;
|
||||||
|
|
||||||
case "edit":
|
case "edit":
|
||||||
return `✏️ Edited ${tool.args.path}`;
|
return `✏️ Edited ${tool.args.path}`;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return `🔧 ${tool.name}: ${resultText.slice(0, 200)}`;
|
return `🔧 ${tool.name}: ${resultText.slice(0, 200)}`;
|
||||||
}
|
}
|
||||||
|
|
@ -520,10 +583,10 @@ The `turn_end` event provides the assistant message and all tool results togethe
|
||||||
```typescript
|
```typescript
|
||||||
if (event.type === "turn_end") {
|
if (event.type === "turn_end") {
|
||||||
const { message, toolResults } = event;
|
const { message, toolResults } = event;
|
||||||
|
|
||||||
// Extract tool calls from assistant message
|
// Extract tool calls from assistant message
|
||||||
const toolCalls = message.content.filter(c => c.type === "toolCall");
|
const toolCalls = message.content.filter(c => c.type === "toolCall");
|
||||||
|
|
||||||
// Match each tool call with its result by toolCallId
|
// Match each tool call with its result by toolCallId
|
||||||
for (const call of toolCalls) {
|
for (const call of toolCalls) {
|
||||||
const result = toolResults.find(r => r.toolCallId === call.id);
|
const result = toolResults.find(r => r.toolCallId === call.id);
|
||||||
|
|
@ -586,14 +649,14 @@ const agent = spawn("pi", ["--mode", "rpc", "--no-session"]);
|
||||||
// Parse output events
|
// Parse output events
|
||||||
readline.createInterface({ input: agent.stdout }).on("line", (line) => {
|
readline.createInterface({ input: agent.stdout }).on("line", (line) => {
|
||||||
const event = JSON.parse(line);
|
const event = JSON.parse(line);
|
||||||
|
|
||||||
if (event.type === "message_update") {
|
if (event.type === "message_update") {
|
||||||
const { assistantMessageEvent } = event;
|
const { assistantMessageEvent } = event;
|
||||||
if (assistantMessageEvent.type === "text_delta") {
|
if (assistantMessageEvent.type === "text_delta") {
|
||||||
process.stdout.write(assistantMessageEvent.delta);
|
process.stdout.write(assistantMessageEvent.delta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "tool_execution_start") {
|
if (event.type === "tool_execution_start") {
|
||||||
console.log(`\n[Tool: ${event.toolName}]`);
|
console.log(`\n[Tool: ${event.toolName}]`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,11 @@ A message in the conversation. The `message` field contains an `AppMessage` (see
|
||||||
{"type":"message","timestamp":"2024-12-03T14:00:01.000Z","message":{"role":"user","content":"Hello","timestamp":1733234567890}}
|
{"type":"message","timestamp":"2024-12-03T14:00:01.000Z","message":{"role":"user","content":"Hello","timestamp":1733234567890}}
|
||||||
{"type":"message","timestamp":"2024-12-03T14:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Hi!"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{...},"stopReason":"stop","timestamp":1733234567891}}
|
{"type":"message","timestamp":"2024-12-03T14:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Hi!"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{...},"stopReason":"stop","timestamp":1733234567891}}
|
||||||
{"type":"message","timestamp":"2024-12-03T14:00:03.000Z","message":{"role":"toolResult","toolCallId":"call_123","toolName":"bash","content":[{"type":"text","text":"output"}],"isError":false,"timestamp":1733234567900}}
|
{"type":"message","timestamp":"2024-12-03T14:00:03.000Z","message":{"role":"toolResult","toolCallId":"call_123","toolName":"bash","content":[{"type":"text","text":"output"}],"isError":false,"timestamp":1733234567900}}
|
||||||
|
{"type":"message","timestamp":"2024-12-03T14:00:04.000Z","message":{"role":"bashExecution","command":"ls -la","output":"total 48\n...","exitCode":0,"cancelled":false,"truncated":false,"timestamp":1733234567950}}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The `bashExecution` role is a custom message type for user-executed bash commands (via `!` in TUI or `bash` RPC command). See [rpc.md](./rpc.md#bashexecutionmessage) for the full schema.
|
||||||
|
|
||||||
### ModelChangeEntry
|
### ModelChangeEntry
|
||||||
|
|
||||||
Emitted when the user switches models mid-session.
|
Emitted when the user switches models mid-session.
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
import type { AppMessage } from "@mariozechner/pi-agent-core";
|
import type { AppMessage } from "@mariozechner/pi-agent-core";
|
||||||
import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai";
|
import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai";
|
||||||
import { complete } from "@mariozechner/pi-ai";
|
import { complete } from "@mariozechner/pi-ai";
|
||||||
|
import { messageTransformer } from "./messages.js";
|
||||||
import type { CompactionEntry, SessionEntry } from "./session-manager.js";
|
import type { CompactionEntry, SessionEntry } from "./session-manager.js";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -184,11 +185,14 @@ export async function generateSummary(
|
||||||
? `${SUMMARIZATION_PROMPT}\n\nAdditional focus: ${customInstructions}`
|
? `${SUMMARIZATION_PROMPT}\n\nAdditional focus: ${customInstructions}`
|
||||||
: SUMMARIZATION_PROMPT;
|
: SUMMARIZATION_PROMPT;
|
||||||
|
|
||||||
|
// Transform custom messages (like bashExecution) to LLM-compatible messages
|
||||||
|
const transformedMessages = messageTransformer(currentMessages);
|
||||||
|
|
||||||
const summarizationMessages = [
|
const summarizationMessages = [
|
||||||
...currentMessages,
|
...transformedMessages,
|
||||||
{
|
{
|
||||||
role: "user" as const,
|
role: "user" as const,
|
||||||
content: prompt,
|
content: [{ type: "text" as const, text: prompt }],
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||||
import { homedir } from "os";
|
import { homedir } from "os";
|
||||||
import { basename } from "path";
|
import { basename } from "path";
|
||||||
import { APP_NAME, VERSION } from "./config.js";
|
import { APP_NAME, VERSION } from "./config.js";
|
||||||
|
import { type BashExecutionMessage, isBashExecutionMessage } from "./messages.js";
|
||||||
import type { SessionManager } from "./session-manager.js";
|
import type { SessionManager } from "./session-manager.js";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -56,6 +57,8 @@ const COLORS = {
|
||||||
toolPendingBg: "rgb(40, 40, 50)",
|
toolPendingBg: "rgb(40, 40, 50)",
|
||||||
toolSuccessBg: "rgb(40, 50, 40)",
|
toolSuccessBg: "rgb(40, 50, 40)",
|
||||||
toolErrorBg: "rgb(60, 40, 40)",
|
toolErrorBg: "rgb(60, 40, 40)",
|
||||||
|
userBashBg: "rgb(50, 48, 35)", // Faint yellow/brown for user-executed bash
|
||||||
|
userBashErrorBg: "rgb(60, 45, 35)", // Slightly more orange for errors
|
||||||
bodyBg: "rgb(24, 24, 30)",
|
bodyBg: "rgb(24, 24, 30)",
|
||||||
containerBg: "rgb(30, 30, 36)",
|
containerBg: "rgb(30, 30, 36)",
|
||||||
text: "rgb(229, 229, 231)",
|
text: "rgb(229, 229, 231)",
|
||||||
|
|
@ -94,6 +97,34 @@ function formatTimestamp(timestamp: number | string | undefined): string {
|
||||||
return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatExpandableOutput(lines: string[], maxLines: number): string {
|
||||||
|
const displayLines = lines.slice(0, maxLines);
|
||||||
|
const remaining = lines.length - maxLines;
|
||||||
|
|
||||||
|
if (remaining > 0) {
|
||||||
|
let out = '<div class="tool-output expandable" onclick="this.classList.toggle(\'expanded\')">';
|
||||||
|
out += '<div class="output-preview">';
|
||||||
|
for (const line of displayLines) {
|
||||||
|
out += `<div>${escapeHtml(replaceTabs(line))}</div>`;
|
||||||
|
}
|
||||||
|
out += `<div class="expand-hint">... (${remaining} more lines) - click to expand</div>`;
|
||||||
|
out += "</div>";
|
||||||
|
out += '<div class="output-full">';
|
||||||
|
for (const line of lines) {
|
||||||
|
out += `<div>${escapeHtml(replaceTabs(line))}</div>`;
|
||||||
|
}
|
||||||
|
out += "</div></div>";
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = '<div class="tool-output">';
|
||||||
|
for (const line of displayLines) {
|
||||||
|
out += `<div>${escapeHtml(replaceTabs(line))}</div>`;
|
||||||
|
}
|
||||||
|
out += "</div>";
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Parsing functions
|
// Parsing functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -304,34 +335,6 @@ function formatToolExecution(
|
||||||
return textBlocks.map((c) => (c as { type: "text"; text: string }).text).join("\n");
|
return textBlocks.map((c) => (c as { type: "text"; text: string }).text).join("\n");
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatExpandableOutput = (lines: string[], maxLines: number): string => {
|
|
||||||
const displayLines = lines.slice(0, maxLines);
|
|
||||||
const remaining = lines.length - maxLines;
|
|
||||||
|
|
||||||
if (remaining > 0) {
|
|
||||||
let out = '<div class="tool-output expandable" onclick="this.classList.toggle(\'expanded\')">';
|
|
||||||
out += '<div class="output-preview">';
|
|
||||||
for (const line of displayLines) {
|
|
||||||
out += `<div>${escapeHtml(replaceTabs(line))}</div>`;
|
|
||||||
}
|
|
||||||
out += `<div class="expand-hint">... (${remaining} more lines) - click to expand</div>`;
|
|
||||||
out += "</div>";
|
|
||||||
out += '<div class="output-full">';
|
|
||||||
for (const line of lines) {
|
|
||||||
out += `<div>${escapeHtml(replaceTabs(line))}</div>`;
|
|
||||||
}
|
|
||||||
out += "</div></div>";
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
let out = '<div class="tool-output">';
|
|
||||||
for (const line of displayLines) {
|
|
||||||
out += `<div>${escapeHtml(replaceTabs(line))}</div>`;
|
|
||||||
}
|
|
||||||
out += "</div>";
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (toolName) {
|
switch (toolName) {
|
||||||
case "bash": {
|
case "bash": {
|
||||||
const command = (args?.command as string) || "";
|
const command = (args?.command as string) || "";
|
||||||
|
|
@ -427,6 +430,35 @@ function formatMessage(message: Message, toolResultsMap: Map<string, ToolResultM
|
||||||
const timestamp = (message as { timestamp?: number }).timestamp;
|
const timestamp = (message as { timestamp?: number }).timestamp;
|
||||||
const timestampHtml = timestamp ? `<div class="message-timestamp">${formatTimestamp(timestamp)}</div>` : "";
|
const timestampHtml = timestamp ? `<div class="message-timestamp">${formatTimestamp(timestamp)}</div>` : "";
|
||||||
|
|
||||||
|
// Handle bash execution messages (user-executed via ! command)
|
||||||
|
if (isBashExecutionMessage(message)) {
|
||||||
|
const bashMsg = message as unknown as BashExecutionMessage;
|
||||||
|
const isError = bashMsg.cancelled || (bashMsg.exitCode !== 0 && bashMsg.exitCode !== null);
|
||||||
|
const bgColor = isError ? COLORS.userBashErrorBg : COLORS.userBashBg;
|
||||||
|
|
||||||
|
html += `<div class="tool-execution" style="background-color: ${bgColor}">`;
|
||||||
|
html += timestampHtml;
|
||||||
|
html += `<div class="tool-command">$ ${escapeHtml(bashMsg.command)}</div>`;
|
||||||
|
|
||||||
|
if (bashMsg.output) {
|
||||||
|
const lines = bashMsg.output.split("\n");
|
||||||
|
html += formatExpandableOutput(lines, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bashMsg.cancelled) {
|
||||||
|
html += `<div class="bash-status" style="color: ${COLORS.yellow}">(cancelled)</div>`;
|
||||||
|
} else if (bashMsg.exitCode !== 0 && bashMsg.exitCode !== null) {
|
||||||
|
html += `<div class="bash-status" style="color: ${COLORS.red}">(exit ${bashMsg.exitCode})</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bashMsg.truncated && bashMsg.fullOutputPath) {
|
||||||
|
html += `<div class="bash-truncation" style="color: ${COLORS.yellow}">Output truncated. Full output: ${escapeHtml(bashMsg.fullOutputPath)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `</div>`;
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
if (message.role === "user") {
|
if (message.role === "user") {
|
||||||
const userMsg = message as UserMessage;
|
const userMsg = message as UserMessage;
|
||||||
let textContent = "";
|
let textContent = "";
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,12 @@ import { Agent, type Attachment, ProviderTransport, type ThinkingLevel } from "@
|
||||||
import type { Api, AssistantMessage, KnownProvider, Model } from "@mariozechner/pi-ai";
|
import type { Api, AssistantMessage, KnownProvider, Model } from "@mariozechner/pi-ai";
|
||||||
import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
|
import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { existsSync, readFileSync, statSync } from "fs";
|
import { spawn } from "child_process";
|
||||||
import { homedir } from "os";
|
import { randomBytes } from "crypto";
|
||||||
|
import { createWriteStream, existsSync, readFileSync, statSync } from "fs";
|
||||||
|
import { homedir, tmpdir } from "os";
|
||||||
import { extname, join, resolve } from "path";
|
import { extname, join, resolve } from "path";
|
||||||
|
import stripAnsi from "strip-ansi";
|
||||||
import { getChangelogPath, getNewEntries, parseChangelog } from "./changelog.js";
|
import { getChangelogPath, getNewEntries, parseChangelog } from "./changelog.js";
|
||||||
import { calculateContextTokens, compact, shouldCompact } from "./compaction.js";
|
import { calculateContextTokens, compact, shouldCompact } from "./compaction.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -17,12 +20,15 @@ import {
|
||||||
VERSION,
|
VERSION,
|
||||||
} from "./config.js";
|
} from "./config.js";
|
||||||
import { exportFromFile } from "./export-html.js";
|
import { exportFromFile } from "./export-html.js";
|
||||||
|
import { type BashExecutionMessage, messageTransformer } from "./messages.js";
|
||||||
import { findModel, getApiKeyForModel, getAvailableModels } from "./model-config.js";
|
import { findModel, getApiKeyForModel, getAvailableModels } from "./model-config.js";
|
||||||
import { loadSessionFromEntries, SessionManager } from "./session-manager.js";
|
import { loadSessionFromEntries, SessionManager } from "./session-manager.js";
|
||||||
import { SettingsManager } from "./settings-manager.js";
|
import { SettingsManager } from "./settings-manager.js";
|
||||||
|
import { getShellConfig } from "./shell.js";
|
||||||
import { expandSlashCommand, loadSlashCommands } from "./slash-commands.js";
|
import { expandSlashCommand, loadSlashCommands } from "./slash-commands.js";
|
||||||
import { initTheme } from "./theme/theme.js";
|
import { initTheme } from "./theme/theme.js";
|
||||||
import { allTools, codingTools, type ToolName } from "./tools/index.js";
|
import { allTools, codingTools, type ToolName } from "./tools/index.js";
|
||||||
|
import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js";
|
||||||
import { ensureTool } from "./tools-manager.js";
|
import { ensureTool } from "./tools-manager.js";
|
||||||
import { SessionSelectorComponent } from "./tui/session-selector.js";
|
import { SessionSelectorComponent } from "./tui/session-selector.js";
|
||||||
import { TuiRenderer } from "./tui/tui-renderer.js";
|
import { TuiRenderer } from "./tui/tui-renderer.js";
|
||||||
|
|
@ -856,6 +862,87 @@ async function runSingleShotMode(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a bash command for RPC mode.
|
||||||
|
* Similar to tui-renderer's executeBashCommand but without streaming callbacks.
|
||||||
|
*/
|
||||||
|
async function executeRpcBashCommand(command: string): Promise<{
|
||||||
|
output: string;
|
||||||
|
exitCode: number | null;
|
||||||
|
truncationResult?: ReturnType<typeof truncateTail>;
|
||||||
|
fullOutputPath?: string;
|
||||||
|
}> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const { shell, args } = getShellConfig();
|
||||||
|
const child = spawn(shell, [...args, command], {
|
||||||
|
detached: true,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let chunksBytes = 0;
|
||||||
|
const maxChunksBytes = DEFAULT_MAX_BYTES * 2;
|
||||||
|
|
||||||
|
let tempFilePath: string | undefined;
|
||||||
|
let tempFileStream: ReturnType<typeof createWriteStream> | undefined;
|
||||||
|
let totalBytes = 0;
|
||||||
|
|
||||||
|
const handleData = (data: Buffer) => {
|
||||||
|
totalBytes += data.length;
|
||||||
|
|
||||||
|
// Start writing to temp file if exceeds threshold
|
||||||
|
if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) {
|
||||||
|
const id = randomBytes(8).toString("hex");
|
||||||
|
tempFilePath = join(tmpdir(), `pi-bash-${id}.log`);
|
||||||
|
tempFileStream = createWriteStream(tempFilePath);
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
tempFileStream.write(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tempFileStream) {
|
||||||
|
tempFileStream.write(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep rolling buffer
|
||||||
|
chunks.push(data);
|
||||||
|
chunksBytes += data.length;
|
||||||
|
while (chunksBytes > maxChunksBytes && chunks.length > 1) {
|
||||||
|
const removed = chunks.shift()!;
|
||||||
|
chunksBytes -= removed.length;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
child.stdout?.on("data", handleData);
|
||||||
|
child.stderr?.on("data", handleData);
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (tempFileStream) {
|
||||||
|
tempFileStream.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine buffered chunks
|
||||||
|
const fullBuffer = Buffer.concat(chunks);
|
||||||
|
const fullOutput = stripAnsi(fullBuffer.toString("utf-8")).replace(/\r/g, "");
|
||||||
|
const truncationResult = truncateTail(fullOutput);
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
output: fullOutput,
|
||||||
|
exitCode: code,
|
||||||
|
truncationResult: truncationResult.truncated ? truncationResult : undefined,
|
||||||
|
fullOutputPath: tempFilePath,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("error", (err) => {
|
||||||
|
if (tempFileStream) {
|
||||||
|
tempFileStream.end();
|
||||||
|
}
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function runRpcMode(
|
async function runRpcMode(
|
||||||
agent: Agent,
|
agent: Agent,
|
||||||
sessionManager: SessionManager,
|
sessionManager: SessionManager,
|
||||||
|
|
@ -986,6 +1073,37 @@ async function runRpcMode(
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log(JSON.stringify({ type: "error", error: `Compaction failed: ${error.message}` }));
|
console.log(JSON.stringify({ type: "error", error: `Compaction failed: ${error.message}` }));
|
||||||
}
|
}
|
||||||
|
} else if (input.type === "bash" && input.command) {
|
||||||
|
// Execute bash command and add to context
|
||||||
|
try {
|
||||||
|
const result = await executeRpcBashCommand(input.command);
|
||||||
|
|
||||||
|
// Create bash execution message
|
||||||
|
const bashMessage: BashExecutionMessage = {
|
||||||
|
role: "bashExecution",
|
||||||
|
command: input.command,
|
||||||
|
output: result.truncationResult?.content || result.output,
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
cancelled: false,
|
||||||
|
truncated: result.truncationResult?.truncated || false,
|
||||||
|
fullOutputPath: result.fullOutputPath,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add to agent state and save to session
|
||||||
|
agent.appendMessage(bashMessage);
|
||||||
|
sessionManager.saveMessage(bashMessage);
|
||||||
|
|
||||||
|
// Initialize session if needed (same logic as message_end handler)
|
||||||
|
if (sessionManager.shouldInitializeSession(agent.state.messages)) {
|
||||||
|
sessionManager.startSession(agent.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit bash_end event with the message
|
||||||
|
console.log(JSON.stringify({ type: "bash_end", message: bashMessage }));
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log(JSON.stringify({ type: "error", error: `Bash command failed: ${error.message}` }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Output error as JSON
|
// Output error as JSON
|
||||||
|
|
@ -1273,6 +1391,7 @@ export async function main(args: string[]) {
|
||||||
thinkingLevel: initialThinking,
|
thinkingLevel: initialThinking,
|
||||||
tools: selectedTools,
|
tools: selectedTools,
|
||||||
},
|
},
|
||||||
|
messageTransformer,
|
||||||
queueMode: settingsManager.getQueueMode(),
|
queueMode: settingsManager.getQueueMode(),
|
||||||
transport: new ProviderTransport({
|
transport: new ProviderTransport({
|
||||||
// Dynamic API key lookup based on current model's provider
|
// Dynamic API key lookup based on current model's provider
|
||||||
|
|
|
||||||
102
packages/coding-agent/src/messages.ts
Normal file
102
packages/coding-agent/src/messages.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
/**
|
||||||
|
* Custom message types and transformers for the coding agent.
|
||||||
|
*
|
||||||
|
* Extends the base AppMessage type with coding-agent specific message types,
|
||||||
|
* and provides a transformer to convert them to LLM-compatible messages.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AppMessage } from "@mariozechner/pi-agent-core";
|
||||||
|
import type { Message } from "@mariozechner/pi-ai";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Custom Message Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message type for bash executions via the ! command.
|
||||||
|
*/
|
||||||
|
export interface BashExecutionMessage {
|
||||||
|
role: "bashExecution";
|
||||||
|
command: string;
|
||||||
|
output: string;
|
||||||
|
exitCode: number | null;
|
||||||
|
cancelled: boolean;
|
||||||
|
truncated: boolean;
|
||||||
|
fullOutputPath?: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend CustomMessages via declaration merging
|
||||||
|
declare module "@mariozechner/pi-agent-core" {
|
||||||
|
interface CustomMessages {
|
||||||
|
bashExecution: BashExecutionMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Type Guards
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard for BashExecutionMessage.
|
||||||
|
*/
|
||||||
|
export function isBashExecutionMessage(msg: AppMessage | Message): msg is BashExecutionMessage {
|
||||||
|
return (msg as BashExecutionMessage).role === "bashExecution";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Message Formatting
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a BashExecutionMessage to user message text for LLM context.
|
||||||
|
*/
|
||||||
|
export function bashExecutionToText(msg: BashExecutionMessage): string {
|
||||||
|
let text = `Ran \`${msg.command}\`\n`;
|
||||||
|
if (msg.output) {
|
||||||
|
text += "```\n" + msg.output + "\n```";
|
||||||
|
} else {
|
||||||
|
text += "(no output)";
|
||||||
|
}
|
||||||
|
if (msg.cancelled) {
|
||||||
|
text += "\n\n(command cancelled)";
|
||||||
|
} else if (msg.exitCode !== null && msg.exitCode !== 0) {
|
||||||
|
text += `\n\nCommand exited with code ${msg.exitCode}`;
|
||||||
|
}
|
||||||
|
if (msg.truncated && msg.fullOutputPath) {
|
||||||
|
text += `\n\n[Output truncated. Full output: ${msg.fullOutputPath}]`;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Message Transformer
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform AppMessages (including custom types) to LLM-compatible Messages.
|
||||||
|
*
|
||||||
|
* This is used by:
|
||||||
|
* - Agent's messageTransformer option (for prompt calls)
|
||||||
|
* - Compaction's generateSummary (for summarization)
|
||||||
|
*/
|
||||||
|
export function messageTransformer(messages: AppMessage[]): Message[] {
|
||||||
|
return messages
|
||||||
|
.map((m): Message | null => {
|
||||||
|
if (isBashExecutionMessage(m)) {
|
||||||
|
// Convert bash execution to user message
|
||||||
|
return {
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "text", text: bashExecutionToText(m) }],
|
||||||
|
timestamp: m.timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Pass through standard LLM roles
|
||||||
|
if (m.role === "user" || m.role === "assistant" || m.role === "toolResult") {
|
||||||
|
return m as Message;
|
||||||
|
}
|
||||||
|
// Filter out unknown message types
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter((m): m is Message => m !== null);
|
||||||
|
}
|
||||||
117
packages/coding-agent/src/shell.ts
Normal file
117
packages/coding-agent/src/shell.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { spawn, spawnSync } from "child_process";
|
||||||
|
import { SettingsManager } from "./settings-manager.js";
|
||||||
|
|
||||||
|
let cachedShellConfig: { shell: string; args: string[] } | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find bash executable on PATH (Windows)
|
||||||
|
*/
|
||||||
|
function findBashOnPath(): string | null {
|
||||||
|
try {
|
||||||
|
const result = spawnSync("where", ["bash.exe"], { encoding: "utf-8", timeout: 5000 });
|
||||||
|
if (result.status === 0 && result.stdout) {
|
||||||
|
const firstMatch = result.stdout.trim().split(/\r?\n/)[0];
|
||||||
|
if (firstMatch && existsSync(firstMatch)) {
|
||||||
|
return firstMatch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get shell configuration based on platform.
|
||||||
|
* Resolution order:
|
||||||
|
* 1. User-specified shellPath in settings.json
|
||||||
|
* 2. On Windows: Git Bash in known locations
|
||||||
|
* 3. Fallback: bash on PATH (Windows) or sh (Unix)
|
||||||
|
*/
|
||||||
|
export function getShellConfig(): { shell: string; args: string[] } {
|
||||||
|
if (cachedShellConfig) {
|
||||||
|
return cachedShellConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = new SettingsManager();
|
||||||
|
const customShellPath = settings.getShellPath();
|
||||||
|
|
||||||
|
// 1. Check user-specified shell path
|
||||||
|
if (customShellPath) {
|
||||||
|
if (existsSync(customShellPath)) {
|
||||||
|
cachedShellConfig = { shell: customShellPath, args: ["-c"] };
|
||||||
|
return cachedShellConfig;
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Custom shell path not found: ${customShellPath}\n` + `Please update shellPath in ~/.pi/agent/settings.json`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
// 2. Try Git Bash in known locations
|
||||||
|
const paths: string[] = [];
|
||||||
|
const programFiles = process.env.ProgramFiles;
|
||||||
|
if (programFiles) {
|
||||||
|
paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
|
||||||
|
}
|
||||||
|
const programFilesX86 = process.env["ProgramFiles(x86)"];
|
||||||
|
if (programFilesX86) {
|
||||||
|
paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const path of paths) {
|
||||||
|
if (existsSync(path)) {
|
||||||
|
cachedShellConfig = { shell: path, args: ["-c"] };
|
||||||
|
return cachedShellConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
|
||||||
|
const bashOnPath = findBashOnPath();
|
||||||
|
if (bashOnPath) {
|
||||||
|
cachedShellConfig = { shell: bashOnPath, args: ["-c"] };
|
||||||
|
return cachedShellConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`No bash shell found. Options:\n` +
|
||||||
|
` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
|
||||||
|
` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
|
||||||
|
` 3. Set shellPath in ~/.pi/agent/settings.json\n\n` +
|
||||||
|
`Searched Git Bash in:\n${paths.map((p) => ` ${p}`).join("\n")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedShellConfig = { shell: "sh", args: ["-c"] };
|
||||||
|
return cachedShellConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill a process and all its children (cross-platform)
|
||||||
|
*/
|
||||||
|
export function killProcessTree(pid: number): void {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
// Use taskkill on Windows to kill process tree
|
||||||
|
try {
|
||||||
|
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
|
||||||
|
stdio: "ignore",
|
||||||
|
detached: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignore errors if taskkill fails
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use SIGKILL on Unix/Linux/Mac
|
||||||
|
try {
|
||||||
|
process.kill(-pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// Fallback to killing just the child if process group kill fails
|
||||||
|
try {
|
||||||
|
process.kill(pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// Process already dead
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,127 +1,13 @@
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { createWriteStream, existsSync } from "node:fs";
|
import { createWriteStream } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { AgentTool } from "@mariozechner/pi-ai";
|
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
import { spawn, spawnSync } from "child_process";
|
import { spawn } from "child_process";
|
||||||
import { SettingsManager } from "../settings-manager.js";
|
import { getShellConfig, killProcessTree } from "../shell.js";
|
||||||
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js";
|
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js";
|
||||||
|
|
||||||
let cachedShellConfig: { shell: string; args: string[] } | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find bash executable on PATH (Windows)
|
|
||||||
*/
|
|
||||||
function findBashOnPath(): string | null {
|
|
||||||
try {
|
|
||||||
const result = spawnSync("where", ["bash.exe"], { encoding: "utf-8", timeout: 5000 });
|
|
||||||
if (result.status === 0 && result.stdout) {
|
|
||||||
const firstMatch = result.stdout.trim().split(/\r?\n/)[0];
|
|
||||||
if (firstMatch && existsSync(firstMatch)) {
|
|
||||||
return firstMatch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get shell configuration based on platform.
|
|
||||||
* Resolution order:
|
|
||||||
* 1. User-specified shellPath in settings.json
|
|
||||||
* 2. On Windows: Git Bash in known locations
|
|
||||||
* 3. Fallback: bash on PATH (Windows) or sh (Unix)
|
|
||||||
*/
|
|
||||||
function getShellConfig(): { shell: string; args: string[] } {
|
|
||||||
if (cachedShellConfig) {
|
|
||||||
return cachedShellConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
const settings = new SettingsManager();
|
|
||||||
const customShellPath = settings.getShellPath();
|
|
||||||
|
|
||||||
// 1. Check user-specified shell path
|
|
||||||
if (customShellPath) {
|
|
||||||
if (existsSync(customShellPath)) {
|
|
||||||
cachedShellConfig = { shell: customShellPath, args: ["-c"] };
|
|
||||||
return cachedShellConfig;
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
`Custom shell path not found: ${customShellPath}\n` + `Please update shellPath in ~/.pi/agent/settings.json`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
// 2. Try Git Bash in known locations
|
|
||||||
const paths: string[] = [];
|
|
||||||
const programFiles = process.env.ProgramFiles;
|
|
||||||
if (programFiles) {
|
|
||||||
paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
|
|
||||||
}
|
|
||||||
const programFilesX86 = process.env["ProgramFiles(x86)"];
|
|
||||||
if (programFilesX86) {
|
|
||||||
paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const path of paths) {
|
|
||||||
if (existsSync(path)) {
|
|
||||||
cachedShellConfig = { shell: path, args: ["-c"] };
|
|
||||||
return cachedShellConfig;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
|
|
||||||
const bashOnPath = findBashOnPath();
|
|
||||||
if (bashOnPath) {
|
|
||||||
cachedShellConfig = { shell: bashOnPath, args: ["-c"] };
|
|
||||||
return cachedShellConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
`No bash shell found. Options:\n` +
|
|
||||||
` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
|
|
||||||
` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
|
|
||||||
` 3. Set shellPath in ~/.pi/agent/settings.json\n\n` +
|
|
||||||
`Searched Git Bash in:\n${paths.map((p) => ` ${p}`).join("\n")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
cachedShellConfig = { shell: "sh", args: ["-c"] };
|
|
||||||
return cachedShellConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Kill a process and all its children
|
|
||||||
*/
|
|
||||||
function killProcessTree(pid: number): void {
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
// Use taskkill on Windows to kill process tree
|
|
||||||
try {
|
|
||||||
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
|
|
||||||
stdio: "ignore",
|
|
||||||
detached: true,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore errors if taskkill fails
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Use SIGKILL on Unix/Linux/Mac
|
|
||||||
try {
|
|
||||||
process.kill(-pid, "SIGKILL");
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback to killing just the child if process group kill fails
|
|
||||||
try {
|
|
||||||
process.kill(pid, "SIGKILL");
|
|
||||||
} catch (e2) {
|
|
||||||
// Process already dead
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a unique temp file path for bash output
|
* Generate a unique temp file path for bash output
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
171
packages/coding-agent/src/tui/bash-execution.ts
Normal file
171
packages/coding-agent/src/tui/bash-execution.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
/**
|
||||||
|
* Component for displaying bash command execution with streaming output.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Container, Loader, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
|
||||||
|
import stripAnsi from "strip-ansi";
|
||||||
|
import { theme } from "../theme/theme.js";
|
||||||
|
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateTail } from "../tools/truncate.js";
|
||||||
|
import { DynamicBorder } from "./dynamic-border.js";
|
||||||
|
|
||||||
|
// Preview line limit when not expanded (matches tool execution behavior)
|
||||||
|
const PREVIEW_LINES = 20;
|
||||||
|
|
||||||
|
export class BashExecutionComponent extends Container {
|
||||||
|
private command: string;
|
||||||
|
private outputLines: string[] = [];
|
||||||
|
private status: "running" | "complete" | "cancelled" | "error" = "running";
|
||||||
|
private exitCode: number | null = null;
|
||||||
|
private loader: Loader;
|
||||||
|
private truncationResult?: TruncationResult;
|
||||||
|
private fullOutputPath?: string;
|
||||||
|
private expanded = false;
|
||||||
|
private contentContainer: Container;
|
||||||
|
|
||||||
|
constructor(command: string, ui: TUI) {
|
||||||
|
super();
|
||||||
|
this.command = command;
|
||||||
|
|
||||||
|
const borderColor = (str: string) => theme.fg("bashMode", str);
|
||||||
|
|
||||||
|
// Add spacer
|
||||||
|
this.addChild(new Spacer(1));
|
||||||
|
|
||||||
|
// Top border
|
||||||
|
this.addChild(new DynamicBorder(borderColor));
|
||||||
|
|
||||||
|
// Content container (holds dynamic content between borders)
|
||||||
|
this.contentContainer = new Container();
|
||||||
|
this.addChild(this.contentContainer);
|
||||||
|
|
||||||
|
// Command header
|
||||||
|
const header = new Text(theme.fg("bashMode", theme.bold(`$ ${command}`)), 1, 0);
|
||||||
|
this.contentContainer.addChild(header);
|
||||||
|
|
||||||
|
// Loader
|
||||||
|
this.loader = new Loader(
|
||||||
|
ui,
|
||||||
|
(spinner) => theme.fg("bashMode", spinner),
|
||||||
|
(text) => theme.fg("muted", text),
|
||||||
|
"Running... (esc to cancel)",
|
||||||
|
);
|
||||||
|
this.contentContainer.addChild(this.loader);
|
||||||
|
|
||||||
|
// Bottom border
|
||||||
|
this.addChild(new DynamicBorder(borderColor));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set whether the output is expanded (shows full output) or collapsed (preview only).
|
||||||
|
*/
|
||||||
|
setExpanded(expanded: boolean): void {
|
||||||
|
this.expanded = expanded;
|
||||||
|
this.updateDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
appendOutput(chunk: string): void {
|
||||||
|
// Strip ANSI codes and normalize line endings
|
||||||
|
const clean = stripAnsi(chunk).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||||
|
|
||||||
|
// Append to output lines
|
||||||
|
const newLines = clean.split("\n");
|
||||||
|
if (this.outputLines.length > 0 && newLines.length > 0) {
|
||||||
|
// Append first chunk to last line (incomplete line continuation)
|
||||||
|
this.outputLines[this.outputLines.length - 1] += newLines[0];
|
||||||
|
this.outputLines.push(...newLines.slice(1));
|
||||||
|
} else {
|
||||||
|
this.outputLines.push(...newLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
setComplete(
|
||||||
|
exitCode: number | null,
|
||||||
|
cancelled: boolean,
|
||||||
|
truncationResult?: TruncationResult,
|
||||||
|
fullOutputPath?: string,
|
||||||
|
): void {
|
||||||
|
this.exitCode = exitCode;
|
||||||
|
this.status = cancelled ? "cancelled" : exitCode !== 0 && exitCode !== null ? "error" : "complete";
|
||||||
|
this.truncationResult = truncationResult;
|
||||||
|
this.fullOutputPath = fullOutputPath;
|
||||||
|
|
||||||
|
// Stop loader
|
||||||
|
this.loader.stop();
|
||||||
|
|
||||||
|
this.updateDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateDisplay(): void {
|
||||||
|
// Apply truncation for LLM context limits (same limits as bash tool)
|
||||||
|
const fullOutput = this.outputLines.join("\n");
|
||||||
|
const contextTruncation = truncateTail(fullOutput, {
|
||||||
|
maxLines: DEFAULT_MAX_LINES,
|
||||||
|
maxBytes: DEFAULT_MAX_BYTES,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the lines to potentially display (after context truncation)
|
||||||
|
const availableLines = contextTruncation.content ? contextTruncation.content.split("\n") : [];
|
||||||
|
|
||||||
|
// Apply preview truncation based on expanded state
|
||||||
|
const maxDisplayLines = this.expanded ? availableLines.length : PREVIEW_LINES;
|
||||||
|
const displayLines = availableLines.slice(-maxDisplayLines); // Show last N lines (tail)
|
||||||
|
const hiddenLineCount = availableLines.length - displayLines.length;
|
||||||
|
|
||||||
|
// Rebuild content container
|
||||||
|
this.contentContainer.clear();
|
||||||
|
|
||||||
|
// Command header
|
||||||
|
const header = new Text(theme.fg("bashMode", theme.bold(`$ ${this.command}`)), 1, 0);
|
||||||
|
this.contentContainer.addChild(header);
|
||||||
|
|
||||||
|
// Output
|
||||||
|
if (displayLines.length > 0) {
|
||||||
|
const displayText = displayLines.map((line) => theme.fg("muted", line)).join("\n");
|
||||||
|
this.contentContainer.addChild(new Text("\n" + displayText, 1, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loader or status
|
||||||
|
if (this.status === "running") {
|
||||||
|
this.contentContainer.addChild(this.loader);
|
||||||
|
} else {
|
||||||
|
const statusParts: string[] = [];
|
||||||
|
|
||||||
|
// Show how many lines are hidden (collapsed preview)
|
||||||
|
if (hiddenLineCount > 0) {
|
||||||
|
statusParts.push(theme.fg("dim", `... ${hiddenLineCount} more lines (ctrl+o to expand)`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.status === "cancelled") {
|
||||||
|
statusParts.push(theme.fg("warning", "(cancelled)"));
|
||||||
|
} else if (this.status === "error") {
|
||||||
|
statusParts.push(theme.fg("error", `(exit ${this.exitCode})`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add truncation warning (context truncation, not preview truncation)
|
||||||
|
const wasTruncated = this.truncationResult?.truncated || contextTruncation.truncated;
|
||||||
|
if (wasTruncated && this.fullOutputPath) {
|
||||||
|
statusParts.push(theme.fg("warning", `Output truncated. Full output: ${this.fullOutputPath}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusParts.length > 0) {
|
||||||
|
this.contentContainer.addChild(new Text("\n" + statusParts.join("\n"), 1, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the raw output for creating BashExecutionMessage.
|
||||||
|
*/
|
||||||
|
getOutput(): string {
|
||||||
|
return this.outputLines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the command that was executed.
|
||||||
|
*/
|
||||||
|
getCommand(): string {
|
||||||
|
return this.command;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
|
import { createWriteStream, type WriteStream } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import type { Agent, AgentEvent, AgentState, ThinkingLevel } from "@mariozechner/pi-agent-core";
|
import { join } from "node:path";
|
||||||
|
import type { Agent, AgentEvent, AgentState, AppMessage, ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||||
import type { AssistantMessage, Message, Model } from "@mariozechner/pi-ai";
|
import type { AssistantMessage, Message, Model } from "@mariozechner/pi-ai";
|
||||||
import type { SlashCommand } from "@mariozechner/pi-tui";
|
import type { SlashCommand } from "@mariozechner/pi-tui";
|
||||||
import {
|
import {
|
||||||
|
|
@ -16,12 +20,14 @@ import {
|
||||||
TUI,
|
TUI,
|
||||||
visibleWidth,
|
visibleWidth,
|
||||||
} from "@mariozechner/pi-tui";
|
} from "@mariozechner/pi-tui";
|
||||||
import { exec } from "child_process";
|
import { exec, spawn } from "child_process";
|
||||||
|
import stripAnsi from "strip-ansi";
|
||||||
import { getChangelogPath, parseChangelog } from "../changelog.js";
|
import { getChangelogPath, parseChangelog } from "../changelog.js";
|
||||||
import { copyToClipboard } from "../clipboard.js";
|
import { copyToClipboard } from "../clipboard.js";
|
||||||
import { calculateContextTokens, compact, shouldCompact } from "../compaction.js";
|
import { calculateContextTokens, compact, shouldCompact } from "../compaction.js";
|
||||||
import { APP_NAME, getDebugLogPath, getModelsPath, getOAuthPath } from "../config.js";
|
import { APP_NAME, getDebugLogPath, getModelsPath, getOAuthPath } from "../config.js";
|
||||||
import { exportSessionToHtml } from "../export-html.js";
|
import { exportSessionToHtml } from "../export-html.js";
|
||||||
|
import { type BashExecutionMessage, isBashExecutionMessage } from "../messages.js";
|
||||||
import { getApiKeyForModel, getAvailableModels, invalidateOAuthCache } from "../model-config.js";
|
import { getApiKeyForModel, getAvailableModels, invalidateOAuthCache } from "../model-config.js";
|
||||||
import { listOAuthProviders, login, logout, type SupportedOAuthProvider } from "../oauth/index.js";
|
import { listOAuthProviders, login, logout, type SupportedOAuthProvider } from "../oauth/index.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -32,9 +38,12 @@ import {
|
||||||
SUMMARY_SUFFIX,
|
SUMMARY_SUFFIX,
|
||||||
} from "../session-manager.js";
|
} from "../session-manager.js";
|
||||||
import type { SettingsManager } from "../settings-manager.js";
|
import type { SettingsManager } from "../settings-manager.js";
|
||||||
|
import { getShellConfig, killProcessTree } from "../shell.js";
|
||||||
import { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from "../slash-commands.js";
|
import { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from "../slash-commands.js";
|
||||||
import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../theme/theme.js";
|
import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../theme/theme.js";
|
||||||
|
import { DEFAULT_MAX_BYTES, type TruncationResult, truncateTail } from "../tools/truncate.js";
|
||||||
import { AssistantMessageComponent } from "./assistant-message.js";
|
import { AssistantMessageComponent } from "./assistant-message.js";
|
||||||
|
import { BashExecutionComponent } from "./bash-execution.js";
|
||||||
import { CompactionComponent } from "./compaction.js";
|
import { CompactionComponent } from "./compaction.js";
|
||||||
import { CustomEditor } from "./custom-editor.js";
|
import { CustomEditor } from "./custom-editor.js";
|
||||||
import { DynamicBorder } from "./dynamic-border.js";
|
import { DynamicBorder } from "./dynamic-border.js";
|
||||||
|
|
@ -121,6 +130,15 @@ export class TuiRenderer {
|
||||||
// File-based slash commands
|
// File-based slash commands
|
||||||
private fileCommands: FileSlashCommand[] = [];
|
private fileCommands: FileSlashCommand[] = [];
|
||||||
|
|
||||||
|
// Track if editor is in bash mode (text starts with !)
|
||||||
|
private isBashMode = false;
|
||||||
|
|
||||||
|
// Track running bash command process for cancellation
|
||||||
|
private bashProcess: ReturnType<typeof spawn> | null = null;
|
||||||
|
|
||||||
|
// Track current bash execution component
|
||||||
|
private bashComponent: BashExecutionComponent | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
agent: Agent,
|
agent: Agent,
|
||||||
sessionManager: SessionManager,
|
sessionManager: SessionManager,
|
||||||
|
|
@ -295,6 +313,9 @@ export class TuiRenderer {
|
||||||
theme.fg("dim", "/") +
|
theme.fg("dim", "/") +
|
||||||
theme.fg("muted", " for commands") +
|
theme.fg("muted", " for commands") +
|
||||||
"\n" +
|
"\n" +
|
||||||
|
theme.fg("dim", "!") +
|
||||||
|
theme.fg("muted", " to run bash") +
|
||||||
|
"\n" +
|
||||||
theme.fg("dim", "drop files") +
|
theme.fg("dim", "drop files") +
|
||||||
theme.fg("muted", " to attach");
|
theme.fg("muted", " to attach");
|
||||||
const header = new Text(logo + "\n" + instructions, 1, 0);
|
const header = new Text(logo + "\n" + instructions, 1, 0);
|
||||||
|
|
@ -355,6 +376,17 @@ export class TuiRenderer {
|
||||||
|
|
||||||
// Abort
|
// Abort
|
||||||
this.agent.abort();
|
this.agent.abort();
|
||||||
|
} else if (this.bashProcess) {
|
||||||
|
// Kill running bash command
|
||||||
|
if (this.bashProcess.pid) {
|
||||||
|
killProcessTree(this.bashProcess.pid);
|
||||||
|
}
|
||||||
|
this.bashProcess = null;
|
||||||
|
} else if (this.isBashMode) {
|
||||||
|
// Cancel bash mode and clear editor
|
||||||
|
this.editor.setText("");
|
||||||
|
this.isBashMode = false;
|
||||||
|
this.updateEditorBorderColor();
|
||||||
} else if (!this.editor.getText().trim()) {
|
} else if (!this.editor.getText().trim()) {
|
||||||
// Double-escape with empty editor triggers /branch
|
// Double-escape with empty editor triggers /branch
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
@ -387,6 +419,15 @@ export class TuiRenderer {
|
||||||
this.toggleThinkingBlockVisibility();
|
this.toggleThinkingBlockVisibility();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle editor text changes for bash mode detection
|
||||||
|
this.editor.onChange = (text: string) => {
|
||||||
|
const wasBashMode = this.isBashMode;
|
||||||
|
this.isBashMode = text.trimStart().startsWith("!");
|
||||||
|
if (wasBashMode !== this.isBashMode) {
|
||||||
|
this.updateEditorBorderColor();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Handle editor submission
|
// Handle editor submission
|
||||||
this.editor.onSubmit = async (text: string) => {
|
this.editor.onSubmit = async (text: string) => {
|
||||||
text = text.trim();
|
text = text.trim();
|
||||||
|
|
@ -507,6 +548,27 @@ export class TuiRenderer {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for bash command (!<command>)
|
||||||
|
if (text.startsWith("!")) {
|
||||||
|
const command = text.slice(1).trim();
|
||||||
|
if (command) {
|
||||||
|
// Block if bash already running
|
||||||
|
if (this.bashProcess) {
|
||||||
|
this.showWarning("A bash command is already running. Press Esc to cancel it first.");
|
||||||
|
// Restore text since editor clears on submit
|
||||||
|
this.editor.setText(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Add to history for up/down arrow navigation
|
||||||
|
this.editor.addToHistory(text);
|
||||||
|
this.handleBashCommand(command);
|
||||||
|
// Reset bash mode since editor is now empty
|
||||||
|
this.isBashMode = false;
|
||||||
|
this.updateEditorBorderColor();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check for file-based slash commands
|
// Check for file-based slash commands
|
||||||
text = expandSlashCommand(text, this.fileCommands);
|
text = expandSlashCommand(text, this.fileCommands);
|
||||||
|
|
||||||
|
|
@ -808,7 +870,24 @@ export class TuiRenderer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private addMessageToChat(message: Message): void {
|
private addMessageToChat(message: Message | AppMessage): void {
|
||||||
|
// Handle bash execution messages
|
||||||
|
if (isBashExecutionMessage(message)) {
|
||||||
|
const bashMsg = message as BashExecutionMessage;
|
||||||
|
const component = new BashExecutionComponent(bashMsg.command, this.ui);
|
||||||
|
if (bashMsg.output) {
|
||||||
|
component.appendOutput(bashMsg.output);
|
||||||
|
}
|
||||||
|
component.setComplete(
|
||||||
|
bashMsg.exitCode,
|
||||||
|
bashMsg.cancelled,
|
||||||
|
bashMsg.truncated ? ({ truncated: true } as TruncationResult) : undefined,
|
||||||
|
bashMsg.fullOutputPath,
|
||||||
|
);
|
||||||
|
this.chatContainer.addChild(component);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (message.role === "user") {
|
if (message.role === "user") {
|
||||||
const userMsg = message;
|
const userMsg = message;
|
||||||
// Extract text content from content blocks
|
// Extract text content from content blocks
|
||||||
|
|
@ -850,6 +929,12 @@ export class TuiRenderer {
|
||||||
for (let i = 0; i < state.messages.length; i++) {
|
for (let i = 0; i < state.messages.length; i++) {
|
||||||
const message = state.messages[i];
|
const message = state.messages[i];
|
||||||
|
|
||||||
|
// Handle bash execution messages
|
||||||
|
if (isBashExecutionMessage(message)) {
|
||||||
|
this.addMessageToChat(message);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (message.role === "user") {
|
if (message.role === "user") {
|
||||||
const userMsg = message;
|
const userMsg = message;
|
||||||
const textBlocks =
|
const textBlocks =
|
||||||
|
|
@ -950,6 +1035,12 @@ export class TuiRenderer {
|
||||||
const compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries());
|
const compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries());
|
||||||
|
|
||||||
for (const message of this.agent.state.messages) {
|
for (const message of this.agent.state.messages) {
|
||||||
|
// Handle bash execution messages
|
||||||
|
if (isBashExecutionMessage(message)) {
|
||||||
|
this.addMessageToChat(message);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (message.role === "user") {
|
if (message.role === "user") {
|
||||||
const userMsg = message;
|
const userMsg = message;
|
||||||
const textBlocks =
|
const textBlocks =
|
||||||
|
|
@ -1016,8 +1107,12 @@ export class TuiRenderer {
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateEditorBorderColor(): void {
|
private updateEditorBorderColor(): void {
|
||||||
const level = this.agent.state.thinkingLevel || "off";
|
if (this.isBashMode) {
|
||||||
this.editor.borderColor = theme.getThinkingBorderColor(level);
|
this.editor.borderColor = theme.getBashModeBorderColor();
|
||||||
|
} else {
|
||||||
|
const level = this.agent.state.thinkingLevel || "off";
|
||||||
|
this.editor.borderColor = theme.getThinkingBorderColor(level);
|
||||||
|
}
|
||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1168,12 +1263,14 @@ export class TuiRenderer {
|
||||||
private toggleToolOutputExpansion(): void {
|
private toggleToolOutputExpansion(): void {
|
||||||
this.toolOutputExpanded = !this.toolOutputExpanded;
|
this.toolOutputExpanded = !this.toolOutputExpanded;
|
||||||
|
|
||||||
// Update all tool execution and compaction components
|
// Update all tool execution, compaction, and bash execution components
|
||||||
for (const child of this.chatContainer.children) {
|
for (const child of this.chatContainer.children) {
|
||||||
if (child instanceof ToolExecutionComponent) {
|
if (child instanceof ToolExecutionComponent) {
|
||||||
child.setExpanded(this.toolOutputExpanded);
|
child.setExpanded(this.toolOutputExpanded);
|
||||||
} else if (child instanceof CompactionComponent) {
|
} else if (child instanceof CompactionComponent) {
|
||||||
child.setExpanded(this.toolOutputExpanded);
|
child.setExpanded(this.toolOutputExpanded);
|
||||||
|
} else if (child instanceof BashExecutionComponent) {
|
||||||
|
child.setExpanded(this.toolOutputExpanded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1972,6 +2069,152 @@ export class TuiRenderer {
|
||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleBashCommand(command: string): Promise<void> {
|
||||||
|
// Create component and add to chat
|
||||||
|
this.bashComponent = new BashExecutionComponent(command, this.ui);
|
||||||
|
this.chatContainer.addChild(this.bashComponent);
|
||||||
|
this.ui.requestRender();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.executeBashCommand(command, (chunk) => {
|
||||||
|
if (this.bashComponent) {
|
||||||
|
this.bashComponent.appendOutput(chunk);
|
||||||
|
this.ui.requestRender();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.bashComponent) {
|
||||||
|
this.bashComponent.setComplete(
|
||||||
|
result.exitCode,
|
||||||
|
result.cancelled,
|
||||||
|
result.truncationResult,
|
||||||
|
result.fullOutputPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create and save message (even if cancelled, for consistency with LLM aborts)
|
||||||
|
const bashMessage: BashExecutionMessage = {
|
||||||
|
role: "bashExecution",
|
||||||
|
command,
|
||||||
|
output: result.truncationResult?.content || this.bashComponent.getOutput(),
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
cancelled: result.cancelled,
|
||||||
|
truncated: result.truncationResult?.truncated || false,
|
||||||
|
fullOutputPath: result.fullOutputPath,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add to agent state
|
||||||
|
this.agent.appendMessage(bashMessage);
|
||||||
|
|
||||||
|
// Save to session
|
||||||
|
this.sessionManager.saveMessage(bashMessage);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
if (this.bashComponent) {
|
||||||
|
this.bashComponent.setComplete(null, false);
|
||||||
|
}
|
||||||
|
this.showError(`Bash command failed: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bashComponent = null;
|
||||||
|
this.ui.requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
private executeBashCommand(
|
||||||
|
command: string,
|
||||||
|
onChunk: (chunk: string) => void,
|
||||||
|
): Promise<{
|
||||||
|
exitCode: number | null;
|
||||||
|
cancelled: boolean;
|
||||||
|
truncationResult?: TruncationResult;
|
||||||
|
fullOutputPath?: string;
|
||||||
|
}> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const { shell, args } = getShellConfig();
|
||||||
|
const child = spawn(shell, [...args, command], {
|
||||||
|
detached: true,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.bashProcess = child;
|
||||||
|
|
||||||
|
// Track output for truncation
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let chunksBytes = 0;
|
||||||
|
const maxChunksBytes = DEFAULT_MAX_BYTES * 2;
|
||||||
|
|
||||||
|
// Temp file for large output
|
||||||
|
let tempFilePath: string | undefined;
|
||||||
|
let tempFileStream: WriteStream | undefined;
|
||||||
|
let totalBytes = 0;
|
||||||
|
|
||||||
|
const handleData = (data: Buffer) => {
|
||||||
|
totalBytes += data.length;
|
||||||
|
|
||||||
|
// Start writing to temp file if exceeds threshold
|
||||||
|
if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) {
|
||||||
|
const id = randomBytes(8).toString("hex");
|
||||||
|
tempFilePath = join(tmpdir(), `pi-bash-${id}.log`);
|
||||||
|
tempFileStream = createWriteStream(tempFilePath);
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
tempFileStream.write(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tempFileStream) {
|
||||||
|
tempFileStream.write(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep rolling buffer
|
||||||
|
chunks.push(data);
|
||||||
|
chunksBytes += data.length;
|
||||||
|
while (chunksBytes > maxChunksBytes && chunks.length > 1) {
|
||||||
|
const removed = chunks.shift()!;
|
||||||
|
chunksBytes -= removed.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream to component (strip ANSI)
|
||||||
|
const text = stripAnsi(data.toString()).replace(/\r/g, "");
|
||||||
|
onChunk(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
child.stdout?.on("data", handleData);
|
||||||
|
child.stderr?.on("data", handleData);
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (tempFileStream) {
|
||||||
|
tempFileStream.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bashProcess = null;
|
||||||
|
|
||||||
|
// Combine buffered chunks for truncation
|
||||||
|
const fullBuffer = Buffer.concat(chunks);
|
||||||
|
const fullOutput = stripAnsi(fullBuffer.toString("utf-8")).replace(/\r/g, "");
|
||||||
|
const truncationResult = truncateTail(fullOutput);
|
||||||
|
|
||||||
|
// code === null means killed (cancelled)
|
||||||
|
const cancelled = code === null;
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
exitCode: code,
|
||||||
|
cancelled,
|
||||||
|
truncationResult: truncationResult.truncated ? truncationResult : undefined,
|
||||||
|
fullOutputPath: tempFilePath,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("error", (err) => {
|
||||||
|
if (tempFileStream) {
|
||||||
|
tempFileStream.end();
|
||||||
|
}
|
||||||
|
this.bashProcess = null;
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private compactionAbortController: AbortController | null = null;
|
private compactionAbortController: AbortController | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -193,10 +193,12 @@ describe("createSummaryMessage", () => {
|
||||||
it("should create user message with prefix", () => {
|
it("should create user message with prefix", () => {
|
||||||
const msg = createSummaryMessage("This is the summary");
|
const msg = createSummaryMessage("This is the summary");
|
||||||
expect(msg.role).toBe("user");
|
expect(msg.role).toBe("user");
|
||||||
expect(msg.content).toContain(
|
if (msg.role === "user") {
|
||||||
"The conversation history before this point was compacted into the following summary:",
|
expect(msg.content).toContain(
|
||||||
);
|
"The conversation history before this point was compacted into the following summary:",
|
||||||
expect(msg.content).toContain("This is the summary");
|
);
|
||||||
|
expect(msg.content).toContain("This is the summary");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import * as readline from "node:readline";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import type { AgentEvent } from "@mariozechner/pi-agent-core";
|
import type { AgentEvent } from "@mariozechner/pi-agent-core";
|
||||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import type { BashExecutionMessage } from "../src/messages.js";
|
||||||
import type { CompactionEntry } from "../src/session-manager.js";
|
import type { CompactionEntry } from "../src/session-manager.js";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
@ -230,4 +231,199 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_T
|
||||||
expect(compactionEntries.length).toBe(1);
|
expect(compactionEntries.length).toBe(1);
|
||||||
expect(compactionEntries[0].summary).toBeDefined();
|
expect(compactionEntries[0].summary).toBeDefined();
|
||||||
}, 120000);
|
}, 120000);
|
||||||
|
|
||||||
|
test("should execute bash command and add to context", async () => {
|
||||||
|
// Spawn agent in RPC mode
|
||||||
|
agent = spawn(
|
||||||
|
"node",
|
||||||
|
["dist/cli.js", "--mode", "rpc", "--provider", "anthropic", "--model", "claude-sonnet-4-5"],
|
||||||
|
{
|
||||||
|
cwd: join(__dirname, ".."),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PI_CODING_AGENT_DIR: sessionDir,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const events: (
|
||||||
|
| AgentEvent
|
||||||
|
| { type: "bash_end"; message: BashExecutionMessage }
|
||||||
|
| { type: "error"; error: string }
|
||||||
|
)[] = [];
|
||||||
|
|
||||||
|
const rl = readline.createInterface({ input: agent.stdout!, terminal: false });
|
||||||
|
|
||||||
|
let stderr = "";
|
||||||
|
agent.stderr?.on("data", (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up persistent event collector BEFORE sending any commands
|
||||||
|
// This is critical for fast commands like bash that complete before
|
||||||
|
// a per-call handler would be registered
|
||||||
|
rl.on("line", (line: string) => {
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(line);
|
||||||
|
events.push(event);
|
||||||
|
} catch {
|
||||||
|
// Ignore non-JSON
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to wait for a specific event type by polling collected events
|
||||||
|
const waitForEvent = (eventType: string, timeout = 60000) =>
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => reject(new Error(`Timeout waiting for ${eventType}. Stderr: ${stderr}`)),
|
||||||
|
timeout,
|
||||||
|
);
|
||||||
|
const check = () => {
|
||||||
|
if (events.some((e) => e.type === eventType)) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
setTimeout(check, 50);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send a bash command
|
||||||
|
agent.stdin!.write(JSON.stringify({ type: "bash", command: "echo hello" }) + "\n");
|
||||||
|
await waitForEvent("bash_end");
|
||||||
|
|
||||||
|
// Verify bash_end event
|
||||||
|
const bashEvent = events.find((e) => e.type === "bash_end") as
|
||||||
|
| { type: "bash_end"; message: BashExecutionMessage }
|
||||||
|
| undefined;
|
||||||
|
expect(bashEvent).toBeDefined();
|
||||||
|
expect(bashEvent!.message.role).toBe("bashExecution");
|
||||||
|
expect(bashEvent!.message.command).toBe("echo hello");
|
||||||
|
expect(bashEvent!.message.output.trim()).toBe("hello");
|
||||||
|
expect(bashEvent!.message.exitCode).toBe(0);
|
||||||
|
expect(bashEvent!.message.cancelled).toBe(false);
|
||||||
|
|
||||||
|
// Clear events for next phase
|
||||||
|
events.length = 0;
|
||||||
|
|
||||||
|
// Session only initializes after user+assistant exchange, so send a prompt
|
||||||
|
agent.stdin!.write(JSON.stringify({ type: "prompt", message: "Say hi" }) + "\n");
|
||||||
|
await waitForEvent("agent_end");
|
||||||
|
|
||||||
|
// Wait for file writes
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
agent.kill("SIGTERM");
|
||||||
|
|
||||||
|
// Verify bash execution was saved to session file
|
||||||
|
const sessionsPath = join(sessionDir, "sessions");
|
||||||
|
const sessionDirs = readdirSync(sessionsPath);
|
||||||
|
const cwdSessionDir = join(sessionsPath, sessionDirs[0]);
|
||||||
|
const sessionFiles = readdirSync(cwdSessionDir).filter((f) => f.endsWith(".jsonl"));
|
||||||
|
const sessionContent = readFileSync(join(cwdSessionDir, sessionFiles[0]), "utf8");
|
||||||
|
const entries = sessionContent
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => JSON.parse(line));
|
||||||
|
|
||||||
|
// Should have a bashExecution message
|
||||||
|
const bashMessages = entries.filter(
|
||||||
|
(e: { type: string; message?: { role: string } }) =>
|
||||||
|
e.type === "message" && e.message?.role === "bashExecution",
|
||||||
|
);
|
||||||
|
expect(bashMessages.length).toBe(1);
|
||||||
|
expect(bashMessages[0].message.command).toBe("echo hello");
|
||||||
|
expect(bashMessages[0].message.output.trim()).toBe("hello");
|
||||||
|
}, 90000);
|
||||||
|
|
||||||
|
test("should include bash output in LLM context", async () => {
|
||||||
|
// Spawn agent in RPC mode
|
||||||
|
agent = spawn(
|
||||||
|
"node",
|
||||||
|
["dist/cli.js", "--mode", "rpc", "--provider", "anthropic", "--model", "claude-sonnet-4-5"],
|
||||||
|
{
|
||||||
|
cwd: join(__dirname, ".."),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PI_CODING_AGENT_DIR: sessionDir,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const events: (
|
||||||
|
| AgentEvent
|
||||||
|
| { type: "bash_end"; message: BashExecutionMessage }
|
||||||
|
| { type: "error"; error: string }
|
||||||
|
)[] = [];
|
||||||
|
|
||||||
|
const rl = readline.createInterface({ input: agent.stdout!, terminal: false });
|
||||||
|
|
||||||
|
let stderr = "";
|
||||||
|
agent.stderr?.on("data", (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up persistent event collector BEFORE sending any commands
|
||||||
|
rl.on("line", (line: string) => {
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(line);
|
||||||
|
events.push(event);
|
||||||
|
} catch {
|
||||||
|
// Ignore non-JSON
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to wait for a specific event type by polling collected events
|
||||||
|
const waitForEvent = (eventType: string, timeout = 60000) =>
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => reject(new Error(`Timeout waiting for ${eventType}. Stderr: ${stderr}`)),
|
||||||
|
timeout,
|
||||||
|
);
|
||||||
|
const check = () => {
|
||||||
|
if (events.some((e) => e.type === eventType)) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
setTimeout(check, 50);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for agent to initialize (session manager, etc.)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
// First, run a bash command with a unique value
|
||||||
|
const uniqueValue = `test-${Date.now()}`;
|
||||||
|
agent.stdin!.write(JSON.stringify({ type: "bash", command: `echo ${uniqueValue}` }) + "\n");
|
||||||
|
await waitForEvent("bash_end");
|
||||||
|
|
||||||
|
// Clear events but keep collecting new ones
|
||||||
|
events.length = 0;
|
||||||
|
|
||||||
|
// Now ask the LLM what the output was - it should be in context
|
||||||
|
agent.stdin!.write(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "prompt",
|
||||||
|
message: `What was the exact output of the echo command I just ran? Reply with just the value, nothing else.`,
|
||||||
|
}) + "\n",
|
||||||
|
);
|
||||||
|
await waitForEvent("agent_end");
|
||||||
|
|
||||||
|
// Find the assistant's response
|
||||||
|
const messageEndEvents = events.filter((e) => e.type === "message_end") as AgentEvent[];
|
||||||
|
const assistantMessage = messageEndEvents.find(
|
||||||
|
(e) => e.type === "message_end" && (e as any).message?.role === "assistant",
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
expect(assistantMessage).toBeDefined();
|
||||||
|
|
||||||
|
// The assistant should mention the unique value from the bash output
|
||||||
|
const textContent = assistantMessage.message.content.find((c: any) => c.type === "text");
|
||||||
|
expect(textContent?.text).toContain(uniqueValue);
|
||||||
|
|
||||||
|
agent.kill("SIGTERM");
|
||||||
|
}, 90000);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue