Initial commit
diff --git a/loop/webui/src/timeline.ts b/loop/webui/src/timeline.ts
new file mode 100644
index 0000000..eef2726
--- /dev/null
+++ b/loop/webui/src/timeline.ts
@@ -0,0 +1,641 @@
+import { TimelineMessage } from "./timeline/types";
+import { formatNumber } from "./timeline/utils";
+import { checkShouldScroll } from "./timeline/scroll";
+import { ChartManager } from "./timeline/charts";
+import { ConnectionStatus, DataManager } from "./timeline/data";
+import { DiffViewer } from "./timeline/diffviewer";
+import { MessageRenderer } from "./timeline/renderer";
+import { TerminalHandler } from "./timeline/terminal";
+
+/**
+ * TimelineManager - Class to manage the timeline UI and functionality
+ */
+class TimelineManager {
+  private diffViewer = new DiffViewer();
+  private terminalHandler = new TerminalHandler();
+  private chartManager = new ChartManager();
+  private messageRenderer = new MessageRenderer();
+  private dataManager = new DataManager();
+
+  private viewMode: "chat" | "diff2" | "charts" | "terminal" = "chat";
+  shouldScrollToBottom: boolean;
+
+  constructor() {
+    // Initialize when DOM is ready
+    document.addEventListener("DOMContentLoaded", () => {
+      // First initialize from URL params to prevent flash of incorrect view
+      // This must happen before setting up other event handlers
+      void this.initializeViewFromUrl()
+        .then(() => {
+          // Continue with the rest of initialization
+          return this.initialize();
+        })
+        .catch((err) => {
+          console.error("Failed to initialize timeline:", err);
+        });
+    });
+
+    // Add popstate event listener to handle browser back/forward navigation
+    window.addEventListener("popstate", (event) => {
+      if (event.state && event.state.mode) {
+        // Using void to handle the promise returned by toggleViewMode
+        void this.toggleViewMode(event.state.mode);
+      } else {
+        // If no state or no mode in state, default to chat view
+        void this.toggleViewMode("chat");
+      }
+    });
+
+    // Listen for commit diff event from MessageRenderer
+    document.addEventListener("showCommitDiff", ((e: CustomEvent) => {
+      const { commitHash } = e.detail;
+      this.diffViewer.showCommitDiff(
+        commitHash,
+        (mode: "chat" | "diff2" | "terminal" | "charts") =>
+          this.toggleViewMode(mode)
+      );
+    }) as EventListener);
+  }
+
+  /**
+   * Initialize the timeline manager
+   */
+  private async initialize(): Promise<void> {
+    // Set up data manager event listeners
+    this.dataManager.addEventListener(
+      "dataChanged",
+      this.handleDataChanged.bind(this)
+    );
+    this.dataManager.addEventListener(
+      "connectionStatusChanged",
+      this.handleConnectionStatusChanged.bind(this)
+    );
+
+    // Initialize the data manager
+    await this.dataManager.initialize();
+
+    // URL parameters have already been read in constructor
+    // to prevent flash of incorrect content
+
+    // Set up conversation button handler
+    document
+      .getElementById("showConversationButton")
+      ?.addEventListener("click", async () => {
+        this.toggleViewMode("chat");
+      });
+
+    // Set up diff2 button handler
+    document
+      .getElementById("showDiff2Button")
+      ?.addEventListener("click", async () => {
+        this.toggleViewMode("diff2");
+      });
+
+    // Set up charts button handler
+    document
+      .getElementById("showChartsButton")
+      ?.addEventListener("click", async () => {
+        this.toggleViewMode("charts");
+      });
+
+    // Set up terminal button handler
+    document
+      .getElementById("showTerminalButton")
+      ?.addEventListener("click", async () => {
+        this.toggleViewMode("terminal");
+      });
+
+    // The active button will be set by toggleViewMode
+    // We'll initialize view based on URL params or default to chat view if no params
+    // We defer button activation to the toggleViewMode function
+
+    // Set up stop button handler
+    document
+      .getElementById("stopButton")
+      ?.addEventListener("click", async () => {
+        this.stopInnerLoop();
+      });
+
+    const pollToggleCheckbox = document.getElementById(
+      "pollToggle"
+    ) as HTMLInputElement;
+    pollToggleCheckbox?.addEventListener("change", () => {
+      this.dataManager.setPollingEnabled(pollToggleCheckbox.checked);
+      const statusText = document.getElementById("statusText");
+      if (statusText) {
+        if (pollToggleCheckbox.checked) {
+          statusText.textContent = "Polling for updates...";
+        } else {
+          statusText.textContent = "Polling stopped";
+        }
+      }
+    });
+
+    // Initial data fetch and polling is now handled by the DataManager
+
+    // Set up chat functionality
+    this.setupChatBox();
+
+    // Set up keyboard shortcuts
+    this.setupKeyboardShortcuts();
+
+    // Set up spacing adjustments
+    this.adjustChatSpacing();
+    window.addEventListener("resize", () => this.adjustChatSpacing());
+  }
+
+  /**
+   * Set up chat box event listeners
+   */
+  private setupChatBox(): void {
+    const chatInput = document.getElementById(
+      "chatInput"
+    ) as HTMLTextAreaElement;
+    const sendButton = document.getElementById("sendChatButton");
+
+    // Handle pressing Enter in the text area
+    chatInput?.addEventListener("keydown", (event: KeyboardEvent) => {
+      // Send message if Enter is pressed without Shift key
+      if (event.key === "Enter" && !event.shiftKey) {
+        event.preventDefault(); // Prevent default newline
+        this.sendChatMessage();
+      }
+    });
+
+    // Handle send button click
+    sendButton?.addEventListener("click", () => this.sendChatMessage());
+
+    // Set up mutation observer for the chat container
+    if (chatInput) {
+      chatInput.addEventListener("input", () => {
+        // When content changes, adjust the spacing
+        requestAnimationFrame(() => this.adjustChatSpacing());
+      });
+    }
+  }
+
+  /**
+   * Send the chat message to the server
+   */
+  private async sendChatMessage(): Promise<void> {
+    const chatInput = document.getElementById(
+      "chatInput"
+    ) as HTMLTextAreaElement;
+    if (!chatInput) return;
+
+    const message = chatInput.value.trim();
+
+    // Don't send empty messages
+    if (!message) return;
+
+    try {
+      // Send the message to the server
+      const response = await fetch("chat", {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/json",
+        },
+        body: JSON.stringify({ message }),
+      });
+
+      if (!response.ok) {
+        const errorData = await response.text();
+        throw new Error(`Server error: ${response.status} - ${errorData}`);
+      }
+
+      // Clear the input after sending
+      chatInput.value = "";
+
+      // Reset data manager state to force a full refresh after sending a message
+      // This ensures we get all messages in the correct order
+      // Use private API for now - TODO: add a resetState() method to DataManager
+      (this.dataManager as any).nextFetchIndex = 0;
+      (this.dataManager as any).currentFetchStartIndex = 0;
+
+      // If in diff view, switch to conversation view
+      if (this.viewMode === "diff2") {
+        await this.toggleViewMode("chat");
+      }
+
+      // Refresh the timeline data to show the new message
+      await this.dataManager.fetchData();
+    } catch (error) {
+      console.error("Error sending chat message:", error);
+      const statusText = document.getElementById("statusText");
+      if (statusText) {
+        statusText.textContent = "Error sending message";
+      }
+    }
+  }
+
+  /**
+   * Handle data changed event from the data manager
+   */
+  private handleDataChanged(eventData: {
+    state: any;
+    newMessages: TimelineMessage[];
+    isFirstFetch?: boolean;
+  }): void {
+    const { state, newMessages, isFirstFetch } = eventData;
+
+    // Check if we should scroll to bottom BEFORE handling new data
+    this.shouldScrollToBottom = this.checkShouldScroll();
+
+    // Update state info in the UI
+    this.updateUIWithState(state);
+
+    // Update the timeline if there are new messages
+    if (newMessages.length > 0) {
+      // Initialize the message renderer with current state
+      this.messageRenderer.initialize(
+        this.dataManager.getIsFirstLoad(),
+        this.dataManager.getCurrentFetchStartIndex()
+      );
+
+      this.messageRenderer.renderTimeline(newMessages, isFirstFetch || false);
+
+      // Update chart data using our full messages array
+      this.chartManager.setChartData(
+        this.chartManager.calculateCumulativeCostData(
+          this.dataManager.getMessages()
+        )
+      );
+
+      // If in charts view, update the charts
+      if (this.viewMode === "charts") {
+        this.chartManager.renderCharts();
+      }
+
+      const statusTextEl = document.getElementById("statusText");
+      if (statusTextEl) {
+        statusTextEl.textContent = "Updated just now";
+      }
+    } else {
+      const statusTextEl = document.getElementById("statusText");
+      if (statusTextEl) {
+        statusTextEl.textContent = "No new messages";
+      }
+    }
+  }
+
+  /**
+   * Handle connection status changed event from the data manager
+   */
+  private handleConnectionStatusChanged(
+    status: ConnectionStatus,
+    errorMessage?: string
+  ): void {
+    const pollingIndicator = document.getElementById("pollingIndicator");
+    if (!pollingIndicator) return;
+
+    // Remove all status classes
+    pollingIndicator.classList.remove("active", "error");
+
+    // Add appropriate class based on status
+    if (status === "connected") {
+      pollingIndicator.classList.add("active");
+    } else if (status === "disconnected") {
+      pollingIndicator.classList.add("error");
+    }
+
+    // Update status text if error message is provided
+    if (errorMessage) {
+      const statusTextEl = document.getElementById("statusText");
+      if (statusTextEl) {
+        statusTextEl.textContent = errorMessage;
+      }
+    }
+  }
+
+  /**
+   * Update UI elements with state data
+   */
+  private updateUIWithState(state: any): void {
+    // Update state info in the UI with safe getters
+    const hostnameEl = document.getElementById("hostname");
+    if (hostnameEl) {
+      hostnameEl.textContent = state?.hostname ?? "Unknown";
+    }
+
+    const workingDirEl = document.getElementById("workingDir");
+    if (workingDirEl) {
+      workingDirEl.textContent = state?.working_dir ?? "Unknown";
+    }
+
+    const initialCommitEl = document.getElementById("initialCommit");
+    if (initialCommitEl) {
+      initialCommitEl.textContent = state?.initial_commit
+        ? state.initial_commit.substring(0, 8)
+        : "Unknown";
+    }
+
+    const messageCountEl = document.getElementById("messageCount");
+    if (messageCountEl) {
+      messageCountEl.textContent = state?.message_count ?? "0";
+    }
+
+    const chatTitleEl = document.getElementById("chatTitle");
+    const bannerTitleEl = document.querySelector(".banner-title");
+
+    if (chatTitleEl && bannerTitleEl) {
+      if (state?.title) {
+        chatTitleEl.textContent = state.title;
+        chatTitleEl.style.display = "block";
+        bannerTitleEl.textContent = "sketch"; // Shorten title when chat title exists
+      } else {
+        chatTitleEl.style.display = "none";
+        bannerTitleEl.textContent = "sketch coding assistant"; // Full title when no chat title
+      }
+    }
+
+    // Get token and cost info safely
+    const inputTokens = state?.total_usage?.input_tokens ?? 0;
+    const outputTokens = state?.total_usage?.output_tokens ?? 0;
+    const cacheReadInputTokens =
+      state?.total_usage?.cache_read_input_tokens ?? 0;
+    const cacheCreationInputTokens =
+      state?.total_usage?.cache_creation_input_tokens ?? 0;
+    const totalCost = state?.total_usage?.total_cost_usd ?? 0;
+
+    const inputTokensEl = document.getElementById("inputTokens");
+    if (inputTokensEl) {
+      inputTokensEl.textContent = formatNumber(inputTokens, "0");
+    }
+
+    const outputTokensEl = document.getElementById("outputTokens");
+    if (outputTokensEl) {
+      outputTokensEl.textContent = formatNumber(outputTokens, "0");
+    }
+
+    const cacheReadInputTokensEl = document.getElementById(
+      "cacheReadInputTokens"
+    );
+    if (cacheReadInputTokensEl) {
+      cacheReadInputTokensEl.textContent = formatNumber(
+        cacheReadInputTokens,
+        "0"
+      );
+    }
+
+    const cacheCreationInputTokensEl = document.getElementById(
+      "cacheCreationInputTokens"
+    );
+    if (cacheCreationInputTokensEl) {
+      cacheCreationInputTokensEl.textContent = formatNumber(
+        cacheCreationInputTokens,
+        "0"
+      );
+    }
+
+    const totalCostEl = document.getElementById("totalCost");
+    if (totalCostEl) {
+      totalCostEl.textContent = `$${totalCost.toFixed(2)}`;
+    }
+  }
+
+  /**
+   * Check if we should scroll to the bottom
+   */
+  private checkShouldScroll(): boolean {
+    return checkShouldScroll(this.dataManager.getIsFirstLoad());
+  }
+
+  /**
+   * Dynamically adjust body padding based on the chat container height and top banner
+   */
+  private adjustChatSpacing(): void {
+    const chatContainer = document.querySelector(".chat-container");
+    const topBanner = document.querySelector(".top-banner");
+
+    if (chatContainer) {
+      const chatHeight = (chatContainer as HTMLElement).offsetHeight;
+      document.body.style.paddingBottom = `${chatHeight + 20}px`; // 20px extra for spacing
+    }
+
+    if (topBanner) {
+      const topHeight = (topBanner as HTMLElement).offsetHeight;
+      document.body.style.paddingTop = `${topHeight + 20}px`; // 20px extra for spacing
+    }
+  }
+
+  /**
+   * Set up keyboard shortcuts
+   */
+  private setupKeyboardShortcuts(): void {
+    // Add keyboard shortcut to automatically copy selected text with Ctrl+C (or Command+C on Mac)
+    document.addEventListener("keydown", (e: KeyboardEvent) => {
+      // We only want to handle Ctrl+C or Command+C
+      if ((e.ctrlKey || e.metaKey) && e.key === "c") {
+        // If text is already selected, we don't need to do anything special
+        // as the browser's default behavior will handle copying
+        // But we could add additional behavior here if needed
+      }
+    });
+  }
+
+  /**
+   * Toggle between different view modes: chat, diff2, charts
+   */
+  public async toggleViewMode(
+    mode: "chat" | "diff2" | "charts" | "terminal"
+  ): Promise<void> {
+    // Set the new view mode
+    this.viewMode = mode;
+
+    // Update URL with the current view mode
+    this.updateUrlForViewMode(mode);
+
+    // Get DOM elements
+    const timeline = document.getElementById("timeline");
+    const diff2View = document.getElementById("diff2View");
+    const chartView = document.getElementById("chartView");
+    const container = document.querySelector(".timeline-container");
+    const terminalView = document.getElementById("terminalView");
+    const conversationButton = document.getElementById(
+      "showConversationButton"
+    );
+    const diff2Button = document.getElementById("showDiff2Button");
+    const chartsButton = document.getElementById("showChartsButton");
+    const terminalButton = document.getElementById("showTerminalButton");
+
+    if (
+      !timeline ||
+      !diff2View ||
+      !chartView ||
+      !container ||
+      !conversationButton ||
+      !diff2Button ||
+      !chartsButton ||
+      !terminalView ||
+      !terminalButton
+    ) {
+      console.error("Required DOM elements not found");
+      return;
+    }
+
+    // Hide all views first
+    timeline.style.display = "none";
+    diff2View.style.display = "none";
+    chartView.style.display = "none";
+    terminalView.style.display = "none";
+
+    // Reset all button states
+    conversationButton.classList.remove("active");
+    diff2Button.classList.remove("active");
+    chartsButton.classList.remove("active");
+    terminalButton.classList.remove("active");
+
+    // Remove diff2-active and diff-active classes from container
+    container.classList.remove("diff2-active");
+    container.classList.remove("diff-active");
+
+    // If switching to chat view, clear the current commit hash
+    if (mode === "chat") {
+      this.diffViewer.clearCurrentCommitHash();
+    }
+
+    // Add class to indicate views are initialized (prevents flash of content)
+    container.classList.add("view-initialized");
+
+    // Show the selected view based on mode
+    switch (mode) {
+      case "chat":
+        timeline.style.display = "block";
+        conversationButton.classList.add("active");
+        break;
+      case "diff2":
+        diff2View.style.display = "block";
+        diff2Button.classList.add("active");
+        this.diffViewer.setViewMode(mode); // Update view mode in diff viewer
+        await this.diffViewer.loadDiff2HtmlContent();
+        break;
+      case "charts":
+        chartView.style.display = "block";
+        chartsButton.classList.add("active");
+        await this.chartManager.renderCharts();
+        break;
+      case "terminal":
+        terminalView.style.display = "block";
+        terminalButton.classList.add("active");
+        this.terminalHandler.setViewMode(mode); // Update view mode in terminal handler
+        this.diffViewer.setViewMode(mode); // Update view mode in diff viewer
+        await this.initializeTerminal();
+        break;
+    }
+  }
+
+  /**
+   * Initialize the terminal view
+   */
+  private async initializeTerminal(): Promise<void> {
+    // Use the TerminalHandler to initialize the terminal
+    await this.terminalHandler.initializeTerminal();
+  }
+
+  /**
+   * Initialize the view based on URL parameters
+   * This allows bookmarking and sharing of specific views
+   */
+  private async initializeViewFromUrl(): Promise<void> {
+    // Parse the URL parameters
+    const urlParams = new URLSearchParams(window.location.search);
+    const viewParam = urlParams.get("view");
+    const commitParam = urlParams.get("commit");
+
+    // Default to chat view if no valid view parameter is provided
+    if (!viewParam) {
+      // Explicitly set chat view to ensure button state is correct
+      await this.toggleViewMode("chat");
+      return;
+    }
+
+    // Check if the view parameter is valid
+    if (
+      viewParam === "chat" ||
+      viewParam === "diff2" ||
+      viewParam === "charts" ||
+      viewParam === "terminal"
+    ) {
+      // If it's a diff view with a commit hash, set the commit hash
+      if (viewParam === "diff2" && commitParam) {
+        this.diffViewer.setCurrentCommitHash(commitParam);
+      }
+
+      // Set the view mode
+      await this.toggleViewMode(
+        viewParam as "chat" | "diff2" | "charts" | "terminal"
+      );
+    }
+  }
+
+  /**
+   * Update URL to reflect current view mode for bookmarking and sharing
+   * @param mode The current view mode
+   */
+  private updateUrlForViewMode(
+    mode: "chat" | "diff2" | "charts" | "terminal"
+  ): void {
+    // Get the current URL without search parameters
+    const url = new URL(window.location.href);
+
+    // Clear existing parameters
+    url.search = "";
+
+    // Only add view parameter if not in default chat view
+    if (mode !== "chat") {
+      url.searchParams.set("view", mode);
+
+      // If in diff view and there's a commit hash, include that too
+      if (mode === "diff2" && this.diffViewer.getCurrentCommitHash()) {
+        url.searchParams.set("commit", this.diffViewer.getCurrentCommitHash());
+      }
+    }
+
+    // Update the browser history without reloading the page
+    window.history.pushState({ mode }, "", url.toString());
+  }
+
+  /**
+   * Stop the inner loop by calling the /cancel endpoint
+   */
+  private async stopInnerLoop(): Promise<void> {
+    if (!confirm("Are you sure you want to stop the current operation?")) {
+      return;
+    }
+
+    try {
+      const statusText = document.getElementById("statusText");
+      if (statusText) {
+        statusText.textContent = "Cancelling...";
+      }
+
+      const response = await fetch("cancel", {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/json",
+        },
+        body: JSON.stringify({ reason: "User requested cancellation via UI" }),
+      });
+
+      if (!response.ok) {
+        const errorData = await response.text();
+        throw new Error(`Server error: ${response.status} - ${errorData}`);
+      }
+
+      // Parse the response
+      const _result = await response.json();
+      if (statusText) {
+        statusText.textContent = "Operation cancelled";
+      }
+    } catch (error) {
+      console.error("Error cancelling operation:", error);
+      const statusText = document.getElementById("statusText");
+      if (statusText) {
+        statusText.textContent = "Error cancelling operation";
+      }
+    }
+  }
+}
+
+// Create and initialize the timeline manager when the page loads
+const _timelineManager = new TimelineManager();