From 1d8bc9d6eb80548cdfdf0f29604ce78b8b17db45 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 21 Nov 2025 12:47:23 +0100 Subject: [PATCH 1/4] Fix path completion hanging and autocomplete arrow key behavior - Fixed catastrophic regex backtracking in extractPathPrefix that caused terminal to hang when text contained many / characters (e.g., URLs). Replaced complex regex with simple lastIndexOf approach. (#18) - Fixed arrow keys moving both autocomplete selection and editor cursor by adding missing return statement after handling arrow keys in autocomplete mode. Closes #18 --- packages/ai/src/models.generated.ts | 148 +++++++++++++------------- packages/coding-agent/CHANGELOG.md | 7 ++ packages/tui/src/autocomplete.ts | 24 ++--- packages/tui/src/components/editor.ts | 6 ++ 4 files changed, 97 insertions(+), 88 deletions(-) diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index b63a48af..a49853b0 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -5102,23 +5102,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 16384, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-405b-instruct": { - id: "meta-llama/llama-3.1-405b-instruct", - name: "Meta: Llama 3.1 405B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 3.5, - output: 3.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 130815, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-70b-instruct": { id: "meta-llama/llama-3.1-70b-instruct", name: "Meta: Llama 3.1 70B Instruct", @@ -5136,6 +5119,23 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-405b-instruct": { + id: "meta-llama/llama-3.1-405b-instruct", + name: "Meta: Llama 3.1 405B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 3.5, + output: 3.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 130815, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/mistral-nemo": { id: "mistralai/mistral-nemo", name: "Mistral: Mistral Nemo", @@ -5153,9 +5153,9 @@ export const MODELS = { contextWindow: 131072, maxTokens: 16384, } satisfies Model<"openai-completions">, - "openai/gpt-4o-mini-2024-07-18": { - id: "openai/gpt-4o-mini-2024-07-18", - name: "OpenAI: GPT-4o-mini (2024-07-18)", + "openai/gpt-4o-mini": { + id: "openai/gpt-4o-mini", + name: "OpenAI: GPT-4o-mini", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -5170,9 +5170,9 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, - "openai/gpt-4o-mini": { - id: "openai/gpt-4o-mini", - name: "OpenAI: GPT-4o-mini", + "openai/gpt-4o-mini-2024-07-18": { + id: "openai/gpt-4o-mini-2024-07-18", + name: "OpenAI: GPT-4o-mini (2024-07-18)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -5272,23 +5272,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-4o-2024-05-13": { - id: "openai/gpt-4o-2024-05-13", - name: "OpenAI: GPT-4o (2024-05-13)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 5, - output: 15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4o": { id: "openai/gpt-4o", name: "OpenAI: GPT-4o", @@ -5323,6 +5306,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } satisfies Model<"openai-completions">, + "openai/gpt-4o-2024-05-13": { + id: "openai/gpt-4o-2024-05-13", + name: "OpenAI: GPT-4o (2024-05-13)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 5, + output: 15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "meta-llama/llama-3-70b-instruct": { id: "meta-llama/llama-3-70b-instruct", name: "Meta: Llama 3 70B Instruct", @@ -5442,23 +5442,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-3.5-turbo-0613": { - id: "openai/gpt-3.5-turbo-0613", - name: "OpenAI: GPT-3.5 Turbo (older v0613)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 4095, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4-turbo-preview": { id: "openai/gpt-4-turbo-preview", name: "OpenAI: GPT-4 Turbo Preview", @@ -5476,6 +5459,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-3.5-turbo-0613": { + id: "openai/gpt-3.5-turbo-0613", + name: "OpenAI: GPT-3.5 Turbo (older v0613)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 4095, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/mistral-small": { id: "mistralai/mistral-small", name: "Mistral Small", @@ -5578,23 +5578,6 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-4": { - id: "openai/gpt-4", - name: "OpenAI: GPT-4", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 30, - output: 60, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8191, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-3.5-turbo": { id: "openai/gpt-3.5-turbo", name: "OpenAI: GPT-3.5 Turbo", @@ -5612,6 +5595,23 @@ export const MODELS = { contextWindow: 16385, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-4": { + id: "openai/gpt-4", + name: "OpenAI: GPT-4", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 30, + output: 60, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8191, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openrouter/auto": { id: "openrouter/auto", name: "OpenRouter: Auto Router", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b3c46c03..82e1cb74 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +## [0.8.5] - 2025-11-21 + +### Fixed + +- **Path Completion Hanging**: Fixed catastrophic regex backtracking in path completion that caused the terminal to hang when text contained many `/` characters (e.g., URLs). Replaced complex regex with simple string operations. ([#18](https://github.com/badlogic/pi-mono/issues/18)) +- **Autocomplete Arrow Keys**: Fixed issue where arrow keys would move both the autocomplete selection and the editor cursor simultaneously when the file selector list was shown. + ## [0.8.4] - 2025-11-21 ### Fixed diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 9d366fff..d06391dd 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -283,21 +283,17 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { return atMatch[0]; // Return the full @path pattern } - // Match paths - including those ending with /, ~/, or any word at end for forced extraction - // This regex captures: - // - Paths starting from beginning of line or after space/quote/equals - // - Absolute paths starting with / - // - Relative paths with ./ or ../ - // - Home directory paths with ~/ - // - The path itself (can include / in the middle) - // - For forced extraction, capture any word at the end - const matches = text.match(/(?:^|[\s"'=])((?:\/|~\/|\.{1,2}\/)?(?:[^\s"'=]*\/?)*[^\s"'=]*)$/); - if (!matches) { - // If forced extraction and no matches, return empty string to trigger from current dir - return forceExtract ? "" : null; - } + // Simple approach: find the last whitespace/delimiter and extract the word after it + // This avoids catastrophic backtracking from nested quantifiers + const lastDelimiterIndex = Math.max( + text.lastIndexOf(" "), + text.lastIndexOf("\t"), + text.lastIndexOf('"'), + text.lastIndexOf("'"), + text.lastIndexOf("="), + ); - const pathPrefix = matches[1] || ""; + const pathPrefix = lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1); // For forced extraction (Tab key), always return something if (forceExtract) { diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 78424fd5..65f35a90 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -192,6 +192,7 @@ export class Editor implements Component { // Only pass arrow keys to the list, not Enter/Tab (we handle those directly) if (data === "\x1b[A" || data === "\x1b[B") { this.autocompleteList.handleInput(data); + return; } // If Tab was pressed, always apply the selection @@ -832,6 +833,11 @@ export class Editor implements Component { this.tryTriggerAutocomplete(true); } + /* +https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/559322883 +17 this job fails with https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19 +536643416/job/55932288317 havea look at .gi + */ private forceFileAutocomplete(): void { if (!this.autocompleteProvider) return; From 38524ea9006cfb06991e18d84aa9c8483010363c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 21 Nov 2025 12:48:18 +0100 Subject: [PATCH 2/4] Release v0.8.5 --- package-lock.json | 30 +++++++++++++++--------------- packages/agent/package.json | 6 +++--- packages/ai/package.json | 2 +- packages/coding-agent/package.json | 8 ++++---- packages/pods/package.json | 4 ++-- packages/proxy/package.json | 2 +- packages/tui/package.json | 2 +- packages/web-ui/package.json | 6 +++--- 8 files changed, 30 insertions(+), 30 deletions(-) diff --git a/package-lock.json b/package-lock.json index 118427fe..3aa125f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3847,11 +3847,11 @@ }, "packages/agent": { "name": "@mariozechner/pi-agent", - "version": "0.8.4", + "version": "0.8.5", "license": "MIT", "dependencies": { - "@mariozechner/pi-ai": "^0.8.3", - "@mariozechner/pi-tui": "^0.8.3" + "@mariozechner/pi-ai": "^0.8.4", + "@mariozechner/pi-tui": "^0.8.4" }, "devDependencies": { "@types/node": "^24.3.0", @@ -3881,7 +3881,7 @@ }, "packages/ai": { "name": "@mariozechner/pi-ai", - "version": "0.8.4", + "version": "0.8.5", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.61.0", @@ -3922,12 +3922,12 @@ }, "packages/coding-agent": { "name": "@mariozechner/pi-coding-agent", - "version": "0.8.4", + "version": "0.8.5", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent": "^0.8.3", - "@mariozechner/pi-ai": "^0.8.3", - "@mariozechner/pi-tui": "^0.8.3", + "@mariozechner/pi-agent": "^0.8.4", + "@mariozechner/pi-ai": "^0.8.4", + "@mariozechner/pi-tui": "^0.8.4", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" @@ -3964,10 +3964,10 @@ }, "packages/pods": { "name": "@mariozechner/pi", - "version": "0.8.4", + "version": "0.8.5", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent": "^0.8.3", + "@mariozechner/pi-agent": "^0.8.4", "chalk": "^5.5.0" }, "bin": { @@ -3980,7 +3980,7 @@ }, "packages/proxy": { "name": "@mariozechner/pi-proxy", - "version": "0.8.4", + "version": "0.8.5", "dependencies": { "@hono/node-server": "^1.14.0", "hono": "^4.6.16" @@ -3996,7 +3996,7 @@ }, "packages/tui": { "name": "@mariozechner/pi-tui", - "version": "0.8.4", + "version": "0.8.5", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", @@ -4031,12 +4031,12 @@ }, "packages/web-ui": { "name": "@mariozechner/pi-web-ui", - "version": "0.8.4", + "version": "0.8.5", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.8.3", - "@mariozechner/pi-tui": "^0.8.3", + "@mariozechner/pi-ai": "^0.8.4", + "@mariozechner/pi-tui": "^0.8.4", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", diff --git a/packages/agent/package.json b/packages/agent/package.json index 763f5c27..f24ad119 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-agent", - "version": "0.8.4", + "version": "0.8.5", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -18,8 +18,8 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-ai": "^0.8.4", - "@mariozechner/pi-tui": "^0.8.4" + "@mariozechner/pi-ai": "^0.8.5", + "@mariozechner/pi-tui": "^0.8.5" }, "keywords": [ "ai", diff --git a/packages/ai/package.json b/packages/ai/package.json index 2b408903..3b510255 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-ai", - "version": "0.8.4", + "version": "0.8.5", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index f66214d9..b73b6edf 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-coding-agent", - "version": "0.8.4", + "version": "0.8.5", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "bin": { @@ -22,9 +22,9 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-agent": "^0.8.4", - "@mariozechner/pi-ai": "^0.8.4", - "@mariozechner/pi-tui": "^0.8.4", + "@mariozechner/pi-agent": "^0.8.5", + "@mariozechner/pi-ai": "^0.8.5", + "@mariozechner/pi-tui": "^0.8.5", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" diff --git a/packages/pods/package.json b/packages/pods/package.json index dd15d3b9..5a0ec29a 100644 --- a/packages/pods/package.json +++ b/packages/pods/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi", - "version": "0.8.4", + "version": "0.8.5", "description": "CLI tool for managing vLLM deployments on GPU pods", "type": "module", "bin": { @@ -34,7 +34,7 @@ "node": ">=20.0.0" }, "dependencies": { - "@mariozechner/pi-agent": "^0.8.4", + "@mariozechner/pi-agent": "^0.8.5", "chalk": "^5.5.0" }, "devDependencies": {} diff --git a/packages/proxy/package.json b/packages/proxy/package.json index c8278d7f..581c77c7 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-proxy", - "version": "0.8.4", + "version": "0.8.5", "type": "module", "description": "CORS and authentication proxy for pi-ai", "main": "dist/index.js", diff --git a/packages/tui/package.json b/packages/tui/package.json index 4fd93403..2cfe2f44 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-tui", - "version": "0.8.4", + "version": "0.8.5", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index 53e562b0..dfac68f5 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-web-ui", - "version": "0.8.4", + "version": "0.8.5", "description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai", "type": "module", "main": "dist/index.js", @@ -18,8 +18,8 @@ }, "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.8.4", - "@mariozechner/pi-tui": "^0.8.4", + "@mariozechner/pi-ai": "^0.8.5", + "@mariozechner/pi-tui": "^0.8.5", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", From 59af0fd53a6128392a436decae967e0631dd2f33 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 21 Nov 2025 20:14:32 +0100 Subject: [PATCH 3/4] fix: markdown link rendering for links with identical text and href --- packages/coding-agent/CHANGELOG.md | 4 ++++ packages/tui/src/components/markdown.ts | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 82e1cb74..48ba1695 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### 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. + ## [0.8.5] - 2025-11-21 ### Fixed diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 79b03ddb..fa59d48c 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -321,7 +321,8 @@ export class Markdown implements Component { case "link": { const linkText = this.renderInlineTokens(token.tokens || []); // If link text matches href, only show the link once - if (linkText === token.href) { + // Compare raw text (token.text) not styled text (linkText) since linkText has ANSI codes + if (token.text === token.href) { result += this.theme.link(this.theme.underline(linkText)) + this.applyDefaultStyle(""); } else { result += From 1b6a70ccb15c432b3f67adbfba344420dbd112e9 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 21 Nov 2025 20:59:00 +0100 Subject: [PATCH 4/4] 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();