# 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

## Strategy: Keep Old Code for Reference

We create new files alongside old ones instead of modifying in place:
- `src/modes/print-mode.ts` (new) - old code stays in `main.ts`
- `src/modes/rpc-mode.ts` (new) - old code stays in `main.ts`
- `src/modes/interactive/interactive-mode.ts` (new) - old code stays in `tui/tui-renderer.ts`
- `src/main-new.ts` (new) - old code stays in `main.ts`
- `src/cli-new.ts` (new) - old code stays in `cli.ts`

This allows:
- Parallel comparison of old vs new behavior
- Gradual migration and testing
- Easy rollback if needed

Final switchover: When everything works, rename files and delete old code.

---

## 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 {
  // ─── Read-only State Access ───
  get state(): AgentState;
  get model(): Model<any> | null;
  get thinkingLevel(): ThinkingLevel;
  get isStreaming(): boolean;
  get messages(): AppMessage[];  // Includes custom types like BashExecutionMessage
  get queueMode(): QueueMode;

  // ─── Event Subscription ───
  // Handles session persistence internally (saves messages, checks auto-compaction)
  subscribe(listener: (event: AgentEvent) => void): () => void;

  // ─── Prompting ───
  prompt(text: string, options?: PromptOptions): Promise<void>;
  queueMessage(text: string): Promise<void>;
  clearQueue(): string[];
  abort(): Promise<void>;
  reset(): Promise<void>;

  // ─── Model Management ───
  setModel(model: Model<any>): Promise<void>;  // Validates API key, saves to session + settings
  cycleModel(): Promise<ModelCycleResult | null>;
  getAvailableModels(): Promise<Model<any>[]>;

  // ─── Thinking Level ───
  setThinkingLevel(level: ThinkingLevel): void;  // Saves to session + settings
  cycleThinkingLevel(): ThinkingLevel | null;
  supportsThinking(): boolean;

  // ─── Queue Mode ───
  setQueueMode(mode: QueueMode): void;  // Saves to settings

  // ─── Compaction ───
  compact(customInstructions?: string): Promise<CompactionResult>;
  abortCompaction(): void;
  checkAutoCompaction(): Promise<CompactionResult | null>;  // Called internally after assistant messages
  setAutoCompactionEnabled(enabled: boolean): void;  // Saves to settings
  get autoCompactionEnabled(): boolean;

  // ─── Bash Execution ───
  executeBash(command: string, onChunk?: (chunk: string) => void): Promise<BashResult>;
  abortBash(): void;
  get isBashRunning(): boolean;

  // Session management
  switchSession(sessionPath: string): Promise<void>;
  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<BashResult>;
```

**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

- [x] Create `src/core/bash-executor.ts` with `executeBash()` function
- [x] Add proper TypeScript types and exports
- [x] 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, AppMessage, ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { Model } 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<any>; thinkingLevel: ThinkingLevel }>;
  fileCommands?: FileSlashCommand[];
}

export class AgentSession {
  readonly agent: Agent;
  readonly sessionManager: SessionManager;
  readonly settingsManager: SettingsManager;

  private scopedModels: Array<{ model: Model<any>; 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<any> | null { return this.agent.state.model; }
  get thinkingLevel(): ThinkingLevel { return this.agent.state.thinkingLevel; }
  get isStreaming(): boolean { return this.agent.state.isStreaming; }
  get messages(): AppMessage[] { 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)

- [x] Create `src/core/agent-session.ts` with basic structure
- [x] Create `src/core/index.ts` barrel export
- [x] 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

- [x] Add `subscribe()` method to AgentSession
- [x] Add `_disconnectFromAgent()` private method (renamed from unsubscribeAll)
- [x] Add `_reconnectToAgent()` private method (renamed from resubscribe)
- [x] Add `dispose()` public method for full cleanup
- [x] 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<void> {
  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<void> {
  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<void> {
  this.agent.abort();
  await this.agent.waitForIdle();
}

/**
 * Reset agent and session. Starts a fresh session.
 */
async reset(): Promise<void> {
  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

- [x] Add `prompt()` method with validation and slash command expansion
- [x] Add `queueMessage()` method
- [x] Add `clearQueue()` method
- [x] Add `abort()` method
- [x] Add `reset()` method
- [x] Add `queuedMessageCount` getter and `getQueuedMessages()` method
- [x] 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<any>;
  thinkingLevel: ThinkingLevel;
  isScoped: boolean;
}

/**
 * Set model directly. Validates API key, saves to session and settings.
 */
async setModel(model: Model<any>): Promise<void> {
  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<ModelCycleResult | null> {
  if (this.scopedModels.length > 0) {
    return this.cycleScopedModel();
  } else {
    return this.cycleAvailableModel();
  }
}

private async cycleScopedModel(): Promise<ModelCycleResult | null> {
  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<ModelCycleResult | null> {
  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<Model<any>[]> {
  const { models, error } = await getAvailableModels();
  if (error) throw new Error(error);
  return models;
}
```

**Verification:**
1. `npm run check` passes

- [x] Add `ModelCycleResult` interface
- [x] Add `setModel()` method
- [x] Add `cycleModel()` method with scoped/available variants
- [x] Add `getAvailableModels()` method
- [x] 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

- [x] Add `setThinkingLevel()` method
- [x] Add `cycleThinkingLevel()` method
- [x] Add `supportsThinking()` method
- [x] Add `setQueueMode()` method and `queueMode` getter (see below)
- [x] Verify with `npm run check`

**Queue mode (add to same WP):**
```typescript
// Add to AgentSession class

get queueMode(): QueueMode {
  return this.agent.getQueueMode();
}

/**
 * Set message queue mode. Saves to settings.
 */
setQueueMode(mode: QueueMode): void {
  this.agent.setQueueMode(mode);
  this.settingsManager.setQueueMode(mode);
}
```

---

### 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<CompactionResult> {
  // 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<CompactionResult | null> {
  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

