From 1b6a70ccb15c432b3f67adbfba344420dbd112e9 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 21 Nov 2025 20:59:00 +0100 Subject: [PATCH] feat: add /clear command to reset context and start fresh session --- packages/agent/src/agent.ts | 30 +++++++ packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/README.md | 10 +++ packages/coding-agent/src/main.ts | 47 +--------- packages/coding-agent/src/session-manager.ts | 7 ++ packages/coding-agent/src/tui/tui-renderer.ts | 87 +++++++++++++++++-- 6 files changed, 131 insertions(+), 54 deletions(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index b8b98b92..08791bc6 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -77,6 +77,8 @@ export class Agent { private messageTransformer: (messages: AppMessage[]) => Message[] | Promise; private messageQueue: Array> = []; private queueMode: "all" | "one-at-a-time"; + private runningPrompt?: Promise; + private resolveRunningPrompt?: () => void; constructor(opts: AgentOptions) { this._state = { ...this._state, ...opts.initialState }; @@ -148,12 +150,37 @@ export class Agent { this.abortController?.abort(); } + /** + * Returns a promise that resolves when the current prompt completes. + * Returns immediately resolved promise if no prompt is running. + */ + waitForIdle(): Promise { + return this.runningPrompt ?? Promise.resolve(); + } + + /** + * Clear all messages and state. Call abort() first if a prompt is in flight. + */ + reset() { + this._state.messages = []; + this._state.isStreaming = false; + this._state.streamMessage = null; + this._state.pendingToolCalls = new Set(); + this._state.error = undefined; + this.messageQueue = []; + } + async prompt(input: string, attachments?: Attachment[]) { const model = this._state.model; if (!model) { throw new Error("No model configured"); } + // Set up running prompt tracking + this.runningPrompt = new Promise((resolve) => { + this.resolveRunningPrompt = resolve; + }); + // Build user message with attachments const content: Array = [{ type: "text", text: input }]; if (attachments?.length) { @@ -322,6 +349,9 @@ export class Agent { this._state.streamMessage = null; this._state.pendingToolCalls = new Set(); this.abortController = undefined; + this.resolveRunningPrompt?.(); + this.runningPrompt = undefined; + this.resolveRunningPrompt = undefined; } } diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 48ba1695..53729bbe 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- **`/clear` Command**: New slash command to reset the conversation context and start a fresh session. Aborts any in-flight agent work, clears all messages, and creates a new session file. ([#48](https://github.com/badlogic/pi-mono/pull/48)) + ### Fixed - **Markdown Link Rendering**: Fixed links with identical text and href (e.g., `https://github.com/badlogic/pi-mono/pull/48/files`) being rendered twice. Now correctly compares raw text instead of styled text (which contains ANSI codes) when determining if link text matches href. diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index af280036..c79c6c4a 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -445,6 +445,16 @@ Logout from OAuth providers: Shows a list of logged-in providers to logout from. +### /clear + +Clear the conversation context and start a fresh session: + +``` +/clear +``` + +Aborts any in-flight agent work, clears all messages, and creates a new session file. + ## Editor Features The interactive input editor includes several productivity features: diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 6d827f9d..76e18da0 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -21,17 +21,6 @@ const __dirname = dirname(__filename); const packageJson = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8")); const VERSION = packageJson.version; -const envApiKeyMap: Record = { - google: ["GEMINI_API_KEY"], - openai: ["OPENAI_API_KEY"], - anthropic: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], - xai: ["XAI_API_KEY"], - groq: ["GROQ_API_KEY"], - cerebras: ["CEREBRAS_API_KEY"], - openrouter: ["OPENROUTER_API_KEY"], - zai: ["ZAI_API_KEY"], -}; - const defaultModelPerProvider: Record = { anthropic: "claude-sonnet-4-5", openai: "gpt-5.1-codex", @@ -455,14 +444,9 @@ async function runInteractiveMode( scopedModels, ); - // Initialize TUI + // Initialize TUI (subscribes to agent events internally) await renderer.init(); - // Set interrupt callback - renderer.setInterruptCallback(() => { - agent.abort(); - }); - // Render any existing messages (from --continue mode) renderer.renderInitialMessages(agent.state); @@ -471,12 +455,6 @@ async function runInteractiveMode( renderer.showWarning(modelFallbackMessage); } - // Subscribe to agent events - agent.subscribe(async (event) => { - // Pass all events to the renderer - await renderer.handleEvent(event, agent.state); - }); - // Interactive loop while (true) { const userInput = await renderer.getUserInput(); @@ -683,11 +661,6 @@ export async function main(args: string[]) { // Load previous messages if continuing or resuming // This may update initialModel if restoring from session if (parsed.continue || parsed.resume) { - const messages = sessionManager.loadMessages(); - if (messages.length > 0 && shouldPrintMessages) { - console.log(chalk.dim(`Loaded ${messages.length} messages from previous session`)); - } - // Load and restore model (overrides initialModel if found and has API key) const savedModel = sessionManager.loadModel(); if (savedModel) { @@ -831,9 +804,6 @@ export async function main(args: string[]) { } } - // Note: Session will be started lazily after first user+assistant message exchange - // (unless continuing/resuming, in which case it's already initialized) - // Log loaded context files (they're already in the system prompt) if (shouldPrintMessages && !parsed.continue && !parsed.resume) { const contextFiles = loadProjectContextFiles(); @@ -845,19 +815,6 @@ export async function main(args: string[]) { } } - // Subscribe to agent events to save messages - agent.subscribe((event) => { - // Save messages on completion - if (event.type === "message_end") { - sessionManager.saveMessage(event.message); - - // Check if we should initialize session now (after first user+assistant exchange) - if (sessionManager.shouldInitializeSession(agent.state.messages)) { - sessionManager.startSession(agent.state); - } - } - }); - // Route to appropriate mode if (mode === "rpc") { // RPC mode - headless operation @@ -890,8 +847,6 @@ export async function main(args: string[]) { } } else { // Parse current and last versions - const currentParts = VERSION.split(".").map(Number); - const current = { major: currentParts[0] || 0, minor: currentParts[1] || 0, patch: currentParts[2] || 0 }; const changelogPath = getChangelogPath(); const entries = parseChangelog(changelogPath); const newEntries = getNewEntries(entries, lastVersion); diff --git a/packages/coding-agent/src/session-manager.ts b/packages/coding-agent/src/session-manager.ts index f56a76d9..42e7e017 100644 --- a/packages/coding-agent/src/session-manager.ts +++ b/packages/coding-agent/src/session-manager.ts @@ -97,6 +97,13 @@ export class SessionManager { this.sessionFile = join(this.sessionDir, `${timestamp}_${this.sessionId}.jsonl`); } + /** Reset to a fresh session. Clears pending messages and starts a new session file. */ + reset(): void { + this.pendingMessages = []; + this.sessionInitialized = false; + this.initNewSession(); + } + private findMostRecentlyModifiedSession(): string | null { try { const files = readdirSync(this.sessionDir) diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index c3de2e36..860cd37e 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -53,7 +53,7 @@ export class TuiRenderer { private isInitialized = false; private onInputCallback?: (text: string) => void; private loadingAnimation: Loader | null = null; - private onInterruptCallback?: () => void; + private lastSigintTime = 0; private changelogMarkdown: string | null = null; private newVersion: string | null = null; @@ -94,6 +94,9 @@ export class TuiRenderer { // Tool output expansion state private toolOutputExpanded = false; + // Agent subscription unsubscribe function + private unsubscribe?: () => void; + constructor( agent: Agent, sessionManager: SessionManager, @@ -170,6 +173,11 @@ export class TuiRenderer { description: "Select color theme (opens selector UI)", }; + const clearCommand: SlashCommand = { + name: "clear", + description: "Clear context and start a fresh session", + }; + // Setup autocomplete for file paths and slash commands const autocompleteProvider = new CombinedAutocompleteProvider( [ @@ -183,6 +191,7 @@ export class TuiRenderer { loginCommand, logoutCommand, queueCommand, + clearCommand, ], process.cwd(), ); @@ -265,7 +274,7 @@ export class TuiRenderer { // Set up custom key handlers on the editor this.editor.onEscape = () => { // Intercept Escape key when processing - if (this.loadingAnimation && this.onInterruptCallback) { + if (this.loadingAnimation) { // Get all queued messages const queuedText = this.queuedMessages.join("\n\n"); @@ -286,7 +295,7 @@ export class TuiRenderer { this.agent.clearMessageQueue(); // Abort - this.onInterruptCallback(); + this.agent.abort(); } }; @@ -383,6 +392,13 @@ export class TuiRenderer { return; } + // Check for /clear command + if (text === "/clear") { + this.handleClearCommand(); + this.editor.setText(""); + return; + } + // Normal message submission - validate model and API key first const currentModel = this.agent.state.model; if (!currentModel) { @@ -436,6 +452,9 @@ export class TuiRenderer { this.ui.start(); this.isInitialized = true; + // Subscribe to agent events for UI updates and session saving + this.subscribeToAgent(); + // Set up theme file watcher for live reload onThemeChange(() => { this.ui.invalidate(); @@ -444,7 +463,24 @@ export class TuiRenderer { }); } - async handleEvent(event: AgentEvent, state: AgentState): Promise { + private subscribeToAgent(): void { + this.unsubscribe = this.agent.subscribe(async (event) => { + // Handle UI updates + await this.handleEvent(event, this.agent.state); + + // Save messages to session + if (event.type === "message_end") { + this.sessionManager.saveMessage(event.message); + + // Check if we should initialize session now (after first user+assistant exchange) + if (this.sessionManager.shouldInitializeSession(this.agent.state.messages)) { + this.sessionManager.startSession(this.agent.state); + } + } + }); + } + + private async handleEvent(event: AgentEvent, state: AgentState): Promise { if (!this.isInitialized) { await this.init(); } @@ -710,10 +746,6 @@ export class TuiRenderer { }); } - setInterruptCallback(callback: () => void): void { - this.onInterruptCallback = callback; - } - private handleCtrlC(): void { // Handle Ctrl+C double-press logic const now = Date.now(); @@ -1373,6 +1405,45 @@ export class TuiRenderer { this.ui.requestRender(); } + private async handleClearCommand(): Promise { + // Unsubscribe first to prevent processing abort events + this.unsubscribe?.(); + + // Abort and wait for completion + this.agent.abort(); + await this.agent.waitForIdle(); + + // Stop loading animation + if (this.loadingAnimation) { + this.loadingAnimation.stop(); + this.loadingAnimation = null; + } + this.statusContainer.clear(); + + // Reset agent and session + this.agent.reset(); + this.sessionManager.reset(); + + // Resubscribe to agent + this.subscribeToAgent(); + + // Clear UI state + this.chatContainer.clear(); + this.pendingMessagesContainer.clear(); + this.queuedMessages = []; + this.streamingComponent = null; + this.pendingTools.clear(); + this.isFirstUserMessage = true; + + // Show confirmation + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild( + new Text(theme.fg("accent", "✓ Context cleared") + "\n" + theme.fg("muted", "Started fresh session"), 1, 1), + ); + + this.ui.requestRender(); + } + private updatePendingMessagesDisplay(): void { this.pendingMessagesContainer.clear();