From 1507f8b7a3efb033bf49f37adab077902ecdd114 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 8 Dec 2025 23:54:41 +0100 Subject: [PATCH] Add refactoring plan for coding-agent --- packages/coding-agent/docs/refactor.md | 1668 ++++++++++++++++++++++++ 1 file changed, 1668 insertions(+) create mode 100644 packages/coding-agent/docs/refactor.md diff --git a/packages/coding-agent/docs/refactor.md b/packages/coding-agent/docs/refactor.md new file mode 100644 index 00000000..cde5adce --- /dev/null +++ b/packages/coding-agent/docs/refactor.md @@ -0,0 +1,1668 @@ +# Coding Agent Refactoring Plan + +## Status + +**Branch:** `refactor` +**Started:** 2024-12-08 + +To resume work on this refactoring: +1. Read this document fully +2. Run `git diff` to see current work in progress +3. Check the work packages below - find first unchecked item +4. Read any files mentioned in that work package before making changes + +--- + +## Goals + +1. **Eliminate code duplication** between the three run modes (interactive, print/json, rpc) +2. **Create a testable core** (`AgentSession`) that encapsulates all agent/session logic +3. **Separate concerns**: TUI rendering vs agent state management vs I/O +4. **Improve naming**: `TuiRenderer` → `InteractiveMode` (it's not just a renderer) +5. **Simplify main.ts**: Move setup logic out, make it just arg parsing + mode routing + +--- + +## Architecture Overview + +### Current State (Problems) + +``` +main.ts (1100+ lines) +├── parseArgs, printHelp +├── buildSystemPrompt, loadProjectContextFiles +├── resolveModelScope, model resolution logic +├── runInteractiveMode() - thin wrapper around TuiRenderer +├── runSingleShotMode() - duplicates event handling, session saving +├── runRpcMode() - duplicates event handling, session saving, auto-compaction, bash execution +└── executeRpcBashCommand() - duplicate of TuiRenderer.executeBashCommand() + +tui/tui-renderer.ts (2400+ lines) +├── TUI lifecycle (init, render, event loop) +├── Agent event handling + session persistence (duplicated in main.ts) +├── Auto-compaction logic (duplicated in main.ts runRpcMode) +├── Bash execution (duplicated in main.ts) +├── All slash command implementations (/export, /copy, /model, /thinking, etc.) +├── All hotkey handlers (Ctrl+C, Ctrl+P, Shift+Tab, etc.) +├── Model/thinking cycling logic +└── 6 different selector UIs (model, thinking, theme, session, branch, oauth) +``` + +### Target State + +``` +src/ +├── main.ts (~200 lines) +│ ├── parseArgs, printHelp +│ └── Route to appropriate mode +│ +├── core/ +│ ├── agent-session.ts # Shared agent/session logic (THE key abstraction) +│ ├── bash-executor.ts # Bash execution with streaming + cancellation +│ └── setup.ts # Model resolution, system prompt building, session loading +│ +└── modes/ + ├── print-mode.ts # Simple: prompt, output result + ├── rpc-mode.ts # JSON stdin/stdout protocol + └── interactive/ + ├── interactive-mode.ts # Main orchestrator + ├── command-handlers.ts # Slash command implementations + ├── hotkeys.ts # Hotkey handling + └── selectors.ts # Modal selector management +``` + +--- + +## AgentSession API + +This is the core abstraction shared by all modes. See full API design below. + +```typescript +class AgentSession { + // State access + get state(): AgentState; + get model(): Model | null; + get thinkingLevel(): ThinkingLevel; + get isStreaming(): boolean; + get messages(): Message[]; + + // Event subscription (handles session persistence internally) + subscribe(listener: (event: AgentEvent) => void): () => void; + + // Prompting + prompt(text: string, options?: PromptOptions): Promise; + queueMessage(text: string): Promise; + clearQueue(): string[]; + abort(): Promise; + reset(): Promise; + + // Model management + setModel(model: Model): Promise; + cycleModel(): Promise; + getAvailableModels(): Promise[]>; + + // Thinking level + setThinkingLevel(level: ThinkingLevel): void; + cycleThinkingLevel(): ThinkingLevel | null; + supportsThinking(): boolean; + + // Compaction + compact(customInstructions?: string): Promise; + abortCompaction(): void; + checkAutoCompaction(): Promise; + setAutoCompactionEnabled(enabled: boolean): void; + get autoCompactionEnabled(): boolean; + + // Bash execution + executeBash(command: string, onChunk?: (chunk: string) => void): Promise; + abortBash(): void; + get isBashRunning(): boolean; + + // Session management + switchSession(sessionPath: string): Promise; + branch(entryIndex: number): string; + getUserMessagesForBranching(): Array<{ entryIndex: number; text: string }>; + getSessionStats(): SessionStats; + exportToHtml(outputPath?: string): string; + + // Utilities + getLastAssistantText(): string | null; +} +``` + +--- + +## Work Packages + +### WP1: Create bash-executor.ts +> Extract bash execution into a standalone module that both AgentSession and tests can use. + +**Files to create:** +- `src/core/bash-executor.ts` + +**Extract from:** +- `src/tui/tui-renderer.ts`: `executeBashCommand()` method (lines ~2190-2270) +- `src/main.ts`: `executeRpcBashCommand()` function (lines ~640-700) + +**Implementation:** +```typescript +// src/core/bash-executor.ts +export interface BashExecutorOptions { + onChunk?: (chunk: string) => void; + signal?: AbortSignal; +} + +export interface BashResult { + output: string; + exitCode: number | null; + cancelled: boolean; + truncated: boolean; + fullOutputPath?: string; +} + +export function executeBash(command: string, options?: BashExecutorOptions): Promise; +``` + +**Logic to include:** +- Spawn shell process with `getShellConfig()` +- Stream stdout/stderr through `onChunk` callback (if provided) +- Handle temp file creation for large output (> DEFAULT_MAX_BYTES) +- Sanitize output (stripAnsi, sanitizeBinaryOutput, normalize newlines) +- Apply truncation via `truncateTail()` +- Support cancellation via AbortSignal (calls `killProcessTree`) +- Return structured result + +**Verification:** +1. `npm run check` passes +2. Manual test: Run `pi` in interactive mode, execute `!ls -la`, verify output appears +3. Manual test: Run `!sleep 10`, press Esc, verify cancellation works + +- [ ] Create `src/core/bash-executor.ts` with `executeBash()` function +- [ ] Add proper TypeScript types and exports +- [ ] Verify with `npm run check` + +--- + +### WP2: Create agent-session.ts (Core Structure) +> Create the AgentSession class with basic structure and state access. + +**Files to create:** +- `src/core/agent-session.ts` +- `src/core/index.ts` (barrel export) + +**Dependencies:** None (can use existing imports) + +**Implementation - Phase 1 (structure + state access):** +```typescript +// src/core/agent-session.ts +import type { Agent, AgentEvent, AgentState, ThinkingLevel } from "@mariozechner/pi-agent-core"; +import type { Model, Message } from "@mariozechner/pi-ai"; +import type { SessionManager } from "../session-manager.js"; +import type { SettingsManager } from "../settings-manager.js"; + +export interface AgentSessionConfig { + agent: Agent; + sessionManager: SessionManager; + settingsManager: SettingsManager; + scopedModels?: Array<{ model: Model; thinkingLevel: ThinkingLevel }>; + fileCommands?: FileSlashCommand[]; +} + +export class AgentSession { + readonly agent: Agent; + readonly sessionManager: SessionManager; + readonly settingsManager: SettingsManager; + + private scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }>; + private fileCommands: FileSlashCommand[]; + + constructor(config: AgentSessionConfig) { + this.agent = config.agent; + this.sessionManager = config.sessionManager; + this.settingsManager = config.settingsManager; + this.scopedModels = config.scopedModels ?? []; + this.fileCommands = config.fileCommands ?? []; + } + + // State access (simple getters) + get state(): AgentState { return this.agent.state; } + get model(): Model | null { return this.agent.state.model; } + get thinkingLevel(): ThinkingLevel { return this.agent.state.thinkingLevel; } + get isStreaming(): boolean { return this.agent.state.isStreaming; } + get messages(): Message[] { return this.agent.state.messages; } + get sessionFile(): string { return this.sessionManager.getSessionFile(); } + get sessionId(): string { return this.sessionManager.getSessionId(); } +} +``` + +**Verification:** +1. `npm run check` passes +2. Class can be instantiated (will test via later integration) + +- [ ] Create `src/core/agent-session.ts` with basic structure +- [ ] Create `src/core/index.ts` barrel export +- [ ] Verify with `npm run check` + +--- + +### WP3: AgentSession - Event Subscription + Session Persistence +> Add subscribe() method that wraps agent subscription and handles session persistence. + +**Files to modify:** +- `src/core/agent-session.ts` + +**Extract from:** +- `src/tui/tui-renderer.ts`: `subscribeToAgent()` method (lines ~470-495) +- `src/main.ts`: `runRpcMode()` subscription logic (lines ~720-745) +- `src/main.ts`: `runSingleShotMode()` subscription logic (lines ~605-610) + +**Implementation:** +```typescript +// Add to AgentSession class + +private unsubscribeAgent?: () => void; +private eventListeners: Array<(event: AgentEvent) => void> = []; + +/** + * Subscribe to agent events. Session persistence is handled internally. + * Multiple listeners can be added. Returns unsubscribe function. + */ +subscribe(listener: (event: AgentEvent) => void): () => void { + this.eventListeners.push(listener); + + // Set up agent subscription if not already done + if (!this.unsubscribeAgent) { + this.unsubscribeAgent = this.agent.subscribe(async (event) => { + // Notify all listeners + for (const l of this.eventListeners) { + l(event); + } + + // Handle session persistence + if (event.type === "message_end") { + this.sessionManager.saveMessage(event.message); + + // Initialize session after first user+assistant exchange + if (this.sessionManager.shouldInitializeSession(this.agent.state.messages)) { + this.sessionManager.startSession(this.agent.state); + } + + // Check auto-compaction after assistant messages + if (event.message.role === "assistant") { + await this.checkAutoCompaction(); + } + } + }); + } + + // Return unsubscribe function for this specific listener + return () => { + const index = this.eventListeners.indexOf(listener); + if (index !== -1) { + this.eventListeners.splice(index, 1); + } + }; +} + +/** + * Unsubscribe from agent entirely (used during cleanup/reset) + */ +private unsubscribeAll(): void { + if (this.unsubscribeAgent) { + this.unsubscribeAgent(); + this.unsubscribeAgent = undefined; + } + this.eventListeners = []; +} +``` + +**Verification:** +1. `npm run check` passes + +- [ ] Add `subscribe()` method to AgentSession +- [ ] Add `unsubscribeAll()` private method +- [ ] Verify with `npm run check` + +--- + +### WP4: AgentSession - Prompting Methods +> Add prompt(), queueMessage(), clearQueue(), abort(), reset() methods. + +**Files to modify:** +- `src/core/agent-session.ts` + +**Extract from:** +- `src/tui/tui-renderer.ts`: editor.onSubmit validation logic (lines ~340-380) +- `src/tui/tui-renderer.ts`: handleClearCommand() (lines ~2005-2035) +- Slash command expansion from `expandSlashCommand()` + +**Implementation:** +```typescript +// Add to AgentSession class + +private queuedMessages: string[] = []; + +/** + * Send a prompt to the agent. + * - Validates model and API key + * - Expands slash commands by default + * - Throws if no model or no API key + */ +async prompt(text: string, options?: { + expandSlashCommands?: boolean; + attachments?: Attachment[]; +}): Promise { + const expandCommands = options?.expandSlashCommands ?? true; + + // Validate model + if (!this.model) { + throw new Error( + "No model selected.\n\n" + + "Set an API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.)\n" + + `or create ${getModelsPath()}\n\n` + + "Then use /model to select a model." + ); + } + + // Validate API key + const apiKey = await getApiKeyForModel(this.model); + if (!apiKey) { + throw new Error( + `No API key found for ${this.model.provider}.\n\n` + + `Set the appropriate environment variable or update ${getModelsPath()}` + ); + } + + // Expand slash commands + const expandedText = expandCommands ? expandSlashCommand(text, this.fileCommands) : text; + + await this.agent.prompt(expandedText, options?.attachments); +} + +/** + * Queue a message while agent is streaming. + */ +async queueMessage(text: string): Promise { + this.queuedMessages.push(text); + await this.agent.queueMessage({ + role: "user", + content: [{ type: "text", text }], + timestamp: Date.now(), + }); +} + +/** + * Clear queued messages. Returns them for restoration to editor. + */ +clearQueue(): string[] { + const queued = [...this.queuedMessages]; + this.queuedMessages = []; + this.agent.clearMessageQueue(); + return queued; +} + +/** + * Abort current operation and wait for idle. + */ +async abort(): Promise { + this.agent.abort(); + await this.agent.waitForIdle(); +} + +/** + * Reset agent and session. Starts a fresh session. + */ +async reset(): Promise { + this.unsubscribeAll(); + await this.abort(); + this.agent.reset(); + this.sessionManager.reset(); + this.queuedMessages = []; + // Re-subscribe (caller may have added listeners before reset) + // Actually, listeners are cleared in unsubscribeAll, so caller needs to re-subscribe +} +``` + +**Verification:** +1. `npm run check` passes + +- [ ] Add `prompt()` method with validation and slash command expansion +- [ ] Add `queueMessage()` method +- [ ] Add `clearQueue()` method +- [ ] Add `abort()` method +- [ ] Add `reset()` method +- [ ] Verify with `npm run check` + +--- + +### WP5: AgentSession - Model Management +> Add setModel(), cycleModel(), getAvailableModels() methods. + +**Files to modify:** +- `src/core/agent-session.ts` + +**Extract from:** +- `src/tui/tui-renderer.ts`: `cycleModel()` method (lines ~970-1070) +- Model validation scattered throughout + +**Implementation:** +```typescript +// Add to AgentSession class + +export interface ModelCycleResult { + model: Model; + thinkingLevel: ThinkingLevel; + isScoped: boolean; +} + +/** + * Set model directly. Validates API key, saves to session and settings. + */ +async setModel(model: Model): Promise { + const apiKey = await getApiKeyForModel(model); + if (!apiKey) { + throw new Error(`No API key for ${model.provider}/${model.id}`); + } + + this.agent.setModel(model); + this.sessionManager.saveModelChange(model.provider, model.id); + this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); +} + +/** + * Cycle to next model. Uses scoped models if available. + * Returns null if only one model available. + */ +async cycleModel(): Promise { + if (this.scopedModels.length > 0) { + return this.cycleScopedModel(); + } else { + return this.cycleAvailableModel(); + } +} + +private async cycleScopedModel(): Promise { + if (this.scopedModels.length <= 1) return null; + + const currentModel = this.model; + let currentIndex = this.scopedModels.findIndex( + (sm) => sm.model.id === currentModel?.id && sm.model.provider === currentModel?.provider + ); + + if (currentIndex === -1) currentIndex = 0; + const nextIndex = (currentIndex + 1) % this.scopedModels.length; + const next = this.scopedModels[nextIndex]; + + // Validate API key + const apiKey = await getApiKeyForModel(next.model); + if (!apiKey) { + throw new Error(`No API key for ${next.model.provider}/${next.model.id}`); + } + + // Apply model + this.agent.setModel(next.model); + this.sessionManager.saveModelChange(next.model.provider, next.model.id); + this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id); + + // Apply thinking level (silently use "off" if not supported) + const effectiveThinking = next.model.reasoning ? next.thinkingLevel : "off"; + this.agent.setThinkingLevel(effectiveThinking); + this.sessionManager.saveThinkingLevelChange(effectiveThinking); + this.settingsManager.setDefaultThinkingLevel(effectiveThinking); + + return { model: next.model, thinkingLevel: effectiveThinking, isScoped: true }; +} + +private async cycleAvailableModel(): Promise { + const { models: availableModels, error } = await getAvailableModels(); + if (error) throw new Error(`Failed to load models: ${error}`); + if (availableModels.length <= 1) return null; + + const currentModel = this.model; + let currentIndex = availableModels.findIndex( + (m) => m.id === currentModel?.id && m.provider === currentModel?.provider + ); + + if (currentIndex === -1) currentIndex = 0; + const nextIndex = (currentIndex + 1) % availableModels.length; + const nextModel = availableModels[nextIndex]; + + const apiKey = await getApiKeyForModel(nextModel); + if (!apiKey) { + throw new Error(`No API key for ${nextModel.provider}/${nextModel.id}`); + } + + this.agent.setModel(nextModel); + this.sessionManager.saveModelChange(nextModel.provider, nextModel.id); + this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id); + + return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false }; +} + +/** + * Get all available models with valid API keys. + */ +async getAvailableModels(): Promise[]> { + const { models, error } = await getAvailableModels(); + if (error) throw new Error(error); + return models; +} +``` + +**Verification:** +1. `npm run check` passes + +- [ ] Add `ModelCycleResult` interface +- [ ] Add `setModel()` method +- [ ] Add `cycleModel()` method with scoped/available variants +- [ ] Add `getAvailableModels()` method +- [ ] Verify with `npm run check` + +--- + +### WP6: AgentSession - Thinking Level Management +> Add setThinkingLevel(), cycleThinkingLevel(), supportsThinking() methods. + +**Files to modify:** +- `src/core/agent-session.ts` + +**Extract from:** +- `src/tui/tui-renderer.ts`: `cycleThinkingLevel()` method (lines ~940-970) + +**Implementation:** +```typescript +// Add to AgentSession class + +/** + * Set thinking level. Silently uses "off" if model doesn't support it. + * Saves to session and settings. + */ +setThinkingLevel(level: ThinkingLevel): void { + const effectiveLevel = this.supportsThinking() ? level : "off"; + this.agent.setThinkingLevel(effectiveLevel); + this.sessionManager.saveThinkingLevelChange(effectiveLevel); + this.settingsManager.setDefaultThinkingLevel(effectiveLevel); +} + +/** + * Cycle to next thinking level. + * Returns new level, or null if model doesn't support thinking. + */ +cycleThinkingLevel(): ThinkingLevel | null { + if (!this.supportsThinking()) return null; + + const modelId = this.model?.id || ""; + const supportsXhigh = modelId.includes("codex-max"); + const levels: ThinkingLevel[] = supportsXhigh + ? ["off", "minimal", "low", "medium", "high", "xhigh"] + : ["off", "minimal", "low", "medium", "high"]; + + const currentIndex = levels.indexOf(this.thinkingLevel); + const nextIndex = (currentIndex + 1) % levels.length; + const nextLevel = levels[nextIndex]; + + this.setThinkingLevel(nextLevel); + return nextLevel; +} + +/** + * Check if current model supports thinking. + */ +supportsThinking(): boolean { + return !!this.model?.reasoning; +} +``` + +**Verification:** +1. `npm run check` passes + +- [ ] Add `setThinkingLevel()` method +- [ ] Add `cycleThinkingLevel()` method +- [ ] Add `supportsThinking()` method +- [ ] Verify with `npm run check` + +--- + +### WP7: AgentSession - Compaction +> Add compact(), abortCompaction(), checkAutoCompaction(), autoCompactionEnabled methods. + +**Files to modify:** +- `src/core/agent-session.ts` + +**Extract from:** +- `src/tui/tui-renderer.ts`: `executeCompaction()` (lines ~2280-2370) +- `src/tui/tui-renderer.ts`: `checkAutoCompaction()` (lines ~495-525) +- `src/main.ts`: `runRpcMode()` auto-compaction logic (lines ~730-770) + +**Implementation:** +```typescript +// Add to AgentSession class + +export interface CompactionResult { + tokensBefore: number; + tokensAfter: number; + summary: string; +} + +private compactionAbortController: AbortController | null = null; + +/** + * Manually compact the session context. + * Aborts current agent operation first. + */ +async compact(customInstructions?: string): Promise { + // Abort any running operation + this.unsubscribeAll(); + await this.abort(); + + // Create abort controller + this.compactionAbortController = new AbortController(); + + try { + const apiKey = await getApiKeyForModel(this.model!); + if (!apiKey) { + throw new Error(`No API key for ${this.model!.provider}`); + } + + const entries = this.sessionManager.loadEntries(); + const settings = this.settingsManager.getCompactionSettings(); + const compactionEntry = await compact( + entries, + this.model!, + settings, + apiKey, + this.compactionAbortController.signal, + customInstructions, + ); + + if (this.compactionAbortController.signal.aborted) { + throw new Error("Compaction cancelled"); + } + + // Save and reload + this.sessionManager.saveCompaction(compactionEntry); + const loaded = loadSessionFromEntries(this.sessionManager.loadEntries()); + this.agent.replaceMessages(loaded.messages); + + return { + tokensBefore: compactionEntry.tokensBefore, + tokensAfter: compactionEntry.tokensAfter, + summary: compactionEntry.summary, + }; + } finally { + this.compactionAbortController = null; + // Note: caller needs to re-subscribe after compaction + } +} + +/** + * Cancel in-progress compaction. + */ +abortCompaction(): void { + this.compactionAbortController?.abort(); +} + +/** + * Check if auto-compaction should run, and run if so. + * Returns result if compaction occurred, null otherwise. + */ +async checkAutoCompaction(): Promise { + const settings = this.settingsManager.getCompactionSettings(); + if (!settings.enabled) return null; + + // Get last non-aborted assistant message + const messages = this.messages; + let lastAssistant: AssistantMessage | null = null; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role === "assistant") { + const assistantMsg = msg as AssistantMessage; + if (assistantMsg.stopReason !== "aborted") { + lastAssistant = assistantMsg; + break; + } + } + } + if (!lastAssistant) return null; + + const contextTokens = calculateContextTokens(lastAssistant.usage); + const contextWindow = this.model?.contextWindow ?? 0; + + if (!shouldCompact(contextTokens, contextWindow, settings)) return null; + + // Perform auto-compaction (don't abort current operation for auto) + try { + const apiKey = await getApiKeyForModel(this.model!); + if (!apiKey) return null; + + const entries = this.sessionManager.loadEntries(); + const compactionEntry = await compact(entries, this.model!, settings, apiKey); + + this.sessionManager.saveCompaction(compactionEntry); + const loaded = loadSessionFromEntries(this.sessionManager.loadEntries()); + this.agent.replaceMessages(loaded.messages); + + return { + tokensBefore: compactionEntry.tokensBefore, + tokensAfter: compactionEntry.tokensAfter, + summary: compactionEntry.summary, + }; + } catch { + return null; // Silently fail auto-compaction + } +} + +/** + * Toggle auto-compaction setting. + */ +setAutoCompactionEnabled(enabled: boolean): void { + this.settingsManager.setCompactionEnabled(enabled); +} + +get autoCompactionEnabled(): boolean { + return this.settingsManager.getCompactionEnabled(); +} +``` + +**Verification:** +1. `npm run check` passes + +- [ ] Add `CompactionResult` interface +- [ ] Add `compact()` method +- [ ] Add `abortCompaction()` method +- [ ] Add `checkAutoCompaction()` method +- [ ] Add `setAutoCompactionEnabled()` and getter +- [ ] Verify with `npm run check` + +--- + +### WP8: AgentSession - Bash Execution +> Add executeBash(), abortBash(), isBashRunning using the bash-executor module. + +**Files to modify:** +- `src/core/agent-session.ts` + +**Dependencies:** WP1 (bash-executor.ts) + +**Implementation:** +```typescript +// Add to AgentSession class + +import { executeBash as executeBashCommand, type BashResult } from "./bash-executor.js"; +import type { BashExecutionMessage } from "../messages.js"; + +private bashAbortController: AbortController | null = null; + +/** + * Execute a bash command. Adds result to agent context and session. + */ +async executeBash(command: string, onChunk?: (chunk: string) => void): Promise { + this.bashAbortController = new AbortController(); + + try { + const result = await executeBashCommand(command, { + onChunk, + signal: this.bashAbortController.signal, + }); + + // Create and save message + const bashMessage: BashExecutionMessage = { + role: "bashExecution", + command, + output: result.output, + exitCode: result.exitCode, + cancelled: result.cancelled, + truncated: result.truncated, + fullOutputPath: result.fullOutputPath, + timestamp: Date.now(), + }; + + this.agent.appendMessage(bashMessage); + this.sessionManager.saveMessage(bashMessage); + + // Initialize session if needed + if (this.sessionManager.shouldInitializeSession(this.agent.state.messages)) { + this.sessionManager.startSession(this.agent.state); + } + + return result; + } finally { + this.bashAbortController = null; + } +} + +/** + * Cancel running bash command. + */ +abortBash(): void { + this.bashAbortController?.abort(); +} + +get isBashRunning(): boolean { + return this.bashAbortController !== null; +} +``` + +**Verification:** +1. `npm run check` passes + +- [ ] Add bash execution methods using bash-executor module +- [ ] Verify with `npm run check` + +--- + +### WP9: AgentSession - Session Management +> Add switchSession(), branch(), getUserMessagesForBranching(), getSessionStats(), exportToHtml(). + +**Files to modify:** +- `src/core/agent-session.ts` + +**Extract from:** +- `src/tui/tui-renderer.ts`: `handleResumeSession()` (lines ~1650-1710) +- `src/tui/tui-renderer.ts`: `showUserMessageSelector()` branch logic (lines ~1560-1600) +- `src/tui/tui-renderer.ts`: `handleSessionCommand()` (lines ~1870-1930) + +**Implementation:** +```typescript +// Add to AgentSession class + +export interface SessionStats { + sessionFile: string; + sessionId: string; + userMessages: number; + assistantMessages: number; + toolCalls: number; + toolResults: number; + totalMessages: number; + tokens: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; + }; + cost: number; +} + +/** + * Switch to a different session file. + * Aborts current operation, loads messages, restores model/thinking. + */ +async switchSession(sessionPath: string): Promise { + this.unsubscribeAll(); + await this.abort(); + this.queuedMessages = []; + + this.sessionManager.setSessionFile(sessionPath); + const loaded = loadSessionFromEntries(this.sessionManager.loadEntries()); + this.agent.replaceMessages(loaded.messages); + + // Restore model + const savedModel = this.sessionManager.loadModel(); + if (savedModel) { + const availableModels = (await getAvailableModels()).models; + const match = availableModels.find( + (m) => m.provider === savedModel.provider && m.id === savedModel.modelId + ); + if (match) { + this.agent.setModel(match); + } + } + + // Restore thinking level + const savedThinking = this.sessionManager.loadThinkingLevel(); + if (savedThinking) { + this.agent.setThinkingLevel(savedThinking as ThinkingLevel); + } + + // Note: caller needs to re-subscribe after switch +} + +/** + * Create a branch from a specific entry index. + * Returns the text of the selected user message (for editor pre-fill). + */ +branch(entryIndex: number): string { + const entries = this.sessionManager.loadEntries(); + const selectedEntry = entries[entryIndex]; + + if (selectedEntry.type !== "message" || selectedEntry.message.role !== "user") { + throw new Error("Invalid entry index for branching"); + } + + const selectedText = this.extractUserMessageText(selectedEntry.message.content); + + // Create branched session + const newSessionFile = this.sessionManager.createBranchedSessionFromEntries(entries, entryIndex); + this.sessionManager.setSessionFile(newSessionFile); + + // Reload + const loaded = loadSessionFromEntries(this.sessionManager.loadEntries()); + this.agent.replaceMessages(loaded.messages); + + return selectedText; +} + +/** + * Get all user messages from session for branch selector. + */ +getUserMessagesForBranching(): Array<{ entryIndex: number; text: string }> { + const entries = this.sessionManager.loadEntries(); + const result: Array<{ entryIndex: number; text: string }> = []; + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (entry.type !== "message") continue; + if (entry.message.role !== "user") continue; + + const text = this.extractUserMessageText(entry.message.content); + if (text) { + result.push({ entryIndex: i, text }); + } + } + + return result; +} + +private extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + } + return ""; +} + +/** + * Get session statistics. + */ +getSessionStats(): SessionStats { + const state = this.state; + const userMessages = state.messages.filter((m) => m.role === "user").length; + const assistantMessages = state.messages.filter((m) => m.role === "assistant").length; + const toolResults = state.messages.filter((m) => m.role === "toolResult").length; + + let toolCalls = 0; + let totalInput = 0; + let totalOutput = 0; + let totalCacheRead = 0; + let totalCacheWrite = 0; + let totalCost = 0; + + for (const message of state.messages) { + if (message.role === "assistant") { + const assistantMsg = message as AssistantMessage; + toolCalls += assistantMsg.content.filter((c) => c.type === "toolCall").length; + totalInput += assistantMsg.usage.input; + totalOutput += assistantMsg.usage.output; + totalCacheRead += assistantMsg.usage.cacheRead; + totalCacheWrite += assistantMsg.usage.cacheWrite; + totalCost += assistantMsg.usage.cost.total; + } + } + + return { + sessionFile: this.sessionFile, + sessionId: this.sessionId, + userMessages, + assistantMessages, + toolCalls, + toolResults, + totalMessages: state.messages.length, + tokens: { + input: totalInput, + output: totalOutput, + cacheRead: totalCacheRead, + cacheWrite: totalCacheWrite, + total: totalInput + totalOutput + totalCacheRead + totalCacheWrite, + }, + cost: totalCost, + }; +} + +/** + * Export session to HTML. + */ +exportToHtml(outputPath?: string): string { + return exportSessionToHtml(this.sessionManager, this.state, outputPath); +} +``` + +**Verification:** +1. `npm run check` passes + +- [ ] Add `SessionStats` interface +- [ ] Add `switchSession()` method +- [ ] Add `branch()` method +- [ ] Add `getUserMessagesForBranching()` method +- [ ] Add `getSessionStats()` method +- [ ] Add `exportToHtml()` method +- [ ] Verify with `npm run check` + +--- + +### WP10: AgentSession - Utility Methods +> Add getLastAssistantText() and any remaining utilities. + +**Files to modify:** +- `src/core/agent-session.ts` + +**Extract from:** +- `src/tui/tui-renderer.ts`: `handleCopyCommand()` (lines ~1840-1870) + +**Implementation:** +```typescript +// Add to AgentSession class + +/** + * Get text content of last assistant message (for /copy). + * Returns null if no assistant message exists. + */ +getLastAssistantText(): string | null { + const lastAssistant = this.messages + .slice() + .reverse() + .find((m) => m.role === "assistant"); + + if (!lastAssistant) return null; + + let text = ""; + for (const content of lastAssistant.content) { + if (content.type === "text") { + text += content.text; + } + } + + return text.trim() || null; +} + +/** + * Get queued message count (for UI display). + */ +get queuedMessageCount(): number { + return this.queuedMessages.length; +} + +/** + * Get queued messages (for display, not modification). + */ +getQueuedMessages(): readonly string[] { + return this.queuedMessages; +} +``` + +**Verification:** +1. `npm run check` passes + +- [ ] Add `getLastAssistantText()` method +- [ ] Add `queuedMessageCount` getter +- [ ] Add `getQueuedMessages()` method +- [ ] Verify with `npm run check` + +--- + +### WP11: Create print-mode.ts +> Extract single-shot mode into its own module using AgentSession. + +**Files to create:** +- `src/modes/print-mode.ts` + +**Extract from:** +- `src/main.ts`: `runSingleShotMode()` function (lines ~615-640) + +**Implementation:** +```typescript +// src/modes/print-mode.ts + +import type { Attachment } from "@mariozechner/pi-agent-core"; +import type { AssistantMessage } from "@mariozechner/pi-ai"; +import type { AgentSession } from "../core/agent-session.js"; + +export async function runPrintMode( + session: AgentSession, + mode: "text" | "json", + messages: string[], + initialMessage?: string, + initialAttachments?: Attachment[], +): Promise { + + if (mode === "json") { + // Output all events as JSON + session.subscribe((event) => { + console.log(JSON.stringify(event)); + }); + } + + // Send initial message with attachments + if (initialMessage) { + await session.prompt(initialMessage, { attachments: initialAttachments }); + } + + // Send remaining messages + for (const message of messages) { + await session.prompt(message); + } + + // In text mode, output final response + if (mode === "text") { + const state = session.state; + const lastMessage = state.messages[state.messages.length - 1]; + + if (lastMessage?.role === "assistant") { + const assistantMsg = lastMessage as AssistantMessage; + + // Check for error/aborted + if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") { + console.error(assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`); + process.exit(1); + } + + // Output text content + for (const content of assistantMsg.content) { + if (content.type === "text") { + console.log(content.text); + } + } + } + } +} +``` + +**Verification:** +1. `npm run check` passes +2. Manual test: `pi -p "echo hello"` still works + +- [ ] Create `src/modes/print-mode.ts` +- [ ] Verify with `npm run check` + +--- + +### WP12: Create rpc-mode.ts +> Extract RPC mode into its own module using AgentSession. + +**Files to create:** +- `src/modes/rpc-mode.ts` + +**Extract from:** +- `src/main.ts`: `runRpcMode()` function (lines ~700-800) + +**Implementation:** +```typescript +// src/modes/rpc-mode.ts + +import * as readline from "readline"; +import type { AgentSession } from "../core/agent-session.js"; + +export async function runRpcMode(session: AgentSession): Promise { + // Output all events as JSON + session.subscribe((event) => { + console.log(JSON.stringify(event)); + + // Emit auto-compaction events + // (checkAutoCompaction is called internally by AgentSession after assistant messages) + }); + + // Listen for JSON input + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false, + }); + + rl.on("line", async (line: string) => { + try { + const input = JSON.parse(line); + + switch (input.type) { + case "prompt": + if (input.message) { + await session.prompt(input.message, { + attachments: input.attachments, + expandSlashCommands: false, // RPC mode doesn't expand slash commands + }); + } + break; + + case "abort": + await session.abort(); + break; + + case "compact": + try { + const result = await session.compact(input.customInstructions); + console.log(JSON.stringify({ type: "compaction", ...result })); + } catch (error: any) { + console.log(JSON.stringify({ type: "error", error: `Compaction failed: ${error.message}` })); + } + break; + + case "bash": + if (input.command) { + try { + const result = await session.executeBash(input.command); + console.log(JSON.stringify({ type: "bash_end", message: result })); + } catch (error: any) { + console.log(JSON.stringify({ type: "error", error: `Bash failed: ${error.message}` })); + } + } + break; + + default: + console.log(JSON.stringify({ type: "error", error: `Unknown command: ${input.type}` })); + } + } catch (error: any) { + console.log(JSON.stringify({ type: "error", error: error.message })); + } + }); + + // Keep process alive forever + return new Promise(() => {}); +} +``` + +**Verification:** +1. `npm run check` passes +2. Manual test: RPC mode still works (if you have a way to test it) + +- [ ] Create `src/modes/rpc-mode.ts` +- [ ] Verify with `npm run check` + +--- + +### WP13: Create modes/index.ts barrel export +> Create barrel export for all modes. + +**Files to create:** +- `src/modes/index.ts` + +**Implementation:** +```typescript +// src/modes/index.ts +export { runPrintMode } from "./print-mode.js"; +export { runRpcMode } from "./rpc-mode.js"; +// InteractiveMode will be added later +``` + +- [ ] Create `src/modes/index.ts` +- [ ] Verify with `npm run check` + +--- + +### WP14: Update main.ts to use AgentSession and new modes +> Refactor main.ts to use AgentSession and the new mode modules. + +**Files to modify:** +- `src/main.ts` + +**Changes:** +1. Remove `runSingleShotMode()` function (replaced by print-mode.ts) +2. Remove `runRpcMode()` function (replaced by rpc-mode.ts) +3. Remove `executeRpcBashCommand()` function (replaced by bash-executor.ts) +4. Create `AgentSession` instance after agent setup +5. Pass `AgentSession` to mode functions + +**Key changes in main():** +```typescript +// After agent creation, create AgentSession +const session = new AgentSession({ + agent, + sessionManager, + settingsManager, + scopedModels, + fileCommands: loadSlashCommands(), +}); + +// Route to modes +if (mode === "rpc") { + await runRpcMode(session); +} else if (isInteractive) { + // For now, still use TuiRenderer directly (will refactor in WP15+) + await runInteractiveMode(agent, sessionManager, ...); +} else { + await runPrintMode(session, mode, parsed.messages, initialMessage, initialAttachments); +} +``` + +**Verification:** +1. `npm run check` passes +2. Manual test: `pi -p "hello"` works +3. Manual test: `pi --mode json "hello"` works +4. Manual test: `pi --mode rpc` works + +- [ ] Remove `runSingleShotMode()` from main.ts +- [ ] Remove `runRpcMode()` from main.ts +- [ ] Remove `executeRpcBashCommand()` from main.ts +- [ ] Import and use `runPrintMode` from modes +- [ ] Import and use `runRpcMode` from modes +- [ ] Create `AgentSession` in main() +- [ ] Update mode routing to use new functions +- [ ] Verify with `npm run check` +- [ ] Manual test all three modes + +--- + +### WP15: Refactor TuiRenderer to use AgentSession +> Update TuiRenderer to use AgentSession instead of direct agent/sessionManager access. + +**Files to modify:** +- `src/tui/tui-renderer.ts` + +**This is the largest change. Strategy:** +1. Change constructor to accept `AgentSession` instead of separate agent/sessionManager/settingsManager +2. Replace all `this.agent.*` calls with `this.session.agent.*` or appropriate AgentSession methods +3. Replace all `this.sessionManager.*` calls with AgentSession methods +4. Replace all `this.settingsManager.*` calls with AgentSession methods where applicable +5. Remove duplicated logic that now lives in AgentSession + +**Key replacements:** +| Old | New | +|-----|-----| +| `this.agent.prompt()` | `this.session.prompt()` | +| `this.agent.abort()` | `this.session.abort()` | +| `this.sessionManager.saveMessage()` | (handled internally by AgentSession.subscribe) | +| `this.cycleThinkingLevel()` | `this.session.cycleThinkingLevel()` | +| `this.cycleModel()` | `this.session.cycleModel()` | +| `this.executeBashCommand()` | `this.session.executeBash()` | +| `this.executeCompaction()` | `this.session.compact()` | +| `this.checkAutoCompaction()` | (handled internally by AgentSession) | +| `this.handleClearCommand()` reset logic | `this.session.reset()` | +| `this.handleResumeSession()` | `this.session.switchSession()` | + +**Constructor change:** +```typescript +// Old +constructor( + agent: Agent, + sessionManager: SessionManager, + settingsManager: SettingsManager, + version: string, + ... +) + +// New +constructor( + session: AgentSession, + version: string, + ... +) +``` + +**Verification:** +1. `npm run check` passes +2. Manual test: Full interactive mode works +3. Manual test: All slash commands work +4. Manual test: All hotkeys work +5. Manual test: Bash execution works +6. Manual test: Model/thinking cycling works + +- [ ] Change TuiRenderer constructor to accept AgentSession +- [ ] Update all agent access to go through session +- [ ] Remove `subscribeToAgent()` method (use session.subscribe) +- [ ] Remove `checkAutoCompaction()` method (handled by session) +- [ ] Update `cycleThinkingLevel()` to use session method +- [ ] Update `cycleModel()` to use session method +- [ ] Update bash execution to use session.executeBash() +- [ ] Update compaction to use session.compact() +- [ ] Update reset logic to use session.reset() +- [ ] Update session switching to use session.switchSession() +- [ ] Update branch logic to use session.branch() +- [ ] Remove all direct sessionManager access +- [ ] Verify with `npm run check` +- [ ] Manual test interactive mode thoroughly + +--- + +### WP16: Update runInteractiveMode to use AgentSession +> Update the runInteractiveMode function in main.ts to create and pass AgentSession. + +**Files to modify:** +- `src/main.ts` + +**Changes:** +```typescript +async function runInteractiveMode( + session: AgentSession, // Changed from individual params + version: string, + changelogMarkdown: string | null, + collapseChangelog: boolean, + modelFallbackMessage: string | null, + versionCheckPromise: Promise, + initialMessages: string[], + initialMessage?: string, + initialAttachments?: Attachment[], + fdPath: string | null, +): Promise { + const renderer = new TuiRenderer( + session, + version, + changelogMarkdown, + collapseChangelog, + fdPath, + ); + // ... rest stays similar +} +``` + +**Verification:** +1. `npm run check` passes +2. Manual test: Interactive mode works + +- [ ] Update `runInteractiveMode()` signature +- [ ] Update TuiRenderer instantiation +- [ ] Verify with `npm run check` + +--- + +### WP17: Rename TuiRenderer to InteractiveMode +> Rename the class and file to better reflect its purpose. + +**Files to rename/modify:** +- `src/tui/tui-renderer.ts` → `src/modes/interactive/interactive-mode.ts` +- Update all imports + +**Steps:** +1. Create `src/modes/interactive/` directory +2. Move and rename file +3. Rename class from `TuiRenderer` to `InteractiveMode` +4. Update imports in main.ts +5. Update barrel export in modes/index.ts + +**Verification:** +1. `npm run check` passes +2. Manual test: Interactive mode works + +- [ ] Create `src/modes/interactive/` directory +- [ ] Move `tui/tui-renderer.ts` to `modes/interactive/interactive-mode.ts` +- [ ] Rename class to `InteractiveMode` +- [ ] Update imports in main.ts +- [ ] Update modes/index.ts barrel export +- [ ] Verify with `npm run check` + +--- + +### WP18: Move remaining TUI components +> Move TUI-specific components to the interactive mode directory. + +**Files to move:** +- `src/tui/assistant-message.ts` → `src/modes/interactive/components/` +- `src/tui/bash-execution.ts` → `src/modes/interactive/components/` +- `src/tui/compaction.ts` → `src/modes/interactive/components/` +- `src/tui/custom-editor.ts` → `src/modes/interactive/components/` +- `src/tui/dynamic-border.ts` → `src/modes/interactive/components/` +- `src/tui/footer.ts` → `src/modes/interactive/components/` +- `src/tui/model-selector.ts` → `src/modes/interactive/selectors/` +- `src/tui/oauth-selector.ts` → `src/modes/interactive/selectors/` +- `src/tui/queue-mode-selector.ts` → `src/modes/interactive/selectors/` +- `src/tui/session-selector.ts` → `src/modes/interactive/selectors/` +- `src/tui/theme-selector.ts` → `src/modes/interactive/selectors/` +- `src/tui/thinking-selector.ts` → `src/modes/interactive/selectors/` +- `src/tui/tool-execution.ts` → `src/modes/interactive/components/` +- `src/tui/user-message.ts` → `src/modes/interactive/components/` +- `src/tui/user-message-selector.ts` → `src/modes/interactive/selectors/` + +**Note:** This is optional reorganization. Can be done later or skipped if too disruptive. + +- [ ] Create directory structure under `src/modes/interactive/` +- [ ] Move component files +- [ ] Move selector files +- [ ] Update all imports +- [ ] Remove empty `src/tui/` directory +- [ ] Verify with `npm run check` + +--- + +### WP19: Extract setup logic from main.ts +> Create setup.ts with model resolution, system prompt building, etc. + +**Files to create:** +- `src/core/setup.ts` + +**Extract from main.ts:** +- `buildSystemPrompt()` function +- `loadProjectContextFiles()` function +- `loadContextFileFromDir()` function +- `resolveModelScope()` function +- Model resolution logic (the priority system) +- Session loading/restoration logic + +**Implementation:** +```typescript +// src/core/setup.ts + +export interface SetupOptions { + provider?: string; + model?: string; + apiKey?: string; + systemPrompt?: string; + appendSystemPrompt?: string; + thinking?: ThinkingLevel; + continue?: boolean; + resume?: boolean; + models?: string[]; + tools?: ToolName[]; + sessionManager: SessionManager; + settingsManager: SettingsManager; +} + +export interface SetupResult { + agent: Agent; + initialModel: Model | null; + initialThinking: ThinkingLevel; + scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }>; + modelFallbackMessage: string | null; +} + +export async function setupAgent(options: SetupOptions): Promise; + +export function buildSystemPrompt( + customPrompt?: string, + selectedTools?: ToolName[], + appendSystemPrompt?: string +): string; + +export function loadProjectContextFiles(): Array<{ path: string; content: string }>; + +export async function resolveModelScope( + patterns: string[] +): Promise; thinkingLevel: ThinkingLevel }>>; +``` + +**Verification:** +1. `npm run check` passes +2. All modes still work + +- [ ] Create `src/core/setup.ts` +- [ ] Move `buildSystemPrompt()` from main.ts +- [ ] Move `loadProjectContextFiles()` from main.ts +- [ ] Move `loadContextFileFromDir()` from main.ts +- [ ] Move `resolveModelScope()` from main.ts +- [ ] Create `setupAgent()` function +- [ ] Update main.ts to use setup.ts +- [ ] Verify with `npm run check` + +--- + +### WP20: Final cleanup and documentation +> Clean up main.ts, add documentation, verify everything works. + +**Tasks:** +1. Remove any dead code from main.ts +2. Ensure main.ts is ~200-300 lines (just arg parsing + routing) +3. Add JSDoc comments to AgentSession public methods +4. Update README if needed +5. Final manual testing of all features + +**Verification:** +1. `npm run check` passes +2. All three modes work +3. All slash commands work +4. All hotkeys work +5. Session persistence works +6. Compaction works +7. Bash execution works +8. Model/thinking cycling works + +- [ ] Remove dead code from main.ts +- [ ] Add JSDoc to AgentSession +- [ ] Final testing +- [ ] Update README if needed + +--- + +## Testing Checklist (E2E) + +After refactoring is complete, verify these scenarios: + +### Interactive Mode +- [ ] Start fresh session: `pi` +- [ ] Continue session: `pi -c` +- [ ] Resume session: `pi -r` +- [ ] Initial message: `pi "hello"` +- [ ] File attachment: `pi @file.txt "summarize"` +- [ ] Model cycling: Ctrl+P +- [ ] Thinking cycling: Shift+Tab +- [ ] Tool expansion: Ctrl+O +- [ ] Thinking toggle: Ctrl+T +- [ ] Abort: Esc during streaming +- [ ] Clear: Ctrl+C twice to exit +- [ ] Bash command: `!ls -la` +- [ ] Bash cancel: Esc during bash +- [ ] /thinking command +- [ ] /model command +- [ ] /export command +- [ ] /copy command +- [ ] /session command +- [ ] /changelog command +- [ ] /branch command +- [ ] /login and /logout commands +- [ ] /queue command +- [ ] /theme command +- [ ] /clear command +- [ ] /compact command +- [ ] /autocompact command +- [ ] /resume command +- [ ] Message queuing while streaming + +### Print Mode +- [ ] Basic: `pi -p "hello"` +- [ ] JSON: `pi --mode json "hello"` +- [ ] Multiple messages: `pi -p "first" "second"` +- [ ] File attachment: `pi -p @file.txt "summarize"` + +### RPC Mode +- [ ] Start: `pi --mode rpc` +- [ ] Send prompt via JSON +- [ ] Abort via JSON +- [ ] Compact via JSON +- [ ] Bash via JSON + +--- + +## Notes + +- This refactoring should be done incrementally, testing after each work package +- If a WP introduces regressions, fix them before moving to the next +- The most risky WP is WP15 (updating TuiRenderer) - take extra care there +- Consider creating git commits after each major WP for easy rollback