- [x] Add `CompactionResult` interface
- [x] Add `compact()` method
- [x] Add `abortCompaction()` method
- [x] Add `checkAutoCompaction()` method
- [x] Add `setAutoCompactionEnabled()` and getter
- [x] 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<BashResult> {
  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

- [x] Add bash execution methods using bash-executor module
- [x] 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<void> {
  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

- [x] Add `SessionStats` interface
- [x] Add `switchSession()` method
- [x] Add `branch()` method
- [x] Add `getUserMessagesForBranching()` method
- [x] Add `getSessionStats()` method
- [x] Add `exportToHtml()` method
- [x] 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

- [x] Add `getLastAssistantText()` method
- [x] Add `queuedMessageCount` getter (done in WP4)
- [x] Add `getQueuedMessages()` method (done in WP4)
- [x] 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<void> {

  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

- [x] Create `src/modes/print-mode.ts`
- [x] 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<never> {
  // 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)

- [x] Create `src/modes/rpc-mode.ts`
- [x] 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
```

- [x] Create `src/modes/index.ts`
- [x] Verify with `npm run check`

---

### WP14: Create main-new.ts using AgentSession and new modes
> Create a new main file that uses AgentSession and the new mode modules.
> Old main.ts is kept for reference/comparison.

**Files to create:**
- `src/main-new.ts` (copy from main.ts, then modify)
- `src/cli-new.ts` (copy from cli.ts, point to main-new.ts)

**Changes to main-new.ts:**
1. Remove `runSingleShotMode()` function (use print-mode.ts)
2. Remove `runRpcMode()` function (use rpc-mode.ts)
3. Remove `executeRpcBashCommand()` function (use 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);
}
```

**cli-new.ts:**
```typescript
#!/usr/bin/env node
import { main } from "./main-new.js";
main(process.argv.slice(2));
```

**Testing the new implementation:**
```bash
# Run new implementation directly
npx tsx src/cli-new.ts -p "hello"
npx tsx src/cli-new.ts --mode json "hello"
npx tsx src/cli-new.ts  # interactive mode
```

**Verification:**
1. `npm run check` passes
2. Manual test: `npx tsx src/cli-new.ts -p "hello"` works
3. Manual test: `npx tsx src/cli-new.ts --mode json "hello"` works
4. Manual test: `npx tsx src/cli-new.ts --mode rpc` works

- [x] Copy main.ts to main-new.ts
- [x] Remove `runSingleShotMode()` from main-new.ts
- [x] Remove `runRpcMode()` from main-new.ts
- [x] Remove `executeRpcBashCommand()` from main-new.ts
- [x] Import and use `runPrintMode` from modes
- [x] Import and use `runRpcMode` from modes
- [x] Create `AgentSession` in main()
- [x] Update mode routing to use new functions
- [x] Create cli-new.ts
- [x] Verify with `npm run check`
- [ ] Manual test all three modes via cli-new.ts

---

### WP15: Create InteractiveMode using AgentSession
> Create a new interactive mode class that uses AgentSession.
> Old tui-renderer.ts is kept for reference.

**Files to create:**
- `src/modes/interactive/interactive-mode.ts` (based on tui-renderer.ts)

**This is the largest change. Strategy:**
1. Copy tui-renderer.ts to new location
2. Rename class from `TuiRenderer` to `InteractiveMode`
3. Change constructor to accept `AgentSession` instead of separate agent/sessionManager/settingsManager
4. Replace all `this.agent.*` calls with `this.session.agent.*` or appropriate AgentSession methods
5. Replace all `this.sessionManager.*` calls with AgentSession methods
6. Replace all `this.settingsManager.*` calls with AgentSession methods where applicable
7. 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 via cli-new.ts: 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

- [ ] Create `src/modes/interactive/` directory
- [ ] Copy tui-renderer.ts to interactive-mode.ts
- [ ] Rename class to `InteractiveMode`
- [ ] Change 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
- [ ] Update imports to point to `../../tui/` for components (keep old components in place for now)
- [ ] Update modes/index.ts to export InteractiveMode
- [ ] Verify with `npm run check`
- [ ] Manual test interactive mode via cli-new.ts

---

### WP16: Update main-new.ts runInteractiveMode to use InteractiveMode
> Update runInteractiveMode in main-new.ts to use the new InteractiveMode class.

**Files to modify:**
- `src/main-new.ts`

**Changes:**
```typescript
import { InteractiveMode } from "./modes/interactive/interactive-mode.js";

async function runInteractiveMode(
  session: AgentSession,
  version: string,
  changelogMarkdown: string | null,
  collapseChangelog: boolean,
  modelFallbackMessage: string | null,
  versionCheckPromise: Promise<string | null>,
  initialMessages: string[],
  initialMessage?: string,
  initialAttachments?: Attachment[],
  fdPath: string | null,
): Promise<void> {
  const mode = new InteractiveMode(
    session,
    version,
    changelogMarkdown,
    collapseChangelog,
    fdPath,
  );
  // ... rest stays similar
}
```

**Verification:**
1. `npm run check` passes
2. Manual test via cli-new.ts: Interactive mode works

- [ ] Update `runInteractiveMode()` in main-new.ts
- [ ] Update InteractiveMode instantiation
- [ ] Verify with `npm run check`

---

### WP17: (OPTIONAL) Move TUI components to modes/interactive/
> Move TUI-specific components to the interactive mode directory.
> This is optional cleanup - can be skipped if too disruptive.

**Note:** The old `src/tui/` directory is kept. We just create copies/moves as needed.
For now, InteractiveMode can import from `../../tui/` to reuse existing components.

**Files to potentially move (if doing this WP):**
- `src/tui/assistant-message.ts` → `src/modes/interactive/components/`
- `src/tui/bash-execution.ts` → `src/modes/interactive/components/`
- etc.

**Skip this WP for now** - focus on getting the new architecture working first.
The component organization can be cleaned up later.

- [ ] SKIPPED (optional cleanup for later)

---

### 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<any> | null;
  initialThinking: ThinkingLevel;
  scopedModels: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
  modelFallbackMessage: string | null;
}

export async function setupAgent(options: SetupOptions): Promise<SetupResult>;

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<Array<{ model: Model<any>; 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


Good, I'm refreshed. Now continuing with WP15. The approach is to:

1. Create `src/modes/interactive/interactive-mode.ts` based on `tui-renderer.ts`
2. Replace direct agent/sessionManager/settingsManager access with AgentSession methods
3. Keep imports pointing to `../../tui/` for existing components

Let me start by creating the directory and copying the file:


you havne't read @packages/coding-agent/src/tui/tui-renderer.ts in full i suppose


The user is right - I should read tui-renderer.ts fully before attempting to refactor it. It's 2376 lines, so let me read it in chunks to understand the full structure.

You're right. Let me read it in chunks:


import { randomBytes } from "node:crypto";
import * as fs from "node:fs";
import { createWriteStream, type WriteStream } from "node:fs";
import { tmpdir } from "node:os";
import * as path from "node:path";
import { join } from "node:path";
import type { Agent, AgentEvent, AgentState, AppMessage, ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, Message, Model } from "@mariozechner/pi-ai";
import type { SlashCommand } from "@mariozechner/pi-tui";
import {
	CombinedAutocompleteProvider,
	Container,
	Input,
	Loader,
	Markdown,
	ProcessTerminal,
	Spacer,
	Text,
	TruncatedText,
	TUI,
	visibleWidth,
} from "@mariozechner/pi-tui";
import { exec, spawn } from "child_process";
import stripAnsi from "strip-ansi";
import { getChangelogPath, parseChangelog } from "../changelog.js";
import { copyToClipboard } from "../clipboard.js";
import { calculateContextTokens, compact, shouldCompact } from "../compaction.js";
import { APP_NAME, getDebugLogPath, getModelsPath, getOAuthPath } from "../config.js";
import { exportSessionToHtml } from "../export-html.js";
import { type BashExecutionMessage, isBashExecutionMessage } from "../messages.js";
import { getApiKeyForModel, getAvailableModels, invalidateOAuthCache } from "../model-config.js";
import { listOAuthProviders, login, logout, type SupportedOAuthProvider } from "../oauth/index.js";
import {
	getLatestCompactionEntry,
	loadSessionFromEntries,
	type SessionManager,
	SUMMARY_PREFIX,
	SUMMARY_SUFFIX,
} from "../session-manager.js";
import type { SettingsManager } from "../settings-manager.js";
import { getShellConfig, killProcessTree, sanitizeBinaryOutput } from "../shell.js";
import { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from "../slash-commands.js";
import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../theme/theme.js";
import { DEFAULT_MAX_BYTES, type TruncationResult, truncateTail } from "../tools/truncate.js";
import { AssistantMessageComponent } from "./assistant-message.js";
import { BashExecutionComponent } from "./bash-execution.js";
import { CompactionComponent } from "./compaction.js";
import { CustomEditor } from "./custom-editor.js";
import { DynamicBorder } from "./dynamic-border.js";
import { FooterComponent } from "./footer.js";
import { ModelSelectorComponent } from "./model-selector.js";
import { OAuthSelectorComponent } from "./oauth-selector.js";
import { QueueModeSelectorComponent } from "./queue-mode-selector.js";
import { SessionSelectorComponent } from "./session-selector.js";
import { ThemeSelectorComponent } from "./theme-selector.js";
import { ThinkingSelectorComponent } from "./thinking-selector.js";
import { ToolExecutionComponent } from "./tool-execution.js";
import { UserMessageComponent } from "./user-message.js";
import { UserMessageSelectorComponent } from "./user-message-selector.js";

/**
 * TUI renderer for the coding agent
 */
export class TuiRenderer {
	private ui: TUI;
	private chatContainer: Container;
	private pendingMessagesContainer: Container;
	private statusContainer: Container;
	private editor: CustomEditor;
	private editorContainer: Container; // Container to swap between editor and selector
	private footer: FooterComponent;
	private agent: Agent;
	private sessionManager: SessionManager;
	private settingsManager: SettingsManager;
	private version: string;
	private isInitialized = false;
	private onInputCallback?: (text: string) => void;
	private loadingAnimation: Loader | null = null;

	private lastSigintTime = 0;
	private lastEscapeTime = 0;
	private changelogMarkdown: string | null = null;
	private collapseChangelog = false;

	// Message queueing
	private queuedMessages: string[] = [];

	// Streaming message tracking
	private streamingComponent: AssistantMessageComponent | null = null;

	// Tool execution tracking: toolCallId -> component
	private pendingTools = new Map<string, ToolExecutionComponent>();

	// Thinking level selector
	private thinkingSelector: ThinkingSelectorComponent | null = null;

	// Queue mode selector
	private queueModeSelector: QueueModeSelectorComponent | null = null;

	// Theme selector
	private themeSelector: ThemeSelectorComponent | null = null;

	// Model selector
	private modelSelector: ModelSelectorComponent | null = null;

	// User message selector (for branching)
	private userMessageSelector: UserMessageSelectorComponent | null = null;

	// Session selector (for resume)
	private sessionSelector: SessionSelectorComponent | null = null;

	// OAuth selector
	private oauthSelector: any | null = null;

	// Track if this is the first user message (to skip spacer)
	private isFirstUserMessage = true;

	// Model scope for quick cycling
	private scopedModels: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }> = [];

	// Tool output expansion state
	private toolOutputExpanded = false;

	// Thinking block visibility state
	private hideThinkingBlock = false;

	// Agent subscription unsubscribe function
	private unsubscribe?: () => void;

	// File-based slash commands
	private fileCommands: FileSlashCommand[] = [];

	// Track if editor is in bash mode (text starts with !)
	private isBashMode = false;

	// Track running bash command process for cancellation
	private bashProcess: ReturnType<typeof spawn> | null = null;

	// Track current bash execution component
	private bashComponent: BashExecutionComponent | null = null;

	constructor(
		agent: Agent,
		sessionManager: SessionManager,
		settingsManager: SettingsManager,
		version: string,
		changelogMarkdown: string | null = null,
		collapseChangelog = false,
		scopedModels: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }> = [],
		fdPath: string | null = null,
	) {
		this.agent = agent;
		this.sessionManager = sessionManager;
		this.settingsManager = settingsManager;
		this.version = version;
		this.changelogMarkdown = changelogMarkdown;
		this.collapseChangelog = collapseChangelog;
		this.scopedModels = scopedModels;
		this.ui = new TUI(new ProcessTerminal());
		this.chatContainer = new Container();
		this.pendingMessagesContainer = new Container();
		this.statusContainer = new Container();
		this.editor = new CustomEditor(getEditorTheme());
		this.editorContainer = new Container(); // Container to hold editor or selector
		this.editorContainer.addChild(this.editor); // Start with editor
		this.footer = new FooterComponent(agent.state);
		this.footer.setAutoCompactEnabled(this.settingsManager.getCompactionEnabled());

		// Define slash commands
		const thinkingCommand: SlashCommand = {
			name: "thinking",
			description: "Select reasoning level (opens selector UI)",
		};

		const modelCommand: SlashCommand = {
			name: "model",
			description: "Select model (opens selector UI)",
		};

		const exportCommand: SlashCommand = {
			name: "export",
			description: "Export session to HTML file",
		};

		const copyCommand: SlashCommand = {
			name: "copy",
			description: "Copy last agent message to clipboard",
		};

		const sessionCommand: SlashCommand = {
			name: "session",
			description: "Show session info and stats",
		};

		const changelogCommand: SlashCommand = {
			name: "changelog",
			description: "Show changelog entries",
		};

		const branchCommand: SlashCommand = {

[2177 more lines in file. Use offset=201 to continue]


		const branchCommand: SlashCommand = {
			name: "branch",
			description: "Create a new branch from a previous message",
		};

		const loginCommand: SlashCommand = {
			name: "login",
			description: "Login with OAuth provider",
		};

		const logoutCommand: SlashCommand = {
			name: "logout",
			description: "Logout from OAuth provider",
		};

		const queueCommand: SlashCommand = {
			name: "queue",
			description: "Select message queue mode (opens selector UI)",
		};

		const themeCommand: SlashCommand = {
			name: "theme",
			description: "Select color theme (opens selector UI)",
		};

		const clearCommand: SlashCommand = {
			name: "clear",
			description: "Clear context and start a fresh session",
		};

		const compactCommand: SlashCommand = {
			name: "compact",
			description: "Manually compact the session context",
		};

		const autocompactCommand: SlashCommand = {
			name: "autocompact",
			description: "Toggle automatic context compaction",
		};

		const resumeCommand: SlashCommand = {
			name: "resume",
			description: "Resume a different session",
		};

		// Load hide thinking block setting
		this.hideThinkingBlock = settingsManager.getHideThinkingBlock();

		// Load file-based slash commands
		this.fileCommands = loadSlashCommands();

		// Convert file commands to SlashCommand format
		const fileSlashCommands: SlashCommand[] = this.fileCommands.map((cmd) => ({
			name: cmd.name,
			description: cmd.description,
		}));

		// Setup autocomplete for file paths and slash commands
		const autocompleteProvider = new CombinedAutocompleteProvider(
			[
				thinkingCommand,
				modelCommand,
				themeCommand,
				exportCommand,
				copyCommand,
				sessionCommand,
				changelogCommand,
				branchCommand,
				loginCommand,
				logoutCommand,
				queueCommand,
				clearCommand,
				compactCommand,
				autocompactCommand,
				resumeCommand,
				...fileSlashCommands,
			],
			process.cwd(),
			fdPath,
		);
		this.editor.setAutocompleteProvider(autocompleteProvider);
	}

	async init(): Promise<void> {
		if (this.isInitialized) return;

		// Add header with logo and instructions
		const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`);
		const instructions =
			theme.fg("dim", "esc") +
			theme.fg("muted", " to interrupt") +
			"\n" +
			theme.fg("dim", "ctrl+c") +
			theme.fg("muted", " to clear") +
			"\n" +
			theme.fg("dim", "ctrl+c twice") +
			theme.fg("muted", " to exit") +
			"\n" +
			theme.fg("dim", "ctrl+k") +
			theme.fg("muted", " to delete line") +
			"\n" +
			theme.fg("dim", "shift+tab") +
			theme.fg("muted", " to cycle thinking") +
			"\n" +
			theme.fg("dim", "ctrl+p") +
			theme.fg("muted", " to cycle models") +
			"\n" +
			theme.fg("dim", "ctrl+o") +
			theme.fg("muted", " to expand tools") +
			"\n" +
			theme.fg("dim", "ctrl+t") +
			theme.fg("muted", " to toggle thinking") +
			"\n" +
			theme.fg("dim", "/") +
			theme.fg("muted", " for commands") +
			"\n" +
			theme.fg("dim", "!") +
			theme.fg("muted", " to run bash") +
			"\n" +
			theme.fg("dim", "drop files") +
			theme.fg("muted", " to attach");
		const header = new Text(logo + "\n" + instructions, 1, 0);

		// Setup UI layout
		this.ui.addChild(new Spacer(1));
		this.ui.addChild(header);
		this.ui.addChild(new Spacer(1));

		// Add changelog if provided
		if (this.changelogMarkdown) {
			this.ui.addChild(new DynamicBorder());
			if (this.collapseChangelog) {
				// Show condensed version with hint to use /changelog
				const versionMatch = this.changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/);
				const latestVersion = versionMatch ? versionMatch[1] : this.version;
				const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`;
				this.ui.addChild(new Text(condensedText, 1, 0));
			} else {
				this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
				this.ui.addChild(new Spacer(1));
				this.ui.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0, getMarkdownTheme()));
				this.ui.addChild(new Spacer(1));
			}
			this.ui.addChild(new DynamicBorder());
		}

		this.ui.addChild(this.chatContainer);
		this.ui.addChild(this.pendingMessagesContainer);
		this.ui.addChild(this.statusContainer);
		this.ui.addChild(new Spacer(1));
		this.ui.addChild(this.editorContainer); // Use container that can hold editor or selector
		this.ui.addChild(this.footer);
		this.ui.setFocus(this.editor);

		// Set up custom key handlers on the editor
		this.editor.onEscape = () => {
			// Intercept Escape key when processing
			if (this.loadingAnimation) {
				// Get all queued messages
				const queuedText = this.queuedMessages.join("\n\n");

				// Get current editor text
				const currentText = this.editor.getText();

				// Combine: queued messages + current editor text
				const combinedText = [queuedText, currentText].filter((t) => t.trim()).join("\n\n");

				// Put back in editor
				this.editor.setText(combinedText);

				// Clear queued messages
				this.queuedMessages = [];
				this.updatePendingMessagesDisplay();

				// Clear agent's queue too
				this.agent.clearMessageQueue();

				// Abort
				this.agent.abort();
			} else if (this.bashProcess) {
				// Kill running bash command
				if (this.bashProcess.pid) {
					killProcessTree(this.bashProcess.pid);
				}
				this.bashProcess = null;
			} else if (this.isBashMode) {
				// Cancel bash mode and clear editor
				this.editor.setText("");
				this.isBashMode = false;
				this.updateEditorBorderColor();
			} else if (!this.editor.getText().trim()) {
				// Double-escape with empty editor triggers /branch
				const now = Date.now();
				if (now - this.lastEscapeTime < 500) {
					this.showUserMessageSelector();
					this.lastEscapeTime = 0; // Reset to prevent triple-escape
				} else {
					this.lastEscapeTime = now;
				}
			}

[1978 more lines in file. Use offset=400 to continue]


		};

		this.editor.onCtrlC = () => {
			this.handleCtrlC();
		};

		this.editor.onShiftTab = () => {
			this.cycleThinkingLevel();
		};

		this.editor.onCtrlP = () => {
			this.cycleModel();
		};

		this.editor.onCtrlO = () => {
			this.toggleToolOutputExpansion();
		};

		this.editor.onCtrlT = () => {
			this.toggleThinkingBlockVisibility();
		};

		// Handle editor text changes for bash mode detection
		this.editor.onChange = (text: string) => {
			const wasBashMode = this.isBashMode;
			this.isBashMode = text.trimStart().startsWith("!");
			if (wasBashMode !== this.isBashMode) {
				this.updateEditorBorderColor();
			}
		};

		// Handle editor submission
		this.editor.onSubmit = async (text: string) => {
			text = text.trim();
			if (!text) return;

			// Check for /thinking command
			if (text === "/thinking") {
				// Show thinking level selector
				this.showThinkingSelector();
				this.editor.setText("");
				return;
			}

			// Check for /model command
			if (text === "/model") {
				// Show model selector
				this.showModelSelector();
				this.editor.setText("");
				return;
			}

			// Check for /export command
			if (text.startsWith("/export")) {
				this.handleExportCommand(text);
				this.editor.setText("");
				return;
			}

			// Check for /copy command
			if (text === "/copy") {
				this.handleCopyCommand();
				this.editor.setText("");
				return;
			}

			// Check for /session command
			if (text === "/session") {
				this.handleSessionCommand();
				this.editor.setText("");
				return;
			}

			// Check for /changelog command
			if (text === "/changelog") {
				this.handleChangelogCommand();
				this.editor.setText("");
				return;
			}

			// Check for /branch command
			if (text === "/branch") {
				this.showUserMessageSelector();
				this.editor.setText("");
				return;
			}

			// Check for /login command
			if (text === "/login") {
				this.showOAuthSelector("login");
				this.editor.setText("");
				return;
			}

			// Check for /logout command
			if (text === "/logout") {
				this.showOAuthSelector("logout");
				this.editor.setText("");
				return;
			}

			// Check for /queue command
			if (text === "/queue") {
				this.showQueueModeSelector();
				this.editor.setText("");
				return;
			}

			// Check for /theme command
			if (text === "/theme") {
				this.showThemeSelector();
				this.editor.setText("");
				return;
			}

			// Check for /clear command
			if (text === "/clear") {
				this.handleClearCommand();
				this.editor.setText("");
				return;
			}

			// Check for /compact command
			if (text === "/compact" || text.startsWith("/compact ")) {
				const customInstructions = text.startsWith("/compact ") ? text.slice(9).trim() : undefined;
				this.handleCompactCommand(customInstructions);
				this.editor.setText("");
				return;
			}

			// Check for /autocompact command
			if (text === "/autocompact") {
				this.handleAutocompactCommand();
				this.editor.setText("");
				return;
			}

			// Check for /debug command
			if (text === "/debug") {
				this.handleDebugCommand();
				this.editor.setText("");
				return;
			}

			// Check for /resume command
			if (text === "/resume") {
				this.showSessionSelector();
				this.editor.setText("");
				return;
			}

			// Check for bash command (!<command>)
			if (text.startsWith("!")) {
				const command = text.slice(1).trim();
				if (command) {
					// Block if bash already running
					if (this.bashProcess) {
						this.showWarning("A bash command is already running. Press Esc to cancel it first.");
						// Restore text since editor clears on submit
						this.editor.setText(text);
						return;
					}
					// Add to history for up/down arrow navigation
					this.editor.addToHistory(text);
					this.handleBashCommand(command);
					// Reset bash mode since editor is now empty
					this.isBashMode = false;
					this.updateEditorBorderColor();
					return;
				}
			}

			// Check for file-based slash commands
			text = expandSlashCommand(text, this.fileCommands);

			// Normal message submission - validate model and API key first
			const currentModel = this.agent.state.model;
			if (!currentModel) {
				this.showError(
					"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.",
				);
				return;
			}

			// Validate API key (async)
			const apiKey = await getApiKeyForModel(currentModel);
			if (!apiKey) {
				this.showError(
					`No API key found for ${currentModel.provider}.\n\n` +
						`Set the appropriate environment variable or update ${getModelsPath()}`,
				);
				this.editor.setText(text);
				return;
			}

			// Check if agent is currently streaming
			if (this.agent.state.isStreaming) {

[1778 more lines in file. Use offset=600 to continue]


				// Queue the message instead of submitting
				this.queuedMessages.push(text);

				// Queue in agent
				await this.agent.queueMessage({
					role: "user",
					content: [{ type: "text", text }],
					timestamp: Date.now(),
				});

				// Update pending messages display
				this.updatePendingMessagesDisplay();

				// Add to history for up/down arrow navigation
				this.editor.addToHistory(text);

				// Clear editor
				this.editor.setText("");
				this.ui.requestRender();
				return;
			}

			// All good, proceed with submission
			if (this.onInputCallback) {
				this.onInputCallback(text);
			}

			// Add to history for up/down arrow navigation
			this.editor.addToHistory(text);
		};

		// Start the UI
		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();
			this.updateEditorBorderColor();
			this.ui.requestRender();
		});

		// Set up git branch watcher
		this.footer.watchBranch(() => {
			this.ui.requestRender();
		});
	}

	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);
				}

				// Check for auto-compaction after assistant messages
				if (event.message.role === "assistant") {
					await this.checkAutoCompaction();
				}
			}
		});
	}

	private async checkAutoCompaction(): Promise<void> {
		const settings = this.settingsManager.getCompactionSettings();
		if (!settings.enabled) return;

		// Get last non-aborted assistant message from agent state
		const messages = this.agent.state.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;

		const contextTokens = calculateContextTokens(lastAssistant.usage);
		const contextWindow = this.agent.state.model.contextWindow;

		if (!shouldCompact(contextTokens, contextWindow, settings)) return;

		// Trigger auto-compaction
		await this.executeCompaction(undefined, true);
	}

	private async handleEvent(event: AgentEvent, state: AgentState): Promise<void> {
		if (!this.isInitialized) {
			await this.init();
		}

		// Update footer with current stats
		this.footer.updateState(state);

		switch (event.type) {
			case "agent_start":
				// Show loading animation
				// Note: Don't disable submit - we handle queuing in onSubmit callback
				// Stop old loader before clearing
				if (this.loadingAnimation) {
					this.loadingAnimation.stop();
				}
				this.statusContainer.clear();
				this.loadingAnimation = new Loader(
					this.ui,
					(spinner) => theme.fg("accent", spinner),
					(text) => theme.fg("muted", text),
					"Working... (esc to interrupt)",
				);
				this.statusContainer.addChild(this.loadingAnimation);
				this.ui.requestRender();
				break;

			case "message_start":
				if (event.message.role === "user") {
					// Check if this is a queued message
					const userMsg = event.message;
					const textBlocks =
						typeof userMsg.content === "string"
							? [{ type: "text", text: userMsg.content }]
							: userMsg.content.filter((c) => c.type === "text");
					const messageText = textBlocks.map((c) => c.text).join("");

					const queuedIndex = this.queuedMessages.indexOf(messageText);
					if (queuedIndex !== -1) {
						// Remove from queued messages
						this.queuedMessages.splice(queuedIndex, 1);
						this.updatePendingMessagesDisplay();
					}

					// Show user message immediately and clear editor
					this.addMessageToChat(event.message);
					this.editor.setText("");
					this.ui.requestRender();
				} else if (event.message.role === "assistant") {
					// Create assistant component for streaming
					this.streamingComponent = new AssistantMessageComponent(undefined, this.hideThinkingBlock);
					this.chatContainer.addChild(this.streamingComponent);
					this.streamingComponent.updateContent(event.message as AssistantMessage);
					this.ui.requestRender();
				}
				break;

			case "message_update":
				// Update streaming component
				if (this.streamingComponent && event.message.role === "assistant") {
					const assistantMsg = event.message as AssistantMessage;
					this.streamingComponent.updateContent(assistantMsg);

					// Create tool execution components as soon as we see tool calls
					for (const content of assistantMsg.content) {
						if (content.type === "toolCall") {
							// Only create if we haven't created it yet
							if (!this.pendingTools.has(content.id)) {
								this.chatContainer.addChild(new Text("", 0, 0));
								const component = new ToolExecutionComponent(content.name, content.arguments);
								this.chatContainer.addChild(component);
								this.pendingTools.set(content.id, component);
							} else {
								// Update existing component with latest arguments as they stream
								const component = this.pendingTools.get(content.id);
								if (component) {
									component.updateArgs(content.arguments);
								}
							}
						}
					}

					this.ui.requestRender();
				}
				break;

			case "message_end":
				// Skip user messages (already shown in message_start)
				if (event.message.role === "user") {
					break;
				}
				if (this.streamingComponent && event.message.role === "assistant") {
					const assistantMsg = event.message as AssistantMessage;

					// Update streaming component with final message (includes stopReason)
					this.streamingComponent.updateContent(assistantMsg);

					// If message was aborted or errored, mark all pending tool components as failed
					if (assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error") {

[1578 more lines in file. Use offset=800 to continue]


						const errorMessage =
							assistantMsg.stopReason === "aborted" ? "Operation aborted" : assistantMsg.errorMessage || "Error";
						for (const [toolCallId, component] of this.pendingTools.entries()) {
							component.updateResult({
								content: [{ type: "text", text: errorMessage }],
								isError: true,
							});
						}
						this.pendingTools.clear();
					}

					// Keep the streaming component - it's now the final assistant message
					this.streamingComponent = null;

					// Invalidate footer cache to refresh git branch (in case agent executed git commands)
					this.footer.invalidate();
				}
				this.ui.requestRender();
				break;

			case "tool_execution_start": {
				// Component should already exist from message_update, but create if missing
				if (!this.pendingTools.has(event.toolCallId)) {
					const component = new ToolExecutionComponent(event.toolName, event.args);
					this.chatContainer.addChild(component);
					this.pendingTools.set(event.toolCallId, component);
					this.ui.requestRender();
				}
				break;
			}

			case "tool_execution_end": {
				// Update the existing tool component with the result
				const component = this.pendingTools.get(event.toolCallId);
				if (component) {
					// Convert result to the format expected by updateResult
					const resultData =
						typeof event.result === "string"
							? {
									content: [{ type: "text" as const, text: event.result }],
									details: undefined,
									isError: event.isError,
								}
							: {
									content: event.result.content,
									details: event.result.details,
									isError: event.isError,
								};
					component.updateResult(resultData);
					this.pendingTools.delete(event.toolCallId);
					this.ui.requestRender();
				}
				break;
			}

			case "agent_end":
				// Stop loading animation
				if (this.loadingAnimation) {
					this.loadingAnimation.stop();
					this.loadingAnimation = null;
					this.statusContainer.clear();
				}
				if (this.streamingComponent) {
					this.chatContainer.removeChild(this.streamingComponent);
					this.streamingComponent = null;
				}
				this.pendingTools.clear();
				// Note: Don't need to re-enable submit - we never disable it
				this.ui.requestRender();
				break;
		}
	}

	private addMessageToChat(message: Message | AppMessage): void {
		// Handle bash execution messages
		if (isBashExecutionMessage(message)) {
			const bashMsg = message as BashExecutionMessage;
			const component = new BashExecutionComponent(bashMsg.command, this.ui);
			if (bashMsg.output) {
				component.appendOutput(bashMsg.output);
			}
			component.setComplete(
				bashMsg.exitCode,
				bashMsg.cancelled,
				bashMsg.truncated ? ({ truncated: true } as TruncationResult) : undefined,
				bashMsg.fullOutputPath,
			);
			this.chatContainer.addChild(component);
			return;
		}

		if (message.role === "user") {
			const userMsg = message;
			// Extract text content from content blocks
			const textBlocks =
				typeof userMsg.content === "string"
					? [{ type: "text", text: userMsg.content }]
					: userMsg.content.filter((c) => c.type === "text");
			const textContent = textBlocks.map((c) => c.text).join("");
			if (textContent) {
				const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);
				this.chatContainer.addChild(userComponent);
				this.isFirstUserMessage = false;
			}
		} else if (message.role === "assistant") {
			const assistantMsg = message;

			// Add assistant message component
			const assistantComponent = new AssistantMessageComponent(assistantMsg, this.hideThinkingBlock);
			this.chatContainer.addChild(assistantComponent);
		}
		// Note: tool calls and results are now handled via tool_execution_start/end events
	}

	renderInitialMessages(state: AgentState): void {
		// Render all existing messages (for --continue mode)
		// Reset first user message flag for initial render
		this.isFirstUserMessage = true;

		// Update footer with loaded state
		this.footer.updateState(state);

		// Update editor border color based on current thinking level
		this.updateEditorBorderColor();

		// Get compaction info if any
		const compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries());

		// Render messages
		for (let i = 0; i < state.messages.length; i++) {
			const message = state.messages[i];

			// Handle bash execution messages
			if (isBashExecutionMessage(message)) {
				this.addMessageToChat(message);
				continue;
			}

			if (message.role === "user") {
				const userMsg = message;
				const textBlocks =
					typeof userMsg.content === "string"
						? [{ type: "text", text: userMsg.content }]
						: userMsg.content.filter((c) => c.type === "text");
				const textContent = textBlocks.map((c) => c.text).join("");
				if (textContent) {
					// Check if this is a compaction summary message
					if (textContent.startsWith(SUMMARY_PREFIX) && compactionEntry) {
						const summary = textContent.slice(SUMMARY_PREFIX.length, -SUMMARY_SUFFIX.length);
						const component = new CompactionComponent(compactionEntry.tokensBefore, summary);
						component.setExpanded(this.toolOutputExpanded);
						this.chatContainer.addChild(component);
					} else {
						const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);
						this.chatContainer.addChild(userComponent);
						this.isFirstUserMessage = false;
					}
				}
			} else if (message.role === "assistant") {
				const assistantMsg = message as AssistantMessage;
				const assistantComponent = new AssistantMessageComponent(assistantMsg, this.hideThinkingBlock);
				this.chatContainer.addChild(assistantComponent);

				// Create tool execution components for any tool calls
				for (const content of assistantMsg.content) {
					if (content.type === "toolCall") {
						const component = new ToolExecutionComponent(content.name, content.arguments);
						this.chatContainer.addChild(component);

						// If message was aborted/errored, immediately mark tool as failed
						if (assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error") {
							const errorMessage =
								assistantMsg.stopReason === "aborted"
									? "Operation aborted"
									: assistantMsg.errorMessage || "Error";
							component.updateResult({
								content: [{ type: "text", text: errorMessage }],
								isError: true,
							});
						} else {
							// Store in map so we can update with results later
							this.pendingTools.set(content.id, component);
						}
					}
				}
			} else if (message.role === "toolResult") {
				// Update existing tool execution component with results				;
				const component = this.pendingTools.get(message.toolCallId);
				if (component) {
					component.updateResult({
						content: message.content,
						details: message.details,
						isError: message.isError,
					});
					// Remove from pending map since it's complete
					this.pendingTools.delete(message.toolCallId);
				}
			}
		}
		// Clear pending tools after rendering initial messages

