diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4e63445d..82b3f673 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Shell commands without context contribution: use `!!command` to execute a bash command that is shown in the TUI and saved to session history but excluded from LLM context. Useful for running commands you don't want the AI to see. ([#414](https://github.com/badlogic/pi-mono/issues/414)) + ## [0.32.0] - 2026-01-03 ### Breaking Changes diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index bf9984ec..e8b69918 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1395,8 +1395,13 @@ export class AgentSession { * Adds result to agent context and session. * @param command The bash command to execute * @param onChunk Optional streaming callback for output + * @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix) */ - async executeBash(command: string, onChunk?: (chunk: string) => void): Promise { + async executeBash( + command: string, + onChunk?: (chunk: string) => void, + options?: { excludeFromContext?: boolean }, + ): Promise { this._bashAbortController = new AbortController(); try { @@ -1415,6 +1420,7 @@ export class AgentSession { truncated: result.truncated, fullOutputPath: result.fullOutputPath, timestamp: Date.now(), + excludeFromContext: options?.excludeFromContext, }; // If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering diff --git a/packages/coding-agent/src/core/messages.ts b/packages/coding-agent/src/core/messages.ts index 2726903d..87c37fac 100644 --- a/packages/coding-agent/src/core/messages.ts +++ b/packages/coding-agent/src/core/messages.ts @@ -35,6 +35,8 @@ export interface BashExecutionMessage { truncated: boolean; fullOutputPath?: string; timestamp: number; + /** If true, this message is excluded from LLM context (!! prefix) */ + excludeFromContext?: boolean; } /** @@ -148,6 +150,10 @@ export function convertToLlm(messages: AgentMessage[]): Message[] { .map((m): Message | undefined => { switch (m.role) { case "bashExecution": + // Skip messages excluded from context (!! prefix) + if (m.excludeFromContext) { + return undefined; + } return { role: "user", content: [{ type: "text", text: bashExecutionToText(m) }], diff --git a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts index 2e914fa9..8f98b45e 100644 --- a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts @@ -29,12 +29,14 @@ export class BashExecutionComponent extends Container { private contentContainer: Container; private ui: TUI; - constructor(command: string, ui: TUI) { + constructor(command: string, ui: TUI, excludeFromContext = false) { super(); this.command = command; this.ui = ui; - const borderColor = (str: string) => theme.fg("bashMode", str); + // Use dim border for excluded-from-context commands (!! prefix) + const colorKey = excludeFromContext ? "dim" : "bashMode"; + const borderColor = (str: string) => theme.fg(colorKey, str); // Add spacer this.addChild(new Spacer(1)); @@ -47,13 +49,13 @@ export class BashExecutionComponent extends Container { this.addChild(this.contentContainer); // Command header - const header = new Text(theme.fg("bashMode", theme.bold(`$ ${command}`)), 1, 0); + const header = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0); this.contentContainer.addChild(header); // Loader this.loader = new Loader( ui, - (spinner) => theme.fg("bashMode", spinner), + (spinner) => theme.fg(colorKey, spinner), (text) => theme.fg("muted", text), "Running... (esc to cancel)", ); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 871b21f7..238aaa5c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -892,9 +892,10 @@ export class InteractiveMode { return; } - // Handle bash command + // Handle bash command (! for normal, !! for excluded from context) if (text.startsWith("!")) { - const command = text.slice(1).trim(); + const isExcluded = text.startsWith("!!"); + const command = isExcluded ? text.slice(2).trim() : text.slice(1).trim(); if (command) { if (this.session.isBashRunning) { this.showWarning("A bash command is already running. Press Esc to cancel it first."); @@ -902,7 +903,7 @@ export class InteractiveMode { return; } this.editor.addToHistory(text); - await this.handleBashCommand(command); + await this.handleBashCommand(command, isExcluded); this.isBashMode = false; this.updateEditorBorderColor(); return; @@ -1250,7 +1251,7 @@ export class InteractiveMode { private addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void { switch (message.role) { case "bashExecution": { - const component = new BashExecutionComponent(message.command, this.ui); + const component = new BashExecutionComponent(message.command, this.ui, message.excludeFromContext); if (message.output) { component.appendOutput(message.output); } @@ -2362,9 +2363,9 @@ export class InteractiveMode { this.ui.requestRender(); } - private async handleBashCommand(command: string): Promise { + private async handleBashCommand(command: string, excludeFromContext = false): Promise { const isDeferred = this.session.isStreaming; - this.bashComponent = new BashExecutionComponent(command, this.ui); + this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext); if (isDeferred) { // Show in pending area when agent is streaming @@ -2377,12 +2378,16 @@ export class InteractiveMode { this.ui.requestRender(); try { - const result = await this.session.executeBash(command, (chunk) => { - if (this.bashComponent) { - this.bashComponent.appendOutput(chunk); - this.ui.requestRender(); - } - }); + const result = await this.session.executeBash( + command, + (chunk) => { + if (this.bashComponent) { + this.bashComponent.appendOutput(chunk); + this.ui.requestRender(); + } + }, + { excludeFromContext }, + ); if (this.bashComponent) { this.bashComponent.setComplete(