[1378 more lines in file. Use offset=1000 to continue]


		this.pendingTools.clear();

		// Populate editor history with user messages from the session (oldest first so newest is at index 0)
		for (const message of state.messages) {
			if (message.role === "user") {
				const textBlocks =
					typeof message.content === "string"
						? [{ type: "text", text: message.content }]
						: message.content.filter((c) => c.type === "text");
				const textContent = textBlocks.map((c) => c.text).join("");
				// Skip compaction summary messages
				if (textContent && !textContent.startsWith(SUMMARY_PREFIX)) {
					this.editor.addToHistory(textContent);
				}
			}
		}

		this.ui.requestRender();
	}

	async getUserInput(): Promise<string> {
		return new Promise((resolve) => {
			this.onInputCallback = (text: string) => {
				this.onInputCallback = undefined;
				resolve(text);
			};
		});
	}

	private rebuildChatFromMessages(): void {
		// Reset state and re-render messages from agent state
		this.isFirstUserMessage = true;
		this.pendingTools.clear();

		// Get compaction info if any
		const compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries());

		for (const message of this.agent.state.messages) {
			// Handle bash execution messages
			if (isBashExecutionMessage(message)) {
				this.addMessageToChat(message);
				continue;
			}

			if (message.role === "user") {
				const userMsg = message;
				const textBlocks =
					typeof userMsg.content === "string"
						? [{ type: "text", text: userMsg.content }]
						: userMsg.content.filter((c) => c.type === "text");
				const textContent = textBlocks.map((c) => c.text).join("");
				if (textContent) {
					// Check if this is a compaction summary message
					if (textContent.startsWith(SUMMARY_PREFIX) && compactionEntry) {
						const summary = textContent.slice(SUMMARY_PREFIX.length, -SUMMARY_SUFFIX.length);
						const component = new CompactionComponent(compactionEntry.tokensBefore, summary);
						component.setExpanded(this.toolOutputExpanded);
						this.chatContainer.addChild(component);
					} else {
						const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);
						this.chatContainer.addChild(userComponent);
						this.isFirstUserMessage = false;
					}
				}
			} else if (message.role === "assistant") {
				const assistantMsg = message;
				const assistantComponent = new AssistantMessageComponent(assistantMsg, this.hideThinkingBlock);
				this.chatContainer.addChild(assistantComponent);

				for (const content of assistantMsg.content) {
					if (content.type === "toolCall") {
						const component = new ToolExecutionComponent(content.name, content.arguments);
						this.chatContainer.addChild(component);
						this.pendingTools.set(content.id, component);
					}
				}
			} else if (message.role === "toolResult") {
				const component = this.pendingTools.get(message.toolCallId);
				if (component) {
					component.updateResult({
						content: message.content,
						details: message.details,
						isError: message.isError,
					});
					this.pendingTools.delete(message.toolCallId);
				}
			}
		}

		this.pendingTools.clear();
		this.ui.requestRender();
	}

	private handleCtrlC(): void {
		// Handle Ctrl+C double-press logic
		const now = Date.now();
		const timeSinceLastCtrlC = now - this.lastSigintTime;

		if (timeSinceLastCtrlC < 500) {
			// Second Ctrl+C within 500ms - exit
			this.stop();
			process.exit(0);
		} else {
			// First Ctrl+C - clear the editor
			this.clearEditor();
			this.lastSigintTime = now;
		}
	}

	private updateEditorBorderColor(): void {
		if (this.isBashMode) {
			this.editor.borderColor = theme.getBashModeBorderColor();
		} else {
			const level = this.agent.state.thinkingLevel || "off";
			this.editor.borderColor = theme.getThinkingBorderColor(level);
		}
		this.ui.requestRender();
	}

	private cycleThinkingLevel(): void {
		// Only cycle if model supports thinking
		if (!this.agent.state.model?.reasoning) {
			this.chatContainer.addChild(new Spacer(1));
			this.chatContainer.addChild(new Text(theme.fg("dim", "Current model does not support thinking"), 1, 0));
			this.ui.requestRender();
			return;
		}

		// xhigh is only available for codex-max models
		const modelId = this.agent.state.model?.id || "";
		const supportsXhigh = modelId.includes("codex-max");
		const levels: ThinkingLevel[] = supportsXhigh
			? ["off", "minimal", "low", "medium", "high", "xhigh"]
			: ["off", "minimal", "low", "medium", "high"];
		const currentLevel = this.agent.state.thinkingLevel || "off";
		const currentIndex = levels.indexOf(currentLevel);
		const nextIndex = (currentIndex + 1) % levels.length;
		const nextLevel = levels[nextIndex];

		// Apply the new thinking level
		this.agent.setThinkingLevel(nextLevel);

		// Save thinking level change to session and settings
		this.sessionManager.saveThinkingLevelChange(nextLevel);
		this.settingsManager.setDefaultThinkingLevel(nextLevel);

		// Update border color
		this.updateEditorBorderColor();

		// Show brief notification
		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(new Text(theme.fg("dim", `Thinking level: ${nextLevel}`), 1, 0));
		this.ui.requestRender();
	}

	private async cycleModel(): Promise<void> {
		// Use scoped models if available, otherwise all available models
		if (this.scopedModels.length > 0) {
			// Use scoped models with thinking levels
			if (this.scopedModels.length === 1) {
				this.chatContainer.addChild(new Spacer(1));
				this.chatContainer.addChild(new Text(theme.fg("dim", "Only one model in scope"), 1, 0));
				this.ui.requestRender();
				return;
			}

			const currentModel = this.agent.state.model;
			let currentIndex = this.scopedModels.findIndex(
				(sm) => sm.model.id === currentModel?.id && sm.model.provider === currentModel?.provider,
			);

			// If current model not in scope, start from first
			if (currentIndex === -1) {
				currentIndex = 0;
			}

			const nextIndex = (currentIndex + 1) % this.scopedModels.length;
			const nextEntry = this.scopedModels[nextIndex];
			const nextModel = nextEntry.model;
			const nextThinking = nextEntry.thinkingLevel;

			// Validate API key
			const apiKey = await getApiKeyForModel(nextModel);
			if (!apiKey) {
				this.showError(`No API key for ${nextModel.provider}/${nextModel.id}`);
				return;
			}

			// Switch model
			this.agent.setModel(nextModel);

			// Save model change to session and settings
			this.sessionManager.saveModelChange(nextModel.provider, nextModel.id);
			this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);

			// Apply thinking level (silently use "off" if model doesn't support thinking)
			const effectiveThinking = nextModel.reasoning ? nextThinking : "off";
			this.agent.setThinkingLevel(effectiveThinking);
			this.sessionManager.saveThinkingLevelChange(effectiveThinking);
			this.settingsManager.setDefaultThinkingLevel(effectiveThinking);

[1178 more lines in file. Use offset=1200 to continue]


			this.updateEditorBorderColor();

			// Show notification
			this.chatContainer.addChild(new Spacer(1));
			const thinkingStr = nextModel.reasoning && nextThinking !== "off" ? ` (thinking: ${nextThinking})` : "";
			this.chatContainer.addChild(
				new Text(theme.fg("dim", `Switched to ${nextModel.name || nextModel.id}${thinkingStr}`), 1, 0),
			);
			this.ui.requestRender();
		} else {
			// Fallback to all available models (no thinking level changes)
			const { models: availableModels, error } = await getAvailableModels();
			if (error) {
				this.showError(`Failed to load models: ${error}`);
				return;
			}

			if (availableModels.length === 0) {
				this.showError("No models available to cycle");
				return;
			}

			if (availableModels.length === 1) {
				this.chatContainer.addChild(new Spacer(1));
				this.chatContainer.addChild(new Text(theme.fg("dim", "Only one model available"), 1, 0));
				this.ui.requestRender();
				return;
			}

			const currentModel = this.agent.state.model;
			let currentIndex = availableModels.findIndex(
				(m) => m.id === currentModel?.id && m.provider === currentModel?.provider,
			);

			// If current model not in scope, start from first
			if (currentIndex === -1) {
				currentIndex = 0;
			}

			const nextIndex = (currentIndex + 1) % availableModels.length;
			const nextModel = availableModels[nextIndex];

			// Validate API key
			const apiKey = await getApiKeyForModel(nextModel);
			if (!apiKey) {
				this.showError(`No API key for ${nextModel.provider}/${nextModel.id}`);
				return;
			}

			// Switch model
			this.agent.setModel(nextModel);

			// Save model change to session and settings
			this.sessionManager.saveModelChange(nextModel.provider, nextModel.id);
			this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);

			// Show notification
			this.chatContainer.addChild(new Spacer(1));
			this.chatContainer.addChild(new Text(theme.fg("dim", `Switched to ${nextModel.name || nextModel.id}`), 1, 0));
			this.ui.requestRender();
		}
	}

	private toggleToolOutputExpansion(): void {
		this.toolOutputExpanded = !this.toolOutputExpanded;

		// Update all tool execution, compaction, and bash execution components
		for (const child of this.chatContainer.children) {
			if (child instanceof ToolExecutionComponent) {
				child.setExpanded(this.toolOutputExpanded);
			} else if (child instanceof CompactionComponent) {
				child.setExpanded(this.toolOutputExpanded);
			} else if (child instanceof BashExecutionComponent) {
				child.setExpanded(this.toolOutputExpanded);
			}
		}

		this.ui.requestRender();
	}

	private toggleThinkingBlockVisibility(): void {
		this.hideThinkingBlock = !this.hideThinkingBlock;
		this.settingsManager.setHideThinkingBlock(this.hideThinkingBlock);

		// Update all assistant message components and rebuild their content
		for (const child of this.chatContainer.children) {
			if (child instanceof AssistantMessageComponent) {
				child.setHideThinkingBlock(this.hideThinkingBlock);
			}
		}

		// Rebuild chat to apply visibility change
		this.chatContainer.clear();
		this.rebuildChatFromMessages();

		// Show brief notification
		const status = this.hideThinkingBlock ? "hidden" : "visible";
		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(new Text(theme.fg("dim", `Thinking blocks: ${status}`), 1, 0));
		this.ui.requestRender();
	}

	clearEditor(): void {
		this.editor.setText("");
		this.ui.requestRender();
	}

	showError(errorMessage: string): void {
		// Show error message in the chat
		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0));
		this.ui.requestRender();
	}

	showWarning(warningMessage: string): void {
		// Show warning message in the chat
		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(new Text(theme.fg("warning", `Warning: ${warningMessage}`), 1, 0));
		this.ui.requestRender();
	}

	showNewVersionNotification(newVersion: string): void {
		// Show new version notification in the chat
		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
		this.chatContainer.addChild(
			new Text(
				theme.bold(theme.fg("warning", "Update Available")) +
					"\n" +
					theme.fg("muted", `New version ${newVersion} is available. Run: `) +
					theme.fg("accent", "npm install -g @mariozechner/pi-coding-agent"),
				1,
				0,
			),
		);
		this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
		this.ui.requestRender();
	}

	private showThinkingSelector(): void {
		// Create thinking selector with current level
		this.thinkingSelector = new ThinkingSelectorComponent(
			this.agent.state.thinkingLevel,
			(level) => {
				// Apply the selected thinking level
				this.agent.setThinkingLevel(level);

				// Save thinking level change to session and settings
				this.sessionManager.saveThinkingLevelChange(level);
				this.settingsManager.setDefaultThinkingLevel(level);

				// Update border color
				this.updateEditorBorderColor();

				// Show confirmation message with proper spacing
				this.chatContainer.addChild(new Spacer(1));
				const confirmText = new Text(theme.fg("dim", `Thinking level: ${level}`), 1, 0);
				this.chatContainer.addChild(confirmText);

				// Hide selector and show editor again
				this.hideThinkingSelector();
				this.ui.requestRender();
			},
			() => {
				// Just hide the selector
				this.hideThinkingSelector();
				this.ui.requestRender();
			},
		);

		// Replace editor with selector
		this.editorContainer.clear();
		this.editorContainer.addChild(this.thinkingSelector);
		this.ui.setFocus(this.thinkingSelector.getSelectList());
		this.ui.requestRender();
	}

	private hideThinkingSelector(): void {
		// Replace selector with editor in the container
		this.editorContainer.clear();
		this.editorContainer.addChild(this.editor);
		this.thinkingSelector = null;
		this.ui.setFocus(this.editor);
	}

	private showQueueModeSelector(): void {
		// Create queue mode selector with current mode
		this.queueModeSelector = new QueueModeSelectorComponent(
			this.agent.getQueueMode(),
			(mode) => {
				// Apply the selected queue mode
				this.agent.setQueueMode(mode);

				// Save queue mode to settings
				this.settingsManager.setQueueMode(mode);

				// Show confirmation message with proper spacing
				this.chatContainer.addChild(new Spacer(1));
				const confirmText = new Text(theme.fg("dim", `Queue mode: ${mode}`), 1, 0);
				this.chatContainer.addChild(confirmText);

[978 more lines in file. Use offset=1400 to continue]