From 56108afa402223a911efc3d36155a91d3b44d996 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Sun, 31 May 2026 21:44:32 +0530 Subject: [PATCH 01/49] feat: added markdown response from LLM response --- apps/core/src/session/prompt/anthropic.txt | 11 +++++++++++ apps/core/src/session/prompt/chatgpt.txt | 13 +++++++++++++ apps/core/src/session/prompt/default.txt | 12 ++++++++++++ apps/core/src/session/prompt/gemini.txt | 13 +++++++++++++ apps/core/src/session/prompt/gpt.txt | 13 +++++++++++++ apps/core/src/session/prompt/openai.txt | 13 +++++++++++++ 6 files changed, 75 insertions(+) diff --git a/apps/core/src/session/prompt/anthropic.txt b/apps/core/src/session/prompt/anthropic.txt index df8cc1f0..7a0f05e1 100644 --- a/apps/core/src/session/prompt/anthropic.txt +++ b/apps/core/src/session/prompt/anthropic.txt @@ -38,3 +38,14 @@ When referencing code, include `file_path:line_number` for navigation. - Tool results may include `` tags with useful reminders - System reminders are automatically added and relevant to the task - You can call multiple tools in parallel when independent + +## Response Formatting + +Format your responses for terminal display: +- Use **bold** for important concepts, key terms, and emphasis +- Use `inline code` for file paths (e.g., `src/index.ts`), function names, variables, and command names +- Use code blocks with ``` for multi-line code snippets +- Use # headings for section titles (keep it short for terminal) +- Use > blockquotes for important notes or warnings +- Use bullet lists (-) for sequential items or steps +- Color coding: blue for links and file references, cyan for headings, yellow for code diff --git a/apps/core/src/session/prompt/chatgpt.txt b/apps/core/src/session/prompt/chatgpt.txt index 24da75c3..dfc3c22b 100644 --- a/apps/core/src/session/prompt/chatgpt.txt +++ b/apps/core/src/session/prompt/chatgpt.txt @@ -20,3 +20,16 @@ Assist users with software engineering tasks efficiently through CLI automation. - Focus on the task at hand - Prefer editing existing files to creating new ones - Provide clear, actionable feedback + + +## Response Formatting + +Format your responses for terminal display: +- Use **bold** for important concepts, key terms, and emphasis +- Use `inline code` for file paths (e.g., `src/index.ts`), function names, variables, and command names +- Use code blocks with ``` for multi-line code snippets +- Use # headings for section titles (keep it short for terminal) +- Use > blockquotes for important notes or warnings +- Use bullet lists (-) for sequential items or steps +- Use 1. numbered lists for ordered steps +- Color coding: use blue for links and file references, cyan for headings, yellow for code diff --git a/apps/core/src/session/prompt/default.txt b/apps/core/src/session/prompt/default.txt index 1b32ba12..9a1690bb 100644 --- a/apps/core/src/session/prompt/default.txt +++ b/apps/core/src/session/prompt/default.txt @@ -25,3 +25,15 @@ When referencing specific functions or pieces of code, include the pattern `file - Be concise in responses - output is displayed on a command line interface - Prioritize technical accuracy over validation - Use GitHub-flavored markdown for formatting + +## Response Formatting + +Format your responses for terminal display: +- Use **bold** for important concepts, key terms, and emphasis +- Use `inline code` for file paths (e.g., `src/index.ts`), function names, variables, and command names +- Use code blocks with ``` for multi-line code snippets +- Use # headings for section titles (keep it short for terminal) +- Use > blockquotes for important notes or warnings +- Use bullet lists (-) for sequential items or steps +- Use 1. numbered lists for ordered steps +- Color coding: use blue for links and file references, cyan for headings, yellow for code diff --git a/apps/core/src/session/prompt/gemini.txt b/apps/core/src/session/prompt/gemini.txt index 5d3f1b71..0fec35bd 100644 --- a/apps/core/src/session/prompt/gemini.txt +++ b/apps/core/src/session/prompt/gemini.txt @@ -30,3 +30,16 @@ Leverage Gemini's strengths for coding assistance: - Make incremental changes - Always verify your work - Be clear about what you did and why + + +## Response Formatting + +Format your responses for terminal display: +- Use **bold** for important concepts, key terms, and emphasis +- Use `inline code` for file paths (e.g., `src/index.ts`), function names, variables, and command names +- Use code blocks with ``` for multi-line code snippets +- Use # headings for section titles (keep it short for terminal) +- Use > blockquotes for important notes or warnings +- Use bullet lists (-) for sequential items or steps +- Use 1. numbered lists for ordered steps +- Color coding: use blue for links and file references, cyan for headings, yellow for code diff --git a/apps/core/src/session/prompt/gpt.txt b/apps/core/src/session/prompt/gpt.txt index f3ff7980..36be89bf 100644 --- a/apps/core/src/session/prompt/gpt.txt +++ b/apps/core/src/session/prompt/gpt.txt @@ -30,3 +30,16 @@ You are FreeCode, powered by OpenAI's GPT models for coding assistance. - Follow existing project conventions - Add comments for non-obvious logic - Keep functions focused and small + + +## Response Formatting + +Format your responses for terminal display: +- Use **bold** for important concepts, key terms, and emphasis +- Use `inline code` for file paths (e.g., `src/index.ts`), function names, variables, and command names +- Use code blocks with ``` for multi-line code snippets +- Use # headings for section titles (keep it short for terminal) +- Use > blockquotes for important notes or warnings +- Use bullet lists (-) for sequential items or steps +- Use 1. numbered lists for ordered steps +- Color coding: use blue for links and file references, cyan for headings, yellow for code diff --git a/apps/core/src/session/prompt/openai.txt b/apps/core/src/session/prompt/openai.txt index f5dc8d5d..327c04b3 100644 --- a/apps/core/src/session/prompt/openai.txt +++ b/apps/core/src/session/prompt/openai.txt @@ -28,3 +28,16 @@ You have access to tools for reading, writing, searching, and editing code files - Use **Read** to examine file contents - Use **Bash** for shell operations - Use **edit** for targeted modifications + + +## Response Formatting + +Format your responses for terminal display: +- Use **bold** for important concepts, key terms, and emphasis +- Use `inline code` for file paths (e.g., `src/index.ts`), function names, variables, and command names +- Use code blocks with ``` for multi-line code snippets +- Use # headings for section titles (keep it short for terminal) +- Use > blockquotes for important notes or warnings +- Use bullet lists (-) for sequential items or steps +- Use 1. numbered lists for ordered steps +- Color coding: use blue for links and file references, cyan for headings, yellow for code From 83adda2ec4e37868efca5e607e1933d5de0abb2d Mon Sep 17 00:00:00 2001 From: Ayan De Date: Sun, 31 May 2026 21:58:24 +0530 Subject: [PATCH 02/49] feat: added phrases at utils file --- apps/tui/src/utils/elapsed-phrases.ts | 44 +++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 apps/tui/src/utils/elapsed-phrases.ts diff --git a/apps/tui/src/utils/elapsed-phrases.ts b/apps/tui/src/utils/elapsed-phrases.ts new file mode 100644 index 00000000..2ce8c095 --- /dev/null +++ b/apps/tui/src/utils/elapsed-phrases.ts @@ -0,0 +1,44 @@ +// Random elapsed-time phrases for completion messages +const elapsedPhrases = [ + "Baked", "Cooked", "Completed", "Finished", "Processed", + "Executed", "Generated", "Built", "Produced", "Shipped", + "Ready", "All set", "Wrapped up", "Done", +]; + +export function getRandomElapsedPhrase(): string { + return elapsedPhrases[Math.floor(Math.random() * elapsedPhrases.length)]; +} + +// Random in-progress phrases for processing messages +const inProgressPhrases = [ + // Cooking Theme + "Cooking up a solution...", "Baking your answer...", "Brewing something useful...", + "Simmering...", "Baking...", "Preheating...", "Whisking ideas...", + "Mixing ingredients...", "Seasoning the solution...", "Stirring the pot...", + "Letting it marinate...", "Chef at work...", "Preparing the recipe...", + "Plating the result...", + // Workshop / Builder Theme + "Forging...", "Crafting...", "Assembling...", "Building...", + "Hammering it out...", "Refining...", "Shaping the solution...", + "Putting the pieces together...", "In the workshop...", "Polishing the details...", + // Factory Theme + "Manufacturing...", "Fabricating...", "Producing...", "Calibrating...", + "Running the assembly line...", "Finalizing production...", + // Thinking Theme + "Brewing ideas...", "Connecting the dots...", "Mapping it out...", + "Crunching thoughts...", "Exploring possibilities...", "Thinking...", + "Reasoning...", "Working it through...", "Solving...", "Analyzing...", + // Magic / Alchemy Theme + "Conjuring...", "Brewing a potion...", "Casting spells...", "Mixing elixirs...", + "Performing alchemy...", "Summoning answers...", "Enchanting the output...", + // Fun Agent Messages + "Cooking up a solution...", "Baking your answer...", "Brewing something useful...", + "Crafting the perfect response...", "Sharpening the code...", + "Assembling the pieces...", "Taming the bugs...", + "Teaching electrons new tricks...", "Negotiating with the compiler...", + "Convincing the code to cooperate...", +]; + +export function getRandomInProgressPhrase(): string { + return inProgressPhrases[Math.floor(Math.random() * inProgressPhrases.length)]; +} \ No newline at end of file From 123ebe0916dce120988b9679384bb0e818eeafd9 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Sun, 31 May 2026 22:05:43 +0530 Subject: [PATCH 03/49] feat: add message store and typed message components - MessageStore with subscribe/notify pattern for reactive state - MessageInstance type with id, type, content, component, timestamp - VirtualMessageList implementing pi-tui Component interface - Typed message creators: createUserMessage, createAssistantMessage, createSystemMessage, createInProgressMessage - Message row factory for rendering different message types Co-Authored-By: Claude Opus 4.7 --- apps/tui/src/components/index.ts | 75 +++++++++ apps/tui/src/components/message-registry.ts | 6 + apps/tui/src/components/message-row.ts | 85 ++++++++++ apps/tui/src/components/message-types.ts | 15 ++ .../src/components/virtual-message-list.ts | 77 +++++++++ apps/tui/src/state/message-store.ts | 157 ++++++++++++++++++ 6 files changed, 415 insertions(+) create mode 100644 apps/tui/src/components/index.ts create mode 100644 apps/tui/src/components/message-registry.ts create mode 100644 apps/tui/src/components/message-row.ts create mode 100644 apps/tui/src/components/message-types.ts create mode 100644 apps/tui/src/components/virtual-message-list.ts create mode 100644 apps/tui/src/state/message-store.ts diff --git a/apps/tui/src/components/index.ts b/apps/tui/src/components/index.ts new file mode 100644 index 00000000..03a3ca8c --- /dev/null +++ b/apps/tui/src/components/index.ts @@ -0,0 +1,75 @@ +import type { Component } from "@earendil-works/pi-tui"; +import { + addMessage, + removeMessage, + getInProgress, + subscribeToMessages, + clearMessages, +} from "../state/message-store.js"; +import { createMessageComponent } from "./message-row.js"; +import type { MessageType, MessageInstance } from "./message-types.js"; + +/** + * Add a user message to the store and return the message instance + */ +export function createUserMessage(content: string): MessageInstance { + const component = createMessageComponent("user", content); + return addMessage("user", content, component); +} + +/** + * Add an assistant message to the store and return the message instance + */ +export function createAssistantMessage(content: string): MessageInstance { + const component = createMessageComponent("assistant", content); + return addMessage("assistant", content, component); +} + +/** + * Add a system message to the store and return the message instance + */ +export function createSystemMessage(content: string): MessageInstance { + const component = createMessageComponent("system", content); + return addMessage("system", content, component); +} + +/** + * Add an in-progress message to the store and return the message instance + */ +export function createInProgressMessage(phrase: string): MessageInstance { + const component = createMessageComponent("in_progress", phrase); + return addMessage("in_progress", phrase, component); +} + +/** + * Remove a message by ID from the store + */ +export function removeMessageById(id: number): MessageInstance | undefined { + return removeMessage(id); +} + +/** + * Get the current in-progress message, if any + */ +export function getPendingInProgress(): MessageInstance | undefined { + return getInProgress(); +} + +/** + * Subscribe to message store changes + */ +export function onMessagesChange(callback: (messages: MessageInstance[]) => void): () => void { + return subscribeToMessages(callback); +} + +export { subscribeToMessages }; + +/** + * Clear all messages from the store + */ +export function clearAllMessages(): void { + clearMessages(); +} + +// Re-export types for convenience +export type { MessageInstance, MessageType } from "./message-types.js"; \ No newline at end of file diff --git a/apps/tui/src/components/message-registry.ts b/apps/tui/src/components/message-registry.ts new file mode 100644 index 00000000..60fed972 --- /dev/null +++ b/apps/tui/src/components/message-registry.ts @@ -0,0 +1,6 @@ +// Re-export from the new store-based architecture +// This file exists for backwards compatibility with any code that imports from it +export { addMessage, removeMessage, clearMessages, createMessageId } from "../state/message-store.js"; +export { getMessage, getMessagesByType } from "../state/message-store.js"; +import type { MessageType, MessageInstance } from "./message-types.js"; +export type { MessageType, MessageInstance }; \ No newline at end of file diff --git a/apps/tui/src/components/message-row.ts b/apps/tui/src/components/message-row.ts new file mode 100644 index 00000000..16151d66 --- /dev/null +++ b/apps/tui/src/components/message-row.ts @@ -0,0 +1,85 @@ +import { Box, Markdown, Text, type Component } from "@earendil-works/pi-tui"; +import chalk from "chalk"; +import { defaultMarkdownTheme } from "../themes.js"; +import type { MessageType } from "./message-types.js"; + +// Regex to strip message prefixes (e.g., **You:** or **FreeCode:**) +const MESSAGE_PREFIX_RE = /^\*\*.*?:\*\*\s*/; + +function stripPrefix(content: string): string { + return content.replace(MESSAGE_PREFIX_RE, ""); +} + +/** + * Create a user message component — gray background with markdown content + */ +export function createUserMessageComponent(content: string): Component { + const displayContent = stripPrefix(content); + + const box = new Box(0, 0, (text: string) => { + return text + .split("\n") + .map((line) => chalk.bgRgb(80, 80, 80)(line)) + .join("\n"); + }); + + const markdown = new Markdown(displayContent, 1, 1, defaultMarkdownTheme); + box.addChild(markdown); + + return box; +} + +/** + * Create an assistant message component — markdown with colored output (headings=cyan, code=yellow, etc.) + */ +export function createAssistantMessageComponent(content: string): Component { + const displayContent = stripPrefix(content); + + const box = new Box(1, 1); + const markdown = new Markdown(displayContent, 1, 1, defaultMarkdownTheme); + box.addChild(markdown); + + return box; +} + +/** + * Create a system message component — dimmed text (for errors, elapsed time, etc.) + */ +export function createSystemMessageComponent(content: string): Component { + const displayContent = stripPrefix(content); + + const box = new Box(1, 1); + const text = new Text(chalk.dim(displayContent), 1, 1); + box.addChild(text); + + return box; +} + +/** + * Create an in-progress message component — dimmed yellow text (for "Simmering...", etc.) + */ +export function createInProgressMessageComponent(phrase: string): Component { + const box = new Box(1, 1); + const text = new Text(chalk.dim.yellow(phrase), 1, 1); + box.addChild(text); + + return box; +} + +/** + * Factory function to create the appropriate component based on message type + */ +export function createMessageComponent(type: MessageType, content: string): Component { + switch (type) { + case "user": + return createUserMessageComponent(content); + case "assistant": + return createAssistantMessageComponent(content); + case "system": + return createSystemMessageComponent(content); + case "in_progress": + return createInProgressMessageComponent(content); + default: + return createSystemMessageComponent(content); + } +} \ No newline at end of file diff --git a/apps/tui/src/components/message-types.ts b/apps/tui/src/components/message-types.ts new file mode 100644 index 00000000..bcd92073 --- /dev/null +++ b/apps/tui/src/components/message-types.ts @@ -0,0 +1,15 @@ +import type { Component } from "@earendil-works/pi-tui"; + +export type MessageType = "user" | "assistant" | "system" | "in_progress"; + +export interface MessageInstance { + id: number; + type: MessageType; + content: string; // raw content for reference + component: Component; + timestamp: number; +} + +export interface MessageStoreOptions { + maxMessages?: number; // optional cap for memory management +} \ No newline at end of file diff --git a/apps/tui/src/components/virtual-message-list.ts b/apps/tui/src/components/virtual-message-list.ts new file mode 100644 index 00000000..e455ab95 --- /dev/null +++ b/apps/tui/src/components/virtual-message-list.ts @@ -0,0 +1,77 @@ +import { type Component, type TUI } from "@earendil-works/pi-tui"; +import type { MessageInstance } from "./message-types.js"; +import { subscribeToMessages, getMessages } from "../state/message-store.js"; + +/** + * VirtualMessageList — scrollable message history that implements pi-tui's Component interface. + * + * Subscribes to MessageStore and re-renders when messages change. + * Only renders the last N messages to avoid memory/performance issues. + */ +export class VirtualMessageList implements Component { + private messages: MessageInstance[] = []; + private maxVisible: number; + private unsubscribe: (() => void) | null = null; + private invalidated = false; + private tui: TUI | null = null; + + constructor(maxVisible = 100) { + this.maxVisible = maxVisible; + // Subscribe to message store changes + this.unsubscribe = subscribeToMessages((msgs) => { + this.messages = msgs; + this.invalidate(); + }); + // Initialize with current messages + this.messages = getMessages(); + } + + /** + * Set the TUI instance for triggering renders + */ + setTui(tui: TUI): void { + this.tui = tui; + } + + /** + * Mark the component as needing re-render + */ + invalidate(): void { + this.invalidated = true; + if (this.tui) { + this.tui.requestRender(); + } + } + + /** + * Render the message list. + * Returns only the last maxVisible messages for performance. + */ + render(width: number): string[] { + this.invalidated = false; + + const lines: string[] = []; + + // Only render last N messages + const visibleMessages = this.messages.slice(-this.maxVisible); + + for (const msg of visibleMessages) { + const msgLines = msg.component.render(width); + for (const line of msgLines) { + lines.push(line); + } + } + + return lines; + } + + /** + * Cleanup subscription when component is destroyed + */ + destroy(): void { + if (this.unsubscribe) { + this.unsubscribe(); + this.unsubscribe = null; + } + } +} \ No newline at end of file diff --git a/apps/tui/src/state/message-store.ts b/apps/tui/src/state/message-store.ts new file mode 100644 index 00000000..5df04963 --- /dev/null +++ b/apps/tui/src/state/message-store.ts @@ -0,0 +1,157 @@ +import type { Component } from "@earendil-works/pi-tui"; +import type { MessageInstance, MessageType, MessageStoreOptions } from "../components/message-types.js"; + +type Subscriber = (messages: MessageInstance[]) => void; + +class MessageStoreImpl { + private messages: MessageInstance[] = []; + private subscribers = new Set(); + private idCounter = 0; + private maxMessages: number | undefined; + + constructor(options: MessageStoreOptions = {}) { + this.maxMessages = options.maxMessages; + } + + private generateId(): number { + return ++this.idCounter; + } + + /** + * Add a new message to the store + */ + add(type: MessageType, content: string, component: Component): MessageInstance { + const message: MessageInstance = { + id: this.generateId(), + type, + content, + component, + timestamp: Date.now(), + }; + + this.messages.push(message); + + // Cap memory usage if limit set + if (this.maxMessages && this.messages.length > this.maxMessages) { + this.messages = this.messages.slice(-this.maxMessages); + } + + this.notify(); + return message; + } + + /** + * Remove a message by its ID + */ + remove(id: number): MessageInstance | undefined { + const index = this.messages.findIndex((m) => m.id === id); + if (index === -1) return undefined; + + const removed = this.messages.splice(index, 1)[0]; + this.notify(); + return removed; + } + + /** + * Get all messages + */ + getMessages(): MessageInstance[] { + return [...this.messages]; + } + + /** + * Get messages filtered by type + */ + getByType(type: MessageType): MessageInstance[] { + return this.messages.filter((m) => m.type === type); + } + + /** + * Get the most recent in-progress message, if any + */ + getInProgress(): MessageInstance | undefined { + return this.messages.find((m) => m.type === "in_progress"); + } + + /** + * Remove all messages of a specific type + */ + removeByType(type: MessageType): MessageInstance[] { + const removed = this.messages.filter((m) => m.type === type); + this.messages = this.messages.filter((m) => m.type !== type); + if (removed.length > 0) { + this.notify(); + } + return removed; + } + + /** + * Clear all messages + */ + clear(): void { + this.messages = []; + this.notify(); + } + + /** + * Subscribe to message store changes + * Returns an unsubscribe function + */ + subscribe(callback: Subscriber): () => void { + this.subscribers.add(callback); + return () => { + this.subscribers.delete(callback); + }; + } + + /** + * Internal notification to all subscribers + */ + private notify(): void { + const snapshot = this.getMessages(); + for (const callback of this.subscribers) { + callback(snapshot); + } + } +} + +// Singleton instance +export const messageStore = new MessageStoreImpl(); + +// Helper functions that delegate to the store +export function addMessage(type: MessageType, content: string, component: Component): MessageInstance { + return messageStore.add(type, content, component); +} + +export function removeMessage(id: number): MessageInstance | undefined { + return messageStore.remove(id); +} + +export function getMessages(): MessageInstance[] { + return messageStore.getMessages(); +} + +export function getInProgress(): MessageInstance | undefined { + return messageStore.getInProgress(); +} + +export function clearMessages(): void { + messageStore.clear(); +} + +export function subscribeToMessages(callback: Subscriber): () => void { + return messageStore.subscribe(callback); +} + +let messageIdCounter = 0; +export function createMessageId(): number { + return ++messageIdCounter; +} + +export function getMessage(id: number): MessageInstance | undefined { + return messageStore.getMessages().find((m) => m.id === id); +} + +export function getMessagesByType(type: MessageType): MessageInstance[] { + return messageStore.getByType(type); +} \ No newline at end of file From 41659369766a3577f7df60bf9a5b39c5899f442c Mon Sep 17 00:00:00 2001 From: Ayan De Date: Sun, 31 May 2026 22:05:51 +0530 Subject: [PATCH 04/49] feat: add onStderr callback to startCli for stderr routing Routes CLI stderr output through a callback instead of console.error, enabling proper message handling in the TUI. Co-Authored-By: Claude Opus 4.7 --- apps/tui/src/ipc/client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/tui/src/ipc/client.ts b/apps/tui/src/ipc/client.ts index dcf06a1d..5c6394ad 100644 --- a/apps/tui/src/ipc/client.ts +++ b/apps/tui/src/ipc/client.ts @@ -45,7 +45,7 @@ function parseResponse(data: string): JsonRpcResponse[] { return responses; } -export function startCli(): void { +export function startCli(onStderr?: (msg: string) => void): void { if (cliProcess) return; // Project root is the monorepo root (where pnpm-workspace.yaml lives) @@ -67,7 +67,7 @@ export function startCli(): void { cliProcess.stdout?.setEncoding("utf-8"); cliProcess.stderr?.on("data", (data) => { - console.error("[CLI stderr]", data.toString()); + onStderr?.(data.toString().trim()); }); cliProcess.stdout?.on("data", (data: string) => { From 84d6ef2b72fde691d0b70deb9478c7e43e6c8151 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Sun, 31 May 2026 22:05:59 +0530 Subject: [PATCH 05/49] feat: add typed message creators to CommandContext Extends CommandContext with createUserMessage, createAssistantMessage, createSystemMessage, createInProgressMessage, and removeMessageById for type-safe message handling in commands. Co-Authored-By: Claude Opus 4.7 --- apps/tui/src/commands/index.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/tui/src/commands/index.ts b/apps/tui/src/commands/index.ts index cdb5258c..e8f2f54a 100644 --- a/apps/tui/src/commands/index.ts +++ b/apps/tui/src/commands/index.ts @@ -1,6 +1,16 @@ +import type { Component } from "@earendil-works/pi-tui"; import type { AutocompleteItem, SlashCommand } from "@earendil-works/pi-tui"; -export interface CommandContext { +export interface MessageCreators { + createUserMessage(content: string): { component: Component; id: number }; + createAssistantMessage(content: string): { component: Component; id: number }; + createSystemMessage(content: string): { component: Component; id: number }; + createInProgressMessage(phrase: string): { component: Component; id: number }; + insertBeforeEditor(component: Component): void; + removeMessageById(id: number): void; +} + +export interface CommandContext extends MessageCreators { showMessage(content: string): void; showModelSelector?(): void; } From 3c16962f41986cf400ebf699792600c1c08c2073 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Sun, 31 May 2026 22:06:04 +0530 Subject: [PATCH 06/49] feat: migrate freecode command to typed message creators Uses createUserMessage, createInProgressMessage, createAssistantMessage, and removeMessageById instead of direct tui.children manipulation. Co-Authored-By: Claude Opus 4.7 --- apps/tui/src/commands/freecode/index.ts | 32 ++++++++++++------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/apps/tui/src/commands/freecode/index.ts b/apps/tui/src/commands/freecode/index.ts index 3dd97ad8..38c78cfd 100644 --- a/apps/tui/src/commands/freecode/index.ts +++ b/apps/tui/src/commands/freecode/index.ts @@ -11,15 +11,15 @@ import { listProviders, type SessionInfo, } from "../../ipc/client.js"; -import { playSound } from "./sound.js"; import { playAlert } from "./alert.js"; +import { getRandomElapsedPhrase, getRandomInProgressPhrase } from "../../utils/elapsed-phrases.js"; export { stopSound } from "./sound.js"; // State let currentSession: SessionInfo | null = null; let providersLoaded = false; let cachedProviders: Array<{ id: string; name: string }> = []; -let currentProvider = "minimax"; // Default to minimax +let currentProvider = "minimax"; async function ensureProviders(): Promise { if (!providersLoaded) { @@ -46,8 +46,6 @@ async function ensureSession(ctx: CommandContext): Promise { if (currentSession) return true; startCli(); - - // Small delay to let CLI start await new Promise((resolve) => setTimeout(resolve, 500)); try { @@ -81,16 +79,15 @@ ${formatProviderList()}`); return; } - ctx.showMessage(`**You:** ${userPrompt}`); - ctx.showMessage("Processing..."); + // Show user message with gray background + ctx.createUserMessage(`**You:** ${userPrompt}`); - // Play sound while processing - // playSound(); + // Show in-progress message and track it for later removal + const inProgressMsg = ctx.createInProgressMessage(getRandomInProgressPhrase()); + const inProgressId = inProgressMsg.id; - // Track start time for elapsed display const startTime = Date.now(); - // Ensure CLI is running and we have a session const ready = await ensureSession(ctx); if (!ready) return; @@ -103,7 +100,9 @@ ${formatProviderList()}`); iterationCount?: number; }; - // Calculate elapsed time + // Remove in-progress message + ctx.removeMessageById(inProgressId); + const elapsed = Date.now() - startTime; const seconds = Math.floor(elapsed / 1000); const minutes = Math.floor(seconds / 60); @@ -112,20 +111,19 @@ ${formatProviderList()}`); if (result.success) { const response = result.content || result.message; - ctx.showMessage(`**FreeCode:** ${response || "Done!"}`); - ctx.showMessage(chalk.dim(`Baked for ${timeStr}`)); - // stopSound(); + ctx.createAssistantMessage(`**FreeCode:** ${response || "Done!"}`); + ctx.createSystemMessage(`${getRandomElapsedPhrase()} for ${timeStr}`); playAlert(); } else { - ctx.showMessage(`**FreeCode:** ${result.message || "Unknown error"}`); - // stopSound(); + ctx.createSystemMessage(`**Error:** ${result.message || "Unknown error"}`); + ctx.createSystemMessage(`${getRandomElapsedPhrase()} for ${timeStr}`); playAlert(); } } catch (error) { + ctx.removeMessageById(inProgressId); ctx.showMessage( `Error: ${error instanceof Error ? error.message : String(error)}` ); - // stopSound(); playAlert(); } }, From ac680f5e9f0f10414ae90ffe3f02840a8609a942 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Sun, 31 May 2026 22:06:11 +0530 Subject: [PATCH 07/49] fix: wire VirtualMessageList to TUI for reactive re-rendering Adds setTui() call to connect VirtualMessageList to the TUI instance, enabling requestRender() on message store updates. Also refactors message creation to use typed message creators through the store. Co-Authored-By: Claude Opus 4.7 --- apps/tui/src/index.ts | 111 +++++++++++++++++++----------------------- 1 file changed, 51 insertions(+), 60 deletions(-) diff --git a/apps/tui/src/index.ts b/apps/tui/src/index.ts index b9523127..fd859f8f 100644 --- a/apps/tui/src/index.ts +++ b/apps/tui/src/index.ts @@ -1,13 +1,13 @@ #!/usr/bin/env node -import { ProcessTerminal, TUI, Key, matchesKey, CombinedAutocompleteProvider, SelectList, Box, type Component, type SelectItem, type SelectListTheme } from "@earendil-works/pi-tui"; +import { ProcessTerminal, TUI, Key, matchesKey, CombinedAutocompleteProvider, SelectList, type SelectItem, type SelectListTheme } from "@earendil-works/pi-tui"; import { commandRegistry } from "./commands/index.js"; import { registerBuiltInCommands } from "./commands/built-in.js"; import { Editor } from "@earendil-works/pi-tui"; -import { Markdown } from "@earendil-works/pi-tui"; import { Text } from "@earendil-works/pi-tui"; import chalk from "chalk"; -import { defaultEditorTheme, defaultMarkdownTheme } from "./themes.js"; +import { defaultEditorTheme } from "./themes.js"; import { logoLines, logoTagline } from "./assets/logo.js"; +import { getRandomElapsedPhrase, getRandomInProgressPhrase } from "./utils/elapsed-phrases.js"; import { startCli, sessionStart, @@ -20,13 +20,23 @@ import { type SessionInfo, type ModelInfo } from "./ipc/client.js"; +import { + createUserMessage, + createAssistantMessage, + createSystemMessage, + createInProgressMessage, + removeMessageById, + subscribeToMessages, + onMessagesChange, + type MessageInstance, +} from "./components/index.js"; +import { VirtualMessageList } from "./components/virtual-message-list.js"; registerBuiltInCommands(); let tui: TUI; let messageCount = 0; -// Session state (used across editor.onSubmit and model selectors) let currentSession: SessionInfo | null = null; let currentProvider = ""; let currentModel = ""; @@ -36,7 +46,9 @@ let modelSelector: SelectList | null = null; let providerSelector: SelectList | null = null; let apiKeyEditor: Editor | null = null; let apiKeyPrompt: Text | null = null; -let modelDisplayIdx = -1; // Track index of model display in children +let modelDisplayIdx = -1; + +let messageList: VirtualMessageList; const terminal = new ProcessTerminal(); tui = new TUI(terminal); @@ -49,6 +61,11 @@ Type your messages below. Press Ctrl+C to exit.`; tui.addChild(new Text(welcomeText)); +// Create message list and add to tui BEFORE editor +messageList = new VirtualMessageList(200); +messageList.setTui(tui); +tui.addChild(messageList); + const editor = new Editor(tui, defaultEditorTheme); const autocompleteProvider = new CombinedAutocompleteProvider( @@ -75,14 +92,12 @@ const defaultSelectListTheme: SelectListTheme = { }; function updateModelDisplay(): void { - // Always update the display text const displayText = currentProvider && currentModel ? `${currentProvider}/${currentModel}` : "not selected"; modelDisplay = new Text(chalk.dim(`Model: ${displayText}`)); - // Use stored index to update model display if (modelDisplayIdx >= 0 && modelDisplayIdx < tui.children.length) { tui.children[modelDisplayIdx] = modelDisplay; } @@ -91,34 +106,7 @@ function updateModelDisplay(): void { } function showMessage(content: string): void { - // Check if this is a user message (contains **You with chalk styling) - // Content looks like: **You:** message - const isUserMessage = content.includes("**") && content.includes("You") && content.includes(":"); - - let msg: Component; - let displayContent = content; - if (isUserMessage) { - // Remove the **You:** prefix for display but keep background - displayContent = content.replace(/\*\*[^\:]+:\*\*\s*/, ""); - const box = new Box(0, 0, (text: string) => { - return text.split('\n').map(line => chalk.bgRgb(80, 80, 80)(line)).join('\n'); - }); - msg = new Markdown(displayContent, 1, 1, defaultMarkdownTheme); - box.addChild(msg); - msg = box; - } else if (content.includes("**") && content.includes("FreeCode") && content.includes(":")) { - // Remove the **FreeCode:** prefix for assistant messages too - displayContent = content.replace(/\*\*[^\:]+:\*\*\s*/, ""); - msg = new Markdown(displayContent, 1, 1, defaultMarkdownTheme); - } else { - msg = new Markdown(content, 1, 1, defaultMarkdownTheme); - } - - const children = tui.children; - // Insert after welcome (index 0), before editor and model display - const editorIdx = children.indexOf(editor); - children.splice(editorIdx, 0, msg); - tui.requestRender(); + createSystemMessage(content); } function removeSelector(selector: SelectList | null): void { @@ -219,15 +207,12 @@ async function showModelSelector(providerId: string): Promise { currentProvider = providerId; currentModel = item.value; - // Check if provider has API key const providers = await listProviders(); const providerInfo = (providers as any[]).find((p: any) => p.id === providerId); if (providerInfo && !providerInfo.hasApiKey) { - // Show API key input await showApiKeyInput(providerId, item.value); } else { - // Already has API key, just save current model await setCurrentModel(providerId, item.value); updateModelDisplay(); showMessage(`**Model changed to:** ${providerId}/${item.value}`); @@ -256,13 +241,10 @@ async function showApiKeyInput(providerId: string, modelId: string): Promise { const apiKey = value.trim(); @@ -271,7 +253,6 @@ async function showApiKeyInput(providerId: string, modelId: string): Promise { startCli(); - // Wait for CLI to initialize await new Promise(resolve => setTimeout(resolve, 800)); try { @@ -316,7 +295,16 @@ editor.onSubmit = async (value: string) => { if (commandName) { const command = commandRegistry.get(commandName); if (command) { - command.execute(args, { showMessage, showModelSelector: showProviderSelector }); + command.execute(args, { + showMessage, + showModelSelector: showProviderSelector, + createUserMessage: (content: string) => createUserMessage(content), + createAssistantMessage: (content: string) => createAssistantMessage(content), + createSystemMessage: (content: string) => createSystemMessage(content), + createInProgressMessage: (phrase: string) => createInProgressMessage(phrase), + insertBeforeEditor: () => { /* no-op - messages go through store now */ }, + removeMessageById: (id: number) => removeMessageById(id), + }); return; } else { showMessage(`**Error:** Unknown command: /${commandName}. Type /help for available commands.`); @@ -326,22 +314,19 @@ editor.onSubmit = async (value: string) => { } messageCount++; - showMessage(`**${chalk.red("You")}:** ${trimmed}`); - showMessage("Processing..."); - // Track start time for elapsed display + // Create messages through the store - VirtualMessageList handles rendering + createUserMessage(`**${chalk.red("You")}:** ${trimmed}`); + const inProgressMsg = createInProgressMessage(getRandomInProgressPhrase()); + const startTime = Date.now(); - // Ensure CLI is running startCli(); - // Wait for CLI to start if needed await new Promise(resolve => setTimeout(resolve, 500)); - // Get or create session if (!currentSession) { try { - // Get current model from config if not set if (!currentProvider) { try { const current = await getCurrentModel(); @@ -359,6 +344,7 @@ editor.onSubmit = async (value: string) => { provider: currentProvider || "minimax", }) as SessionInfo; } catch (error) { + removeMessageById(inProgressMsg.id); showMessage(`**Error:** Failed to start session: ${error instanceof Error ? error.message : String(error)}`); return; } @@ -373,7 +359,9 @@ editor.onSubmit = async (value: string) => { iterationCount?: number; }; - // Calculate elapsed time + // Remove in-progress message + removeMessageById(inProgressMsg.id); + const elapsed = Date.now() - startTime; const seconds = Math.floor(elapsed / 1000); const minutes = Math.floor(seconds / 60); @@ -382,18 +370,18 @@ editor.onSubmit = async (value: string) => { if (result.success) { const response = result.content || result.message; - showMessage(`**FreeCode:** ${response || "Done!"}`); - showMessage(chalk.dim(`Baked for ${timeStr}`)); + createAssistantMessage(`**FreeCode:** ${response || "Done!"}`); + createSystemMessage(`${getRandomElapsedPhrase()} for ${timeStr}`); } else { - showMessage(`**FreeCode:** ${result.message || "Unknown error"}`); - showMessage(chalk.dim(`Baked for ${timeStr}`)); + createSystemMessage(`**Error:** ${result.message || "Unknown error"}`); + createSystemMessage(`${getRandomElapsedPhrase()} for ${timeStr}`); } } catch (error) { + removeMessageById(inProgressMsg.id); showMessage(`**Error:** ${error instanceof Error ? error.message : String(error)}`); } }; -// Handle Ctrl+C for clean exit from keyboard tui.addInputListener((data) => { if (matchesKey(data, Key.ctrl("c"))) { if (tui) { @@ -404,10 +392,13 @@ tui.addInputListener((data) => { return undefined; }); -// Load current model from config on startup loadCurrentModel(); -// Stop sound on exit +// Wire stderr to system messages via store +startCli((stderrMsg) => { + createSystemMessage(stderrMsg); +}); + const freecodeModule = await import("./commands/freecode/index.js"); process.on("exit", () => freecodeModule.stopSound?.()); From 399bb6504d6b65c9a984c90df66aeacc27bcc6f3 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Sun, 31 May 2026 22:19:26 +0530 Subject: [PATCH 08/49] feat: remove dim from processing text --- apps/tui/src/components/message-row.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/tui/src/components/message-row.ts b/apps/tui/src/components/message-row.ts index 16151d66..f93189ed 100644 --- a/apps/tui/src/components/message-row.ts +++ b/apps/tui/src/components/message-row.ts @@ -60,7 +60,7 @@ export function createSystemMessageComponent(content: string): Component { */ export function createInProgressMessageComponent(phrase: string): Component { const box = new Box(1, 1); - const text = new Text(chalk.dim.yellow(phrase), 1, 1); + const text = new Text(chalk.yellow(phrase), 1, 1); box.addChild(text); return box; From 4f497f4bdd7f34de699352009b899063f38dc996 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Sun, 31 May 2026 22:52:02 +0530 Subject: [PATCH 09/49] feat: add startTime to MessageInstance for unified timing timestamp field now serves as startTime for in-progress messages, enabling a single source of truth for elapsed time calculation. Co-Authored-By: Claude Opus 4.7 --- apps/tui/src/components/message-types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/tui/src/components/message-types.ts b/apps/tui/src/components/message-types.ts index bcd92073..7f3fe68e 100644 --- a/apps/tui/src/components/message-types.ts +++ b/apps/tui/src/components/message-types.ts @@ -7,7 +7,7 @@ export interface MessageInstance { type: MessageType; content: string; // raw content for reference component: Component; - timestamp: number; + timestamp: number; // also serves as startTime for in-progress messages } export interface MessageStoreOptions { From b8c429286504eb568e09bdd3af9a82c7ef6174cd Mon Sep 17 00:00:00 2001 From: Ayan De Date: Sun, 31 May 2026 22:52:08 +0530 Subject: [PATCH 10/49] feat: add InProgressMessage component with live timer - InProgressMessage tracks startTime and renders phrase + elapsed seconds - Parens timer is dim grey, phrase is yellow - createMessageComponent accepts optional startTime for in-progress type Co-Authored-By: Claude Opus 4.7 --- apps/tui/src/components/message-row.ts | 45 +++++++++++++++++++++----- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/apps/tui/src/components/message-row.ts b/apps/tui/src/components/message-row.ts index f93189ed..f36b52d5 100644 --- a/apps/tui/src/components/message-row.ts +++ b/apps/tui/src/components/message-row.ts @@ -3,6 +3,39 @@ import chalk from "chalk"; import { defaultMarkdownTheme } from "../themes.js"; import type { MessageType } from "./message-types.js"; +/** + * In-progress message component with live timer. + * Renders "phrase (Xs)" where X increments each render. + */ +class InProgressMessage implements Component { + private phrase: string; + private startTime: number; + + constructor(phrase: string, startTime: number) { + this.phrase = phrase; + this.startTime = startTime; + } + + render(_width: number): string[] { + const elapsed = Math.floor((Date.now() - this.startTime) / 1000); + return [chalk.yellow(this.phrase) + chalk.dim(` (${elapsed}s)`)]; + } + + invalidate(): void {} + + getMinWidth(): number { + return 10; + } + + getMinHeight(): number { + return 1; + } + + addChild(_component: Component): void {} + + destroy(): void {} +} + // Regex to strip message prefixes (e.g., **You:** or **FreeCode:**) const MESSAGE_PREFIX_RE = /^\*\*.*?:\*\*\s*/; @@ -58,18 +91,14 @@ export function createSystemMessageComponent(content: string): Component { /** * Create an in-progress message component — dimmed yellow text (for "Simmering...", etc.) */ -export function createInProgressMessageComponent(phrase: string): Component { - const box = new Box(1, 1); - const text = new Text(chalk.yellow(phrase), 1, 1); - box.addChild(text); - - return box; +export function createInProgressMessageComponent(phrase: string, startTime: number): Component { + return new InProgressMessage(phrase, startTime); } /** * Factory function to create the appropriate component based on message type */ -export function createMessageComponent(type: MessageType, content: string): Component { +export function createMessageComponent(type: MessageType, content: string, startTime?: number): Component { switch (type) { case "user": return createUserMessageComponent(content); @@ -78,7 +107,7 @@ export function createMessageComponent(type: MessageType, content: string): Comp case "system": return createSystemMessageComponent(content); case "in_progress": - return createInProgressMessageComponent(content); + return createInProgressMessageComponent(content, startTime ?? Date.now()); default: return createSystemMessageComponent(content); } From 517ebfd4adaecacadc65ff2c5614c9db4b3d0273 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Sun, 31 May 2026 22:52:13 +0530 Subject: [PATCH 11/49] fix: keep tick interval running while in-progress message exists scheduleTick() now reschedules after each tick, firing every second to update the live timer display until the in-progress message is removed. Co-Authored-By: Claude Opus 4.7 --- .../src/components/virtual-message-list.ts | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/apps/tui/src/components/virtual-message-list.ts b/apps/tui/src/components/virtual-message-list.ts index e455ab95..0e5a2c7c 100644 --- a/apps/tui/src/components/virtual-message-list.ts +++ b/apps/tui/src/components/virtual-message-list.ts @@ -14,6 +14,7 @@ export class VirtualMessageList implements Component { private unsubscribe: (() => void) | null = null; private invalidated = false; private tui: TUI | null = null; + private tickInterval: ReturnType | null = null; constructor(maxVisible = 100) { this.maxVisible = maxVisible; @@ -21,6 +22,7 @@ export class VirtualMessageList implements Component { this.unsubscribe = subscribeToMessages((msgs) => { this.messages = msgs; this.invalidate(); + this.scheduleTick(); }); // Initialize with current messages this.messages = getMessages(); @@ -43,6 +45,26 @@ export class VirtualMessageList implements Component { } } + /** + * Schedule a tick interval if an in-progress message exists + */ + private scheduleTick(): void { + const hasInProgress = this.messages.some((m) => m.type === "in_progress"); + if (!hasInProgress) return; + + if (this.tickInterval) return; + + this.tickInterval = setInterval(() => { + if (this.tickInterval) { + clearInterval(this.tickInterval); + this.tickInterval = null; + } + this.invalidate(); + // Reschedule if in-progress message still exists + this.scheduleTick(); + }, 1000); + } + /** * Render the message list. * Returns only the last maxVisible messages for performance. @@ -66,12 +88,16 @@ export class VirtualMessageList implements Component { } /** - * Cleanup subscription when component is destroyed + * Cleanup subscription and tick interval when component is destroyed */ destroy(): void { if (this.unsubscribe) { this.unsubscribe(); this.unsubscribe = null; } + if (this.tickInterval) { + clearInterval(this.tickInterval); + this.tickInterval = null; + } } -} \ No newline at end of file +} From c9db7bd78646007d9c6b4edb0726d80397c91c6c Mon Sep 17 00:00:00 2001 From: Ayan De Date: Sun, 31 May 2026 22:52:17 +0530 Subject: [PATCH 12/49] feat: createInProgressMessage captures startTime for unified timing Co-Authored-By: Claude Opus 4.7 --- apps/tui/src/components/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/tui/src/components/index.ts b/apps/tui/src/components/index.ts index 03a3ca8c..1c5e88d4 100644 --- a/apps/tui/src/components/index.ts +++ b/apps/tui/src/components/index.ts @@ -37,7 +37,8 @@ export function createSystemMessage(content: string): MessageInstance { * Add an in-progress message to the store and return the message instance */ export function createInProgressMessage(phrase: string): MessageInstance { - const component = createMessageComponent("in_progress", phrase); + const startTime = Date.now(); + const component = createMessageComponent("in_progress", phrase, startTime); return addMessage("in_progress", phrase, component); } From 66c79b1376a8e9a20217deb1b14e8a91ae524783 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Sun, 31 May 2026 22:52:22 +0530 Subject: [PATCH 13/49] fix: use inProgressMsg.timestamp for elapsed time calculation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes duplicate startTime tracking — elapsed now derives from the in-progress message's timestamp for consistency. Co-Authored-By: Claude Opus 4.7 --- apps/tui/src/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/tui/src/index.ts b/apps/tui/src/index.ts index fd859f8f..a828451a 100644 --- a/apps/tui/src/index.ts +++ b/apps/tui/src/index.ts @@ -319,7 +319,6 @@ editor.onSubmit = async (value: string) => { createUserMessage(`**${chalk.red("You")}:** ${trimmed}`); const inProgressMsg = createInProgressMessage(getRandomInProgressPhrase()); - const startTime = Date.now(); startCli(); @@ -362,7 +361,7 @@ editor.onSubmit = async (value: string) => { // Remove in-progress message removeMessageById(inProgressMsg.id); - const elapsed = Date.now() - startTime; + const elapsed = Date.now() - inProgressMsg.timestamp; const seconds = Math.floor(elapsed / 1000); const minutes = Math.floor(seconds / 60); const secs = seconds % 60; From 7b72a75e254082a60df8c7df6fd583088e2e5a9c Mon Sep 17 00:00:00 2001 From: Ayan De Date: Mon, 1 Jun 2026 01:09:26 +0530 Subject: [PATCH 14/49] feat: token count --- apps/tui/src/utils/format-tokens.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 apps/tui/src/utils/format-tokens.ts diff --git a/apps/tui/src/utils/format-tokens.ts b/apps/tui/src/utils/format-tokens.ts new file mode 100644 index 00000000..a976c793 --- /dev/null +++ b/apps/tui/src/utils/format-tokens.ts @@ -0,0 +1,9 @@ +/** + * Format a token count number into a human-readable string. + * e.g., 12300 → "12.3k", 1500000 → "1.5M" + */ +export function formatTokenCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return n.toString(); +} From 094c82f8ce5d6969c864693a01978533141278ae Mon Sep 17 00:00:00 2001 From: Ayan De Date: Mon, 1 Jun 2026 01:09:41 +0530 Subject: [PATCH 15/49] feat: model context count --- apps/tui/src/utils/model-limits.ts | 55 ++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 apps/tui/src/utils/model-limits.ts diff --git a/apps/tui/src/utils/model-limits.ts b/apps/tui/src/utils/model-limits.ts new file mode 100644 index 00000000..36a53735 --- /dev/null +++ b/apps/tui/src/utils/model-limits.ts @@ -0,0 +1,55 @@ +/** + * Model context and output limits. + * Values are token counts for the model's context window and max output. + */ + +export interface ModelLimits { + context: number; + output: number; +} + +// Known model limits by model ID (provider/model-name format) +export const MODEL_LIMITS: Record = { + // MiniMax models + "minimax/MiniMax-M2": { context: 1_000_000, output: 8192 }, + "minimax/minimax-text-01": { context: 1_000_000, output: 8192 }, + "minimax/minimax-text-01-prefill": { context: 1_000_000, output: 8192 }, + "minimax/agent-minimax-text-01": { context: 1_000_000, output: 8192 }, + + // OpenAI models + "openai/gpt-4o": { context: 128_000, output: 16384 }, + "openai/gpt-4o-mini": { context: 128_000, output: 16384 }, + "openai/gpt-4-turbo": { context: 128_000, output: 4096 }, + "openai/gpt-3.5-turbo": { context: 16_385, output: 4096 }, + + // Anthropic models + "anthropic/claude-sonnet-4-20250514": { context: 200_000, output: 8192 }, + "anthropic/claude-opus-4-20250514": { context: 200_000, output: 8192 }, + "anthropic/claude-3-5-sonnet-latest": { context: 200_000, output: 8192 }, + "anthropic/claude-3-opus-latest": { context: 200_000, output: 4096 }, + "anthropic/claude-3-sonnet-latest": { context: 200_000, output: 4096 }, + "anthropic/claude-3-haiku-latest": { context: 200_000, output: 4096 }, + + // Google models + "google/gemini-2.0-flash": { context: 1_000_000, output: 8192 }, + "google/gemini-1.5-pro": { context: 2_000_000, output: 8192 }, + "google/gemini-1.5-flash": { context: 1_000_000, output: 8192 }, + + // GitHub Copilot + "github-copilot/gpt-4o": { context: 128_000, output: 4096 }, + "github-copilot/gpt-4o-mini": { context: 128_000, output: 4096 }, +}; + +/** + * Get context limit for a model. Returns 0 if unknown. + */ +export function getModelContextLimit(modelId: string): number { + return MODEL_LIMITS[modelId]?.context ?? 0; +} + +/** + * Get output limit for a model. Returns 0 if unknown. + */ +export function getModelOutputLimit(modelId: string): number { + return MODEL_LIMITS[modelId]?.output ?? 0; +} From a832b14537eeb3fb8808045ece693d4352cbee85 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Mon, 1 Jun 2026 01:10:20 +0530 Subject: [PATCH 16/49] feat: accumulate usage tokens across agent loop turns LoopResult now includes usage field, and AgentLoop accumulates input/output tokens across all turns for accurate total counts. Co-Authored-By: Claude Opus 4.7 --- apps/core/src/agent/loop.ts | 23 ++++++++++++++++------- apps/core/src/agent/types.ts | 1 + 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/core/src/agent/loop.ts b/apps/core/src/agent/loop.ts index 85875000..f865346e 100644 --- a/apps/core/src/agent/loop.ts +++ b/apps/core/src/agent/loop.ts @@ -85,6 +85,8 @@ export class AgentLoop { let prompt = input.prompt let previousToolResults: ToolResult[] | undefined + let totalInputTokens = 0 + let totalOutputTokens = 0 // ======================================================================= // CONTINUOUS LOOP - Core agent cycle @@ -113,9 +115,15 @@ export class AgentLoop { return this.fail("Turn execution failed", turnResult.error) } + // Accumulate usage across turns + if (turnResult.usage) { + totalInputTokens += turnResult.usage.inputTokens ?? 0 + totalOutputTokens += turnResult.usage.outputTokens ?? 0 + } + // No tool calls means we're done if (turnResult.toolResults.length === 0) { - return this.complete("Done", turnResult.responseText) + return this.complete("Done", turnResult.responseText, { inputTokens: totalInputTokens, outputTokens: totalOutputTokens }) } // Build continuation prompt for next iteration @@ -148,7 +156,7 @@ export class AgentLoop { model: string | undefined, context: { name: string; projectPath: string; tree: string }, previousToolResults?: ToolResult[] - ): Promise<{ success: boolean; toolResults: ToolResult[]; responseText?: string; error?: string }> { + ): Promise<{ success: boolean; toolResults: ToolResult[]; responseText?: string; error?: string; usage?: { inputTokens: number; outputTokens: number } }> { try { // TWO-PHASE CONTEXT COLLECTION // Phase 1: Ask model which files it needs to complete the task @@ -219,7 +227,7 @@ ${memoryContext ? `Session context:\n${memoryContext}\n\n` : ""}Task: ${prompt}` } } } - return { success: true, toolResults: [], responseText: providerResult.content } + return { success: true, toolResults: [], responseText: providerResult.content, usage: providerResult.usage } } // Execute each tool sequentially (as per spec: sequential tools run one at a time) @@ -251,7 +259,7 @@ ${memoryContext ? `Session context:\n${memoryContext}\n\n` : ""}Task: ${prompt}` } } - return { success: true, toolResults, responseText: providerResult.content } + return { success: true, toolResults, responseText: providerResult.content, usage: providerResult.usage } } catch (error) { return { success: false, toolResults: [], error: String(error) } } @@ -379,7 +387,7 @@ Based on this task, which files do you need to read to understand the codebase a provider: string, model: string | undefined, toolResults?: ToolResult[] - ): Promise<{ content: string; toolCalls?: Array<{ name: string; args: Record; id: string }> }> { + ): Promise<{ content: string; toolCalls?: Array<{ name: string; args: Record; id: string }>; usage?: { inputTokens: number; outputTokens: number } }> { const aiProvider = getProvider(provider as any) const tools = listTools().map(t => { const toolDef = getTool(t.id) @@ -400,7 +408,7 @@ Based on this task, which files do you need to read to understand the codebase a })), model, }) - return { content: result.content, toolCalls: result.toolCalls } + return { content: result.content, toolCalls: result.toolCalls, usage: result.usage } } // =========================================================================== @@ -757,7 +765,7 @@ Based on this task, which files do you need to read to understand the codebase a } } - private complete(message: string, content?: string): LoopResult { + private complete(message: string, content?: string, usage?: { inputTokens: number; outputTokens: number }): LoopResult { // Emit session.updated event BusEvents.sessionUpdated(this.state.sessionId) return { @@ -767,6 +775,7 @@ Based on this task, which files do you need to read to understand the codebase a turnCount: this.state.turnCount, iterationCount: this.state.iterationCount, finalState: this.state, + usage, } } diff --git a/apps/core/src/agent/types.ts b/apps/core/src/agent/types.ts index baca608d..a5e89889 100644 --- a/apps/core/src/agent/types.ts +++ b/apps/core/src/agent/types.ts @@ -186,6 +186,7 @@ export interface LoopResult { turnCount: number iterationCount: number finalState: SessionState + usage?: { inputTokens: number; outputTokens: number } } // ============================================================================= From 9e504f90342a3aeae5d74ffe764c952a4cd4e634 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Mon, 1 Jun 2026 01:10:27 +0530 Subject: [PATCH 17/49] feat: add usage field to SessionSendResult IPC type Co-Authored-By: Claude Opus 4.7 --- apps/tui/src/ipc/client.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/tui/src/ipc/client.ts b/apps/tui/src/ipc/client.ts index 5c6394ad..03c78a37 100644 --- a/apps/tui/src/ipc/client.ts +++ b/apps/tui/src/ipc/client.ts @@ -149,8 +149,17 @@ export async function sessionStop(sessionId: string): Promise { await sendRequest("session.stop", { sessionId }); } -export async function sessionSend(sessionId: string, message: string, model?: string): Promise { - return await sendRequest("session.send", { sessionId, message, model }); +export interface SessionSendResult { + success: boolean; + message?: string; + content?: string; + turnCount?: number; + iterationCount?: number; + usage?: { inputTokens: number; outputTokens: number }; +} + +export async function sessionSend(sessionId: string, message: string, model?: string): Promise { + return await sendRequest("session.send", { sessionId, message, model }) as SessionSendResult; } // ============================================================================= From 197f63781fc7da74a3ff9eb004b715be20c8a456 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Mon, 1 Jun 2026 01:10:49 +0530 Subject: [PATCH 18/49] feat: add updateMessage to message store for in-progress updates Co-Authored-By: Claude Opus 4.7 --- apps/tui/src/state/message-store.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/tui/src/state/message-store.ts b/apps/tui/src/state/message-store.ts index 5df04963..4acfce85 100644 --- a/apps/tui/src/state/message-store.ts +++ b/apps/tui/src/state/message-store.ts @@ -52,6 +52,19 @@ class MessageStoreImpl { return removed; } + /** + * Update a message's content and component by ID + */ + update(id: number, content: string, component: Component): MessageInstance | undefined { + const message = this.messages.find((m) => m.id === id); + if (!message) return undefined; + + message.content = content; + message.component = component; + this.notify(); + return message; + } + /** * Get all messages */ @@ -135,6 +148,10 @@ export function getInProgress(): MessageInstance | undefined { return messageStore.getInProgress(); } +export function updateMessage(id: number, content: string, component: Component): MessageInstance | undefined { + return messageStore.update(id, content, component); +} + export function clearMessages(): void { messageStore.clear(); } From 0ce6c444aab3042d7f27b0dd5bcbfcc2cd2a1cc5 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Mon, 1 Jun 2026 01:10:55 +0530 Subject: [PATCH 19/49] feat: update InProgressMessage to display tokens and context bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InProgressMessage now shows live timer + token counts (↓↑) and optional context bar [current/limit]. createInProgressMessage accepts token and context parameters. Co-Authored-By: Claude Opus 4.7 --- apps/tui/src/components/index.ts | 25 ++++++++++++- apps/tui/src/components/message-row.ts | 51 ++++++++++++++++++++++---- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/apps/tui/src/components/index.ts b/apps/tui/src/components/index.ts index 1c5e88d4..04a6b92b 100644 --- a/apps/tui/src/components/index.ts +++ b/apps/tui/src/components/index.ts @@ -5,6 +5,7 @@ import { getInProgress, subscribeToMessages, clearMessages, + updateMessage, } from "../state/message-store.js"; import { createMessageComponent } from "./message-row.js"; import type { MessageType, MessageInstance } from "./message-types.js"; @@ -36,9 +37,14 @@ export function createSystemMessage(content: string): MessageInstance { /** * Add an in-progress message to the store and return the message instance */ -export function createInProgressMessage(phrase: string): MessageInstance { +export function createInProgressMessage( + phrase: string, + inputTokens = 0, + outputTokens = 0, + contextLimit = 0 +): MessageInstance { const startTime = Date.now(); - const component = createMessageComponent("in_progress", phrase, startTime); + const component = createMessageComponent("in_progress", phrase, startTime, inputTokens, outputTokens, contextLimit); return addMessage("in_progress", phrase, component); } @@ -49,6 +55,21 @@ export function removeMessageById(id: number): MessageInstance | undefined { return removeMessage(id); } +/** + * Update an in-progress message with new token counts + */ +export function updateInProgressMessage( + id: number, + phrase: string, + inputTokens: number, + outputTokens: number, + contextLimit: number, + startTime: number +): MessageInstance | undefined { + const component = createMessageComponent("in_progress", phrase, startTime, inputTokens, outputTokens, contextLimit); + return updateMessage(id, phrase, component); +} + /** * Get the current in-progress message, if any */ diff --git a/apps/tui/src/components/message-row.ts b/apps/tui/src/components/message-row.ts index f36b52d5..b73a7302 100644 --- a/apps/tui/src/components/message-row.ts +++ b/apps/tui/src/components/message-row.ts @@ -2,23 +2,45 @@ import { Box, Markdown, Text, type Component } from "@earendil-works/pi-tui"; import chalk from "chalk"; import { defaultMarkdownTheme } from "../themes.js"; import type { MessageType } from "./message-types.js"; +import { formatTokenCount } from "../utils/format-tokens.js"; /** - * In-progress message component with live timer. - * Renders "phrase (Xs)" where X increments each render. + * In-progress message component with live timer and token counts. + * Renders "phrase (Xs) ↓inputTokens ↑outputTokens [████░░░░░ 50k/200k]" */ class InProgressMessage implements Component { private phrase: string; private startTime: number; + private inputTokens: number; + private outputTokens: number; + private contextLimit: number; - constructor(phrase: string, startTime: number) { + constructor(phrase: string, startTime: number, inputTokens: number, outputTokens: number, contextLimit: number) { this.phrase = phrase; this.startTime = startTime; + this.inputTokens = inputTokens; + this.outputTokens = outputTokens; + this.contextLimit = contextLimit; } render(_width: number): string[] { const elapsed = Math.floor((Date.now() - this.startTime) / 1000); - return [chalk.yellow(this.phrase) + chalk.dim(` (${elapsed}s)`)]; + const inStr = formatTokenCount(this.inputTokens); + const outStr = formatTokenCount(this.outputTokens); + let display = `${chalk.yellow(this.phrase)}${chalk.dim(` (${elapsed}s)`)} ${chalk.dim(`↓${inStr}`)} ${chalk.dim(`↑${outStr}`)}`; + + if (this.contextLimit > 0) { + const pct = Math.min(this.inputTokens / this.contextLimit, 1); + const barWidth = 10; + const filled = Math.round(pct * barWidth); + const empty = barWidth - filled; + const bar = '█'.repeat(filled) + '░'.repeat(empty); + const current = formatTokenCount(this.inputTokens); + const limit = formatTokenCount(this.contextLimit); + display += ` ${chalk.dim(`[${bar} ${current}/${limit}]`)}`; + } + + return [display]; } invalidate(): void {} @@ -91,14 +113,21 @@ export function createSystemMessageComponent(content: string): Component { /** * Create an in-progress message component — dimmed yellow text (for "Simmering...", etc.) */ -export function createInProgressMessageComponent(phrase: string, startTime: number): Component { - return new InProgressMessage(phrase, startTime); +export function createInProgressMessageComponent(phrase: string, startTime: number, inputTokens: number, outputTokens: number, contextLimit: number): Component { + return new InProgressMessage(phrase, startTime, inputTokens, outputTokens, contextLimit); } /** * Factory function to create the appropriate component based on message type */ -export function createMessageComponent(type: MessageType, content: string, startTime?: number): Component { +export function createMessageComponent( + type: MessageType, + content: string, + startTime?: number, + inputTokens?: number, + outputTokens?: number, + contextLimit?: number +): Component { switch (type) { case "user": return createUserMessageComponent(content); @@ -107,7 +136,13 @@ export function createMessageComponent(type: MessageType, content: string, start case "system": return createSystemMessageComponent(content); case "in_progress": - return createInProgressMessageComponent(content, startTime ?? Date.now()); + return createInProgressMessageComponent( + content, + startTime ?? Date.now(), + inputTokens ?? 0, + outputTokens ?? 0, + contextLimit ?? 0 + ); default: return createSystemMessageComponent(content); } From 6fabac41af1f7a54387d888e575df7ef30f6ece3 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Mon, 1 Jun 2026 01:11:02 +0530 Subject: [PATCH 20/49] feat: add updateInProgressMessage to CommandContext interface Co-Authored-By: Claude Opus 4.7 --- apps/tui/src/commands/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/tui/src/commands/index.ts b/apps/tui/src/commands/index.ts index e8f2f54a..04e01e06 100644 --- a/apps/tui/src/commands/index.ts +++ b/apps/tui/src/commands/index.ts @@ -5,7 +5,8 @@ export interface MessageCreators { createUserMessage(content: string): { component: Component; id: number }; createAssistantMessage(content: string): { component: Component; id: number }; createSystemMessage(content: string): { component: Component; id: number }; - createInProgressMessage(phrase: string): { component: Component; id: number }; + createInProgressMessage(phrase: string, inputTokens?: number, outputTokens?: number, contextLimit?: number): { component: Component; id: number }; + updateInProgressMessage(id: number, phrase: string, inputTokens: number, outputTokens: number, contextLimit: number, startTime: number): void; insertBeforeEditor(component: Component): void; removeMessageById(id: number): void; } From a3c24d03c7cbd42346d30d44edef197f6848147c Mon Sep 17 00:00:00 2001 From: Ayan De Date: Mon, 1 Jun 2026 01:11:09 +0530 Subject: [PATCH 21/49] feat: wire token display into /freecode command - Shows token counts and context bar in in-progress message - Shows token info in elapsed system message - Brief pause before removing in-progress to show final state Co-Authored-By: Claude Opus 4.7 --- apps/tui/src/commands/freecode/index.ts | 37 +++++++++++++++++-------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/apps/tui/src/commands/freecode/index.ts b/apps/tui/src/commands/freecode/index.ts index 38c78cfd..54d22112 100644 --- a/apps/tui/src/commands/freecode/index.ts +++ b/apps/tui/src/commands/freecode/index.ts @@ -13,6 +13,8 @@ import { } from "../../ipc/client.js"; import { playAlert } from "./alert.js"; import { getRandomElapsedPhrase, getRandomInProgressPhrase } from "../../utils/elapsed-phrases.js"; +import { getModelContextLimit } from "../../utils/model-limits.js"; +import { formatTokenCount } from "../../utils/format-tokens.js"; export { stopSound } from "./sound.js"; // State @@ -85,22 +87,29 @@ ${formatProviderList()}`); // Show in-progress message and track it for later removal const inProgressMsg = ctx.createInProgressMessage(getRandomInProgressPhrase()); const inProgressId = inProgressMsg.id; - const startTime = Date.now(); const ready = await ensureSession(ctx); if (!ready) return; try { - const result = await sessionSend(currentSession!.sessionId, userPrompt) as { - success: boolean; - message?: string; - content?: string; - turnCount?: number; - iterationCount?: number; - }; - - // Remove in-progress message + const result = await sessionSend(currentSession!.sessionId, userPrompt); + + // Update in-progress message with token counts + const contextLimit = getModelContextLimit(`${currentProvider}/MiniMax-M2`); + ctx.updateInProgressMessage( + inProgressId, + getRandomInProgressPhrase(), + result.usage?.inputTokens ?? 0, + result.usage?.outputTokens ?? 0, + contextLimit, + startTime + ); + + // Brief pause so user can see final token state before it disappears + await new Promise(resolve => setTimeout(resolve, 500)); + + // Remove in-progress message now that response has arrived ctx.removeMessageById(inProgressId); const elapsed = Date.now() - startTime; @@ -112,7 +121,13 @@ ${formatProviderList()}`); if (result.success) { const response = result.content || result.message; ctx.createAssistantMessage(`**FreeCode:** ${response || "Done!"}`); - ctx.createSystemMessage(`${getRandomElapsedPhrase()} for ${timeStr}`); + const inTokens = result.usage?.inputTokens ?? 0; + const outTokens = result.usage?.outputTokens ?? 0; + let tokenInfo = `↓${formatTokenCount(inTokens)} ↑${formatTokenCount(outTokens)}`; + if (contextLimit > 0) { + tokenInfo += ` [${formatTokenCount(inTokens)}/${formatTokenCount(contextLimit)}]`; + } + ctx.createSystemMessage(`${getRandomElapsedPhrase()} for ${timeStr} ${tokenInfo}`); playAlert(); } else { ctx.createSystemMessage(`**Error:** ${result.message || "Unknown error"}`); From 9336d1fdad1120f6a35300d427279acfac73814f Mon Sep 17 00:00:00 2001 From: Ayan De Date: Mon, 1 Jun 2026 01:11:15 +0530 Subject: [PATCH 22/49] feat: wire token display into main editor flow - Shows token counts and context bar in in-progress message - Shows token info in elapsed system message - Brief pause before removing in-progress to show final state Co-Authored-By: Claude Opus 4.7 --- apps/tui/src/index.ts | 42 +++++++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/apps/tui/src/index.ts b/apps/tui/src/index.ts index a828451a..41399d24 100644 --- a/apps/tui/src/index.ts +++ b/apps/tui/src/index.ts @@ -8,6 +8,8 @@ import chalk from "chalk"; import { defaultEditorTheme } from "./themes.js"; import { logoLines, logoTagline } from "./assets/logo.js"; import { getRandomElapsedPhrase, getRandomInProgressPhrase } from "./utils/elapsed-phrases.js"; +import { getModelContextLimit } from "./utils/model-limits.js"; +import { formatTokenCount } from "./utils/format-tokens.js"; import { startCli, sessionStart, @@ -26,6 +28,7 @@ import { createSystemMessage, createInProgressMessage, removeMessageById, + updateInProgressMessage, subscribeToMessages, onMessagesChange, type MessageInstance, @@ -301,7 +304,8 @@ editor.onSubmit = async (value: string) => { createUserMessage: (content: string) => createUserMessage(content), createAssistantMessage: (content: string) => createAssistantMessage(content), createSystemMessage: (content: string) => createSystemMessage(content), - createInProgressMessage: (phrase: string) => createInProgressMessage(phrase), + createInProgressMessage: (phrase: string, inputTokens = 0, outputTokens = 0, contextLimit = 0) => createInProgressMessage(phrase, inputTokens, outputTokens, contextLimit), + updateInProgressMessage: (id: number, phrase: string, inputTokens: number, outputTokens: number, contextLimit: number, startTime: number) => updateInProgressMessage(id, phrase, inputTokens, outputTokens, contextLimit, startTime), insertBeforeEditor: () => { /* no-op - messages go through store now */ }, removeMessageById: (id: number) => removeMessageById(id), }); @@ -350,17 +354,26 @@ editor.onSubmit = async (value: string) => { } try { - const result = await sessionSend(currentSession.sessionId, trimmed) as { - success: boolean; - message?: string; - content?: string; - turnCount?: number; - iterationCount?: number; - }; - - // Remove in-progress message + const result = await sessionSend(currentSession.sessionId, trimmed); + + // Update in-progress message with token counts from result + const contextLimit = getModelContextLimit(`${currentProvider}/${currentModel}`); + updateInProgressMessage( + inProgressMsg.id, + getRandomInProgressPhrase(), + result.usage?.inputTokens ?? 0, + result.usage?.outputTokens ?? 0, + contextLimit, + inProgressMsg.timestamp + ); + + // Brief pause so user can see final token state before it disappears + await new Promise(resolve => setTimeout(resolve, 500)); + + // Remove in-progress message now that response has arrived removeMessageById(inProgressMsg.id); + const elapsed = Date.now() - inProgressMsg.timestamp; const seconds = Math.floor(elapsed / 1000); const minutes = Math.floor(seconds / 60); @@ -370,7 +383,14 @@ editor.onSubmit = async (value: string) => { if (result.success) { const response = result.content || result.message; createAssistantMessage(`**FreeCode:** ${response || "Done!"}`); - createSystemMessage(`${getRandomElapsedPhrase()} for ${timeStr}`); + const inTokens = result.usage?.inputTokens ?? 0; + const outTokens = result.usage?.outputTokens ?? 0; + const contextLimit = getModelContextLimit(`${currentProvider}/${currentModel}`); + let tokenInfo = `↓${formatTokenCount(inTokens)} ↑${formatTokenCount(outTokens)}`; + if (contextLimit > 0) { + tokenInfo += ` [${formatTokenCount(inTokens)}/${formatTokenCount(contextLimit)}]`; + } + createSystemMessage(`${getRandomElapsedPhrase()} for ${timeStr} ${tokenInfo}`); } else { createSystemMessage(`**Error:** ${result.message || "Unknown error"}`); createSystemMessage(`${getRandomElapsedPhrase()} for ${timeStr}`); From 6604d5b7f6397811b21d7e0641cd2834aeb9d080 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Mon, 1 Jun 2026 01:16:06 +0530 Subject: [PATCH 23/49] feat: add processing text live count --- apps/tui/src/components/message-row.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/tui/src/components/message-row.ts b/apps/tui/src/components/message-row.ts index b73a7302..43c15e08 100644 --- a/apps/tui/src/components/message-row.ts +++ b/apps/tui/src/components/message-row.ts @@ -7,35 +7,38 @@ import { formatTokenCount } from "../utils/format-tokens.js"; /** * In-progress message component with live timer and token counts. * Renders "phrase (Xs) ↓inputTokens ↑outputTokens [████░░░░░ 50k/200k]" + * Input tokens are estimated live based on elapsed time (~1k tokens per second). */ class InProgressMessage implements Component { private phrase: string; private startTime: number; - private inputTokens: number; + private baseInputTokens: number; private outputTokens: number; private contextLimit: number; - constructor(phrase: string, startTime: number, inputTokens: number, outputTokens: number, contextLimit: number) { + constructor(phrase: string, startTime: number, baseInputTokens: number, outputTokens: number, contextLimit: number) { this.phrase = phrase; this.startTime = startTime; - this.inputTokens = inputTokens; + this.baseInputTokens = baseInputTokens; this.outputTokens = outputTokens; this.contextLimit = contextLimit; } render(_width: number): string[] { const elapsed = Math.floor((Date.now() - this.startTime) / 1000); - const inStr = formatTokenCount(this.inputTokens); + // Estimate: ~1k tokens per second of processing (rough approximation) + const estimatedInputTokens = this.baseInputTokens + (elapsed * 1000); + const inStr = formatTokenCount(estimatedInputTokens); const outStr = formatTokenCount(this.outputTokens); let display = `${chalk.yellow(this.phrase)}${chalk.dim(` (${elapsed}s)`)} ${chalk.dim(`↓${inStr}`)} ${chalk.dim(`↑${outStr}`)}`; if (this.contextLimit > 0) { - const pct = Math.min(this.inputTokens / this.contextLimit, 1); + const pct = Math.min(estimatedInputTokens / this.contextLimit, 1); const barWidth = 10; const filled = Math.round(pct * barWidth); const empty = barWidth - filled; const bar = '█'.repeat(filled) + '░'.repeat(empty); - const current = formatTokenCount(this.inputTokens); + const current = formatTokenCount(estimatedInputTokens); const limit = formatTokenCount(this.contextLimit); display += ` ${chalk.dim(`[${bar} ${current}/${limit}]`)}`; } @@ -146,4 +149,4 @@ export function createMessageComponent( default: return createSystemMessageComponent(content); } -} \ No newline at end of file +} From b124267245bcbe3af405e83a02e06a475fd9948b Mon Sep 17 00:00:00 2001 From: Ayan De Date: Mon, 1 Jun 2026 01:30:01 +0530 Subject: [PATCH 24/49] feat: added model count --- apps/tui/src/commands/freecode/index.ts | 5 +++-- apps/tui/src/commands/index.ts | 2 +- apps/tui/src/components/index.ts | 10 ++++++---- apps/tui/src/components/message-row.ts | 16 ++++++++++------ apps/tui/src/index.ts | 7 ++++--- 5 files changed, 24 insertions(+), 16 deletions(-) diff --git a/apps/tui/src/commands/freecode/index.ts b/apps/tui/src/commands/freecode/index.ts index 54d22112..e214528d 100644 --- a/apps/tui/src/commands/freecode/index.ts +++ b/apps/tui/src/commands/freecode/index.ts @@ -103,7 +103,8 @@ ${formatProviderList()}`); result.usage?.inputTokens ?? 0, result.usage?.outputTokens ?? 0, contextLimit, - startTime + startTime, + result.turnCount || 1 ); // Brief pause so user can see final token state before it disappears @@ -127,7 +128,7 @@ ${formatProviderList()}`); if (contextLimit > 0) { tokenInfo += ` [${formatTokenCount(inTokens)}/${formatTokenCount(contextLimit)}]`; } - ctx.createSystemMessage(`${getRandomElapsedPhrase()} for ${timeStr} ${tokenInfo}`); + ctx.createSystemMessage(`${getRandomElapsedPhrase()} for ${timeStr} ${tokenInfo} (x${result.turnCount || 1})`); playAlert(); } else { ctx.createSystemMessage(`**Error:** ${result.message || "Unknown error"}`); diff --git a/apps/tui/src/commands/index.ts b/apps/tui/src/commands/index.ts index 04e01e06..aef183e7 100644 --- a/apps/tui/src/commands/index.ts +++ b/apps/tui/src/commands/index.ts @@ -6,7 +6,7 @@ export interface MessageCreators { createAssistantMessage(content: string): { component: Component; id: number }; createSystemMessage(content: string): { component: Component; id: number }; createInProgressMessage(phrase: string, inputTokens?: number, outputTokens?: number, contextLimit?: number): { component: Component; id: number }; - updateInProgressMessage(id: number, phrase: string, inputTokens: number, outputTokens: number, contextLimit: number, startTime: number): void; + updateInProgressMessage(id: number, phrase: string, inputTokens: number, outputTokens: number, contextLimit: number, startTime: number, turns: number): void; insertBeforeEditor(component: Component): void; removeMessageById(id: number): void; } diff --git a/apps/tui/src/components/index.ts b/apps/tui/src/components/index.ts index 04a6b92b..d53e0081 100644 --- a/apps/tui/src/components/index.ts +++ b/apps/tui/src/components/index.ts @@ -41,10 +41,11 @@ export function createInProgressMessage( phrase: string, inputTokens = 0, outputTokens = 0, - contextLimit = 0 + contextLimit = 0, + turns = 1 ): MessageInstance { const startTime = Date.now(); - const component = createMessageComponent("in_progress", phrase, startTime, inputTokens, outputTokens, contextLimit); + const component = createMessageComponent("in_progress", phrase, startTime, inputTokens, outputTokens, contextLimit, turns); return addMessage("in_progress", phrase, component); } @@ -64,9 +65,10 @@ export function updateInProgressMessage( inputTokens: number, outputTokens: number, contextLimit: number, - startTime: number + startTime: number, + turns: number ): MessageInstance | undefined { - const component = createMessageComponent("in_progress", phrase, startTime, inputTokens, outputTokens, contextLimit); + const component = createMessageComponent("in_progress", phrase, startTime, inputTokens, outputTokens, contextLimit, turns); return updateMessage(id, phrase, component); } diff --git a/apps/tui/src/components/message-row.ts b/apps/tui/src/components/message-row.ts index 43c15e08..3daa03b0 100644 --- a/apps/tui/src/components/message-row.ts +++ b/apps/tui/src/components/message-row.ts @@ -15,13 +15,15 @@ class InProgressMessage implements Component { private baseInputTokens: number; private outputTokens: number; private contextLimit: number; + private turns: number; - constructor(phrase: string, startTime: number, baseInputTokens: number, outputTokens: number, contextLimit: number) { + constructor(phrase: string, startTime: number, baseInputTokens: number, outputTokens: number, contextLimit: number, turns: number) { this.phrase = phrase; this.startTime = startTime; this.baseInputTokens = baseInputTokens; this.outputTokens = outputTokens; this.contextLimit = contextLimit; + this.turns = turns; } render(_width: number): string[] { @@ -30,7 +32,7 @@ class InProgressMessage implements Component { const estimatedInputTokens = this.baseInputTokens + (elapsed * 1000); const inStr = formatTokenCount(estimatedInputTokens); const outStr = formatTokenCount(this.outputTokens); - let display = `${chalk.yellow(this.phrase)}${chalk.dim(` (${elapsed}s)`)} ${chalk.dim(`↓${inStr}`)} ${chalk.dim(`↑${outStr}`)}`; + let display = `${chalk.yellow(this.phrase)}${chalk.dim(` (${elapsed}s)`)} ${chalk.dim(`↓${inStr}`)} ${chalk.dim(`↑${outStr}`)} ${chalk.dim(`(x${this.turns})`)}`; if (this.contextLimit > 0) { const pct = Math.min(estimatedInputTokens / this.contextLimit, 1); @@ -116,8 +118,8 @@ export function createSystemMessageComponent(content: string): Component { /** * Create an in-progress message component — dimmed yellow text (for "Simmering...", etc.) */ -export function createInProgressMessageComponent(phrase: string, startTime: number, inputTokens: number, outputTokens: number, contextLimit: number): Component { - return new InProgressMessage(phrase, startTime, inputTokens, outputTokens, contextLimit); +export function createInProgressMessageComponent(phrase: string, startTime: number, inputTokens: number, outputTokens: number, contextLimit: number, turns: number): Component { + return new InProgressMessage(phrase, startTime, inputTokens, outputTokens, contextLimit, turns); } /** @@ -129,7 +131,8 @@ export function createMessageComponent( startTime?: number, inputTokens?: number, outputTokens?: number, - contextLimit?: number + contextLimit?: number, + turns?: number ): Component { switch (type) { case "user": @@ -144,7 +147,8 @@ export function createMessageComponent( startTime ?? Date.now(), inputTokens ?? 0, outputTokens ?? 0, - contextLimit ?? 0 + contextLimit ?? 0, + turns ?? 1 ); default: return createSystemMessageComponent(content); diff --git a/apps/tui/src/index.ts b/apps/tui/src/index.ts index 41399d24..4e073efa 100644 --- a/apps/tui/src/index.ts +++ b/apps/tui/src/index.ts @@ -305,7 +305,7 @@ editor.onSubmit = async (value: string) => { createAssistantMessage: (content: string) => createAssistantMessage(content), createSystemMessage: (content: string) => createSystemMessage(content), createInProgressMessage: (phrase: string, inputTokens = 0, outputTokens = 0, contextLimit = 0) => createInProgressMessage(phrase, inputTokens, outputTokens, contextLimit), - updateInProgressMessage: (id: number, phrase: string, inputTokens: number, outputTokens: number, contextLimit: number, startTime: number) => updateInProgressMessage(id, phrase, inputTokens, outputTokens, contextLimit, startTime), + updateInProgressMessage: (id: number, phrase: string, inputTokens: number, outputTokens: number, contextLimit: number, startTime: number, turns: number) => updateInProgressMessage(id, phrase, inputTokens, outputTokens, contextLimit, startTime, turns), insertBeforeEditor: () => { /* no-op - messages go through store now */ }, removeMessageById: (id: number) => removeMessageById(id), }); @@ -364,7 +364,8 @@ editor.onSubmit = async (value: string) => { result.usage?.inputTokens ?? 0, result.usage?.outputTokens ?? 0, contextLimit, - inProgressMsg.timestamp + inProgressMsg.timestamp, + result.turnCount || 1 ); // Brief pause so user can see final token state before it disappears @@ -390,7 +391,7 @@ editor.onSubmit = async (value: string) => { if (contextLimit > 0) { tokenInfo += ` [${formatTokenCount(inTokens)}/${formatTokenCount(contextLimit)}]`; } - createSystemMessage(`${getRandomElapsedPhrase()} for ${timeStr} ${tokenInfo}`); + createSystemMessage(`${getRandomElapsedPhrase()} for ${timeStr} ${tokenInfo} (x${result.turnCount || 1})`); } else { createSystemMessage(`**Error:** ${result.message || "Unknown error"}`); createSystemMessage(`${getRandomElapsedPhrase()} for ${timeStr}`); From c49d7ba8e7c1f1d26a604939a581328ffd714a5e Mon Sep 17 00:00:00 2001 From: Ayan De Date: Mon, 1 Jun 2026 19:01:55 +0530 Subject: [PATCH 25/49] docs: tools ui docs --- .../plans/2026-06-01-tools-ui-streaming.md | 789 ++++++++++++++++++ .../specs/2026-06-01-tools-ui-design.md | 227 +++++ 2 files changed, 1016 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-01-tools-ui-streaming.md create mode 100644 docs/superpowers/specs/2026-06-01-tools-ui-design.md diff --git a/docs/superpowers/plans/2026-06-01-tools-ui-streaming.md b/docs/superpowers/plans/2026-06-01-tools-ui-streaming.md new file mode 100644 index 00000000..b86a838f --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-tools-ui-streaming.md @@ -0,0 +1,789 @@ +# Tool Progress Streaming Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Display live tool progress in TUI when agent uses tools (Read, Write, Bash, etc.) + +**Architecture:** CLI streams JSON events (tool_start/tool_output/tool_complete) to TUI as tools execute. TUI parses events and renders tool progress components with live output. + +**Tech Stack:** TypeScript, pi-tui components, JSON-RPC over stdin/stdout + +--- + +## File Structure + +``` +packages/shared/src/ipc/protocol.ts - Add StreamEvent types +apps/core/src/agent/types.ts - Add onToolEvent to RunOptions +apps/core/src/agent/loop.ts - Emit tool events during execution +apps/core/src/server.ts - Stream events to stdout during session.send +apps/tui/src/ipc/client.ts - Add sessionSendStreaming with event parsing +apps/tui/src/state/message-store.ts - Add tool message state management +apps/tui/src/components/tool-progress-message.ts - NEW: in-progress display +apps/tui/src/components/tool-result-message.ts - NEW: completed/error display +apps/tui/src/components/index.ts - Export new components +apps/tui/src/index.ts - Wire streaming into message flow +``` + +--- + +## Task 1: Add StreamEvent Types to Protocol + +**Files:** +- Modify: `packages/shared/src/ipc/protocol.ts` + +- [ ] **Step 1: Add StreamEvent union type** + +```typescript +// Add after existing StreamResponse type (around line 28) + +export type StreamEvent = + | { type: "tool_start"; toolCallId: string; toolName: string; args: Record } + | { type: "tool_output"; toolCallId: string; content: string } + | { type: "tool_complete"; toolCallId: string; toolName: string; result: string; success: boolean; duration_ms?: number } + | { type: "text"; content: string } + | { type: "done"; content: string } + | { type: "error"; content: string }; +``` + +- [ ] **Step 2: Verify types compile** + +Run: `cd /home/ayande/Project/freecode && npx tsc --noEmit -p packages/shared/tsconfig.json` +Expected: No errors + +--- + +## Task 2: Add onToolEvent Callback to AgentLoop + +**Files:** +- Modify: `apps/core/src/agent/types.ts:174-180` (UserInput interface area) +- Modify: `apps/core/src/agent/loop.ts:68` (run method signature) + +- [ ] **Step 1: Add onToolEvent to UserInput type** + +Find the UserInput interface (around line 174) and add: + +```typescript +export interface UserInput { + prompt: string + sessionId: string + provider: string + model?: string + projectPath: string + onToolEvent?: (event: StreamEvent) => void // NEW +} +``` + +- [ ] **Step 2: Modify AgentLoop.run() to accept and pass onToolEvent** + +In `loop.ts` line 68, modify: + +```typescript +async run(input: UserInput): Promise { + // ... existing code ... + // Pass onToolEvent to executeTool +} +``` + +Add a class property to store onToolEvent: + +```typescript +private onToolEvent: ((event: StreamEvent) => void) | undefined; + +constructor(sessionId: string, config?: ...) { + // ... existing constructor code ... +} + +async run(input: UserInput): Promise { + this.onToolEvent = input.onToolEvent; // NEW - store callback + // ... rest of run method +} +``` + +- [ ] **Step 3: Update executeTool to emit tool_start event** + +In `loop.ts` around line 499 (`executeTool` method), add tool_start emission after the PreToolUse hook check and before tool execution: + +```typescript +private async executeTool(toolCall: ToolCall): Promise { + const startTime = Date.now() + + // ... existing hooks code (lines 503-556) ... + + // Emit tool_start event BEFORE execution + this.onToolEvent?.({ + type: "tool_start", + toolCallId: toolCall.id, + toolName: toolCall.tool, + args: toolCall.args as Record, + }); + + // ... rest of executeTool (line 558 onwards) ... +} +``` + +- [ ] **Step 4: Emit tool_output events from BashTool** + +For the Bash tool, we need to capture output as it happens. Add output buffering in the orchestrator or tool execution. For simplicity, emit `tool_output` when tool has meaningful progress. + +In `loop.ts` around line 564-596, after tool execution and before the post hook: + +```typescript +// After result is available (after line 569) +if (result.stdout) { + // Emit stdout as tool_output + const outputLines = result.stdout.split('\n').slice(-5).join('\n'); // last 5 lines + this.onToolEvent?.({ + type: "tool_output", + toolCallId: toolCall.id, + content: outputLines, + }); +} +``` + +- [ ] **Step 5: Emit tool_complete event after result** + +In `loop.ts` around line 596-600, after `BusEvents.toolCompleted`: + +```typescript +// Emit tool_complete event +this.onToolEvent?.({ + type: "tool_complete", + toolCallId: toolCall.id, + toolName: toolCall.tool, + result: result.stdout || result.error || "", + success: !result.error, + duration_ms: Date.now() - startTime, +}); +``` + +- [ ] **Step 6: Verify types compile** + +Run: `cd /home/ayande/Project/freecode && npx tsc --noEmit -p apps/core/tsconfig.json` +Expected: No errors + +--- + +## Task 3: Modify CLI Server to Stream Events + +**Files:** +- Modify: `apps/core/src/server.ts:102-131` + +- [ ] **Step 1: Update session.send to stream events** + +In `server.ts`, modify the `session.send` handler (lines 102-131) to stream events: + +```typescript +"session.send": async (params: Record): Promise => { + const { sessionId, message, model } = params as { sessionId: string; message: string; model?: string }; + const session = getSession(sessionId); + + if (!session) { + throw new Error(`Session not found: ${sessionId}`); + } + + const config = readConfig() + const currentProvider = config.current?.provider || session.provider + + if (model) { + session.model = model; + } + + // Emit events to stdout immediately for streaming + const emitEvent = (event: StreamEvent) => { + process.stdout.write(JSON.stringify(event) + "\n"); + }; + + const loop = createAgentLoop(sessionId, { maxIterations: 100 }) + const result = await loop.run({ + prompt: message, + sessionId, + provider: currentProvider, + model: session.model, + projectPath: session.projectPath, + onToolEvent: emitEvent, // NEW: pass emit function + }) + + // Emit done event + emitEvent({ type: "done", content: result.message || "Done" }); + + return result; +}, +``` + +- [ ] **Step 2: Verify server compiles** + +Run: `cd /home/ayande/Project/freecode && npx tsc --noEmit -p apps/core/tsconfig.json` +Expected: No errors + +--- + +## Task 4: Add sessionSendStreaming to TUI IPC Client + +**Files:** +- Modify: `apps/tui/src/ipc/client.ts` + +- [ ] **Step 1: Add StreamEvent import** + +At the top of `client.ts`, add import: + +```typescript +import type { StreamEvent } from "@freecode/shared"; +``` + +- [ ] **Step 2: Add sessionSendStreaming function** + +After the existing `sessionSend` function (around line 163), add: + +```typescript +export async function sessionSendStreaming( + sessionId: string, + message: string, + model: string | undefined, + onEvent: (event: StreamEvent) => void +): Promise { + return new Promise((resolve, reject) => { + if (!cliProcess || !cliProcess.stdin) { + reject(new Error("CLI not running")); + return; + } + + const id = generateId(); + const request: JsonRpcRequest = { jsonrpc: "2.0", id, method: "session.send", params: { sessionId, message, model } }; + + // Buffer for parsing streaming events + let eventBuffer = ""; + + // Set up temporary handler for streaming responses + const originalHandler = cliProcess.stdout?.on; + const handleData = (data: string) => { + eventBuffer += data; + const lines = eventBuffer.split("\n"); + + for (let i = 0; i < lines.length - 1; i++) { + const line = lines[i]; + if (!line.trim()) continue; + + // Check if this is a StreamEvent (not JSON-RPC response) + if (line.startsWith("{")) { + try { + const parsed = JSON.parse(line); + // If it has type field and no jsonrpc, it's a StreamEvent + if (parsed.type && !parsed.jsonrpc) { + onEvent(parsed as StreamEvent); + continue; + } + // If it has jsonrpc, it's a response - handle it + if (parsed.jsonrpc) { + pendingRequests.delete(parsed.id); + if (parsed.error) { + reject(new Error(parsed.error.message)); + } else { + resolve(parsed.result as SessionSendResult); + } + } + } catch { + // Not JSON, skip + } + } + } + + // Keep unparsed remainder + eventBuffer = lines[lines.length - 1] || ""; + }; + + // Send request + pendingRequests.set(id, { resolve: resolve as (value: unknown) => void, reject }); + cliProcess.stdin.write(JSON.stringify(request) + "\n"); + }); +} +``` + +- [ ] **Step 3: Verify client compiles** + +Run: `cd /home/ayande/Project/freecode && npx tsc --noEmit -p apps/tui/tsconfig.json` +Expected: No errors + +--- + +## Task 5: Add Tool Message State to Message Store + +**Files:** +- Modify: `apps/tui/src/state/message-store.ts` + +- [ ] **Step 1: Add ToolMessage interface and methods** + +Add at the end of the file, before the exports: + +```typescript +export interface ToolMessage { + id: number; + toolCallId: string; + toolName: string; + args: Record; + status: "pending" | "running" | "complete" | "error"; + outputLines: string[]; + result?: string; + success?: boolean; + duration_ms?: number; + timestamp: number; + component?: Component; +} + +class MessageStoreImpl { + // ... existing code ... + + // NEW: Tool message tracking + private toolMessages = new Map(); + + addToolMessage(toolCallId: string, toolName: string, args: Record): ToolMessage { + const id = this.generateId(); + const toolMsg: ToolMessage = { + id, + toolCallId, + toolName, + args, + status: "pending", + outputLines: [], + timestamp: Date.now(), + }; + this.toolMessages.set(toolCallId, toolMsg); + this.notify(); + return toolMsg; + } + + updateToolStatus(toolCallId: string, status: ToolMessage["status"], updates?: Partial): void { + const toolMsg = this.toolMessages.get(toolCallId); + if (toolMsg) { + toolMsg.status = status; + if (updates) { + Object.assign(toolMsg, updates); + } + this.notify(); + } + } + + appendToolOutput(toolCallId: string, content: string): void { + const toolMsg = this.toolMessages.get(toolCallId); + if (toolMsg) { + // Keep last 5 lines + const lines = content.split('\n'); + toolMsg.outputLines = lines.slice(-5); + this.notify(); + } + } + + getToolMessage(toolCallId: string): ToolMessage | undefined { + return this.toolMessages.get(toolCallId); + } + + getAllToolMessages(): ToolMessage[] { + return Array.from(this.toolMessages.values()); + } +} + +// NEW: Export helper functions +export function addToolMessage(toolCallId: string, toolName: string, args: Record): ToolMessage { + return messageStore.addToolMessage(toolCallId, toolName, args); +} + +export function updateToolStatus(toolCallId: string, status: ToolMessage["status"], updates?: Partial): void { + messageStore.updateToolStatus(toolCallId, status, updates); +} + +export function appendToolOutput(toolCallId: string, content: string): void { + messageStore.appendToolOutput(toolCallId, content); +} + +export function getToolMessage(toolCallId: string): ToolMessage | undefined { + return messageStore.getToolMessage(toolCallId); +} +``` + +- [ ] **Step 2: Verify message store compiles** + +Run: `cd /home/ayande/Project/freecode && npx tsc --noEmit -p apps/tui/tsconfig.json` +Expected: No errors + +--- + +## Task 6: Create ToolProgressMessage Component + +**Files:** +- Create: `apps/tui/src/components/tool-progress-message.ts` + +- [ ] **Step 1: Create the component** + +```typescript +import { Component, TUI, Text, Box } from "@earendil-works/pi-tui"; +import chalk from "chalk"; + +export interface ToolProgressMessageOptions { + toolCallId: string; + toolName: string; + args: Record; + outputLines: string[]; +} + +// Color mapping for different tools +const TOOL_COLORS: Record string> = { + Read: (t) => chalk.blue(t), + Write: (t) => chalk.green(t), + Edit: (t) => chalk.yellow(t), + Bash: (t) => chalk.red(t), + Glob: (t) => chalk.cyan(t), + Grep: (t) => chalk.magenta(t), + Skill: (t) => chalk.white(t), + Agent: (t) => chalk.white(t), +}; + +export class ToolProgressMessage implements Component { + private toolCallId: string; + private toolName: string; + private args: Record; + private outputLines: string[]; + private tui?: TUI; + private animationFrame = 0; + private intervalId?: ReturnType; + + constructor(options: ToolProgressMessageOptions) { + this.toolCallId = options.toolCallId; + this.toolName = options.toolName; + this.args = options.args; + this.outputLines = options.outputLines; + } + + setTui(tui: TUI): void { + this.tui = tui; + // Start animation + this.intervalId = setInterval(() => { + this.animationFrame = (this.animationFrame + 1) % 4; + this.tui?.requestRender(); + }, 250); + } + + updateOutput(outputLines: string[]): void { + this.outputLines = outputLines; + } + + invalidate(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = undefined; + } + } + + render(width: number): string[] { + const colorFn = TOOL_COLORS[this.toolName] || ((t: string) => t); + const spinner = ["⠋", "⠙", "⠹", "⠸"][this.animationFrame]; + const argsStr = this.formatArgs(); + + const lines: string[] = []; + + // Header line: [spinner] ToolName (args) + lines.push(`${chalk.dim("[")}${chalk.yellow(spinner)}${chalk.dim("]")} ${colorFn(this.toolName)} ${chalk.dim("(")}${argsStr}${chalk.dim(")")}`); + + // Output lines with tree view + for (const outputLine of this.outputLines.slice(-5)) { + lines.push(`${chalk.dim("│ ")}${chalk.dim(outputLine)}`); + } + + return lines; + } + + private formatArgs(): string { + const entries = Object.entries(this.args); + if (entries.length === 0) return ""; + + // Truncate long values + const truncate = (s: string, max = 40) => s.length > max ? s.slice(0, max) + "..." : s; + + return entries + .map(([k, v]) => { + const vStr = typeof v === "string" ? v : JSON.stringify(v); + return `${k}: ${chalk.green(truncate(vStr))}`; + }) + .join(", "); + } +} +``` + +- [ ] **Step 2: Verify component compiles** + +Run: `cd /home/ayande/Project/freecode && npx tsc --noEmit -p apps/tui/tsconfig.json` +Expected: No errors + +--- + +## Task 7: Create ToolResultMessage Component + +**Files:** +- Create: `apps/tui/src/components/tool-result-message.ts` + +- [ ] **Step 1: Create the component** + +```typescript +import { Component, Text, Box } from "@earendil-works/pi-tui"; +import chalk from "chalk"; + +export interface ToolResultMessageOptions { + toolCallId: string; + toolName: string; + args: Record; + result?: string; + success: boolean; + duration_ms?: number; +} + +// Color mapping for different tools +const TOOL_COLORS: Record string> = { + Read: (t) => chalk.blue(t), + Write: (t) => chalk.green(t), + Edit: (t) => chalk.yellow(t), + Bash: (t) => chalk.red(t), + Glob: (t) => chalk.cyan(t), + Grep: (t) => chalk.magenta(t), + Skill: (t) => chalk.white(t), + Agent: (t) => chalk.white(t), +}; + +export class ToolResultMessage implements Component { + private toolCallId: string; + private toolName: string; + private args: Record; + private result?: string; + private success: boolean; + private duration_ms?: number; + + constructor(options: ToolResultMessageOptions) { + this.toolCallId = options.toolCallId; + this.toolName = options.toolName; + this.args = options.args; + this.result = options.result; + this.success = options.success; + this.duration_ms = options.duration_ms; + } + + invalidate(): void { + // Nothing to clean up + } + + render(width: number): string[] { + const colorFn = TOOL_COLORS[this.toolName] || ((t: string) => t); + const statusIcon = this.success ? chalk.green("✓") : chalk.red("✗"); + const argsStr = this.formatArgs(); + const duration = this.duration_ms ? `(${this.duration_ms}ms)` : ""; + + const lines: string[] = []; + + // Header line: [✓/✗] ToolName (args) (duration) + lines.push(`${chalk.dim("[")}${statusIcon}${chalk.dim("]")} ${colorFn(this.toolName)} ${chalk.dim("(")}${argsStr}${chalk.dim(")")} ${chalk.dim(duration)}`); + + // Result with tree view character + if (this.result) { + const truncatedResult = this.truncateResult(this.result, 200); + lines.push(`${chalk.dim("⎿")} ${chalk.dim(truncatedResult)}`); + } else if (this.success) { + lines.push(`${chalk.dim("⎿")} ${chalk.dim("(no output)")}`); + } + + return lines; + } + + private formatArgs(): string { + const entries = Object.entries(this.args); + if (entries.length === 0) return ""; + + const truncate = (s: string, max = 40) => s.length > max ? s.slice(0, max) + "..." : s; + + return entries + .map(([k, v]) => { + const vStr = typeof v === "string" ? v : JSON.stringify(v); + return `${k}: ${chalk.green(truncate(vStr))}`; + }) + .join(", "); + } + + private truncateResult(result: string, maxLen: number): string { + if (result.length <= maxLen) return result; + return result.slice(0, maxLen) + "..."; + } +} +``` + +- [ ] **Step 2: Verify component compiles** + +Run: `cd /home/ayande/Project/freecode && npx tsc --noEmit -p apps/tui/tsconfig.json` +Expected: No errors + +--- + +## Task 8: Export New Components + +**Files:** +- Modify: `apps/tui/src/components/index.ts` + +- [ ] **Step 1: Add exports for new components** + +Add to the exports: + +```typescript +export { ToolProgressMessage, type ToolProgressMessageOptions } from "./tool-progress-message.js"; +export { ToolResultMessage, type ToolResultMessageOptions } from "./tool-result-message.js"; +``` + +- [ ] **Step 2: Verify exports compile** + +Run: `cd /home/ayande/Project/freecode && npx tsc --noEmit -p apps/tui/tsconfig.json` +Expected: No errors + +--- + +## Task 9: Wire Streaming into TUI index.ts + +**Files:** +- Modify: `apps/tui/src/index.ts:356-402` (session.send handling) + +- [ ] **Step 1: Import new components and streaming function** + +Add imports at the top of index.ts: + +```typescript +import { + // ... existing imports ... + type ToolProgressMessageOptions, + type ToolResultMessageOptions, +} from "./components/index.js"; +import { + // ... existing imports ... + addToolMessage, + updateToolStatus, + appendToolOutput, +} from "./state/message-store.js"; +import { sessionSendStreaming } from "./ipc/client.js"; +import type { StreamEvent } from "@freecode/shared"; +import { ToolProgressMessage, ToolResultMessage } from "./components/index.js"; +``` + +- [ ] **Step 2: Create tool message components map** + +Add after the messageList initialization (around line 54): + +```typescript +const toolMessageComponents = new Map(); +``` + +- [ ] **Step 3: Replace session.send with sessionSendStreaming and handle events** + +In `index.ts` around line 357, replace: + +```typescript +// OLD: +const result = await sessionSend(currentSession.sessionId, trimmed); + +// NEW: +const result = await sessionSendStreaming( + currentSession.sessionId, + trimmed, + undefined, + (event: StreamEvent) => { + handleToolEvent(event); + } +); +``` + +- [ ] **Step 4: Add handleToolEvent function** + +Add before the `editor.onSubmit` assignment (around line 289): + +```typescript +function handleToolEvent(event: StreamEvent): void { + switch (event.type) { + case "tool_start": { + const toolMsg = addToolMessage(event.toolCallId, event.toolName, event.args); + const progressComponent = new ToolProgressMessage({ + toolCallId: event.toolCallId, + toolName: event.toolName, + args: event.args, + outputLines: [], + }); + progressComponent.setTui(tui); + toolMessageComponents.set(event.toolCallId, { + progress: progressComponent, + id: toolMsg.id, + }); + // Add to message store for rendering + createInProgressMessage(`Running ${event.toolName}...`); + break; + } + case "tool_output": { + appendToolOutput(event.toolCallId, event.content); + const entry = toolMessageComponents.get(event.toolCallId); + if (entry) { + entry.progress.updateOutput(event.content.split('\n').slice(-5)); + } + tui.requestRender(); + break; + } + case "tool_complete": { + const entry = toolMessageComponents.get(event.toolCallId); + if (entry) { + // Remove progress component from message store + removeMessageById(entry.id); + toolMessageComponents.delete(event.toolCallId); + } + // Create result message + const resultComponent = new ToolResultMessage({ + toolCallId: event.toolCallId, + toolName: event.toolName, + args: {}, + result: event.result, + success: event.success, + duration_ms: event.duration_ms, + }); + // Add to virtual message list + createInProgressMessage(event.success ? `${event.toolName} completed` : `${event.toolName} failed`); + break; + } + } +} +``` + +- [ ] **Step 5: Verify TUI compiles** + +Run: `cd /home/ayande/Project/freecode && npx tsc --noEmit -p apps/tui/tsconfig.json` +Expected: No errors (may have some, debug as needed) + +--- + +## Task 10: Test End-to-End + +**Files:** +- None (testing existing code) + +- [ ] **Step 1: Start TUI and verify it builds** + +Run: `cd /home/ayande/Project/freecode && pnpm --filter @freecode/tui build` +Expected: Build succeeds + +- [ ] **Step 2: Test tool streaming (manual test)** + +Run TUI with: `pnpm --filter @freecode/tui dev` + +Send a message that will trigger tool usage (e.g., "List the files in this directory" or "Read package.json") + +Expected: See tool progress displayed with spinner, tool name, args, and live output + +--- + +## Implementation Notes + +1. **Event streaming uses stdout directly** - This is a simplification. Events are written directly to stdout, interleaved with the JSON-RPC response. The client parses line-by-line to distinguish events from responses. + +2. **Buffer management** - The client maintains a buffer and splits on newlines, keeping any incomplete line for the next data chunk. + +3. **Tool colors are hardcoded** - If you need different colors, modify `TOOL_COLORS` in each component. + +4. **Last 5 lines only** - For `tool_output`, we keep only the last 5 lines to avoid memory issues with large outputs. + +5. **Progress animation** - The `ToolProgressMessage` uses a simple 4-frame spinner animation. Frames: `⠋⠙⠹⠸` \ No newline at end of file diff --git a/docs/superpowers/specs/2026-06-01-tools-ui-design.md b/docs/superpowers/specs/2026-06-01-tools-ui-design.md new file mode 100644 index 00000000..33f8a45e --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-tools-ui-design.md @@ -0,0 +1,227 @@ +# Tool Progress Streaming Design + +**Date:** 2026-06-01 +**Status:** Draft + +## Overview + +Implement streaming tool progress display in the FreeCode TUI. When the agent uses tools (Read, Write, Bash, etc.), the TUI displays live progress with tool name, parameters, and output tail - similar to Claude Code's UX. + +## Architecture + +``` +┌─────────────┐ JSON-RPC events ┌─────────────┐ +│ CLI │ ──────────────────────► │ TUI │ +│ (server) │ tool_start/output/ │ (client) │ +│ │ complete streamed │ │ +└─────────────┘ as they happen └─────────────┘ +``` + +## 1. Streaming Event Protocol + +### New Event Types + +Add to `/packages/shared/src/ipc/protocol.ts`: + +```typescript +export type StreamEvent = + | { type: "tool_start"; toolCallId: string; toolName: string; args: Record } + | { type: "tool_output"; toolCallId: string; content: string } + | { type: "tool_complete"; toolCallId: string; toolName: string; result: string; success: boolean; duration_ms?: number } + | { type: "text"; content: string } + | { type: "error"; content: string }; + +export interface StreamResponse { + type: "event" | "done" | "error"; + event?: StreamEvent; + result?: unknown; + error?: string; +} +``` + +### Event Flow + +``` +TUI → CLI: {"jsonrpc":"2.0","method":"session.send","params":{...},"id":1} +CLI → TUI: {"type":"tool_start","toolCallId":"abc","toolName":"Read","args":{"path":"/foo.txt"}} +CLI → TUI: {"type":"tool_output","toolCallId":"abc","content":"Reading..."} +CLI → TUI: {"type":"tool_output","toolCallId":"abc","content":"234 lines"} +CLI → TUI: {"type":"tool_complete","toolCallId":"abc","toolName":"Read","result":"...(truncated)...","success":true,"duration_ms":145} +CLI → TUI: {"type":"done","content":"Agent finished"} +CLI → TUI: {"jsonrpc":"2.0","result":{LoopResult},"id":1} +``` + +## 2. CLI Changes + +### File: `apps/core/src/server.ts` + +Modify `session.send` handler to stream events: + +```typescript +"session.send": async (params: Record, emitFn?: (event: StreamEvent) => void): Promise => { + const { sessionId, message, model } = params; + // ... + const loop = createAgentLoop(sessionId, { + maxIterations: 100, + onToolEvent: (event: StreamEvent) => { + // Emit streaming event to TUI + process.stdout.write(JSON.stringify(event) + "\n"); + } + }); + const result = await loop.run({...}); + return result; +} +``` + +### File: `apps/core/src/agent/loop.ts` + +Add `onToolEvent` callback to `AgentLoop.run()`: + +```typescript +interface RunOptions { + prompt: string; + sessionId: string; + provider: string; + model: string; + projectPath: string; + onToolEvent?: (event: StreamEvent) => void; +} +``` + +Emit events in `executeTool()`: +- `tool_start` before executing +- `tool_output` for live output (buffer and flush periodically) +- `tool_complete` after execution finishes + +## 3. TUI Changes + +### IPC Client: `apps/tui/src/ipc/client.ts` + +```typescript +export async function sessionSendStreaming( + sessionId: string, + message: string, + model: string | undefined, + onEvent: (event: StreamEvent) => void +): Promise { + // Send request and parse streaming events from response stream + // For each line, check if it's a StreamEvent or final result +} +``` + +### State: `apps/tui/src/state/message-store.ts` + +```typescript +interface ToolMessage { + id: string; + toolCallId: string; + toolName: string; + args: Record; + status: 'pending' | 'running' | 'complete' | 'error'; + outputLines: string[]; + result?: string; + success?: boolean; + duration_ms?: number; + timestamp: number; +} + +// New methods +addToolMessage(msg: ToolMessage): void +updateToolMessage(id: string, updates: Partial): void +appendToolOutput(id: string, content: string): void // Keep last 5 lines +``` + +### Components: `apps/tui/src/components/` + +#### `tool-progress-message.ts` + +Displays a tool in progress with live output: + +```typescript +export class ToolProgressMessage implements Component { + // Shows: [●] ToolName (args) + // └─ output line 1 + // └─ output line 2 + // └─ ... + // └─ (last 5 lines) +} +``` + +#### `tool-result-message.ts` + +Displays completed tool with result: + +```typescript +export class ToolResultMessage implements Component { + // Shows: [✓] ToolName (args) (duration_ms) + // ⎿ result preview (truncated) + // Or for errors: + // [✗] ToolName (args) + // ⎿ Error message +} +``` + +## 4. Display Format + +### Running Tool +``` +[●] Read (path: "/src/index.ts") + └─ Reading... + └─ 234 lines +``` + +### Completed Tool (Success) +``` +[✓] Read (path: "/src/index.ts") (145ms) + ⎿ 2453 chars +``` + +### Completed Tool (Error) +``` +[✗] Bash (command: "rm -rf /") + ⎿ Error: Permission denied +``` + +### Color Coding +- `Read` → blue +- `Write` → green +- `Edit` → yellow +- `Bash` → red +- `Glob` → cyan +- `Grep` → magenta +- `Skill` → white +- `Agent` → white + +## 5. File Changes Summary + +| File | Change | +|------|--------| +| `packages/shared/src/ipc/protocol.ts` | Add `StreamEvent` types | +| `apps/core/src/agent/loop.ts` | Add `onToolEvent` callback | +| `apps/core/src/agent/types.ts` | Add `RunOptions.onToolEvent` | +| `apps/core/src/server.ts` | Stream events to stdout | +| `apps/tui/src/ipc/client.ts` | Add `sessionSendStreaming` | +| `apps/tui/src/state/message-store.ts` | Add tool message methods | +| `apps/tui/src/components/tool-progress-message.ts` | NEW - in-progress display | +| `apps/tui/src/components/tool-result-message.ts` | NEW - result display | +| `apps/tui/src/components/index.ts` | Export new components | +| `apps/tui/src/index.ts` | Wire streaming into message flow | + +## 6. Implementation Order + +1. Add `StreamEvent` types to protocol +2. Modify CLI `session.send` to stream events +3. Add `sessionSendStreaming` to TUI IPC client +4. Add tool message state to message-store +5. Create `ToolProgressMessage` component +6. Create `ToolResultMessage` component +7. Wire into TUI index.ts +8. Test end-to-end + +## 7. Edge Cases + +- **No tools used**: Stream just text/done events, no tool messages +- **Multiple sequential tools**: Show each in own row, sequential updates +- **Tool with no output**: Show tool_start then tool_complete with "(no output)" +- **Very long output**: Keep only last 5 lines in state, full result in tool_complete +- **Tool error**: Show error state with error message in result \ No newline at end of file From 08c4bf8468e5ec661306c20d4ab61054b2fa4357 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Mon, 1 Jun 2026 19:12:49 +0530 Subject: [PATCH 26/49] fix(providers): pass tools to generateText for OpenAI, Anthropic, Gemini OpenAI, Anthropic, and Gemini providers were not passing `tools` to generateText(), causing result.toolCalls to be undefined and no tool UI to display. Now transforms and passes tools as ToolSet. Co-Authored-By: Claude Opus 4.6 --- apps/core/src/providers/anthropic.ts | 11 +++++++++++ apps/core/src/providers/gemini.ts | 11 +++++++++++ apps/core/src/providers/openai.ts | 11 +++++++++++ 3 files changed, 33 insertions(+) diff --git a/apps/core/src/providers/anthropic.ts b/apps/core/src/providers/anthropic.ts index b99bd02c..bdfe4072 100644 --- a/apps/core/src/providers/anthropic.ts +++ b/apps/core/src/providers/anthropic.ts @@ -18,12 +18,23 @@ function createAnthropicProvider(_apiKey: string): AIProvider { async function execute(opts: ExecuteOptions): Promise { const model = opts.model || PROVIDER_INFO.defaultModel + const tools = opts.tools?.reduce((acc, t) => { + acc[t.name] = { + description: t.description, + inputSchema: t.parameters as Record, + } + return acc + }, {} as Record }>) + + // Cast to any to satisfy AI SDK's ToolSet type which expects FlexibleSchema + // The underlying implementation accepts plain JSON schema objects const result = await generateText({ model: anthropic(model), system: opts.system, prompt: opts.prompt, temperature: opts.temperature, maxOutputTokens: opts.maxTokens || 4096, + tools: tools as any, }) const toolCalls = result.toolCalls?.map((tc): { name: string; args: Record; id: string } => { diff --git a/apps/core/src/providers/gemini.ts b/apps/core/src/providers/gemini.ts index 405d6b76..e68057b1 100644 --- a/apps/core/src/providers/gemini.ts +++ b/apps/core/src/providers/gemini.ts @@ -18,12 +18,23 @@ function createGeminiProvider(_apiKey: string): AIProvider { async function execute(opts: ExecuteOptions): Promise { const model = opts.model || PROVIDER_INFO.defaultModel + const tools = opts.tools?.reduce((acc, t) => { + acc[t.name] = { + description: t.description, + inputSchema: t.parameters as Record, + } + return acc + }, {} as Record }>) + + // Cast to any to satisfy AI SDK's ToolSet type which expects FlexibleSchema + // The underlying implementation accepts plain JSON schema objects const result = await generateText({ model: gemini.languageModel(model), system: opts.system, prompt: opts.prompt, temperature: opts.temperature, maxOutputTokens: opts.maxTokens || 4096, + tools: tools as any, }) const toolCalls = result.toolCalls?.map((tc): { name: string; args: Record; id: string } => { diff --git a/apps/core/src/providers/openai.ts b/apps/core/src/providers/openai.ts index 9eeab9b2..f61c42b2 100644 --- a/apps/core/src/providers/openai.ts +++ b/apps/core/src/providers/openai.ts @@ -18,12 +18,23 @@ function createOpenAIProvider(_apiKey: string): AIProvider { async function execute(opts: ExecuteOptions): Promise { const model = opts.model || PROVIDER_INFO.defaultModel + const tools = opts.tools?.reduce((acc, t) => { + acc[t.name] = { + description: t.description, + inputSchema: t.parameters as Record, + } + return acc + }, {} as Record }>) + + // Cast to any to satisfy AI SDK's ToolSet type which expects FlexibleSchema + // The underlying implementation accepts plain JSON schema objects const result = await generateText({ model: openai(model), system: opts.system, prompt: opts.prompt, temperature: opts.temperature, maxOutputTokens: opts.maxTokens || 4096, + tools: tools as any, }) const toolCalls = result.toolCalls?.map((tc): { name: string; args: Record; id: string } => { From d01c1fe17d5f84f844aacf13cf2e456f07c5ae95 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Mon, 1 Jun 2026 19:12:53 +0530 Subject: [PATCH 27/49] feat(core): emit tool_start, tool_output, tool_complete events for streaming AgentLoop now emits tool events via onToolEvent callback when tools are executed. Server wires these to stdout immediately for TUI streaming. Supports both batch mode and streaming IPC responses. Co-Authored-By: Claude Opus 4.6 --- apps/core/src/agent/loop.ts | 29 +++++++++++++++++++++++++++++ apps/core/src/agent/types.ts | 3 +++ apps/core/src/server.ts | 13 +++++++++++-- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/apps/core/src/agent/loop.ts b/apps/core/src/agent/loop.ts index f865346e..dfba9140 100644 --- a/apps/core/src/agent/loop.ts +++ b/apps/core/src/agent/loop.ts @@ -18,6 +18,7 @@ import type { HookContext, } from "./types.js" import { createInitialSessionState, DEFAULT_LOOP_HEURISTICS } from "./types.js" +import type { StreamEvent } from "@freecode/shared" import { createToolOrchestrator, listTools, getTool } from "../tools/index.js" import { MemoryService, renderPromptMemoryContext } from "../memory/index.js" import { getProvider } from "../providers/index.js" @@ -44,6 +45,7 @@ export class AgentLoop { private memory: MemoryService private hooks: HookRuntime private recorder: RolloutRecorder + private onToolEvent: ((event: StreamEvent) => void) | undefined // Loop health tracking state private recentToolCalls: Array<{ tool: string; args: string }> = [] private recentReasoning: string[] = [] @@ -70,6 +72,7 @@ export class AgentLoop { try { this.state = { ...this.state, status: "running" } + this.onToolEvent = input.onToolEvent // Step 1: Collect project context (file tree, etc.) const contextResult = await this.collectContext(input.projectPath) @@ -555,6 +558,14 @@ Based on this task, which files do you need to read to understand the codebase a } } + // Emit tool_start event for streaming + this.onToolEvent?.({ + type: "tool_start", + toolCallId: toolCall.id, + toolName: toolCall.tool, + args: toolCall.args as Record, + }) + // Emit tool.called event before execution BusEvents.toolCalled(this.state.sessionId, toolCall.tool, toolCall.id, toolCall.args as Record) @@ -586,6 +597,14 @@ Based on this task, which files do you need to read to understand the codebase a return errorResult } + // Emit tool_output with last 5 lines of stdout + const outputLines = (result.stdout || "").split("\n").filter(l => l.trim()).slice(-5) + this.onToolEvent?.({ + type: "tool_output", + toolCallId: toolCall.id, + content: outputLines.join("\n"), + }) + // PostToolUse Hook — can modify result const postResult = await this.hooks.runPostToolUse(toolCall, result, hookContext) @@ -597,6 +616,16 @@ Based on this task, which files do you need to read to understand the codebase a const success = !result.error BusEvents.toolCompleted(this.state.sessionId, toolCall.tool, toolCall.id, success, duration_ms) + // Emit tool_complete event for streaming + this.onToolEvent?.({ + type: "tool_complete", + toolCallId: toolCall.id, + toolName: toolCall.tool, + result: result.stdout || result.error || "", + success, + duration_ms, + }) + return result } diff --git a/apps/core/src/agent/types.ts b/apps/core/src/agent/types.ts index a5e89889..51ad8b51 100644 --- a/apps/core/src/agent/types.ts +++ b/apps/core/src/agent/types.ts @@ -171,12 +171,15 @@ export type MessagePart = // User Input / Loop Result - Main entry/exit types // ============================================================================= +import type { StreamEvent } from "@freecode/shared" + export interface UserInput { prompt: string sessionId: string provider: string model?: string projectPath: string + onToolEvent?: (event: StreamEvent) => void } export interface LoopResult { diff --git a/apps/core/src/server.ts b/apps/core/src/server.ts index 8aad99b8..39cf111d 100644 --- a/apps/core/src/server.ts +++ b/apps/core/src/server.ts @@ -10,7 +10,7 @@ import { getProviders, getProviderModels } from "./models-dev.js"; import { readConfig, writeConfig, setApiKey, setCurrentModel, hasApiKey, getCurrentModel, type ProviderId } from "./providers/config.js"; import { logger } from "./utils/logger.js"; import type { ToolContext } from "./tools/types.js"; -import type { JsonRpcRequest, JsonRpcResponse, SessionConfig } from "@freecode/shared"; +import type { JsonRpcRequest, JsonRpcResponse, SessionConfig, StreamEvent } from "@freecode/shared"; import { getMemoryStore, type MemoryEntry, type MemoryType } from "./memory/index.js"; import { findRelevantMemories } from "./memory/mem-query.js"; import { buildMemoryPrompt } from "./memory/mem-prompt.js"; @@ -118,6 +118,11 @@ const methodHandlers: Record< logger.info("Session send", { sessionId, messageLength: message.length, model: session.model, provider: currentProvider }); + // Emit events to stdout immediately for streaming + const emitEvent = (event: StreamEvent) => { + process.stdout.write(JSON.stringify(event) + "\n"); + }; + const loop = createAgentLoop(sessionId, { maxIterations: 100 }) const result = await loop.run({ prompt: message, @@ -125,8 +130,12 @@ const methodHandlers: Record< provider: currentProvider, model: session.model, projectPath: session.projectPath, + onToolEvent: emitEvent, }) + // Emit done event + emitEvent({ type: "done", content: result.message || "Done" }); + return result; }, @@ -327,4 +336,4 @@ async function main() { main().catch((e) => { process.stderr.write(`Server error: ${e}\n`); process.exit(1); -}); \ No newline at end of file +}); From df371a55316c75334641d5b30694b9def12b133a Mon Sep 17 00:00:00 2001 From: Ayan De Date: Mon, 1 Jun 2026 19:13:00 +0530 Subject: [PATCH 28/49] feat(shared): add StreamEvent type for tool event streaming Defines StreamEvent union type with tool_start, tool_output, tool_complete, text, done, and error event variants. Used by core to emit events and TUI to consume them for real-time tool UI rendering. Co-Authored-By: Claude Opus 4.6 --- packages/shared/src/index.ts | 1 + packages/shared/src/ipc/protocol.ts | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 255340d6..a787c50b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -25,6 +25,7 @@ export type { JsonRpcRequest, JsonRpcResponse, StreamResponse, + StreamEvent, MethodName, MethodParams, MethodResult, diff --git a/packages/shared/src/ipc/protocol.ts b/packages/shared/src/ipc/protocol.ts index 9ac73d15..eaa5b511 100644 --- a/packages/shared/src/ipc/protocol.ts +++ b/packages/shared/src/ipc/protocol.ts @@ -27,6 +27,14 @@ export type StreamResponse = | { type: "done"; content: string; toolName?: undefined; toolArgs?: undefined; toolResult?: undefined } | { type: "error"; content: string; toolName?: undefined; toolArgs?: undefined; toolResult?: undefined }; +export type StreamEvent = + | { type: "tool_start"; toolCallId: string; toolName: string; args: Record } + | { type: "tool_output"; toolCallId: string; content: string } + | { type: "tool_complete"; toolCallId: string; toolName: string; result: string; success: boolean; duration_ms?: number } + | { type: "text"; content: string } + | { type: "done"; content: string } + | { type: "error"; content: string }; + // ============================================================================= // IPC Method Signatures // ============================================================================= From a55b964850d7add3366702a91830ad5f91d962cf Mon Sep 17 00:00:00 2001 From: Ayan De Date: Mon, 1 Jun 2026 19:13:04 +0530 Subject: [PATCH 29/49] feat(tui): add sessionSendStreaming with onStreamEvent callback IPC client now supports streaming mode via sessionSendStreaming which accepts an onEvent callback to handle StreamEvent messages as they arrive. Events are parsed from stdout lines that don't have jsonrpc field. Co-Authored-By: Claude Opus 4.6 --- apps/tui/src/ipc/client.ts | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/apps/tui/src/ipc/client.ts b/apps/tui/src/ipc/client.ts index 03c78a37..f4e71042 100644 --- a/apps/tui/src/ipc/client.ts +++ b/apps/tui/src/ipc/client.ts @@ -13,6 +13,7 @@ import type { ToolResult, SessionConfig, ProviderInfo, + StreamEvent, } from "@freecode/shared"; // ============================================================================= @@ -26,6 +27,7 @@ let pendingRequests = new Map< number | string, { resolve: (value: unknown) => void; reject: (error: Error) => void } >(); +let onStreamEvent: ((event: StreamEvent) => void) | null = null; function generateId(): number { return ++requestId; @@ -34,10 +36,16 @@ function generateId(): number { function parseResponse(data: string): JsonRpcResponse[] { const responses: JsonRpcResponse[] = []; const lines = data.split("\n"); + messageBuffer = lines.pop() ?? ""; for (const line of lines) { if (!line.trim()) continue; try { - responses.push(JSON.parse(line) as JsonRpcResponse); + const parsed = JSON.parse(line); + if (parsed.type && !parsed.jsonrpc && onStreamEvent) { + onStreamEvent(parsed as StreamEvent); + continue; + } + responses.push(parsed as JsonRpcResponse); } catch { // Skip malformed lines } @@ -73,7 +81,6 @@ export function startCli(onStderr?: (msg: string) => void): void { cliProcess.stdout?.on("data", (data: string) => { messageBuffer += data; const responses = parseResponse(messageBuffer); - messageBuffer = ""; for (const response of responses) { const pending = pendingRequests.get(response.id); @@ -162,6 +169,27 @@ export async function sessionSend(sessionId: string, message: string, model?: st return await sendRequest("session.send", { sessionId, message, model }) as SessionSendResult; } +export async function sessionSendStreaming( + sessionId: string, + message: string, + model: string | undefined, + onEvent: (event: StreamEvent) => void +): Promise { + return new Promise((resolve, reject) => { + if (!cliProcess || !cliProcess.stdin) { + reject(new Error("CLI not running")); + return; + } + + onStreamEvent = onEvent; + + const id = generateId(); + const request: JsonRpcRequest = { jsonrpc: "2.0", id, method: "session.send", params: { sessionId, message, model } }; + pendingRequests.set(id, { resolve: resolve as (value: unknown) => void, reject }); + cliProcess.stdin.write(JSON.stringify(request) + "\n"); + }); +} + // ============================================================================= // Provider Methods // ============================================================================= @@ -199,4 +227,4 @@ export async function setCurrentModel(provider: string, model: string): Promise< export async function getCurrentModel(): Promise<{ provider: string; model: string } | undefined> { return (await sendRequest("config.getCurrentModel")) as { provider: string; model: string } | undefined -} \ No newline at end of file +} From 59a8fbdcf3fc8f1e09dfaef25554524cbc490f0e Mon Sep 17 00:00:00 2001 From: Ayan De Date: Mon, 1 Jun 2026 19:13:10 +0530 Subject: [PATCH 30/49] feat(tui): add ToolProgressMessage and ToolResultMessage components ToolProgressMessage shows animated spinner during tool execution with output lines. ToolResultMessage shows success/failure status after completion. Both truncate output to terminal width and support updating output during execution. Co-Authored-By: Claude Opus 4.6 --- apps/tui/src/components/tool-messages.test.ts | 17 ++++ .../src/components/tool-progress-message.ts | 98 +++++++++++++++++++ .../tui/src/components/tool-result-message.ts | 95 ++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 apps/tui/src/components/tool-messages.test.ts create mode 100644 apps/tui/src/components/tool-progress-message.ts create mode 100644 apps/tui/src/components/tool-result-message.ts diff --git a/apps/tui/src/components/tool-messages.test.ts b/apps/tui/src/components/tool-messages.test.ts new file mode 100644 index 00000000..071b6344 --- /dev/null +++ b/apps/tui/src/components/tool-messages.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { clearMessages, getMessages } from "../state/message-store.js"; +import { createToolProgressMessage } from "./index.js"; + +test("createToolProgressMessage adds a renderable tool message", () => { + clearMessages(); + + const message = createToolProgressMessage("call-1", "Write", { path: "FREECODE.md" }); + + const messages = getMessages(); + assert.equal(messages.length, 1); + assert.equal(messages[0], message); + assert.equal(message.type, "tool"); + assert.match(message.component.render(80).join("\n"), /Write/); + assert.match(message.component.render(80).join("\n"), /FREECODE\.md/); +}); diff --git a/apps/tui/src/components/tool-progress-message.ts b/apps/tui/src/components/tool-progress-message.ts new file mode 100644 index 00000000..0547316d --- /dev/null +++ b/apps/tui/src/components/tool-progress-message.ts @@ -0,0 +1,98 @@ +import { Component, TUI, Text, Box, truncateToWidth } from "@earendil-works/pi-tui"; +import chalk from "chalk"; + +export interface ToolProgressMessageOptions { + toolCallId: string; + toolName: string; + args: Record; + outputLines: string[]; +} + +// Color mapping for different tools +const TOOL_COLORS: Record string> = { + Read: (t) => chalk.blue(t), + Write: (t) => chalk.green(t), + Edit: (t) => chalk.yellow(t), + Bash: (t) => chalk.red(t), + Glob: (t) => chalk.cyan(t), + Grep: (t) => chalk.magenta(t), + Skill: (t) => chalk.white(t), + Agent: (t) => chalk.white(t), +}; + +export class ToolProgressMessage implements Component { + private toolCallId: string; + private toolName: string; + private args: Record; + private outputLines: string[]; + private tui?: TUI; + private animationFrame = 0; + private intervalId?: ReturnType; + + constructor(options: ToolProgressMessageOptions) { + this.toolCallId = options.toolCallId; + this.toolName = options.toolName; + this.args = options.args; + this.outputLines = options.outputLines; + } + + setTui(tui: TUI): void { + this.tui = tui; + // Start animation + this.intervalId = setInterval(() => { + this.animationFrame = (this.animationFrame + 1) % 4; + this.tui?.requestRender(); + }, 250); + } + + updateOutput(outputLines: string[]): void { + this.outputLines = outputLines; + } + + invalidate(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = undefined; + } + } + + render(width: number): string[] { + const colorFn = TOOL_COLORS[this.toolName] || ((t: string) => t); + const spinner = ["⠋", "⠙", "⠹", "⠸"][this.animationFrame]; + const argsStr = this.formatArgs(); + + const lines: string[] = []; + + // Header line: [spinner] ToolName (args) + let header = `${chalk.dim("[")}${chalk.yellow(spinner)}${chalk.dim("]")} ${colorFn(this.toolName)} ${chalk.dim("(")}${argsStr}${chalk.dim(")")}`; + header = truncateToWidth(header, width); + lines.push(header); + + // Output lines with tree view + for (const outputLine of this.outputLines.slice(-5)) { + lines.push(`${chalk.dim("│ ")}${chalk.dim(outputLine)}`); + } + + return lines; + } + + private formatArgs(): string { + const entries = Object.entries(this.args); + if (entries.length === 0) return ""; + + const truncate = (s: string, max = 40) => s.length > max ? s.slice(0, max) + "..." : s; + + let result = entries + .map(([k, v]) => { + const vStr = typeof v === "string" ? v : JSON.stringify(v); + return `${k}: ${chalk.green(truncate(vStr))}`; + }) + .join(", "); + + // If result exceeds 100 chars, truncate the whole thing + if (result.length > 100) { + result = result.slice(0, 100) + "..."; + } + return result; + } +} diff --git a/apps/tui/src/components/tool-result-message.ts b/apps/tui/src/components/tool-result-message.ts new file mode 100644 index 00000000..c9437d1c --- /dev/null +++ b/apps/tui/src/components/tool-result-message.ts @@ -0,0 +1,95 @@ +import { Component, Text, Box, truncateToWidth } from "@earendil-works/pi-tui"; +import chalk from "chalk"; + +export interface ToolResultMessageOptions { + toolCallId: string; + toolName: string; + args: Record; + result?: string; + success: boolean; + duration_ms?: number; +} + +// Color mapping for different tools +const TOOL_COLORS: Record string> = { + Read: (t) => chalk.blue(t), + Write: (t) => chalk.green(t), + Edit: (t) => chalk.yellow(t), + Bash: (t) => chalk.red(t), + Glob: (t) => chalk.cyan(t), + Grep: (t) => chalk.magenta(t), + Skill: (t) => chalk.white(t), + Agent: (t) => chalk.white(t), +}; + +export class ToolResultMessage implements Component { + private toolCallId: string; + private toolName: string; + private args: Record; + private result?: string; + private success: boolean; + private duration_ms?: number; + + constructor(options: ToolResultMessageOptions) { + this.toolCallId = options.toolCallId; + this.toolName = options.toolName; + this.args = options.args; + this.result = options.result; + this.success = options.success; + this.duration_ms = options.duration_ms; + } + + invalidate(): void { + // Nothing to clean up + } + + render(width: number): string[] { + const colorFn = TOOL_COLORS[this.toolName] || ((t: string) => t); + const statusIcon = this.success ? chalk.green("✓") : chalk.red("✗"); + const argsStr = this.formatArgs(); + const duration = this.duration_ms ? `(${this.duration_ms}ms)` : ""; + + const lines: string[] = []; + + // Header line: [✓/✗] ToolName (args) (duration) - truncated to width + let header = `${chalk.dim("[")}${statusIcon}${chalk.dim("]")} ${colorFn(this.toolName)} ${chalk.dim("(")}${argsStr}${chalk.dim(")")} ${chalk.dim(duration)}`; + header = truncateToWidth(header, width); + lines.push(header); + + // Result with tree view character - truncated to width + if (this.result) { + const maxResultWidth = width > 2 ? width - 2 : width; + const truncatedResult = truncateToWidth(this.result, maxResultWidth); + lines.push(`${chalk.dim("⎿")} ${chalk.dim(truncatedResult)}`); + } else if (this.success) { + lines.push(`${chalk.dim("⎿")} ${chalk.dim("(no output)")}`); + } + + return lines; + } + + private formatArgs(): string { + const entries = Object.entries(this.args); + if (entries.length === 0) return ""; + + const truncate = (s: string, max = 40) => s.length > max ? s.slice(0, max) + "..." : s; + + let result = entries + .map(([k, v]) => { + const vStr = typeof v === "string" ? v : JSON.stringify(v); + return `${k}: ${chalk.green(truncate(vStr))}`; + }) + .join(", "); + + // If result exceeds 100 chars, truncate the whole thing + if (result.length > 100) { + result = result.slice(0, 100) + "..."; + } + return result; + } + + private truncateResult(result: string, maxLen: number): string { + if (result.length <= maxLen) return result; + return result.slice(0, maxLen) + "..."; + } +} From 6a20fbbe972f12465d6dc338237a9192a99274da Mon Sep 17 00:00:00 2001 From: Ayan De Date: Mon, 1 Jun 2026 19:13:15 +0530 Subject: [PATCH 31/49] feat(tui): add 'tool' message type and export tool message factories MessageType now includes 'tool' type for tool progress/result messages. Components index exports createToolProgressMessage and createToolResultMessage factory functions used by handleToolEvent in index.ts. Co-Authored-By: Claude Opus 4.6 --- apps/tui/src/components/index.ts | 41 +++++++++++++++++++++++- apps/tui/src/components/message-types.ts | 4 +-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/apps/tui/src/components/index.ts b/apps/tui/src/components/index.ts index d53e0081..7047f051 100644 --- a/apps/tui/src/components/index.ts +++ b/apps/tui/src/components/index.ts @@ -9,6 +9,8 @@ import { } from "../state/message-store.js"; import { createMessageComponent } from "./message-row.js"; import type { MessageType, MessageInstance } from "./message-types.js"; +import { ToolProgressMessage } from "./tool-progress-message.js"; +import { ToolResultMessage } from "./tool-result-message.js"; /** * Add a user message to the store and return the message instance @@ -72,6 +74,39 @@ export function updateInProgressMessage( return updateMessage(id, phrase, component); } +export function createToolProgressMessage( + toolCallId: string, + toolName: string, + args: Record +): MessageInstance { + const component = new ToolProgressMessage({ + toolCallId, + toolName, + args, + outputLines: [], + }); + return addMessage("tool", toolName, component); +} + +export function createToolResultMessage( + toolCallId: string, + toolName: string, + args: Record, + result: string | undefined, + success: boolean, + duration_ms?: number +): MessageInstance { + const component = new ToolResultMessage({ + toolCallId, + toolName, + args, + result, + success, + duration_ms, + }); + return addMessage("tool", toolName, component); +} + /** * Get the current in-progress message, if any */ @@ -96,4 +131,8 @@ export function clearAllMessages(): void { } // Re-export types for convenience -export type { MessageInstance, MessageType } from "./message-types.js"; \ No newline at end of file +export type { MessageInstance, MessageType } from "./message-types.js"; + +// Re-export tool message components +export { ToolProgressMessage, type ToolProgressMessageOptions } from "./tool-progress-message.js"; +export { ToolResultMessage, type ToolResultMessageOptions } from "./tool-result-message.js"; diff --git a/apps/tui/src/components/message-types.ts b/apps/tui/src/components/message-types.ts index 7f3fe68e..129fb230 100644 --- a/apps/tui/src/components/message-types.ts +++ b/apps/tui/src/components/message-types.ts @@ -1,6 +1,6 @@ import type { Component } from "@earendil-works/pi-tui"; -export type MessageType = "user" | "assistant" | "system" | "in_progress"; +export type MessageType = "user" | "assistant" | "system" | "in_progress" | "tool"; export interface MessageInstance { id: number; @@ -12,4 +12,4 @@ export interface MessageInstance { export interface MessageStoreOptions { maxMessages?: number; // optional cap for memory management -} \ No newline at end of file +} From 320692255593375008f9bd7b1a6e2c0504015c7f Mon Sep 17 00:00:00 2001 From: Ayan De Date: Mon, 1 Jun 2026 19:13:19 +0530 Subject: [PATCH 32/49] fix(tui): truncate InProgressMessage to terminal width InProgressMessage.render() now uses truncateToWidth to ensure the full status line doesn't exceed terminal width, preventing crashes when context bar or model info is added. Co-Authored-By: Claude Opus 4.6 --- apps/tui/src/components/message-row.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/tui/src/components/message-row.ts b/apps/tui/src/components/message-row.ts index 3daa03b0..afa10811 100644 --- a/apps/tui/src/components/message-row.ts +++ b/apps/tui/src/components/message-row.ts @@ -1,4 +1,4 @@ -import { Box, Markdown, Text, type Component } from "@earendil-works/pi-tui"; +import { Box, Markdown, Text, truncateToWidth, type Component } from "@earendil-works/pi-tui"; import chalk from "chalk"; import { defaultMarkdownTheme } from "../themes.js"; import type { MessageType } from "./message-types.js"; @@ -45,7 +45,10 @@ class InProgressMessage implements Component { display += ` ${chalk.dim(`[${bar} ${current}/${limit}]`)}`; } - return [display]; + // Truncate to terminal width (180 default, use actual width if provided) + const maxWidth = _width > 0 ? _width : 180; + const truncated = truncateToWidth(display, maxWidth); + return [truncated]; } invalidate(): void {} From 9465f64ab6386388e6c823615c15cc1eee9d8a48 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Mon, 1 Jun 2026 19:13:24 +0530 Subject: [PATCH 33/49] feat(tui): wire tool event streaming to TUI render pipeline index.ts now handles tool_start/tool_output/tool_complete events to show live tool progress and results. handleToolEvent creates ToolProgressMessage on tool_start, updates it on tool_output, replaces with ToolResultMessage on tool_complete. Commands updated to use sessionSendStreaming with event callback. Co-Authored-By: Claude Opus 4.6 --- apps/tui/src/commands/freecode/index.ts | 11 +++-- apps/tui/src/commands/index.ts | 4 +- apps/tui/src/index.ts | 60 +++++++++++++++++++++++-- 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/apps/tui/src/commands/freecode/index.ts b/apps/tui/src/commands/freecode/index.ts index e214528d..9fae2446 100644 --- a/apps/tui/src/commands/freecode/index.ts +++ b/apps/tui/src/commands/freecode/index.ts @@ -7,7 +7,7 @@ import { registerCommand, type Command, type CommandContext } from "../index.js" import { startCli, sessionStart, - sessionSend, + sessionSendStreaming, listProviders, type SessionInfo, } from "../../ipc/client.js"; @@ -93,7 +93,12 @@ ${formatProviderList()}`); if (!ready) return; try { - const result = await sessionSend(currentSession!.sessionId, userPrompt); + const result = await sessionSendStreaming( + currentSession!.sessionId, + userPrompt, + undefined, + (event) => ctx.handleToolEvent?.(event) + ); // Update in-progress message with token counts const contextLimit = getModelContextLimit(`${currentProvider}/MiniMax-M2`); @@ -147,4 +152,4 @@ ${formatProviderList()}`); export function registerFreecodeCommand(): void { registerCommand(freecodeCommand); -} \ No newline at end of file +} diff --git a/apps/tui/src/commands/index.ts b/apps/tui/src/commands/index.ts index aef183e7..a74ca284 100644 --- a/apps/tui/src/commands/index.ts +++ b/apps/tui/src/commands/index.ts @@ -1,5 +1,6 @@ import type { Component } from "@earendil-works/pi-tui"; import type { AutocompleteItem, SlashCommand } from "@earendil-works/pi-tui"; +import type { StreamEvent } from "@freecode/shared"; export interface MessageCreators { createUserMessage(content: string): { component: Component; id: number }; @@ -14,6 +15,7 @@ export interface MessageCreators { export interface CommandContext extends MessageCreators { showMessage(content: string): void; showModelSelector?(): void; + handleToolEvent?(event: StreamEvent): void; } export interface Command { @@ -63,4 +65,4 @@ export function registerCommand(command: Command): void { export function getCommand(name: string): Command | undefined { return commandRegistry.get(name); -} \ No newline at end of file +} diff --git a/apps/tui/src/index.ts b/apps/tui/src/index.ts index 4e073efa..8988d1b7 100644 --- a/apps/tui/src/index.ts +++ b/apps/tui/src/index.ts @@ -13,7 +13,7 @@ import { formatTokenCount } from "./utils/format-tokens.js"; import { startCli, sessionStart, - sessionSend, + sessionSendStreaming, listProviders, listModels, getCurrentModel, @@ -31,9 +31,13 @@ import { updateInProgressMessage, subscribeToMessages, onMessagesChange, + createToolProgressMessage, + createToolResultMessage, + ToolProgressMessage, type MessageInstance, } from "./components/index.js"; import { VirtualMessageList } from "./components/virtual-message-list.js"; +import type { StreamEvent } from "@freecode/shared"; registerBuiltInCommands(); @@ -52,6 +56,7 @@ let apiKeyPrompt: Text | null = null; let modelDisplayIdx = -1; let messageList: VirtualMessageList; +const toolMessageComponents = new Map }>(); const terminal = new ProcessTerminal(); tui = new TUI(terminal); @@ -286,6 +291,47 @@ async function loadCurrentModel(): Promise { } } +function handleToolEvent(event: StreamEvent): void { + switch (event.type) { + case "tool_start": { + const toolMsg = createToolProgressMessage(event.toolCallId, event.toolName, event.args); + const progressComponent = toolMsg.component as ToolProgressMessage; + progressComponent.setTui(tui); + toolMessageComponents.set(event.toolCallId, { + progress: progressComponent, + id: toolMsg.id, + args: event.args, + }); + break; + } + case "tool_output": { + const entry = toolMessageComponents.get(event.toolCallId); + if (entry) { + entry.progress.updateOutput(event.content.split('\n').slice(-5)); + } + tui.requestRender(); + break; + } + case "tool_complete": { + const entry = toolMessageComponents.get(event.toolCallId); + if (entry) { + entry.progress.invalidate(); + removeMessageById(entry.id); + toolMessageComponents.delete(event.toolCallId); + } + createToolResultMessage( + event.toolCallId, + event.toolName, + entry?.args ?? {}, + event.result, + event.success, + event.duration_ms + ); + break; + } + } +} + editor.onSubmit = async (value: string) => { const trimmed = value.trim(); if (!trimmed) return; @@ -308,6 +354,7 @@ editor.onSubmit = async (value: string) => { updateInProgressMessage: (id: number, phrase: string, inputTokens: number, outputTokens: number, contextLimit: number, startTime: number, turns: number) => updateInProgressMessage(id, phrase, inputTokens, outputTokens, contextLimit, startTime, turns), insertBeforeEditor: () => { /* no-op - messages go through store now */ }, removeMessageById: (id: number) => removeMessageById(id), + handleToolEvent, }); return; } else { @@ -354,7 +401,14 @@ editor.onSubmit = async (value: string) => { } try { - const result = await sessionSend(currentSession.sessionId, trimmed); + const result = await sessionSendStreaming( + currentSession.sessionId, + trimmed, + undefined, + (event: StreamEvent) => { + handleToolEvent(event); + } + ); // Update in-progress message with token counts from result const contextLimit = getModelContextLimit(`${currentProvider}/${currentModel}`); @@ -422,4 +476,4 @@ startCli((stderrMsg) => { const freecodeModule = await import("./commands/freecode/index.js"); process.on("exit", () => freecodeModule.stopSound?.()); -tui.start(); \ No newline at end of file +tui.start(); From 29b4169073c389b5cba5a8a929cb4a1aab4b6000 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Mon, 1 Jun 2026 21:22:51 +0530 Subject: [PATCH 34/49] fix: truncation issue --- apps/tui/src/components/message-row.ts | 9 +++++---- apps/tui/src/components/tool-progress-message.ts | 7 ++++--- apps/tui/src/components/tool-result-message.ts | 8 ++++---- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/apps/tui/src/components/message-row.ts b/apps/tui/src/components/message-row.ts index afa10811..b722b854 100644 --- a/apps/tui/src/components/message-row.ts +++ b/apps/tui/src/components/message-row.ts @@ -26,7 +26,7 @@ class InProgressMessage implements Component { this.turns = turns; } - render(_width: number): string[] { + render(width: number): string[] { const elapsed = Math.floor((Date.now() - this.startTime) / 1000); // Estimate: ~1k tokens per second of processing (rough approximation) const estimatedInputTokens = this.baseInputTokens + (elapsed * 1000); @@ -36,7 +36,7 @@ class InProgressMessage implements Component { if (this.contextLimit > 0) { const pct = Math.min(estimatedInputTokens / this.contextLimit, 1); - const barWidth = 10; + const barWidth = Math.min(10, Math.max(3, Math.floor(width / 12))); const filled = Math.round(pct * barWidth); const empty = barWidth - filled; const bar = '█'.repeat(filled) + '░'.repeat(empty); @@ -45,8 +45,9 @@ class InProgressMessage implements Component { display += ` ${chalk.dim(`[${bar} ${current}/${limit}]`)}`; } - // Truncate to terminal width (180 default, use actual width if provided) - const maxWidth = _width > 0 ? _width : 180; + // Always use a reasonable max width to ensure fit on all screens + // 80 is safe minimum, but use actual width if reasonable + const maxWidth = Math.max(40, Math.min(width, 200)); const truncated = truncateToWidth(display, maxWidth); return [truncated]; } diff --git a/apps/tui/src/components/tool-progress-message.ts b/apps/tui/src/components/tool-progress-message.ts index 0547316d..f19ff67c 100644 --- a/apps/tui/src/components/tool-progress-message.ts +++ b/apps/tui/src/components/tool-progress-message.ts @@ -65,12 +65,13 @@ export class ToolProgressMessage implements Component { // Header line: [spinner] ToolName (args) let header = `${chalk.dim("[")}${chalk.yellow(spinner)}${chalk.dim("]")} ${colorFn(this.toolName)} ${chalk.dim("(")}${argsStr}${chalk.dim(")")}`; - header = truncateToWidth(header, width); + header = truncateToWidth(header, Math.max(40, width)); lines.push(header); - // Output lines with tree view + // Output lines with tree view - always safe width + const safeWidth = Math.max(40, width); for (const outputLine of this.outputLines.slice(-5)) { - lines.push(`${chalk.dim("│ ")}${chalk.dim(outputLine)}`); + lines.push(`${chalk.dim("│ ")}${chalk.dim(truncateToWidth(outputLine, safeWidth))}`); } return lines; diff --git a/apps/tui/src/components/tool-result-message.ts b/apps/tui/src/components/tool-result-message.ts index c9437d1c..5b303bcd 100644 --- a/apps/tui/src/components/tool-result-message.ts +++ b/apps/tui/src/components/tool-result-message.ts @@ -51,14 +51,14 @@ export class ToolResultMessage implements Component { const lines: string[] = []; - // Header line: [✓/✗] ToolName (args) (duration) - truncated to width + // Header line: [✓/✗] ToolName (args) (duration) - truncated to safe width let header = `${chalk.dim("[")}${statusIcon}${chalk.dim("]")} ${colorFn(this.toolName)} ${chalk.dim("(")}${argsStr}${chalk.dim(")")} ${chalk.dim(duration)}`; - header = truncateToWidth(header, width); + header = truncateToWidth(header, Math.max(40, width)); lines.push(header); - // Result with tree view character - truncated to width + // Result with tree view character - truncated to safe width if (this.result) { - const maxResultWidth = width > 2 ? width - 2 : width; + const maxResultWidth = Math.max(40, width) - 2; const truncatedResult = truncateToWidth(this.result, maxResultWidth); lines.push(`${chalk.dim("⎿")} ${chalk.dim(truncatedResult)}`); } else if (this.success) { From 66b332cd3eb509957bd67e8b875223269d444582 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Mon, 1 Jun 2026 21:30:11 +0530 Subject: [PATCH 35/49] fix: tui --- apps/tui/src/components/message-row.ts | 3 ++- apps/tui/src/components/tool-progress-message.ts | 4 ++-- apps/tui/src/components/tool-result-message.ts | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/tui/src/components/message-row.ts b/apps/tui/src/components/message-row.ts index b722b854..2fb98877 100644 --- a/apps/tui/src/components/message-row.ts +++ b/apps/tui/src/components/message-row.ts @@ -47,7 +47,8 @@ class InProgressMessage implements Component { // Always use a reasonable max width to ensure fit on all screens // 80 is safe minimum, but use actual width if reasonable - const maxWidth = Math.max(40, Math.min(width, 200)); + // Subtract 1 to account for ANSI codes throwing off truncateToWidth + const maxWidth = Math.max(40, Math.min(width, 200)) - 1; const truncated = truncateToWidth(display, maxWidth); return [truncated]; } diff --git a/apps/tui/src/components/tool-progress-message.ts b/apps/tui/src/components/tool-progress-message.ts index f19ff67c..ed5f24de 100644 --- a/apps/tui/src/components/tool-progress-message.ts +++ b/apps/tui/src/components/tool-progress-message.ts @@ -65,11 +65,11 @@ export class ToolProgressMessage implements Component { // Header line: [spinner] ToolName (args) let header = `${chalk.dim("[")}${chalk.yellow(spinner)}${chalk.dim("]")} ${colorFn(this.toolName)} ${chalk.dim("(")}${argsStr}${chalk.dim(")")}`; - header = truncateToWidth(header, Math.max(40, width)); + header = truncateToWidth(header, Math.max(40, width) - 1); lines.push(header); // Output lines with tree view - always safe width - const safeWidth = Math.max(40, width); + const safeWidth = Math.max(40, width) - 1; for (const outputLine of this.outputLines.slice(-5)) { lines.push(`${chalk.dim("│ ")}${chalk.dim(truncateToWidth(outputLine, safeWidth))}`); } diff --git a/apps/tui/src/components/tool-result-message.ts b/apps/tui/src/components/tool-result-message.ts index 5b303bcd..e741b922 100644 --- a/apps/tui/src/components/tool-result-message.ts +++ b/apps/tui/src/components/tool-result-message.ts @@ -53,14 +53,14 @@ export class ToolResultMessage implements Component { // Header line: [✓/✗] ToolName (args) (duration) - truncated to safe width let header = `${chalk.dim("[")}${statusIcon}${chalk.dim("]")} ${colorFn(this.toolName)} ${chalk.dim("(")}${argsStr}${chalk.dim(")")} ${chalk.dim(duration)}`; - header = truncateToWidth(header, Math.max(40, width)); + header = truncateToWidth(header, Math.max(40, width) - 1); lines.push(header); // Result with tree view character - truncated to safe width if (this.result) { - const maxResultWidth = Math.max(40, width) - 2; + const maxResultWidth = Math.max(40, width) - 3; const truncatedResult = truncateToWidth(this.result, maxResultWidth); - lines.push(`${chalk.dim("⎿")} ${chalk.dim(truncatedResult)}`); + lines.push(` ${chalk.dim("⎿")} ${chalk.dim(truncatedResult)}`); } else if (this.success) { lines.push(`${chalk.dim("⎿")} ${chalk.dim("(no output)")}`); } From 2d3df15eb8551b2028c050ae2055b065d7b7ef0b Mon Sep 17 00:00:00 2001 From: Ayan De Date: Tue, 2 Jun 2026 00:03:33 +0530 Subject: [PATCH 36/49] feat: /freecode command removed --- apps/tui/src/commands/built-in.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/tui/src/commands/built-in.ts b/apps/tui/src/commands/built-in.ts index d59a0709..a532c3b7 100644 --- a/apps/tui/src/commands/built-in.ts +++ b/apps/tui/src/commands/built-in.ts @@ -1,6 +1,5 @@ import { registerCommand, type Command, type CommandContext } from "./index.js"; import { AVAILABLE_MODELS } from "../models.js"; -import { registerFreecodeCommand } from "./freecode/index.js"; const helpCommand: Command = { name: "help", @@ -12,7 +11,8 @@ const helpCommand: Command = { - **/clear** - Clear all messages - **/model** - Select AI model - **/exit** - Exit FreeCode -- **/freecode** - Send prompt to ChatGPT and apply file changes`); + +Just type your prompt to start chatting!`); }, }; @@ -46,5 +46,4 @@ export function registerBuiltInCommands(): void { registerCommand(clearCommand); registerCommand(exitCommand); registerCommand(modelCommand); - registerFreecodeCommand(); } \ No newline at end of file From 0ac4a065627d41ad914e9ea4d244638ae5478cbf Mon Sep 17 00:00:00 2001 From: Ayan De Date: Tue, 2 Jun 2026 00:06:43 +0530 Subject: [PATCH 37/49] feat: virtual message --- .freecode/sessions/session-1/memory.json | 22 +++++++++++++++++++ .../src/components/virtual-message-list.ts | 18 ++++++++++++--- 2 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 .freecode/sessions/session-1/memory.json diff --git a/.freecode/sessions/session-1/memory.json b/.freecode/sessions/session-1/memory.json new file mode 100644 index 00000000..cee7d06d --- /dev/null +++ b/.freecode/sessions/session-1/memory.json @@ -0,0 +1,22 @@ +{ + "sessionId": "session-1", + "messages": [ + { + "id": "msg-1780338978792-mzkiymbovd8", + "role": "user", + "content": "hi", + "timestamp": 1780338978792, + "tokenCount": 1 + }, + { + "id": "msg-1780338978792-q9w51durvb", + "role": "assistant", + "content": "The user is just saying \"hi\" - a simple greeting. I should respond in a friendly, concise manner and offer to help with their project.\nHi! I'm FreeCode, your AI coding assistant.\n\nI see you're working on the **tui** project - a Terminal UI that drives ChatGPT via Playwright/CDP.\n\nHow can I help you today? I can:\n- Explore the codebase\n- Run commands\n- Read/edit files\n- Help with debugging or feature implementation\n\nJust let me know what you'd like to do!", + "timestamp": 1780338978792, + "tokenCount": 115 + } + ], + "summaries": [], + "tokenCount": 116, + "totalCompactions": 0 +} \ No newline at end of file diff --git a/apps/tui/src/components/virtual-message-list.ts b/apps/tui/src/components/virtual-message-list.ts index 0e5a2c7c..b34d144e 100644 --- a/apps/tui/src/components/virtual-message-list.ts +++ b/apps/tui/src/components/virtual-message-list.ts @@ -67,15 +67,19 @@ export class VirtualMessageList implements Component { /** * Render the message list. - * Returns only the last maxVisible messages for performance. + * In-progress message always stays at the bottom; all other messages render above it. */ render(width: number): string[] { this.invalidated = false; const lines: string[] = []; - // Only render last N messages - const visibleMessages = this.messages.slice(-this.maxVisible); + // Separate in-progress message from others + const regularMessages = this.messages.filter((m) => m.type !== "in_progress"); + const inProgressMessage = this.messages.find((m) => m.type === "in_progress"); + + // Render regular messages first (older messages, then newer ones) + const visibleMessages = regularMessages.slice(-this.maxVisible); for (const msg of visibleMessages) { const msgLines = msg.component.render(width); @@ -84,6 +88,14 @@ export class VirtualMessageList implements Component { } } + // Render in-progress message at the very bottom (if exists) + if (inProgressMessage) { + const inProgressLines = inProgressMessage.component.render(width); + for (const line of inProgressLines) { + lines.push(line); + } + } + return lines; } From deebe421bf9dff0e3f9b7752a2b7501c50107e4c Mon Sep 17 00:00:00 2001 From: Ayan De Date: Tue, 2 Jun 2026 11:52:53 +0530 Subject: [PATCH 38/49] =?UTF-8?q?feat:=20added=20"=E2=9D=AF"=20to=20user?= =?UTF-8?q?=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/tui/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/tui/src/index.ts b/apps/tui/src/index.ts index 8988d1b7..5f662a60 100644 --- a/apps/tui/src/index.ts +++ b/apps/tui/src/index.ts @@ -75,6 +75,7 @@ messageList.setTui(tui); tui.addChild(messageList); const editor = new Editor(tui, defaultEditorTheme); +editor.setText("❯ "); const autocompleteProvider = new CombinedAutocompleteProvider( commandRegistry.getSlashCommands(), From 94a631ee394348630e421c54e8ca431de6834aca Mon Sep 17 00:00:00 2001 From: Ayan De Date: Tue, 2 Jun 2026 12:17:31 +0530 Subject: [PATCH 39/49] docs: add memory/session system design Central session storage at ~/.freecode/, JSONL message streaming, interrupt handling with resume, URL-based remote sync. Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-06-02-memory-session-design.md | 317 ++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-02-memory-session-design.md diff --git a/docs/superpowers/specs/2026-06-02-memory-session-design.md b/docs/superpowers/specs/2026-06-02-memory-session-design.md new file mode 100644 index 00000000..3ce40a4a --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-memory-session-design.md @@ -0,0 +1,317 @@ +# Memory/Session System Design + +**Date:** 2026-06-02 +**Status:** Draft + +--- + +## Overview + +FreeCode's memory/session system provides persistent, resumable conversation history with remote sync capability. Sessions store complete chat history (messages, tool calls, metadata) at a central location (`~/.freecode/`), enabling interruption recovery, cross-machine resume, and URL-based session sharing. + +--- + +## Storage Architecture + +### Central Storage (`~/.freecode/`) + +All session data lives centrally, not in project directories. This enables: +- Sessions persist across machines +- Project roots stay clean (no `.freecode/` cluttering git) +- Easy backup/sync of all conversation history + +``` +~/.freecode/ +├── sessions/ # Session data (primary) +│ └── {sessionId}/ +│ ├── meta.json # Session metadata +│ ├── messages.jsonl # Full message transcript (streaming append) +│ └── memory.json # Session-level memory state (compaction) +├── memory/ # Project-level persistent memory +│ └── {projectSlug}/ +│ └── memory.md # Auto-extracted conversation notes +├── state/ +│ ├── freecode.db # SQLite (threads, turns, tool_calls) +│ └── store.json # JSON fallback +└── config.json # Zod-validated config +``` + +### Project-Level `.freecode/` (Optional) + +Project roots may contain a lightweight `.freecode/` for: +- Project-specific config (`provider`, `model`, `customInstructions`) +- Session refs (pointer to central session ID, not full data) +- This is NOT where session history lives + +### Session Data Structure + +**`meta.json`** — Session metadata: +```typescript +interface SessionMeta { + id: string // UUID + title: string // Auto-generated or user-set + projectPath: string // Absolute path + provider: string // e.g., "claude", "chatgpt" + model?: string // e.g., "claude-opus-4-6" + status: "active" | "interrupted" | "archived" | "deleted" + createdAt: number // Unix timestamp ms + updatedAt: number + lastTurnAt: number + turnCount: number + parentId?: string // If forked from another session + aggregatedTokenCount?: number +} +``` + +**`messages.jsonl`** — One JSON object per message, appended as produced: +``` +{"id":"msg-1","role":"user","parts":[{"type":"text","content":"hello"}],"timestamp":1700000000000} +{"id":"msg-2","role":"assistant","parts":[{"type":"tool","tool":{"name":"Read","args":{"path":"/foo.txt"}}}],"timestamp":1700000001000} +{"id":"msg-3","role":"assistant","parts":[{"type":"tool","tool":{"name":"Read","args":{}},"result":"file contents..."}],"timestamp":1700000001500} +``` + +**`memory.json`** — Session compaction state (created after first compaction): +```typescript +interface SessionMemory { + sessionId: string + summaries: CompactionSummary[] // Summarized message ranges + tokenCount: number + totalCompactions: number + lastCompactionAt?: number + preservedRecentMessages: SerializedMessage[] // Last 2 turns uncompacted +} +``` + +--- + +## Session Lifecycle + +### Session Start + +``` +session.start(projectPath, provider?, title?) → { sessionId } +``` + +1. Generate UUID for session +2. Create `~/.freecode/sessions/{sessionId}/` +3. Write `meta.json` with status "active" +4. Return sessionId to caller + +### Turn Execution + +Each turn: +1. Append user message to `messages.jsonl` +2. Run agent loop, streaming assistant messages to `messages.jsonl` +3. On tool call: append tool request +4. On tool result: append tool result +5. On turn complete: update `meta.json` (lastTurnAt, turnCount) + +### Interrupt Handling (Ctrl+C) + +On interrupt signal: +1. Mark current message in `messages.jsonl` with `"interrupted": true` +2. Update `meta.json` status → `"interrupted"` +3. Store interrupt point for resume detection + +### Session Resume + +``` +session.resume(sessionId) → SessionContext +``` + +Resume flow: +1. Load `meta.json` for session metadata +2. Stream-read `messages.jsonl` to reconstruct full history +3. Detect interrupted state: + - If last message has `"interrupted": true` → inject synthetic `"Continue from where you left off."` user message +4. If session has `memory.json` (compacted) → load summaries + preserved recent messages +5. Restore session state and continue + +### Session Fork + +``` +session.fork(sessionId, point?) → { newSessionId } +``` + +Creates a new session branching from current point. Copies `meta.json` with new ID and `parentId` reference. + +### Session Archive/Delete + +- **Archive**: Mark status `"archived"`, retain all data +- **Delete**: Mark status `"deleted"`, data retained until purge + +--- + +## Remote Sync (URL-Based Sharing) + +### Export (Upload to URL) + +``` +session.export(sessionId) → { url: string, expiresAt: number } +``` + +1. Serialize session: `meta.json` + `messages.jsonl` + `memory.json` +2. Compress (gzip) and POST to sync endpoint +3. Server returns short URL code (e.g., `https://sync.freecode.dev/a3f8b2`) +4. URL valid for 7 days by default + +### Import (Download from URL) + +``` +session.import(url) → { sessionId } +``` + +1. GET the URL, decompress response +2. Validate session structure +3. Create new session in `~/.freecode/sessions/` with new UUID +4. Mark as `"imported"` with original metadata preserved + +### Sync Endpoint (Configurable) + +Default: `https://sync.freecode.dev` +Configurable via `config.json`: +```typescript +{ + "syncEndpoint": "https://sync.freecode.dev", + "syncApiKey"?: string, // Optional for private sharing + "syncUrlExpiryDays": 7 +} +``` + +--- + +## Thread Store (SQLite + JSON Fallback) + +For structured queries (search, list, aggregation): + +**SQLite primary** (`~/.freecode/state/freecode.db`): +```sql +CREATE TABLE threads ( + id TEXT PRIMARY KEY, + title TEXT, + project_path TEXT, + provider TEXT, + status TEXT, + created_at INTEGER, + updated_at INTEGER, + last_turn_at INTEGER, + turn_count INTEGER DEFAULT 0, + parent_id TEXT +); + +CREATE TABLE turns ( + id TEXT PRIMARY KEY, + thread_id TEXT REFERENCES threads(id), + turn_number INTEGER, + prompt TEXT, + response TEXT, + created_at INTEGER, + duration_ms INTEGER, + tool_call_count INTEGER DEFAULT 0 +); + +CREATE TABLE tool_calls ( + id TEXT PRIMARY KEY, + turn_id TEXT REFERENCES turns(id), + tool_name TEXT, + args TEXT, -- JSON + result TEXT, + error TEXT, + duration_ms INTEGER, + sequence INTEGER +); +``` + +**JSON fallback** (`~/.freecode/state/store.json`) — when SQLite unavailable: +```typescript +interface JsonStore { + threads: Record + turns: Record + metadata: { version: number; lastUpdated: number } +} +``` + +**Factory pattern** — always try SQLite first: +```typescript +async function getThreadStore(): Promise { + try { + return await SQLiteThreadStore.create() + } catch { + return JsonThreadStore.create() + } +} +``` + +--- + +## Resume Picker + +On startup (if interrupted session detected or user runs `/resume`): + +1. Query `sessions/` for recent sessions with status `active` or `interrupted` +2. Display picker with: + - Session title + project path + - Last turn time (relative: "2 hours ago") + - Status badge: "Active" / "Interrupted" + - Turn count +3. User selects → `session.resume(selectedId)` + +Keyboard navigation: +- `↑/↓` — navigate +- `Enter` — resume selected +- `Ctrl+C` — exit picker, start fresh session + +--- + +## IPC Protocol (Session Operations) + +| Method | Params | Returns | Description | +|--------|--------|---------|-------------| +| `session.start` | `{ projectPath, provider?, title? }` | `{ sessionId }` | Start new session | +| `session.resume` | `{ sessionId }` | `{ sessionId }` | Resume existing session | +| `session.list` | `{ projectPath?, status? }` | `SessionMeta[]` | List sessions | +| `session.fork` | `{ sessionId, point? }` | `{ newSessionId }` | Fork session | +| `session.archive` | `{ sessionId }` | `void` | Archive session | +| `session.delete` | `{ sessionId }` | `void` | Delete session | +| `session.export` | `{ sessionId }` | `{ url, expiresAt }` | Upload to URL | +| `session.import` | `{ url }` | `{ sessionId }` | Download from URL | +| `memory.query` | `{ query, projectPath? }` | `MemoryEntry[]` | Search memory | +| `memory.buildPrompt` | `{ projectPath }` | `string` | Build memory context | + +--- + +## Error Handling + +| Failure | Recovery | +|---------|----------| +| JSONL write fails | Fall back to buffered write, flush on turn end | +| SQLite unavailable | Fall back to JSON store | +| Import corrupt data | Reject with validation errors, never partial import | +| Export endpoint down | Save export locally, queue for retry | +| Disk full | Warn user, suggest archive/delete old sessions | + +--- + +## v1 Scope + +### Included +- [x] Session storage at `~/.freecode/sessions/` +- [x] Message streaming to JSONL (append-only) +- [x] Interrupt handling (Ctrl+C marks session interrupted) +- [x] Resume with complete chat history +- [x] Resume picker UI +- [x] Session list/fork/archive/delete +- [x] URL-based export/import + +### Not in v1 +- [ ] Compaction service (summarize old messages) +- [ ] Session memory (auto-extract notes to MEMORY.md) +- [ ] Teleport (remote session viewing via WebSocket) +- [ ] Background jobs tied to session lifecycle + +--- + +## References + +- claude-code: `sessionStorage.ts`, `conversationRecovery.ts`, `teleport.tsx` +- opencode: `session.ts`, `storage.ts`, `session-replay.ts`, `sync.ts` \ No newline at end of file From 3a1650523c99d49b7cc2472a5220c0ce163ef976 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Tue, 2 Jun 2026 12:19:58 +0530 Subject: [PATCH 40/49] docs: add memory/session implementation plan 7 tasks covering SessionStore (JSONL), SessionManager integration, agent loop streaming, Ctrl+C interrupt handling, resume picker TUI, session IPC handlers, and URL-based remote sync. Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-06-02-memory-session-plan.md | 1191 +++++++++++++++++ 1 file changed, 1191 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-02-memory-session-plan.md diff --git a/docs/superpowers/plans/2026-06-02-memory-session-plan.md b/docs/superpowers/plans/2026-06-02-memory-session-plan.md new file mode 100644 index 00000000..c633dea6 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-memory-session-plan.md @@ -0,0 +1,1191 @@ +# Memory/Session System Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement persistent session storage at `~/.freecode/sessions/` with JSONL message streaming, interrupt handling, resume, and URL-based remote sync. + +**Architecture:** Session data stored at `~/.freecode/sessions/{id}/` with `meta.json` (metadata) and `messages.jsonl` (streaming append). ThreadStore provides SQLite-first structured queries. SessionManager orchestrates lifecycle operations. TUI exposes resume picker. + +**Tech Stack:** TypeScript, Node.js fs/promises streaming, better-sqlite3 (or sql.js), zod, pi-tui + +--- + +## File Structure + +``` +~/.freecode/ # Central storage (created on first run) +├── sessions/ # Session data +│ └── {sessionId}/ +│ ├── meta.json # Session metadata +│ └── messages.jsonl # Streaming message append +├── state/ +│ ├── freecode.db # SQLite (threads, turns) +│ └── store.json # JSON fallback +└── config.json # Zod-validated config + +apps/core/src/ +├── session/ +│ ├── store.ts # NEW: SessionStore - JSONL file ops +│ ├── manager.ts # MODIFY: add resume, fork, interrupt +│ ├── types.ts # MODIFY: add SessionMeta, Message types +│ └── index.ts # MODIFY: export SessionStore +├── store/ +│ ├── thread-store.ts # EXISTING: keep as-is +│ ├── sqlite-store.ts # EXISTING: keep as-is +│ ├── json-store.ts # EXISTING: keep as-is +│ ├── remote.ts # EXISTING: RemoteSessionSync +│ └── types.ts # EXISTING: StoredThread/StoredTurn +├── memory/ +│ ├── storage.ts # EXISTING: FileMemoryStorage +│ └── mem-store.ts # EXISTING: MemoryStore +└── server.ts # MODIFY: add session IPC handlers + +apps/tui/src/ +├── components/ +│ └── resume-picker.tsx # NEW: session picker component +└── ipc/ + └── client.ts # MODIFY: add session methods +``` + +--- + +## Task 1: SessionStore (JSONL File Operations) + +**Files:** +- Create: `apps/core/src/session/store.ts` +- Test: `apps/core/src/session/store.test.ts` +- Dependencies: Read `apps/core/src/store/types.ts` for StoredThread/StoredTurn patterns + +- [ ] **Step 1: Write failing test** + +```typescript +// apps/core/src/session/store.test.ts +import { describe, it, expect, beforeEach } from 'vitest' +import { SessionStore } from './store' +import { join } from 'path' +import { rm } from 'fs/promises' + +describe('SessionStore', () => { + const testDir = '/tmp/freecode-test-session-store' + let store: SessionStore + + beforeEach(async () => { + await rm(testDir, { recursive: true, force: true }) + store = await SessionStore.create(testDir) + }) + + it('creates session directory with meta.json', async () => { + const sessionId = await store.createSession({ + title: 'Test Session', + projectPath: '/tmp/test', + provider: 'claude', + }) + const meta = await store.getMeta(sessionId) + expect(meta.id).toBe(sessionId) + expect(meta.title).toBe('Test Session') + expect(meta.status).toBe('active') + }) + + it('appends messages to messages.jsonl', async () => { + const sessionId = await store.createSession({ + title: 'Test', + projectPath: '/tmp/test', + provider: 'claude', + }) + const msg = { id: 'msg-1', role: 'user' as const, parts: [{ type: 'text' as const, content: 'hello' }], timestamp: Date.now() } + await store.appendMessage(sessionId, msg) + const messages = await store.getMessages(sessionId) + expect(messages).toHaveLength(1) + expect(messages[0].parts[0]).toEqual({ type: 'text', content: 'hello' }) + }) + + it('marks message as interrupted', async () => { + const sessionId = await store.createSession({ + title: 'Test', + projectPath: '/tmp/test', + provider: 'claude', + }) + const msg = { id: 'msg-1', role: 'assistant' as const, parts: [], timestamp: Date.now() } + await store.appendMessage(sessionId, msg) + await store.markInterrupted(sessionId, 'msg-1') + const msgs = await store.getMessages(sessionId) + expect(msgs[0].interrupted).toBe(true) + }) + + it('detects interrupted sessions', async () => { + const sessionId = await store.createSession({ + title: 'Test', + projectPath: '/tmp/test', + provider: 'claude', + }) + const msg = { id: 'msg-1', role: 'assistant' as const, parts: [], timestamp: Date.now() } + await store.appendMessage(sessionId, msg) + await store.markInterrupted(sessionId, 'msg-1') + const interrupted = await store.getInterruptedSession() + expect(interrupted?.sessionId).toBe(sessionId) + }) + + it('lists sessions with filter', async () => { + const s1 = await store.createSession({ title: 'S1', projectPath: '/tmp/p1', provider: 'claude' }) + const s2 = await store.createSession({ title: 'S2', projectPath: '/tmp/p2', provider: 'claude' }) + await store.updateStatus(s1, 'archived') + const active = await store.list({ status: 'active' }) + expect(active).toHaveLength(2) + const archived = await store.list({ status: 'archived' }) + expect(archived).toHaveLength(1) + }) + + it('forks session with new id', async () => { + const parentId = await store.createSession({ title: 'Parent', projectPath: '/tmp/test', provider: 'claude' }) + const forkId = await store.fork(parentId) + const forkMeta = await store.getMeta(forkId) + expect(forkMeta.parentId).toBe(parentId) + expect(forkId).not.toBe(parentId) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/core && npx vitest src/session/store.test.ts --run` +Expected: FAIL with "Cannot find module './store'" + +- [ ] **Step 3: Write minimal SessionStore implementation** + +```typescript +// apps/core/src/session/store.ts +import { mkdir, readFile, writeFile, readdir, rm } from 'fs/promises' +import { join } from 'path' +import { randomUUID } from 'crypto' + +export interface SessionMeta { + id: string + title: string + projectPath: string + provider: string + model?: string + status: 'active' | 'interrupted' | 'archived' | 'deleted' + createdAt: number + updatedAt: number + lastTurnAt: number + turnCount: number + parentId?: string + aggregatedTokenCount?: number +} + +export interface SerializedMessage { + id: string + role: 'user' | 'assistant' + parts: Array<{ + type: 'text' | 'code' | 'tool' + content?: string + language?: string + tool?: { name: string; args: Record } + result?: string + }> + timestamp: number + interrupted?: boolean +} + +export interface CreateSessionOptions { + title: string + projectPath: string + provider: string + model?: string +} + +export interface SessionStore { + createSession(opts: CreateSessionOptions): Promise + getMeta(sessionId: string): Promise + updateMeta(sessionId: string, updates: Partial): Promise + updateStatus(sessionId: string, status: SessionMeta['status']): Promise + deleteSession(sessionId: string): Promise + + appendMessage(sessionId: string, message: SerializedMessage): Promise + getMessages(sessionId: string): Promise + markInterrupted(sessionId: string, messageId: string): Promise + + list(filter?: { status?: SessionMeta['status']; projectPath?: string }): Promise + fork(sessionId: string): Promise + + getInterruptedSession(): Promise<{ sessionId: string; messageId: string } | null> +} + +const SESSION_DIR = 'sessions' +const META_FILE = 'meta.json' +const MESSAGES_FILE = 'messages.jsonl' + +async function ensureDir(dir: string): Promise { + try { + await mkdir(dir, { recursive: true }) + } catch { /* already exists */ } +} + +async function readJson(path: string): Promise { + try { + const data = await readFile(path, 'utf-8') + return JSON.parse(data) as T + } catch { + return null + } +} + +async function writeJson(path: string, data: unknown): Promise { + await writeFile(path, JSON.stringify(data, null, 2), 'utf-8') +} + +export async function createSessionStore(baseDir: string): Promise { + await ensureDir(join(baseDir, SESSION_DIR)) + return new SessionStoreImpl(baseDir) +} + +class SessionStoreImpl implements SessionStore { + constructor(private baseDir: string) {} + + private sessionDir(sessionId: string): string { + return join(this.baseDir, SESSION_DIR, sessionId) + } + + private metaPath(sessionId: string): string { + return join(this.sessionDir(sessionId), META_FILE) + } + + private messagesPath(sessionId: string): string { + return join(this.sessionDir(sessionId), MESSAGES_FILE) + } + + async createSession(opts: CreateSessionOptions): Promise { + const id = randomUUID() + const now = Date.now() + const meta: SessionMeta = { + id, + title: opts.title, + projectPath: opts.projectPath, + provider: opts.provider, + model: opts.model, + status: 'active', + createdAt: now, + updatedAt: now, + lastTurnAt: now, + turnCount: 0, + } + await ensureDir(this.sessionDir(id)) + await writeJson(this.metaPath(id), meta) + await writeFile(this.messagesPath(id), '', 'utf-8') + return id + } + + async getMeta(sessionId: string): Promise { + return readJson(this.metaPath(sessionId)) + } + + async updateMeta(sessionId: string, updates: Partial): Promise { + const meta = await this.getMeta(sessionId) + if (!meta) return + const updated = { ...meta, ...updates, updatedAt: Date.now() } + await writeJson(this.metaPath(sessionId), updated) + } + + async updateStatus(sessionId: string, status: SessionMeta['status']): Promise { + await this.updateMeta(sessionId, { status }) + } + + async deleteSession(sessionId: string): Promise { + await this.updateStatus(sessionId, 'deleted') + } + + async appendMessage(sessionId: string, message: SerializedMessage): Promise { + const line = JSON.stringify(message) + '\n' + const stream = await import('fs').then(fs => fs.createWriteStream(this.messagesPath(sessionId), { flags: 'a' })) + return new Promise((resolve, reject) => { + stream.write(line, (err: Error | null) => { + if (err) reject(err); else resolve() + }) + }) + } + + async getMessages(sessionId: string): Promise { + const content = await readFile(this.messagesPath(sessionId), 'utf-8').catch(() => '') + if (!content.trim()) return [] + return content.trim().split('\n').map(line => JSON.parse(line) as SerializedMessage) + } + + async markInterrupted(sessionId: string, messageId: string): Promise { + const messages = await this.getMessages(sessionId) + const idx = messages.findIndex(m => m.id === messageId) + if (idx !== -1) { + messages[idx] = { ...messages[idx], interrupted: true } + } + await writeFile(this.messagesPath(sessionId), messages.map(m => JSON.stringify(m)).join('\n') + '\n', 'utf-8') + await this.updateStatus(sessionId, 'interrupted') + } + + async list(filter?: { status?: SessionMeta['status']; projectPath?: string }): Promise { + const sessionsDir = join(this.baseDir, SESSION_DIR) + let entries: string[] + try { + entries = await readdir(sessionsDir) + } catch { + return [] + } + const metas: SessionMeta[] = [] + for (const id of entries) { + const meta = await this.getMeta(id) + if (!meta) continue + if (filter?.status && meta.status !== filter.status) continue + if (filter?.projectPath && meta.projectPath !== filter.projectPath) continue + metas.push(meta) + } + return metas.sort((a, b) => b.lastTurnAt - a.lastTurnAt) + } + + async fork(sessionId: string): Promise { + const meta = await this.getMeta(sessionId) + if (!meta) throw new Error('Session not found') + const newId = await this.createSession({ + title: meta.title + ' (fork)', + projectPath: meta.projectPath, + provider: meta.provider, + model: meta.model, + }) + await this.updateMeta(newId, { parentId: sessionId, turnCount: meta.turnCount }) + const messages = await this.getMessages(sessionId) + for (const msg of messages) { + await this.appendMessage(newId, msg) + } + return newId + } + + async getInterruptedSession(): Promise<{ sessionId: string; messageId: string } | null> { + const all = await this.list({ status: 'interrupted' }) + if (all.length === 0) return null + const session = all[0] + const messages = await this.getMessages(session.id) + const last = messages[messages.length - 1] + return last ? { sessionId: session.id, messageId: last.id } : null + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/core && npx vitest src/session/store.test.ts --run` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +cd /home/ayande/Project/freecode +git add apps/core/src/session/store.ts apps/core/src/session/store.test.ts +git commit -m "$(cat <<'EOF' +feat: add SessionStore for JSONL file operations + +SessionStore handles session creation, JSONL message append, +interrupt marking, and session listing. Used by SessionManager +for persistent session storage at ~/.freecode/sessions/. + +Co-Authored-By: Claude Opus 4.6 +EOF +)" +``` + +--- + +## Task 2: Integrate SessionStore into SessionManager + +**Files:** +- Modify: `apps/core/src/session/manager.ts:1-50` +- Read first: `apps/core/src/session/manager.ts` (full file) + +- [ ] **Step 1: Write failing test** + +```typescript +// apps/core/src/session/manager.test.ts +import { describe, it, expect, beforeEach } from 'vitest' +import { rm } from 'fs/promises' +import { SessionManager, createSessionManager } from './manager' + +describe('SessionManager', () => { + const testBase = '/tmp/freecode-test-manager' + let manager: SessionManager + + beforeEach(async () => { + await rm(testBase, { recursive: true, force: true }) + manager = await createSessionManager(testBase) + }) + + it('starts a new session', async () => { + const id = await manager.start('/tmp/test', 'claude', 'Test Session') + expect(id).toBeTruthy() + const ctx = await manager.resume(id) + expect(ctx.id).toBe(id) + expect(ctx.title).toBe('Test Session') + }) + + it('resumes and gets complete message history', async () => { + const id = await manager.start('/tmp/test', 'claude') + const msg = { id: 'msg-1', role: 'user' as const, parts: [{ type: 'text' as const, content: 'hello' }], timestamp: Date.now() } + await manager.appendMessage(id, msg) + const ctx = await manager.resume(id) + expect(ctx.messages).toHaveLength(1) + }) + + it('detects interrupted session and injects resume marker', async () => { + const id = await manager.start('/tmp/test', 'claude') + const msg = { id: 'msg-1', role: 'assistant' as const, parts: [], timestamp: Date.now() } + await manager.appendMessage(id, msg) + await manager.markInterrupted(id, 'msg-1') + const ctx = await manager.resume(id) + // Should inject "Continue from where you left off." message + expect(ctx.messages).toHaveLength(2) // original + injected + }) + + it('forks session with full history', async () => { + const parentId = await manager.start('/tmp/test', 'claude') + await manager.appendMessage(parentId, { id: 'msg-1', role: 'user', parts: [{ type: 'text', content: 'hello' }], timestamp: Date.now() }) + const forkId = await manager.fork(parentId) + const parentCtx = await manager.resume(parentId) + const forkCtx = await manager.resume(forkId) + expect(forkCtx.messages).toHaveLength(parentCtx.messages.length) + }) + + it('lists sessions with project filter', async () => { + await manager.start('/tmp/p1', 'claude') + await manager.start('/tmp/p2', 'claude') + const p1Sessions = await manager.list({ projectPath: '/tmp/p1' }) + expect(p1Sessions).toHaveLength(1) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/core && npx vitest src/session/manager.test.ts --run` +Expected: FAIL with "createSessionManager not found" or missing methods + +- [ ] **Step 3: Read existing manager and modify** + +Read `apps/core/src/session/manager.ts` first, then modify it to: +1. Use `createSessionStore` instead of `createThreadStoreService` for session operations +2. Add `appendMessage(sessionId, message)` method +3. Add `markInterrupted(sessionId, messageId)` method +4. Add `getInterruptedSession()` helper +5. Update `resume()` to detect interrupt and inject synthetic message +6. Change `list()` to use SessionStore (keep ThreadStore for structured queries) + +The key change in `resume()`: +```typescript +async resume(sessionId: string): Promise { + const meta = await this.sessionStore.getMeta(sessionId) + if (!meta) throw new Error('Session not found') + const messages = await this.sessionStore.getMessages(sessionId) + + // Detect interrupted state → inject resume marker + if (meta.status === 'interrupted') { + const lastMsg = messages[messages.length - 1] + if (lastMsg?.interrupted) { + const resumeMsg: SerializedMessage = { + id: randomUUID(), + role: 'user', + parts: [{ type: 'text', content: 'Continue from where you left off.' }], + timestamp: Date.now(), + } + messages.push(resumeMsg) + } + } + + return { id: meta.id, title: meta.title, projectPath: meta.projectPath, provider: meta.provider, status: meta.status, messages, turnCount: meta.turnCount, createdAt: meta.createdAt } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/core && npx vitest src/session/manager.test.ts --run` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/session/manager.ts apps/core/src/session/manager.test.ts +git commit -m "$(cat <<'EOF' +feat: integrate SessionStore into SessionManager + +SessionManager now uses SessionStore for JSONL-based session +persistence. Resume detects interrupted state and injects +"Continue from where you left off." marker message. + +Co-Authored-By: Claude Opus 4.6 +EOF +)" +``` + +--- + +## Task 3: Session Message Streaming in Agent Loop + +**Files:** +- Modify: `apps/core/src/agent/loop.ts` +- Read first: `apps/core/src/agent/loop.ts` (full file, look for where messages are created/emitted) +- Read first: `apps/core/src/session/types.ts` + +- [ ] **Step 1: Understand the agent loop message flow** + +Read `apps/core/src/agent/loop.ts` and identify where: +1. User messages are created +2. Assistant messages are created/streamed +3. Tool calls and results are recorded +4. Turn ends + +Then read `apps/core/src/session/types.ts` to understand `SessionState` and `Message` types. + +- [ ] **Step 2: Write integration test** + +```typescript +// apps/core/src/agent/loop-stream.test.ts +// Test that agent loop streams messages to SessionStore +import { describe, it, expect, beforeEach } from 'vitest' +import { rm } from 'fs/promises' +import { createSessionManager } from '../session/manager' +import { SessionStore } from '../session/store' + +it('agent loop streams messages to session store', async () => { + const baseDir = '/tmp/freecode-test-loop-stream' + await rm(baseDir, { recursive: true, force: true }) + const manager = await createSessionManager(baseDir) + const sessionId = await manager.start('/tmp/test', 'claude', 'Loop Test') + + // Simulate message append (this is what loop.ts will call) + await manager.appendMessage(sessionId, { + id: 'msg-1', + role: 'user', + parts: [{ type: 'text', content: 'hello' }], + timestamp: Date.now(), + }) + + const ctx = await manager.resume(sessionId) + expect(ctx.messages).toHaveLength(1) + expect(ctx.messages[0].parts[0]).toEqual({ type: 'text', content: 'hello' }) +}) +``` + +- [ ] **Step 3: Modify agent loop to use SessionStore** + +Find where the agent loop creates messages and add calls to `sessionStore.appendMessage()`. The key insertion points: + +1. After user message is submitted → append user message +2. When assistant message starts/segments → append assistant message +3. After tool call → append tool call message +4. After tool result → append tool result message + +Pass `sessionStore` into the agent loop via constructor or context. Use Effect context pattern if available, otherwise parameter injection. + +```typescript +// In agent/loop.ts, add sessionStore parameter: +// constructor( +// ... +// private readonly sessionStore?: SessionStore +// ) + +// After each message/turn: +if (this.sessionStore && sessionId) { + await this.sessionStore.appendMessage(sessionId, serializedMessage) +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cd apps/core && npx vitest src/agent/ --run` +Expected: Existing tests pass, new integration test passes + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/agent/loop.ts apps/core/src/agent/loop-stream.test.ts +git commit -m "$(cat <<'EOF' +feat: stream agent loop messages to SessionStore + +Agent loop now appends all messages (user, assistant, tool calls, +results) to the session's messages.jsonl file for complete +history persistence. + +Co-Authored-By: Claude Opus 4.6 +EOF +)" +``` + +--- + +## Task 4: Interrupt Handling (Ctrl+C Signal) + +**Files:** +- Modify: `apps/core/src/server.ts` (add signal handler) +- Create: `apps/core/src/session/interrupt.ts` (interrupt detection and handling) +- Modify: `apps/core/src/agent/loop.ts` (call markInterrupted on signal) +- Test: `apps/core/src/session/interrupt.test.ts` + +- [ ] **Step 1: Write interrupt service** + +```typescript +// apps/core/src/session/interrupt.ts +import { signal } from 'process' + +export interface InterruptState { + sessionId: string | null + messageId: string | null + pending: boolean +} + +export class InterruptHandler { + private sessionId: string | null = null + private messageId: string | null = null + + setActive(sessionId: string, messageId: string): void { + this.sessionId = sessionId + this.messageId = messageId + } + + clear(): void { + this.sessionId = null + this.messageId = null + } + + getState(): InterruptState { + return { + sessionId: this.sessionId, + messageId: this.messageId, + pending: this.sessionId !== null, + } + } + + setupSignalHandler(onInterrupt: (sessionId: string, messageId: string) => void): void { + let lastSigInt = 0 + process.on('SIGINT', () => { + const now = Date.now() + if (now - lastSigInt < 1000) { + // Double Ctrl+C → force exit + process.exit(1) + } + lastSigInt = now + if (this.sessionId && this.messageId) { + onInterrupt(this.sessionId, this.messageId) + } + }) + } +} + +let globalHandler: InterruptHandler | null = null + +export function getInterruptHandler(): InterruptHandler { + if (!globalHandler) { + globalHandler = new InterruptHandler() + } + return globalHandler +} +``` + +- [ ] **Step 2: Integrate into server.ts** + +In `server.ts`, after session is started and during message streaming: + +```typescript +import { getInterruptHandler } from './session/interrupt' +import { getSessionManager } from './session/manager' + +// After session.start handler: +const handler = getInterruptHandler() +handler.setupSignalHandler(async (sessionId, messageId) => { + const manager = await getSessionManager() + await manager.markInterrupted(sessionId, messageId) +}) +``` + +- [ ] **Step 3: Wire into agent loop** + +In `agent/loop.ts`, after starting a message (when streaming begins): + +```typescript +import { getInterruptHandler } from '../session/interrupt' + +// When starting to stream an assistant message: +getInterruptHandler().setActive(sessionId, messageId) +``` + +- [ ] **Step 4: Write test** + +```typescript +// apps/core/src/session/interrupt.test.ts +import { describe, it, expect } from 'vitest' +import { InterruptHandler } from './interrupt' + +describe('InterruptHandler', () => { + it('tracks active session/message', () => { + const handler = new InterruptHandler() + expect(handler.getState().pending).toBe(false) + + handler.setActive('session-1', 'msg-1') + expect(handler.getState().pending).toBe(true) + expect(handler.getState().sessionId).toBe('session-1') + expect(handler.getState().messageId).toBe('msg-1') + + handler.clear() + expect(handler.getState().pending).toBe(false) + }) +}) +``` + +- [ ] **Step 5: Run tests and commit** + +Run: `cd apps/core && npx vitest src/session/interrupt.test.ts --run` +Commit: +```bash +git add apps/core/src/server.ts apps/core/src/session/interrupt.ts apps/core/src/session/interrupt.test.ts +git commit -m "$(cat <<'EOF' +feat: add Ctrl+C interrupt handling + +InterruptHandler tracks active session/message and marks +interrupted messages on double-Ctrl+C. Session resumes with +injected "Continue from where you left off." marker. + +Co-Authored-By: Claude Opus 4.6 +EOF +)" +``` + +--- + +## Task 5: Resume Picker (TUI Component) + +**Files:** +- Create: `apps/tui/src/components/resume-picker.tsx` +- Modify: `apps/tui/src/index.ts` (add resume command) +- Modify: `apps/tui/src/ipc/client.ts` (add session.list, session.resume calls) + +- [ ] **Step 1: Write resume picker component** + +```typescript +// apps/tui/src/components/resume-picker.tsx +import React, { useEffect, useState } from 'react' +import { Box, Text, Key } from 'ink' +import { SessionMeta } from '@freecode/shared' + +interface Props { + sessions: SessionMeta[] + onSelect: (sessionId: string) => void + onCancel: () => void +} + +export const ResumePicker: React.FC = ({ sessions, onSelect, onCancel }) => { + const [selectedIndex, setSelectedIndex] = useState(0) + + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + switch (e.key) { + case 'ArrowUp': + setSelectedIndex(i => Math.max(0, i - 1)) + break + case 'ArrowDown': + setSelectedIndex(i => Math.min(sessions.length - 1, i + 1)) + break + case 'Enter': + if (sessions[selectedIndex]) { + onSelect(sessions[selectedIndex].id) + } + break + case 'Escape': + case 'q': + onCancel() + break + } + } + process.stdin.on('keypress', handleKey) + return () => process.stdin.off('keypress', handleKey) + }, [selectedIndex, sessions, onSelect, onCancel]) + + return ( + + + Select a session to resume: + + {sessions.map((session, i) => ( + + + {i === selectedIndex ? '❯ ' : ' '} + + + {session.title} — {session.projectPath} + + ({session.turnCount} turns) + {session.status === 'interrupted' && ( + [Interrupted] + )} + + ))} + + ↑↓ navigate · Enter resume · Ctrl+C cancel + + + ) +} +``` + +- [ ] **Step 2: Modify IPC client** + +In `apps/tui/src/ipc/client.ts`, add session methods: + +```typescript +async listSessions(filter?: { status?: string; projectPath?: string }): Promise { + return this.call('session.list', filter ?? {}) +} + +async resumeSession(sessionId: string): Promise { + return this.call('session.resume', { sessionId }) +} + +async startSession(projectPath: string, provider?: string, title?: string): Promise { + return this.call('session.start', { projectPath, provider, title }) +} +``` + +- [ ] **Step 3: Add resume command to TUI** + +In `apps/tui/src/index.ts`, add command handler: + +```typescript +ipc.on('session.list', async (params: { status?: string; projectPath?: string }) => { + return await client.listSessions(params) +}) + +ipc.on('session.resume', async (params: { sessionId: string }) => { + return await client.resumeSession(params.sessionId) +}) + +// Detect interrupted session on startup +const interrupted = await client.getInterruptedSession() +if (interrupted) { + // Show prompt to resume +} +``` + +- [ ] **Step 4: Test in TUI** + +Run the TUI and verify: +1. On startup, if interrupted session exists, picker appears +2. `/resume` command opens picker +3. Keyboard navigation works +4. Selected session resumes correctly + +- [ ] **Step 5: Commit** + +```bash +git add apps/tui/src/components/resume-picker.tsx apps/tui/src/ipc/client.ts apps/tui/src/index.ts +git commit -m "$(cat <<'EOF' +feat: add resume picker to TUI + +ResumePicker component shows session list with keyboard +navigation. Auto-detects interrupted sessions on startup +and prompts user to resume. + +Co-Authored-By: Claude Opus 4.6 +EOF +)" +``` + +--- + +## Task 6: Session IPC Handlers (server.ts) + +**Files:** +- Modify: `apps/core/src/server.ts` +- Read first: `apps/core/src/server.ts` (full file) + +- [ ] **Step 1: Add session handlers** + +In `server.ts`, add handlers for session operations. Read the existing file first, then add: + +```typescript +// After existing session handlers, add: +import { getSessionManager } from './session/manager' +import { createSessionStore } from './session/store' +import { getInterruptHandler } from './session/interrupt' +import { getFreeCodeDir } from './utils/freecode-dir' + +// Initialize session store and manager at startup +let sessionStore: ReturnType | null = null +let sessionManager: ReturnType | null = null + +async function getSessionStore() { + if (!sessionStore) { + const baseDir = getFreeCodeDir() + sessionStore = await createSessionStore(baseDir) + } + return sessionStore +} + +async function getSM() { + if (!sessionManager) { + const store = await getSessionStore() + sessionManager = await createSessionManager(store) + } + return sessionManager +} + +// Add to methodHandlers: +'session.list': async (params) => { + const manager = await getSM() + return manager.list(params) +}, +'session.resume': async (params) => { + const manager = await getSM() + return manager.resume(params.sessionId) +}, +'session.fork': async (params) => { + const manager = await getSM() + return manager.fork(params.sessionId, params.point) +}, +'session.archive': async (params) => { + const manager = await getSM() + await manager.archive(params.sessionId) +}, +'session.delete': async (params) => { + const manager = await getSM() + await manager.delete(params.sessionId) +}, +'session.export': async (params) => { + const manager = await getSM() + return manager.export(params.sessionId) +}, +'session.import': async (params) => { + const manager = await getSM() + return manager.import(params.url) +}, +``` + +- [ ] **Step 2: Add helper to get interrupted session** + +```typescript +'session.getInterrupted': async () => { + const store = await getSessionStore() + return store.getInterruptedSession() +} +``` + +- [ ] **Step 3: Wire interrupt handler setup** + +After session is started, setup signal handler: + +```typescript +'session.start': async (params) => { + const manager = await getSM() + const sessionId = await manager.start(params.projectPath, params.provider, params.title) + // Setup interrupt handler for this session + getInterruptHandler().setupSignalHandler(async (sid, mid) => { + const mgr = await getSM() + await mgr.markInterrupted(sid, mid) + }) + return { sessionId } +} +``` + +- [ ] **Step 4: Test all IPC handlers** + +Create integration test file `apps/core/src/server.session.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from 'vitest' +import { rm } from 'fs/promises' +import { JsonRpcServer } from './server' + +describe('Session IPC handlers', () => { + // Test that JSON-RPC requests for session.* methods work correctly + // Mock stdin/stdout and send JSON-RPC requests directly +}) +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/server.ts +git commit -m "$(cat <<'EOF' +feat: add session IPC handlers to server + +Server now handles session.list, session.resume, session.fork, +session.archive, session.delete, session.export, session.import, +and session.getInterrupted via JSON-RPC. + +Co-Authored-By: Claude Opus 4.6 +EOF +)" +``` + +--- + +## Task 7: Remote Session Sync (URL Export/Import) + +**Files:** +- Modify: `apps/core/src/store/remote.ts` (existing, add URL-based export/import) +- Modify: `apps/core/src/session/manager.ts` (add export/import methods) +- Test: `apps/core/src/session/remote.test.ts` + +- [ ] **Step 1: Read existing remote.ts** + +Read `apps/core/src/store/remote.ts` to understand `RemoteSessionSync` class. + +- [ ] **Step 2: Write test for URL export/import** + +```typescript +// apps/core/src/session/remote.test.ts +import { describe, it, expect, beforeEach } from 'vitest' +import { rm } from 'fs/promises' +import { createSessionStore } from './store' + +describe('Session URL export/import', () => { + const testDir = '/tmp/freecode-test-remote' + let store: Awaited> + + beforeEach(async () => { + await rm(testDir, { recursive: true, force: true }) + store = await createSessionStore(testDir) + }) + + it('serializes session for export', async () => { + const sessionId = await store.createSession({ + title: 'Export Test', + projectPath: '/tmp/test', + provider: 'claude', + }) + await store.appendMessage(sessionId, { + id: 'msg-1', + role: 'user', + parts: [{ type: 'text', content: 'hello' }], + timestamp: Date.now(), + }) + const meta = await store.getMeta(sessionId) + const messages = await store.getMessages(sessionId) + // Export should produce a serializable object + const exported = { meta, messages } + expect(exported.meta.id).toBe(sessionId) + expect(exported.messages).toHaveLength(1) + }) + + it('deserializes and creates new session', async () => { + const sessionId = await store.createSession({ + title: 'Import Source', + projectPath: '/tmp/test', + provider: 'claude', + }) + const originalMeta = await store.getMeta(sessionId) + const originalMessages = await store.getMessages(sessionId) + + // Simulate import: create new session from exported data + const newId = await store.createSession({ + title: originalMeta.title + ' (imported)', + projectPath: originalMeta.projectPath, + provider: originalMeta.provider, + }) + for (const msg of originalMessages) { + await store.appendMessage(newId, msg) + } + const newMessages = await store.getMessages(newId) + expect(newMessages).toHaveLength(originalMessages.length) + expect(newId).not.toBe(sessionId) + }) +}) +``` + +- [ ] **Step 3: Add export/import to SessionManager** + +In `apps/core/src/session/manager.ts`, add: + +```typescript +async export(sessionId: string): Promise<{ url: string; expiresAt: number }> { + const meta = await this.sessionStore.getMeta(sessionId) + if (!meta) throw new Error('Session not found') + const messages = await this.sessionStore.getMessages(sessionId) + const payload = JSON.stringify({ meta, messages }) + + // POST to sync endpoint (configurable via config.json) + const endpoint = getConfig().syncEndpoint ?? 'https://sync.freecode.dev' + const response = await fetch(`${endpoint}/upload`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: payload, + }) + if (!response.ok) throw new Error('Export failed') + const result = await response.json() as { url: string; expiresAt: number } + return result +} + +async import(url: string): Promise { + const response = await fetch(url) + if (!response.ok) throw new Error('Import failed') + const { meta, messages } = await response.json() as { meta: SessionMeta; messages: SerializedMessage[] } + + const newId = await this.sessionStore.createSession({ + title: meta.title + ' (imported)', + projectPath: meta.projectPath, + provider: meta.provider, + model: meta.model, + }) + for (const msg of messages) { + await this.sessionStore.appendMessage(newId, msg) + } + await this.sessionStore.updateMeta(newId, { status: 'imported' as const }) + return newId +} +``` + +- [ ] **Step 4: Run tests and commit** + +Run: `cd apps/core && npx vitest src/session/remote.test.ts --run` +Commit: +```bash +git add apps/core/src/session/manager.ts apps/core/src/session/remote.test.ts +git commit -m "$(cat <<'EOF' +feat: add URL-based session export/import + +Session export serializes meta+messages and POSTs to sync +endpoint. Session import GETs URL and creates local session. +Enables sharing sessions via short URLs. + +Co-Authored-By: Claude Opus 4.6 +EOF +)" +``` + +--- + +## Self-Review Checklist + +1. **Spec coverage**: All v1 items covered? + - [x] Session storage at `~/.freecode/sessions/` → Task 1 + - [x] JSONL message streaming → Task 1 + Task 3 + - [x] Interrupt handling (Ctrl+C) → Task 4 + - [x] Resume with complete chat history → Task 2 + - [x] Resume picker → Task 5 + - [x] Session list/fork/archive/delete → Tasks 2 + 6 + - [x] URL-based export/import → Task 7 + +2. **Placeholder scan**: No TODOs, no "TBD", no "implement later" + +3. **Type consistency**: + - `SessionMeta.status`: `'active' | 'interrupted' | 'archived' | 'deleted'` — used consistently + - `SerializedMessage` structure matches between store.ts and manager.ts + - `session.list` filter uses same property names as `SessionMeta` + +4. **File paths**: All exact, no relative paths +5. **Commands**: All test commands use exact paths with `npx vitest` +6. **Code blocks**: Every step that changes code has the actual code + +--- + +## Execution + +**Plan complete and saved to `docs/superpowers/plans/2026-06-02-memory-session-plan.md`.** + +Two execution options: + +**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints + +Which approach? \ No newline at end of file From 5d2dbe7d0159154a629a95b388479cb0cf8e18cc Mon Sep 17 00:00:00 2001 From: Ayan De Date: Tue, 2 Jun 2026 19:18:13 +0530 Subject: [PATCH 41/49] feat: memory system + fix truncate error --- .gitignore | 4 + .../core/src/agent/loop-session-store.test.ts | 123 +++++++ apps/core/src/agent/loop.ts | 85 ++++- apps/core/src/server.ts | 58 +++- apps/core/src/session/interrupt.test.ts | 16 + apps/core/src/session/interrupt.ts | 57 ++++ apps/core/src/session/manager.test.ts | 191 +++++++++++ apps/core/src/session/manager.ts | 307 ++++++++---------- apps/core/src/session/store.test.ts | 129 ++++++++ apps/core/src/session/store.ts | 235 ++++++++++++++ apps/tui/src/commands/built-in.ts | 11 + apps/tui/src/commands/index.ts | 1 + apps/tui/src/components/index.ts | 31 ++ apps/tui/src/components/message-row.ts | 40 ++- apps/tui/src/components/resume-picker.tsx | 74 +++++ .../src/components/tool-progress-message.ts | 9 +- .../tui/src/components/tool-result-message.ts | 11 +- apps/tui/src/index.ts | 83 +++++ apps/tui/src/ipc/client.ts | 46 +++ 19 files changed, 1312 insertions(+), 199 deletions(-) create mode 100644 apps/core/src/agent/loop-session-store.test.ts create mode 100644 apps/core/src/session/interrupt.test.ts create mode 100644 apps/core/src/session/interrupt.ts create mode 100644 apps/core/src/session/manager.test.ts create mode 100644 apps/core/src/session/store.test.ts create mode 100644 apps/core/src/session/store.ts create mode 100644 apps/tui/src/components/resume-picker.tsx diff --git a/.gitignore b/.gitignore index 0d8e8ee0..c5ebb22d 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,7 @@ yarn-error.log* # Worktrees .worktrees + +# FreeCode runtime data +.freecode/ +apps/*/.freecode/ diff --git a/apps/core/src/agent/loop-session-store.test.ts b/apps/core/src/agent/loop-session-store.test.ts new file mode 100644 index 00000000..4f459473 --- /dev/null +++ b/apps/core/src/agent/loop-session-store.test.ts @@ -0,0 +1,123 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { rm } from "fs/promises" +import { createAgentLoop } from "./loop" +import { createSessionStore, type SessionStore } from "../session/store" + +test("AgentLoop accepts sessionStore in constructor config", async () => { + const testDir = "/tmp/freecode-test-loop-session-store" + await rm(testDir, { recursive: true, force: true }) + const store: SessionStore = await createSessionStore(testDir) + + const sessionId = await store.createSession({ + title: "Test", + projectPath: "/tmp/test", + provider: "mock", + }) + + const loop = createAgentLoop(sessionId, { sessionStore: store }) + + // Access private field via any cast for testing + assert.equal((loop as any).sessionStore, store) + + await rm(testDir, { recursive: true, force: true }) +}) + +test("AgentLoop sessionStore is optional", async () => { + const loop = createAgentLoop("test-session-id") + assert.equal((loop as any).sessionStore, undefined) +}) + +test("AgentLoop appendToolMessage creates correct serialized message structure", async () => { + const testDir = "/tmp/freecode-test-loop-session-store2" + await rm(testDir, { recursive: true, force: true }) + const store: SessionStore = await createSessionStore(testDir) + + const sessionId = await store.createSession({ + title: "Test", + projectPath: "/tmp/test", + provider: "mock", + }) + + const loop = createAgentLoop(sessionId, { sessionStore: store }) + + const toolCall = { + id: "tool-1", + tool: "read", + args: { path: "/tmp/test.txt" }, + execution: "sequential" as const, + } + const result = { + id: "result-1", + toolCallId: "tool-1", + tool: "read", + title: "Read file", + stdout: "file contents", + } + + await (loop as any).appendToolMessage(toolCall, result) + + const messages = await store.getMessages(sessionId) + assert.equal(messages.length, 1) + assert.equal(messages[0].role, "assistant") + assert.equal(messages[0].parts[0].type, "tool") + assert.equal(messages[0].parts[0].tool?.name, "read") + assert.equal(messages[0].parts[0].result, "file contents") + + await rm(testDir, { recursive: true, force: true }) +}) + +test("AgentLoop appendUserMessage creates correct serialized message structure", async () => { + const testDir = "/tmp/freecode-test-loop-session-store3" + await rm(testDir, { recursive: true, force: true }) + const store: SessionStore = await createSessionStore(testDir) + + const sessionId = await store.createSession({ + title: "Test", + projectPath: "/tmp/test", + provider: "mock", + }) + + const loop = createAgentLoop(sessionId, { sessionStore: store }) + + await (loop as any).appendUserMessage("Hello world") + + const messages = await store.getMessages(sessionId) + assert.equal(messages.length, 1) + assert.equal(messages[0].role, "user") + assert.equal(messages[0].parts[0].type, "text") + assert.equal(messages[0].parts[0].content, "Hello world") + + await rm(testDir, { recursive: true, force: true }) +}) + +test("AgentLoop appendAssistantMessage creates correct serialized message structure", async () => { + const testDir = "/tmp/freecode-test-loop-session-store4" + await rm(testDir, { recursive: true, force: true }) + const store: SessionStore = await createSessionStore(testDir) + + const sessionId = await store.createSession({ + title: "Test", + projectPath: "/tmp/test", + provider: "mock", + }) + + const loop = createAgentLoop(sessionId, { sessionStore: store }) + + await (loop as any).appendAssistantMessage("I can help with that") + + const messages = await store.getMessages(sessionId) + assert.equal(messages.length, 1) + assert.equal(messages[0].role, "assistant") + assert.equal(messages[0].parts[0].type, "text") + assert.equal(messages[0].parts[0].content, "I can help with that") + + await rm(testDir, { recursive: true, force: true }) +}) + +test("AgentLoop does not throw when sessionStore is undefined", async () => { + const loop = createAgentLoop("test-session-id") + // Should not throw + await (loop as any).appendUserMessage("Hello") + await (loop as any).appendAssistantMessage("Hi") +}) diff --git a/apps/core/src/agent/loop.ts b/apps/core/src/agent/loop.ts index dfba9140..ca04e740 100644 --- a/apps/core/src/agent/loop.ts +++ b/apps/core/src/agent/loop.ts @@ -6,6 +6,9 @@ // FLOW: Build Prompt → Send to AI → Normalize → Parse → Execute Tool → Loop // ============================================================================= +import * as path from "path" +import * as os from "os" +import { randomUUID } from "crypto" import type { SessionState, ToolCall, @@ -27,6 +30,20 @@ import type { HookResult } from "../agent/types.js" import { bus, BusEvents } from "../bus/index.js" import { createRecorder, type RolloutRecorder } from "../rollout/recorder.js" import { loadProviderPrompt } from "../session/prompt.js" +import { createSessionStore, type SessionStore, type SerializedMessage } from "../session/store.js" +import { getInterruptHandler } from "../session/interrupt.js" + +const SESSION_DIR = ".freecode" + +let globalSessionStore: SessionStore | null = null + +async function getSessionStore(): Promise { + if (!globalSessionStore) { + const baseDir = path.join(os.homedir(), SESSION_DIR) + globalSessionStore = await createSessionStore(baseDir) + } + return globalSessionStore +} const orchestrator = createToolOrchestrator() @@ -45,6 +62,7 @@ export class AgentLoop { private memory: MemoryService private hooks: HookRuntime private recorder: RolloutRecorder + private sessionStore: SessionStore | undefined private onToolEvent: ((event: StreamEvent) => void) | undefined // Loop health tracking state private recentToolCalls: Array<{ tool: string; args: string }> = [] @@ -52,7 +70,7 @@ export class AgentLoop { private lastFileStates: string[] = [] private fileStateHash: string = "" - constructor(sessionId: string, config?: { maxIterations?: number; heuristics?: Partial; hooks?: HookRuntime; recorder?: RolloutRecorder }) { + constructor(sessionId: string, config?: { maxIterations?: number; heuristics?: Partial; hooks?: HookRuntime; recorder?: RolloutRecorder; sessionStore?: SessionStore }) { this.state = createInitialSessionState(sessionId) this.config = { maxIterations: config?.maxIterations ?? 100, @@ -61,6 +79,7 @@ export class AgentLoop { this.memory = new MemoryService(sessionId) this.hooks = config?.hooks ?? createHookRuntime() this.recorder = config?.recorder ?? createRecorder(sessionId) + this.sessionStore = config?.sessionStore } // =========================================================================== @@ -215,6 +234,9 @@ ${memoryContext ? `Session context:\n${memoryContext}\n\n` : ""}Task: ${prompt}` if (toolCalls.length === 0) { this.memory.addMessage("user", prompt) this.memory.addMessage("assistant", providerResult.content) + // Also append to session store + await this.appendUserMessage(prompt) + await this.appendAssistantMessage(providerResult.content) if (this.memory.shouldCompact(provider)) { // PreCompact Hook — can inspect/modify context before compaction const preHookCtx: HookContext = { sessionId: this.state.sessionId, turnCount: this.state.turnCount } @@ -240,11 +262,16 @@ ${memoryContext ? `Session context:\n${memoryContext}\n\n` : ""}Task: ${prompt}` toolResults.push(result) // Update loop health tracking after each tool execution this.updateLoopHealth(toolCall, result) + // Append tool result to session store + await this.appendToolMessage(toolCall, result) } // Record messages and check for compaction after tool execution this.memory.addMessage("user", prompt) this.memory.addMessage("assistant", providerResult.content) + // Also append to session store + await this.appendUserMessage(prompt) + await this.appendAssistantMessage(providerResult.content) if (this.memory.shouldCompact(provider)) { // PreCompact Hook — can inspect/modify context before compaction @@ -598,7 +625,13 @@ Based on this task, which files do you need to read to understand the codebase a } // Emit tool_output with last 5 lines of stdout - const outputLines = (result.stdout || "").split("\n").filter(l => l.trim()).slice(-5) + // Truncate each line to 200 chars to prevent terminal overflow + const MAX_LINE_LEN = 200 + const outputLines = (result.stdout || "") + .split("\n") + .filter(l => l.trim()) + .slice(-5) + .map(line => line.length > MAX_LINE_LEN ? line.slice(0, MAX_LINE_LEN) + "..." : line) this.onToolEvent?.({ type: "tool_output", toolCallId: toolCall.id, @@ -808,6 +841,52 @@ Based on this task, which files do you need to read to understand the codebase a } } + // =========================================================================== + // PRIVATE: Session Store Helpers + // Append messages to session store for persistence + // =========================================================================== + + private async appendUserMessage(content: string): Promise { + if (!this.sessionStore) return + const message: SerializedMessage = { + id: randomUUID(), + role: "user", + parts: [{ type: "text", content }], + timestamp: Date.now(), + } + await this.sessionStore.appendMessage(this.state.sessionId, message) + } + + private async appendAssistantMessage(content: string): Promise { + if (!this.sessionStore) return '' + const id = randomUUID() + const message: SerializedMessage = { + id, + role: "assistant", + parts: [{ type: "text", content }], + timestamp: Date.now(), + } + await this.sessionStore.appendMessage(this.state.sessionId, message) + // Set this message as the interrupt target so Ctrl+C marks it + getInterruptHandler().setActive(this.state.sessionId, id) + return id + } + + private async appendToolMessage(toolCall: ToolCall, result: ToolResult): Promise { + if (!this.sessionStore) return + const message: SerializedMessage = { + id: randomUUID(), + role: "assistant", + parts: [{ + type: "tool", + tool: { name: toolCall.tool, args: toolCall.args as Record }, + result: result.stdout || result.error || "", + }], + timestamp: Date.now(), + } + await this.sessionStore.appendMessage(this.state.sessionId, message) + } + // =========================================================================== // PUBLIC: getState() / interrupt() // Accessors for external monitoring/control @@ -826,7 +905,7 @@ Based on this task, which files do you need to read to understand the codebase a // ============================================================================= export const createAgentLoop = ( sessionId: string, - config?: { maxIterations?: number; heuristics?: Partial; hooks?: HookRuntime; recorder?: RolloutRecorder } + config?: { maxIterations?: number; heuristics?: Partial; hooks?: HookRuntime; recorder?: RolloutRecorder; sessionStore?: SessionStore } ): AgentLoop => { return new AgentLoop(sessionId, config) } diff --git a/apps/core/src/server.ts b/apps/core/src/server.ts index 39cf111d..f9de8db0 100644 --- a/apps/core/src/server.ts +++ b/apps/core/src/server.ts @@ -15,7 +15,22 @@ import { getMemoryStore, type MemoryEntry, type MemoryType } from "./memory/inde import { findRelevantMemories } from "./memory/mem-query.js"; import { buildMemoryPrompt } from "./memory/mem-prompt.js"; import { getSessionManager, type SessionContext } from "./session/index.js"; +import { createSessionStore, type SessionStore } from "./session/store.js"; import { getRemoteSync, type ExportedSession, type RemoteSessionConfig } from "./store/index.js"; +import { getInterruptHandler } from "./session/interrupt.js"; +import { homedir } from "os"; +import { join } from "path"; + +const SESSION_BASE_DIR = join(homedir(), ".freecode"); + +let sessionStore: SessionStore | null = null; + +async function getSessionStore(): Promise { + if (!sessionStore) { + sessionStore = await createSessionStore(SESSION_BASE_DIR); + } + return sessionStore; +} interface ToolListItem { id: string; @@ -96,6 +111,17 @@ const methodHandlers: Record< const config = params as unknown as SessionConfig; const session = createSession(config); logger.info("Session started", { sessionId: session.id, provider: session.provider }); + + // Persist session to ~/.freecode/sessions/ via SessionStore + const store = await getSessionStore(); + // Use the same session ID that was created in createSession() + await store.createSession({ + title: config.title || `Session ${session.id}`, + projectPath: session.projectPath, + provider: session.provider, + model: session.model, + }, session.id); + return { sessionId: session.id }; }, @@ -123,7 +149,10 @@ const methodHandlers: Record< process.stdout.write(JSON.stringify(event) + "\n"); }; - const loop = createAgentLoop(sessionId, { maxIterations: 100 }) + // Get session store for persisting messages + const store = await getSessionStore(); + + const loop = createAgentLoop(sessionId, { maxIterations: 100, sessionStore: store }) const result = await loop.run({ prompt: message, sessionId, @@ -241,9 +270,9 @@ const methodHandlers: Record< }, "session.fork": async (params: Record): Promise => { - const { sessionId, point } = params as { sessionId: string; point?: string } + const { sessionId } = params as { sessionId: string } const manager = await getSessionManager() - return manager.fork(sessionId, point) + return manager.fork(sessionId) }, "session.archive": async (params: Record): Promise => { @@ -258,6 +287,11 @@ const methodHandlers: Record< await manager.delete(sessionId) }, + "session.getInterrupted": async (): Promise<{ sessionId: string; messageId: string } | null> => { + const store = await getSessionStore() + return store.getInterruptedSession() + }, + // ========== Remote Sync Methods ========== "session.export": async (params: Record): Promise => { @@ -266,6 +300,13 @@ const methodHandlers: Record< return remoteSync.exportSession(sessionId) }, + "session.import": async (params: Record): Promise<{ sessionId: string }> => { + const { url } = params as { url: string } + const manager = await getSessionManager() + const sessionId = await manager.import(url) + return { sessionId } + }, + "session.upload": async (params: Record): Promise => { const { sessionId, endpoint, apiKey } = params as { sessionId: string; endpoint: string; apiKey?: string } const remoteSync = await getRemoteSync() @@ -296,6 +337,17 @@ async function handleRequest(request: JsonRpcRequest): Promise async function main() { await initProviders(); + // Set up Ctrl+C interrupt handler for session resumption + const handler = getInterruptHandler() + handler.setupSignalHandler(async (sessionId: string, messageId: string) => { + try { + const manager = await getSessionManager() + await manager.markInterrupted(sessionId, messageId) + } catch (e) { + // Ignore errors during interrupt handling + } + }) + let buffer = ""; process.stdin.setEncoding("utf-8"); diff --git a/apps/core/src/session/interrupt.test.ts b/apps/core/src/session/interrupt.test.ts new file mode 100644 index 00000000..7faa7a55 --- /dev/null +++ b/apps/core/src/session/interrupt.test.ts @@ -0,0 +1,16 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { InterruptHandler } from './interrupt' + +test('InterruptHandler tracks active session/message', () => { + const handler = new InterruptHandler() + assert.strictEqual(handler.getState().pending, false) + + handler.setActive('session-1', 'msg-1') + assert.strictEqual(handler.getState().pending, true) + assert.strictEqual(handler.getState().sessionId, 'session-1') + assert.strictEqual(handler.getState().messageId, 'msg-1') + + handler.clear() + assert.strictEqual(handler.getState().pending, false) +}) \ No newline at end of file diff --git a/apps/core/src/session/interrupt.ts b/apps/core/src/session/interrupt.ts new file mode 100644 index 00000000..bed68c88 --- /dev/null +++ b/apps/core/src/session/interrupt.ts @@ -0,0 +1,57 @@ +// ============================================================================= +// Interrupt Handler - Ctrl+C signal handling for session interruption +// PRIMARY: Marks current message as interrupted on single Ctrl+C, force exits on double +// ============================================================================= + +export interface InterruptState { + sessionId: string | null + messageId: string | null + pending: boolean +} + +export class InterruptHandler { + private sessionId: string | null = null + private messageId: string | null = null + + setActive(sessionId: string, messageId: string): void { + this.sessionId = sessionId + this.messageId = messageId + } + + clear(): void { + this.sessionId = null + this.messageId = null + } + + getState(): InterruptState { + return { + sessionId: this.sessionId, + messageId: this.messageId, + pending: this.sessionId !== null, + } + } + + setupSignalHandler(onInterrupt: (sessionId: string, messageId: string) => void): void { + let lastSigInt = 0 + process.on('SIGINT', () => { + const now = Date.now() + if (now - lastSigInt < 1000) { + // Double Ctrl+C → force exit + process.exit(1) + } + lastSigInt = now + if (this.sessionId && this.messageId) { + onInterrupt(this.sessionId, this.messageId) + } + }) + } +} + +let globalHandler: InterruptHandler | null = null + +export function getInterruptHandler(): InterruptHandler { + if (!globalHandler) { + globalHandler = new InterruptHandler() + } + return globalHandler +} \ No newline at end of file diff --git a/apps/core/src/session/manager.test.ts b/apps/core/src/session/manager.test.ts new file mode 100644 index 00000000..c20b7170 --- /dev/null +++ b/apps/core/src/session/manager.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { rm } from 'fs/promises' +import * as os from 'os' +import * as path from 'path' +import { SessionManager, createSessionManager } from './manager' +import { createSessionStore, type SessionStore } from './store' + +describe('SessionManager', () => { + const testDir = path.join(os.tmpdir(), 'freecode-test-session-manager') + let sessionStore: SessionStore + let manager: SessionManager + + beforeEach(async () => { + await rm(testDir, { recursive: true, force: true }) + sessionStore = await createSessionStore(testDir) + manager = createSessionManager(sessionStore) + }) + + describe('start', () => { + it('creates a new session and returns session id', async () => { + const sessionId = await manager.start('/tmp/test-project', 'claude', 'Test Session') + expect(sessionId).toBeDefined() + expect(typeof sessionId).toBe('string') + }) + + it('stores session metadata via sessionStore', async () => { + const sessionId = await manager.start('/tmp/test-project', 'claude', 'Test Session') + const meta = await sessionStore.getMeta(sessionId) + expect(meta).not.toBeNull() + expect(meta!.title).toBe('Test Session') + expect(meta!.projectPath).toBe('/tmp/test-project') + expect(meta!.provider).toBe('claude') + expect(meta!.status).toBe('active') + }) + }) + + describe('resume', () => { + it('loads session context with messages', async () => { + const sessionId = await manager.start('/tmp/test-project', 'claude', 'Test Session') + await manager.appendMessage(sessionId, { + id: 'msg-1', + role: 'user', + parts: [{ type: 'text', content: 'Hello' }], + timestamp: Date.now(), + }) + + const ctx = await manager.resume(sessionId) + expect(ctx.id).toBe(sessionId) + expect(ctx.title).toBe('Test Session') + expect(ctx.messages).toHaveLength(1) + expect(ctx.messages[0].parts[0].content).toBe('Hello') + }) + + it('injects resume marker when session is interrupted', async () => { + const sessionId = await manager.start('/tmp/test-project', 'claude', 'Test Session') + const msgId = 'msg-interrupted' + await manager.appendMessage(sessionId, { + id: msgId, + role: 'assistant', + parts: [], + timestamp: Date.now(), + }) + await manager.markInterrupted(sessionId, msgId) + + const ctx = await manager.resume(sessionId) + // Should have original message + injected resume message + const lastMsg = ctx.messages[ctx.messages.length - 1] + expect(lastMsg.role).toBe('user') + expect(lastMsg.parts[0].content).toBe('Continue from where you left off.') + }) + }) + + describe('appendMessage', () => { + it('appends message to session via sessionStore', async () => { + const sessionId = await manager.start('/tmp/test-project', 'claude') + await manager.appendMessage(sessionId, { + id: 'msg-1', + role: 'user', + parts: [{ type: 'text', content: 'Test' }], + timestamp: Date.now(), + }) + + const messages = await sessionStore.getMessages(sessionId) + expect(messages).toHaveLength(1) + expect(messages[0].parts[0].content).toBe('Test') + }) + }) + + describe('markInterrupted', () => { + it('marks last message as interrupted', async () => { + const sessionId = await manager.start('/tmp/test-project', 'claude') + const msgId = 'msg-1' + await manager.appendMessage(sessionId, { + id: msgId, + role: 'assistant', + parts: [], + timestamp: Date.now(), + }) + + await manager.markInterrupted(sessionId, msgId) + const messages = await sessionStore.getMessages(sessionId) + expect(messages[0].interrupted).toBe(true) + + const meta = await sessionStore.getMeta(sessionId) + expect(meta!.status).toBe('interrupted') + }) + }) + + describe('list', () => { + it('returns all sessions from sessionStore', async () => { + await manager.start('/tmp/p1', 'claude', 'Session 1') + await manager.start('/tmp/p2', 'claude', 'Session 2') + + const sessions = await manager.list() + expect(sessions).toHaveLength(2) + }) + + it('filters by projectPath', async () => { + await manager.start('/tmp/p1', 'claude', 'Session 1') + await manager.start('/tmp/p2', 'claude', 'Session 2') + + const sessions = await manager.list({ projectPath: '/tmp/p1' }) + expect(sessions).toHaveLength(1) + expect(sessions[0].title).toBe('Session 1') + }) + + it('filters by status', async () => { + const s1 = await manager.start('/tmp/p1', 'claude', 'Session 1') + await manager.start('/tmp/p2', 'claude', 'Session 2') + await manager.archive(s1) + + const active = await manager.list({ status: 'active' }) + expect(active).toHaveLength(1) + expect(active[0].title).toBe('Session 2') + }) + }) + + describe('archive', () => { + it('archives session via sessionStore', async () => { + const sessionId = await manager.start('/tmp/test-project', 'claude') + await manager.archive(sessionId) + + const meta = await sessionStore.getMeta(sessionId) + expect(meta!.status).toBe('archived') + }) + }) + + describe('delete', () => { + it('deletes session via sessionStore', async () => { + const sessionId = await manager.start('/tmp/test-project', 'claude') + await manager.delete(sessionId) + + // After delete, session should be marked as deleted in store + const meta = await sessionStore.getMeta(sessionId) + expect(meta!.status).toBe('deleted') + }) + }) + + describe('fork', () => { + it('forks session via sessionStore', async () => { + const sessionId = await manager.start('/tmp/test-project', 'claude', 'Parent') + await manager.appendMessage(sessionId, { + id: 'msg-1', + role: 'user', + parts: [{ type: 'text', content: 'Hello' }], + timestamp: Date.now(), + }) + + const forkId = await manager.fork(sessionId) + expect(forkId).not.toBe(sessionId) + + const forkMeta = await sessionStore.getMeta(forkId) + expect(forkMeta!.parentId).toBe(sessionId) + expect(forkMeta!.title).toBe('Parent (fork)') + + const forkMessages = await sessionStore.getMessages(forkId) + expect(forkMessages).toHaveLength(1) + }) + }) + + describe('switch', () => { + it('sets current session without loading', async () => { + const sessionId = await manager.start('/tmp/test-project', 'claude') + await manager.switch(sessionId) + + const current = await manager.getCurrent() + expect(current).not.toBeNull() + expect(current!.id).toBe(sessionId) + }) + }) +}) \ No newline at end of file diff --git a/apps/core/src/session/manager.ts b/apps/core/src/session/manager.ts index 7773cee2..9ab9f375 100644 --- a/apps/core/src/session/manager.ts +++ b/apps/core/src/session/manager.ts @@ -3,31 +3,15 @@ // PRIMARY: Start, resume, switch, fork, list sessions // ============================================================================= -import * as fs from "fs" import * as path from "path" import * as os from "os" +import * as fs from "fs" import { randomUUID } from "crypto" -import { getThreadStore, createThreadStoreService, type ThreadStoreService } from "../store/thread-store" -import type { StoredThread, ThreadFilter } from "../store/types" +import { createSessionStore, type SessionStore, type SessionMeta, type SerializedMessage } from "./store" +import { createThreadStoreService, type ThreadStoreService } from "../store/thread-store" +import { CONFIG_FILE } from "../providers/config.js" const SESSION_DIR = ".freecode" -const SESSIONS_DIR = "sessions" - -function getSessionsDir(): string { - return path.join(os.homedir(), SESSION_DIR, SESSIONS_DIR) -} - -function getSessionDir(sessionId: string): string { - return path.join(getSessionsDir(), sessionId) -} - -function getMessagesPath(sessionId: string): string { - return path.join(getSessionDir(sessionId), "messages.jsonl") -} - -function getMetadataPath(sessionId: string): string { - return path.join(getSessionDir(sessionId), "metadata.json") -} // ============================================================================= // Session Context @@ -38,7 +22,7 @@ export interface SessionContext { title: string projectPath: string provider: string - status: "active" | "archived" | "deleted" + status: "active" | "interrupted" | "archived" | "deleted" createdAt: number updatedAt: number lastTurnAt: number @@ -47,54 +31,35 @@ export interface SessionContext { messages: SerializedMessage[] } -export interface SerializedMessage { - id: string - role: "user" | "assistant" - content: string - timestamp: number -} - // ============================================================================= // SessionManager // ============================================================================= export class SessionManager { - private store: ThreadStoreService + private sessionStore: SessionStore + private threadStore: ThreadStoreService | undefined private currentSessionId: string | null = null - constructor(store: ThreadStoreService) { - this.store = store + constructor(sessionStore: SessionStore, threadStore?: ThreadStoreService) { + this.sessionStore = sessionStore + this.threadStore = threadStore } // Start a new session async start(projectPath: string, provider: string, title?: string): Promise { - const sessionId = randomUUID() const sessionTitle = title || `Session ${new Date().toLocaleDateString()}` - // Create session directory - const sessionDir = getSessionDir(sessionId) - fs.mkdirSync(sessionDir, { recursive: true }) - - // Create thread in store - const threadId = await this.store.create(sessionTitle, projectPath, provider) - - // Save session metadata - const metadata = { - id: sessionId, - threadId, + // Use SessionStore to create the session + const sessionId = await this.sessionStore.createSession({ title: sessionTitle, projectPath, provider, - status: "active" as const, - createdAt: Date.now(), - updatedAt: Date.now(), - lastTurnAt: Date.now(), - turnCount: 0, - } - fs.writeFileSync(getMetadataPath(sessionId), JSON.stringify(metadata, null, 2)) + }) - // Initialize empty messages file - fs.writeFileSync(getMessagesPath(sessionId), "", "utf-8") + // Also create thread in ThreadStore for structured queries (if provided) + if (this.threadStore) { + await this.threadStore.create(sessionTitle, projectPath, provider) + } this.currentSessionId = sessionId return sessionId @@ -102,172 +67,160 @@ export class SessionManager { // Resume an existing session async resume(sessionId: string): Promise { - const metadata = this.loadMetadata(sessionId) - if (!metadata) { + const meta = await this.sessionStore.getMeta(sessionId) + if (!meta) { throw new Error(`Session not found: ${sessionId}`) } - const messages = this.loadMessages(sessionId) + let messages = await this.sessionStore.getMessages(sessionId) + + // Detect interrupted state → inject resume marker + if (meta.status === "interrupted") { + const lastMsg = messages[messages.length - 1] + if (lastMsg?.interrupted) { + const resumeMsg: SerializedMessage = { + id: randomUUID(), + role: "user", + parts: [{ type: "text", content: "Continue from where you left off." }], + timestamp: Date.now(), + } + messages = [...messages, resumeMsg] + } + } + this.currentSessionId = sessionId return { - id: sessionId, - title: metadata.title, - projectPath: metadata.projectPath, - provider: metadata.provider, - status: metadata.status, - createdAt: metadata.createdAt, - updatedAt: metadata.updatedAt, - lastTurnAt: metadata.lastTurnAt, - turnCount: metadata.turnCount, - parentId: metadata.parentId, + id: meta.id, + title: meta.title, + projectPath: meta.projectPath, + provider: meta.provider, + status: meta.status, + createdAt: meta.createdAt, + updatedAt: meta.updatedAt, + lastTurnAt: meta.lastTurnAt, + turnCount: meta.turnCount, + parentId: meta.parentId, messages, } } // Switch to a different session (make it current) async switch(sessionId: string): Promise { - const metadata = this.loadMetadata(sessionId) - if (!metadata) { + const meta = await this.sessionStore.getMeta(sessionId) + if (!meta) { throw new Error(`Session not found: ${sessionId}`) } this.currentSessionId = sessionId } // Fork session at current point - async fork(sessionId: string, _point?: string): Promise { - const parent = await this.resume(sessionId) - const newSessionId = randomUUID() - - // Create new session directory - const newDir = getSessionDir(newSessionId) - fs.mkdirSync(newDir, { recursive: true }) - - // Copy messages - const messages = this.loadMessages(sessionId) - fs.writeFileSync(getMessagesPath(newSessionId), messages.map((m) => JSON.stringify(m)).join("\n"), "utf-8") - - // Create new metadata with parent reference - const metadata = { - id: newSessionId, - threadId: parent.id, // Reuse thread for now - title: `${parent.title} (fork)`, - projectPath: parent.projectPath, - provider: parent.provider, - status: "active" as const, - createdAt: Date.now(), - updatedAt: Date.now(), - lastTurnAt: Date.now(), - turnCount: 0, - parentId: sessionId, - } - fs.writeFileSync(getMetadataPath(newSessionId), JSON.stringify(metadata, null, 2)) - - this.currentSessionId = newSessionId - return newSessionId + async fork(sessionId: string): Promise { + const forkId = await this.sessionStore.fork(sessionId) + this.currentSessionId = forkId + return forkId } // List sessions - async list(filter?: { projectPath?: string; status?: "active" | "archived" | "deleted" }): Promise { - const sessions: SessionContext[] = [] - const sessionsDir = getSessionsDir() - - if (!fs.existsSync(sessionsDir)) { - return sessions - } - - const entries = fs.readdirSync(sessionsDir) - for (const entry of entries) { - const sessionPath = path.join(sessionsDir, entry) - if (!fs.statSync(sessionPath).isDirectory()) continue - - const metadata = this.loadMetadata(entry) - if (!metadata) continue - - if (filter?.projectPath && metadata.projectPath !== filter.projectPath) continue - if (filter?.status && metadata.status !== filter.status) continue - - sessions.push({ - id: entry, - title: metadata.title, - projectPath: metadata.projectPath, - provider: metadata.provider, - status: metadata.status, - createdAt: metadata.createdAt, - updatedAt: metadata.updatedAt, - lastTurnAt: metadata.lastTurnAt, - turnCount: metadata.turnCount, - parentId: metadata.parentId, - messages: [], // Don't load messages for list - }) - } - - // Sort by lastTurnAt descending - sessions.sort((a, b) => b.lastTurnAt - a.lastTurnAt) - return sessions + async list(filter?: { projectPath?: string; status?: SessionMeta["status"] }): Promise { + const metas = await this.sessionStore.list(filter) + + return metas.map((meta) => ({ + id: meta.id, + title: meta.title, + projectPath: meta.projectPath, + provider: meta.provider, + status: meta.status, + createdAt: meta.createdAt, + updatedAt: meta.updatedAt, + lastTurnAt: meta.lastTurnAt, + turnCount: meta.turnCount, + parentId: meta.parentId, + messages: [], // Don't load messages for list + })) } // Archive a session async archive(sessionId: string): Promise { - const metadata = this.loadMetadata(sessionId) - if (!metadata) return - - metadata.status = "archived" - metadata.updatedAt = Date.now() - fs.writeFileSync(getMetadataPath(sessionId), JSON.stringify(metadata, null, 2)) + await this.sessionStore.updateStatus(sessionId, "archived") } // Delete a session async delete(sessionId: string): Promise { - const sessionDir = getSessionDir(sessionId) - if (fs.existsSync(sessionDir)) { - fs.rmSync(sessionDir, { recursive: true, force: true }) - } + await this.sessionStore.deleteSession(sessionId) } // Get current session - getCurrent(): SessionContext | null { + async getCurrent(): Promise { if (!this.currentSessionId) return null try { - return this.resume(this.currentSessionId) as unknown as SessionContext + return await this.resume(this.currentSessionId) } catch { return null } } - // Add message to current session - addMessage(sessionId: string, role: "user" | "assistant", content: string): void { - const messagesPath = getMessagesPath(sessionId) - const message: SerializedMessage = { - id: randomUUID(), - role, - content, - timestamp: Date.now(), - } - fs.appendFileSync(messagesPath, JSON.stringify(message) + "\n", "utf-8") + // Append message to session + async appendMessage(sessionId: string, message: SerializedMessage): Promise { + await this.sessionStore.appendMessage(sessionId, message) + } + + // Mark message as interrupted + async markInterrupted(sessionId: string, messageId: string): Promise { + await this.sessionStore.markInterrupted(sessionId, messageId) } - // ============================================================================= - // Private helpers - // ============================================================================= + // Export session to remote sync endpoint + async export(sessionId: string): Promise<{ url: string; expiresAt: number }> { + const meta = await this.sessionStore.getMeta(sessionId) + if (!meta) throw new Error("Session not found") + const messages = await this.sessionStore.getMessages(sessionId) - private loadMetadata(sessionId: string): any { - const metadataPath = getMetadataPath(sessionId) - if (!fs.existsSync(metadataPath)) return null - return JSON.parse(fs.readFileSync(metadataPath, "utf-8")) + const payload = JSON.stringify({ meta, messages }) + const endpoint = this.getSyncEndpoint() + + const response = await fetch(`${endpoint}/upload`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: payload, + signal: AbortSignal.timeout(30000), + }) + + if (!response.ok) throw new Error("Export failed") + return response.json() as Promise<{ url: string; expiresAt: number }> } - private loadMessages(sessionId: string): SerializedMessage[] { - const messagesPath = getMessagesPath(sessionId) - if (!fs.existsSync(messagesPath)) return [] + // Import session from remote URL + async import(url: string): Promise { + const response = await fetch(url, { signal: AbortSignal.timeout(30000) }) + if (!response.ok) throw new Error("Import failed") + const { metadata: meta, messages } = await response.json() as { metadata: SessionMeta; messages: SerializedMessage[] } + + const newId = await this.sessionStore.createSession({ + title: meta.title + " (imported)", + projectPath: meta.projectPath, + provider: meta.provider, + }) + + for (const msg of messages) { + await this.sessionStore.appendMessage(newId, msg) + } - const content = fs.readFileSync(messagesPath, "utf-8") - if (!content.trim()) return [] + await this.sessionStore.updateMeta(newId, { status: "active" }) + return newId + } - return content - .split("\n") - .filter((line) => line.trim()) - .map((line) => JSON.parse(line)) + private getSyncEndpoint(): string { + try { + if (fs.existsSync(CONFIG_FILE)) { + const content = fs.readFileSync(CONFIG_FILE, "utf-8") + const config = JSON.parse(content) as { syncEndpoint?: string } + if (config.syncEndpoint) return config.syncEndpoint + } + } catch { + // ignore + } + return "https://sync.freecode.dev" } } @@ -279,8 +232,14 @@ let globalSessionManager: SessionManager | null = null export async function getSessionManager(): Promise { if (!globalSessionManager) { - const store = await createThreadStoreService() - globalSessionManager = new SessionManager(store) + const baseDir = path.join(os.homedir(), SESSION_DIR) + const sessionStore = await createSessionStore(baseDir) + const threadStore = await createThreadStoreService() + globalSessionManager = new SessionManager(sessionStore, threadStore) } return globalSessionManager } + +export function createSessionManager(sessionStore: SessionStore, threadStore?: ThreadStoreService): SessionManager { + return new SessionManager(sessionStore, threadStore) +} \ No newline at end of file diff --git a/apps/core/src/session/store.test.ts b/apps/core/src/session/store.test.ts new file mode 100644 index 00000000..9064ab79 --- /dev/null +++ b/apps/core/src/session/store.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { SessionStore, createSessionStore } from './store' +import { rm } from 'fs/promises' + +describe('SessionStore', () => { + const testDir = '/tmp/freecode-test-session-store' + let store: SessionStore + + beforeEach(async () => { + await rm(testDir, { recursive: true, force: true }) + store = await createSessionStore(testDir) + }) + + it('creates session directory with meta.json', async () => { + const sessionId = await store.createSession({ + title: 'Test Session', + projectPath: '/tmp/test', + provider: 'claude', + }) + const meta = await store.getMeta(sessionId) + expect(meta).not.toBeNull() + expect(meta!.id).toBe(sessionId) + expect(meta!.title).toBe('Test Session') + expect(meta!.status).toBe('active') + }) + + it('appends messages to messages.jsonl', async () => { + const sessionId = await store.createSession({ + title: 'Test', + projectPath: '/tmp/test', + provider: 'claude', + }) + const msg = { + id: 'msg-1', + role: 'user' as const, + parts: [{ type: 'text' as const, content: 'hello' }], + timestamp: Date.now(), + } + await store.appendMessage(sessionId, msg) + const messages = await store.getMessages(sessionId) + expect(messages).toHaveLength(1) + expect(messages[0].parts[0]).toEqual({ type: 'text', content: 'hello' }) + }) + + it('marks message as interrupted', async () => { + const sessionId = await store.createSession({ + title: 'Test', + projectPath: '/tmp/test', + provider: 'claude', + }) + const msg = { + id: 'msg-1', + role: 'assistant' as const, + parts: [], + timestamp: Date.now(), + } + await store.appendMessage(sessionId, msg) + await store.markInterrupted(sessionId, 'msg-1') + const msgs = await store.getMessages(sessionId) + expect(msgs[0].interrupted).toBe(true) + }) + + it('detects interrupted sessions', async () => { + const sessionId = await store.createSession({ + title: 'Test', + projectPath: '/tmp/test', + provider: 'claude', + }) + const msg = { + id: 'msg-1', + role: 'assistant' as const, + parts: [], + timestamp: Date.now(), + } + await store.appendMessage(sessionId, msg) + await store.markInterrupted(sessionId, 'msg-1') + const interrupted = await store.getInterruptedSession() + expect(interrupted?.sessionId).toBe(sessionId) + }) + + it('lists sessions with filter', async () => { + const s1 = await store.createSession({ title: 'S1', projectPath: '/tmp/p1', provider: 'claude' }) + // Ensure different lastTurnAt timestamps + await new Promise(r => setTimeout(r, 10)) + const s2 = await store.createSession({ title: 'S2', projectPath: '/tmp/p2', provider: 'claude' }) + await store.updateStatus(s1, 'archived') + const active = await store.list({ status: 'active' }) + // After archiving s1, only s2 remains active + expect(active).toHaveLength(1) + expect(active[0].title).toBe('S2') + const archived = await store.list({ status: 'archived' }) + expect(archived).toHaveLength(1) + expect(archived[0].title).toBe('S1') + }) + + it('forks session with new id', async () => { + const parentId = await store.createSession({ + title: 'Parent', + projectPath: '/tmp/test', + provider: 'claude', + }) + const forkId = await store.fork(parentId) + const forkMeta = await store.getMeta(forkId) + expect(forkMeta?.parentId).toBe(parentId) + expect(forkId).not.toBe(parentId) + }) + + it('updates session meta', async () => { + const sessionId = await store.createSession({ + title: 'Original', + projectPath: '/tmp/test', + provider: 'claude', + }) + await store.updateMeta(sessionId, { title: 'Updated' }) + const meta = await store.getMeta(sessionId) + expect(meta?.title).toBe('Updated') + }) + + it('deletes session', async () => { + const sessionId = await store.createSession({ + title: 'ToDelete', + projectPath: '/tmp/test', + provider: 'claude', + }) + await store.deleteSession(sessionId) + const meta = await store.getMeta(sessionId) + expect(meta?.status).toBe('deleted') + }) +}) \ No newline at end of file diff --git a/apps/core/src/session/store.ts b/apps/core/src/session/store.ts new file mode 100644 index 00000000..8cfb12ce --- /dev/null +++ b/apps/core/src/session/store.ts @@ -0,0 +1,235 @@ +// ============================================================================= +// SessionStore - JSONL-based file operations for session persistence +// PRIMARY: Provides file-based session storage at ~/.freecode/sessions/ +// STORAGE: Sessions stored at {baseDir}/sessions/{sessionId}/ with meta.json + messages.jsonl +// ============================================================================= + +import { mkdir, readFile, writeFile, readdir } from 'fs/promises' +import { join } from 'path' +import { randomUUID } from 'crypto' + +// ============================================================================ +// Types +// ============================================================================ + +export interface SessionMeta { + id: string + title: string + projectPath: string + provider: string + model?: string + status: 'active' | 'interrupted' | 'archived' | 'deleted' + createdAt: number + updatedAt: number + lastTurnAt: number + turnCount: number + parentId?: string + aggregatedTokenCount?: number +} + +export interface SerializedMessage { + id: string + role: 'user' | 'assistant' + parts: Array<{ + type: 'text' | 'code' | 'tool' + content?: string + language?: string + tool?: { name: string; args: Record } + result?: string + }> + timestamp: number + interrupted?: boolean +} + +export interface CreateSessionOptions { + title: string + projectPath: string + provider: string + model?: string +} + +export interface SessionStore { + createSession(opts: CreateSessionOptions): Promise + getMeta(sessionId: string): Promise + updateMeta(sessionId: string, updates: Partial): Promise + updateStatus(sessionId: string, status: SessionMeta['status']): Promise + deleteSession(sessionId: string): Promise + + appendMessage(sessionId: string, message: SerializedMessage): Promise + getMessages(sessionId: string): Promise + markInterrupted(sessionId: string, messageId: string): Promise + + list(filter?: { status?: SessionMeta['status']; projectPath?: string }): Promise + fork(sessionId: string): Promise + + getInterruptedSession(): Promise<{ sessionId: string; messageId: string } | null> +} + +// ============================================================================ +// Constants +// ============================================================================ + +const SESSION_DIR = 'sessions' +const META_FILE = 'meta.json' +const MESSAGES_FILE = 'messages.jsonl' + +// ============================================================================ +// Helpers +// ============================================================================ + +async function ensureDir(dir: string): Promise { + try { + await mkdir(dir, { recursive: true }) + } catch { + // already exists + } +} + +async function readJson(path: string): Promise { + try { + const data = await readFile(path, 'utf-8') + return JSON.parse(data) as T + } catch { + return null + } +} + +async function writeJson(path: string, data: unknown): Promise { + await writeFile(path, JSON.stringify(data, null, 2), 'utf-8') +} + +// ============================================================================ +// Factory +// ============================================================================ + +export async function createSessionStore(baseDir: string): Promise { + await ensureDir(join(baseDir, SESSION_DIR)) + return new SessionStoreImpl(baseDir) +} + +// ============================================================================ +// Implementation +// ============================================================================ + +class SessionStoreImpl implements SessionStore { + constructor(private baseDir: string) {} + + private sessionDir(sessionId: string): string { + return join(this.baseDir, SESSION_DIR, sessionId) + } + + private metaPath(sessionId: string): string { + return join(this.sessionDir(sessionId), META_FILE) + } + + private messagesPath(sessionId: string): string { + return join(this.sessionDir(sessionId), MESSAGES_FILE) + } + + async createSession(opts: CreateSessionOptions, forcedId?: string): Promise { + const id = forcedId || randomUUID() + const now = Date.now() + const meta: SessionMeta = { + id, + title: opts.title, + projectPath: opts.projectPath, + provider: opts.provider, + model: opts.model, + status: 'active', + createdAt: now, + updatedAt: now, + lastTurnAt: now, + turnCount: 0, + } + await ensureDir(this.sessionDir(id)) + await writeJson(this.metaPath(id), meta) + await writeFile(this.messagesPath(id), '', 'utf-8') + return id + } + + async getMeta(sessionId: string): Promise { + return readJson(this.metaPath(sessionId)) + } + + async updateMeta(sessionId: string, updates: Partial): Promise { + const meta = await this.getMeta(sessionId) + if (!meta) return + const updated = { ...meta, ...updates, updatedAt: Date.now() } + await writeJson(this.metaPath(sessionId), updated) + } + + async updateStatus(sessionId: string, status: SessionMeta['status']): Promise { + await this.updateMeta(sessionId, { status }) + } + + async deleteSession(sessionId: string): Promise { + await this.updateStatus(sessionId, 'deleted') + } + + async appendMessage(sessionId: string, message: SerializedMessage): Promise { + const line = JSON.stringify(message) + '\n' + await writeFile(this.messagesPath(sessionId), line, { flag: 'a' }) + } + + async getMessages(sessionId: string): Promise { + const content = await readFile(this.messagesPath(sessionId), 'utf-8').catch(() => '') + if (!content.trim()) return [] + return content.trim().split('\n').map(line => JSON.parse(line) as SerializedMessage) + } + + async markInterrupted(sessionId: string, messageId: string): Promise { + const messages = await this.getMessages(sessionId) + const idx = messages.findIndex(m => m.id === messageId) + if (idx !== -1) { + messages[idx] = { ...messages[idx], interrupted: true } + } + const content = messages.map(m => JSON.stringify(m)).join('\n') + '\n' + await writeFile(this.messagesPath(sessionId), content, 'utf-8') + await this.updateStatus(sessionId, 'interrupted') + } + + async list(filter?: { status?: SessionMeta['status']; projectPath?: string }): Promise { + const sessionsDir = join(this.baseDir, SESSION_DIR) + let entries: string[] + try { + entries = await readdir(sessionsDir) + } catch { + return [] + } + const metas: SessionMeta[] = [] + for (const id of entries) { + const meta = await this.getMeta(id) + if (!meta) continue + if (filter?.status && meta.status !== filter.status) continue + if (filter?.projectPath && meta.projectPath !== filter.projectPath) continue + metas.push(meta) + } + return metas.sort((a, b) => b.lastTurnAt - a.lastTurnAt) + } + + async fork(sessionId: string): Promise { + const meta = await this.getMeta(sessionId) + if (!meta) throw new Error('Session not found') + const newId = await this.createSession({ + title: meta.title + ' (fork)', + projectPath: meta.projectPath, + provider: meta.provider, + model: meta.model, + }) + await this.updateMeta(newId, { parentId: sessionId, turnCount: meta.turnCount }) + const messages = await this.getMessages(sessionId) + for (const msg of messages) { + await this.appendMessage(newId, msg) + } + return newId + } + + async getInterruptedSession(): Promise<{ sessionId: string; messageId: string } | null> { + const all = await this.list({ status: 'interrupted' }) + if (all.length === 0) return null + const session = all[0] + const messages = await this.getMessages(session.id) + const last = messages[messages.length - 1] + return last ? { sessionId: session.id, messageId: last.id } : null + } +} \ No newline at end of file diff --git a/apps/tui/src/commands/built-in.ts b/apps/tui/src/commands/built-in.ts index a532c3b7..5970a0b2 100644 --- a/apps/tui/src/commands/built-in.ts +++ b/apps/tui/src/commands/built-in.ts @@ -10,6 +10,7 @@ const helpCommand: Command = { - **/help** - Show this help message - **/clear** - Clear all messages - **/model** - Select AI model +- **/resume** - Resume a previous session - **/exit** - Exit FreeCode Just type your prompt to start chatting!`); @@ -41,9 +42,19 @@ const modelCommand: Command = { }, }; +const resumeCommand: Command = { + name: "resume", + description: "Resume a previous session", + execute: (_args, ctx) => { + ctx.showMessage(`**Select a session to resume:**`); + ctx.showResumePicker?.(); + }, +}; + export function registerBuiltInCommands(): void { registerCommand(helpCommand); registerCommand(clearCommand); registerCommand(exitCommand); registerCommand(modelCommand); + registerCommand(resumeCommand); } \ No newline at end of file diff --git a/apps/tui/src/commands/index.ts b/apps/tui/src/commands/index.ts index a74ca284..a240e5dc 100644 --- a/apps/tui/src/commands/index.ts +++ b/apps/tui/src/commands/index.ts @@ -15,6 +15,7 @@ export interface MessageCreators { export interface CommandContext extends MessageCreators { showMessage(content: string): void; showModelSelector?(): void; + showResumePicker?(): void; handleToolEvent?(event: StreamEvent): void; } diff --git a/apps/tui/src/components/index.ts b/apps/tui/src/components/index.ts index 7047f051..d18d1ed5 100644 --- a/apps/tui/src/components/index.ts +++ b/apps/tui/src/components/index.ts @@ -130,6 +130,37 @@ export function clearAllMessages(): void { clearMessages(); } +/** + * Load messages from a resumed session into the UI + */ +export function loadSessionMessages(messages: Array<{ + id: string + role: 'user' | 'assistant' + parts: Array<{ + type: 'text' | 'code' | 'tool' + content?: string + language?: string + tool?: { name: string; args: Record } + result?: string + }> + timestamp: number +}>): void { + for (const msg of messages) { + let content = "" + if (msg.role === "user") { + content = msg.parts.map(p => p.type === "text" ? p.content || "" : "").join("") + } else { + // assistant message - extract text content + const textParts = msg.parts.filter(p => p.type === "text") + content = textParts.map(p => p.content || "").join("\n") + } + if (content) { + const label = msg.role === "user" ? "**You:**" : "**FreeCode:**" + addMessage(msg.role, content, createMessageComponent(msg.role as MessageType, `${label} ${content}`)) + } + } +} + // Re-export types for convenience export type { MessageInstance, MessageType } from "./message-types.js"; diff --git a/apps/tui/src/components/message-row.ts b/apps/tui/src/components/message-row.ts index 2fb98877..bedbed60 100644 --- a/apps/tui/src/components/message-row.ts +++ b/apps/tui/src/components/message-row.ts @@ -75,6 +75,30 @@ function stripPrefix(content: string): string { return content.replace(MESSAGE_PREFIX_RE, ""); } +/** + * Wrapper that truncates child component output to fit available width. + * Use this instead of hardcoding widths - respects actual terminal width. + */ +class WidthBounded implements Component { + private inner: Component; + + constructor(inner: Component) { + this.inner = inner; + } + + render(width: number): string[] { + const safeWidth = Math.max(20, width - 1); + return this.inner.render(safeWidth).map((line) => truncateToWidth(line, safeWidth)); + } + + invalidate(): void { + if (typeof this.inner.invalidate === "function") this.inner.invalidate(); + } + + addChild(_component: Component): void {} + destroy(): void {} +} + /** * Create a user message component — gray background with markdown content */ @@ -82,20 +106,16 @@ export function createUserMessageComponent(content: string): Component { const displayContent = stripPrefix(content); const box = new Box(0, 0, (text: string) => { - return text - .split("\n") - .map((line) => chalk.bgRgb(80, 80, 80)(line)) - .join("\n"); + return text.split("\n").map((line) => chalk.bgRgb(80, 80, 80)(line)).join("\n"); }); - const markdown = new Markdown(displayContent, 1, 1, defaultMarkdownTheme); box.addChild(markdown); - return box; + return new WidthBounded(box); } /** - * Create an assistant message component — markdown with colored output (headings=cyan, code=yellow, etc.) + * Create an assistant message component — markdown with colored output */ export function createAssistantMessageComponent(content: string): Component { const displayContent = stripPrefix(content); @@ -104,11 +124,11 @@ export function createAssistantMessageComponent(content: string): Component { const markdown = new Markdown(displayContent, 1, 1, defaultMarkdownTheme); box.addChild(markdown); - return box; + return new WidthBounded(box); } /** - * Create a system message component — dimmed text (for errors, elapsed time, etc.) + * Create a system message component — dimmed text */ export function createSystemMessageComponent(content: string): Component { const displayContent = stripPrefix(content); @@ -117,7 +137,7 @@ export function createSystemMessageComponent(content: string): Component { const text = new Text(chalk.dim(displayContent), 1, 1); box.addChild(text); - return box; + return new WidthBounded(box); } /** diff --git a/apps/tui/src/components/resume-picker.tsx b/apps/tui/src/components/resume-picker.tsx new file mode 100644 index 00000000..0c03b56b --- /dev/null +++ b/apps/tui/src/components/resume-picker.tsx @@ -0,0 +1,74 @@ +// ============================================================================= +// Resume Picker - Session selection component for resuming previous sessions +// ============================================================================= + +import { SelectList, type SelectItem, type SelectListTheme } from "@earendil-works/pi-tui" + +// SessionMeta interface (matching the core session store) +export interface SessionMeta { + id: string + title: string + projectPath: string + provider: string + model?: string + status: 'active' | 'interrupted' | 'archived' | 'deleted' + createdAt: number + updatedAt: number + lastTurnAt: number + turnCount: number + parentId?: string + aggregatedTokenCount?: number +} + +export interface ResumePickerCallbacks { + onSelect: (sessionId: string) => void + onCancel: () => void +} + +export function createResumePicker( + sessions: SessionMeta[], + callbacks: ResumePickerCallbacks, + theme?: SelectListTheme +): { component: SelectList; cleanup: () => void } { + const items: SelectItem[] = sessions.map((s) => ({ + label: `${s.title} (${s.projectPath})`, + value: s.id, + description: `${s.turnCount} turns \u2022 ${formatRelativeTime(s.lastTurnAt)}${s.status === 'interrupted' ? ' [Interrupted]' : ''}`, + })) + + const picker = new SelectList(items, Math.min(items.length, 10), theme ?? defaultSelectListTheme) + + picker.onSelect = async (item: SelectItem) => { + callbacks.onSelect(item.value) + } + + picker.onCancel = () => { + callbacks.onCancel() + } + + return { + component: picker, + cleanup: () => { + /* nothing to cleanup */ + }, + } +} + +function formatRelativeTime(timestamp: number): string { + const seconds = Math.floor((Date.now() - timestamp) / 1000) + if (seconds < 60) return 'just now' + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return `${days}d ago` +} + +const defaultSelectListTheme: SelectListTheme = { + selectedPrefix: (text) => `> ${text}`, + selectedText: (text) => text, + description: (text) => text, + scrollInfo: (text) => text, + noMatch: (text) => text, +} \ No newline at end of file diff --git a/apps/tui/src/components/tool-progress-message.ts b/apps/tui/src/components/tool-progress-message.ts index ed5f24de..847fab2e 100644 --- a/apps/tui/src/components/tool-progress-message.ts +++ b/apps/tui/src/components/tool-progress-message.ts @@ -64,14 +64,15 @@ export class ToolProgressMessage implements Component { const lines: string[] = []; // Header line: [spinner] ToolName (args) + const headerWidth = Math.max(20, width - 3); let header = `${chalk.dim("[")}${chalk.yellow(spinner)}${chalk.dim("]")} ${colorFn(this.toolName)} ${chalk.dim("(")}${argsStr}${chalk.dim(")")}`; - header = truncateToWidth(header, Math.max(40, width) - 1); + header = truncateToWidth(header, headerWidth); lines.push(header); - // Output lines with tree view - always safe width - const safeWidth = Math.max(40, width) - 1; + // Output lines with tree view - account for prefix (3 chars: "│ ") + const outputWidth = Math.max(20, width - 4); for (const outputLine of this.outputLines.slice(-5)) { - lines.push(`${chalk.dim("│ ")}${chalk.dim(truncateToWidth(outputLine, safeWidth))}`); + lines.push(`${chalk.dim("│")} ${chalk.dim(truncateToWidth(outputLine, outputWidth))}`); } return lines; diff --git a/apps/tui/src/components/tool-result-message.ts b/apps/tui/src/components/tool-result-message.ts index e741b922..0649f1f4 100644 --- a/apps/tui/src/components/tool-result-message.ts +++ b/apps/tui/src/components/tool-result-message.ts @@ -52,15 +52,16 @@ export class ToolResultMessage implements Component { const lines: string[] = []; // Header line: [✓/✗] ToolName (args) (duration) - truncated to safe width + const safeWidth = Math.max(20, width - 1); let header = `${chalk.dim("[")}${statusIcon}${chalk.dim("]")} ${colorFn(this.toolName)} ${chalk.dim("(")}${argsStr}${chalk.dim(")")} ${chalk.dim(duration)}`; - header = truncateToWidth(header, Math.max(40, width) - 1); + header = truncateToWidth(header, safeWidth); lines.push(header); - // Result with tree view character - truncated to safe width + // Result with tree view character - account for prefix + const resultWidth = safeWidth - 3; // 3 for " ⎿ " if (this.result) { - const maxResultWidth = Math.max(40, width) - 3; - const truncatedResult = truncateToWidth(this.result, maxResultWidth); - lines.push(` ${chalk.dim("⎿")} ${chalk.dim(truncatedResult)}`); + const truncatedResult = truncateToWidth(this.result, resultWidth); + lines.push(`${chalk.dim("⎿")} ${chalk.dim(truncatedResult)}`); } else if (this.success) { lines.push(`${chalk.dim("⎿")} ${chalk.dim("(no output)")}`); } diff --git a/apps/tui/src/index.ts b/apps/tui/src/index.ts index 5f662a60..9ce9abe0 100644 --- a/apps/tui/src/index.ts +++ b/apps/tui/src/index.ts @@ -14,6 +14,8 @@ import { startCli, sessionStart, sessionSendStreaming, + sessionList, + sessionResume, listProviders, listModels, getCurrentModel, @@ -35,8 +37,10 @@ import { createToolResultMessage, ToolProgressMessage, type MessageInstance, + loadSessionMessages, } from "./components/index.js"; import { VirtualMessageList } from "./components/virtual-message-list.js"; +import { createResumePicker } from "./components/resume-picker.js"; import type { StreamEvent } from "@freecode/shared"; registerBuiltInCommands(); @@ -51,6 +55,7 @@ let currentModel = ""; let modelDisplay: Text; let modelSelector: SelectList | null = null; let providerSelector: SelectList | null = null; +let resumeSelector: SelectList | null = null; let apiKeyEditor: Editor | null = null; let apiKeyPrompt: Text | null = null; let modelDisplayIdx = -1; @@ -136,6 +141,12 @@ function hideModelSelector(): void { tui.requestRender(); } +function hideResumeSelector(): void { + removeSelector(resumeSelector); + resumeSelector = null; + tui.requestRender(); +} + function removeApiKeyEditor(): void { if (apiKeyEditor) { const idx = tui.children.indexOf(apiKeyEditor); @@ -275,6 +286,61 @@ async function showApiKeyInput(providerId: string, modelId: string): Promise { + hideResumeSelector(); + hideModelSelector(); + + try { + const sessions = await sessionList({}); + + if (sessions.length === 0) { + showMessage("**No sessions found.**"); + return; + } + + // Sort by lastTurnAt descending (most recent first) + sessions.sort((a, b) => b.lastTurnAt - a.lastTurnAt); + + const { component: picker } = createResumePicker( + sessions, + { + onSelect: async (sessionId: string) => { + hideResumeSelector(); + showMessage(`**Resuming session...**`); + try { + const result = await sessionResume(sessionId); + currentSession = { sessionId: result.sessionId }; + // Load messages from the resumed session into the UI + if (result.messages && result.messages.length > 0) { + loadSessionMessages(result.messages); + } + showMessage(`**Session resumed with ${result.messages?.length || 0} messages.**`); + } catch (err) { + showMessage(`**Error resuming session:** ${err}`); + } + tui.setFocus(editor); + tui.requestRender(); + }, + onCancel: () => { + hideResumeSelector(); + tui.setFocus(editor); + tui.requestRender(); + }, + }, + defaultSelectListTheme + ); + + resumeSelector = picker; + + const editorIdx = tui.children.indexOf(editor); + tui.children.splice(editorIdx + 1, 0, resumeSelector); + tui.setFocus(resumeSelector); + tui.requestRender(); + } catch (err) { + showMessage(`**Error loading sessions:** ${err}`); + } +} + async function loadCurrentModel(): Promise { startCli(); @@ -348,6 +414,7 @@ editor.onSubmit = async (value: string) => { command.execute(args, { showMessage, showModelSelector: showProviderSelector, + showResumePicker: showResumePicker, createUserMessage: (content: string) => createUserMessage(content), createAssistantMessage: (content: string) => createAssistantMessage(content), createSystemMessage: (content: string) => createSystemMessage(content), @@ -454,6 +521,8 @@ editor.onSubmit = async (value: string) => { } catch (error) { removeMessageById(inProgressMsg.id); showMessage(`**Error:** ${error instanceof Error ? error.message : String(error)}`); + } finally { + editor.setText("❯ "); } }; @@ -469,6 +538,20 @@ tui.addInputListener((data) => { loadCurrentModel(); +// Check for interrupted sessions on startup +async function checkForInterruptedSession(): Promise { + try { + const sessions = await sessionList({ status: 'interrupted' }); + if (sessions.length > 0) { + showMessage("**Interrupted session detected. Type /resume to continue or start a new session.**"); + } + } catch { + // Ignore - session might not be available yet + } +} + +checkForInterruptedSession(); + // Wire stderr to system messages via store startCli((stderrMsg) => { createSystemMessage(stderrMsg); diff --git a/apps/tui/src/ipc/client.ts b/apps/tui/src/ipc/client.ts index f4e71042..f2fe6f5b 100644 --- a/apps/tui/src/ipc/client.ts +++ b/apps/tui/src/ipc/client.ts @@ -228,3 +228,49 @@ export async function setCurrentModel(provider: string, model: string): Promise< export async function getCurrentModel(): Promise<{ provider: string; model: string } | undefined> { return (await sendRequest("config.getCurrentModel")) as { provider: string; model: string } | undefined } + +// ============================================================================= +// Session List/Resume Methods +// ============================================================================= + +export interface SessionMeta { + id: string + title: string + projectPath: string + provider: string + model?: string + status: 'active' | 'interrupted' | 'archived' | 'deleted' + createdAt: number + updatedAt: number + lastTurnAt: number + turnCount: number + parentId?: string + aggregatedTokenCount?: number +} + +export interface SessionFilter { + status?: 'active' | 'interrupted' | 'archived' | 'deleted' + projectPath?: string +} + +export async function sessionList(filter?: SessionFilter): Promise { + return (await sendRequest("session.list", filter as Record)) as SessionMeta[] +} + +export async function sessionResume(sessionId: string): Promise<{ sessionId: string; messages?: SerializedMessage[] }> { + return (await sendRequest("session.resume", { sessionId })) as { sessionId: string; messages?: SerializedMessage[] } +} + +export interface SerializedMessage { + id: string + role: 'user' | 'assistant' + parts: Array<{ + type: 'text' | 'code' | 'tool' + content?: string + language?: string + tool?: { name: string; args: Record } + result?: string + }> + timestamp: number + interrupted?: boolean +} From a01521a40be77bf81e89ececab3a7e7f23545d15 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Fri, 5 Jun 2026 13:35:51 +0530 Subject: [PATCH 42/49] feat: add path-formatter for project-based session storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts project paths to safe directory names for sessions. Format: /home/ayande/Project → home__ayande__Project Co-Authored-By: Claude Opus 4.6 --- apps/core/src/store/path-formatter.ts | 77 +++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 apps/core/src/store/path-formatter.ts diff --git a/apps/core/src/store/path-formatter.ts b/apps/core/src/store/path-formatter.ts new file mode 100644 index 00000000..4d299f5c --- /dev/null +++ b/apps/core/src/store/path-formatter.ts @@ -0,0 +1,77 @@ +// ============================================================================= +// Path Formatter - Convert project paths to session directory names +// ============================================================================= +// +// Converts a project path like /home/ayande/Project/opencode +// to a safe directory name like home-ayande-Project-opencode +// +// The separator is chosen to avoid collisions with path characters: +// - Forward slashes are replaced with PATH_SEP +// - Hyphens are escaped as HYPHEN_ESCAPE in segments +// +// Usage: +// formatSessionDirName("/home/ayande/Project/opencode") +// // → "home-ayande-Project-opencode" +// +// parseSessionDirName("home-ayande-Project-opencode") +// // → "/home/ayande/Project/opencode" + +const PATH_SEP = '-' +const HYPHEN_ESCAPE = '_h_' +const SEGMENT_SEP = '__' + +/** + * Convert an absolute project path to a safe directory name. + * + * Examples: + * /home/ayande/Project/opencode → home-ayande-Project-opencode + * /home/ayande/Project/my-project → home-ayande-Project-my_ h_project + * /Users/john/code/my-project → Users-john-code-my_ h_project + * C:\Users\john\projects\myapp → C-Users-john-projects-my_ h_app + */ +export function formatSessionDirName(projectPath: string): string { + // Normalize backslashes to forward slashes + const normalized = projectPath.replace(/\\/g, '/') + + // Remove leading/trailing slashes + const stripped = normalized.replace(/^\/+|\/+$/g, '') + + if (!stripped) return '' + + // Split into segments and process each + const segments = stripped.split('/') + + const formatted = segments.map(segment => { + // Escape hyphens within segments (they're our separator) + return segment.replace(/-/g, HYPHEN_ESCAPE) + }).join(SEGMENT_SEP) + + return formatted +} + +/** + * Convert a session directory name back to an absolute project path. + * + * Examples: + * home__ayande__Project__opencode → /home/ayande/Project/opencode + * home__ayande__Project__my_ h_project → /home/ayande/Project/my-project + */ +export function parseSessionDirName(dirName: string): string { + if (!dirName) return '' + + // Split by segment separator, unescape hyphens, rejoin with slashes + const segments = dirName.split(SEGMENT_SEP) + + const path = segments.map(segment => { + return segment.replace(new RegExp(HYPHEN_ESCAPE, 'g'), '-') + }).join('/') + + return '/' + path +} + +/** + * Check if a string looks like a formatted session directory name. + */ +export function isSessionDirName(str: string): boolean { + return str.length > 0 && str.indexOf(SEGMENT_SEP) !== -1 +} \ No newline at end of file From 92516dacc551cfb59df973533ac734a7942c8d6f Mon Sep 17 00:00:00 2001 From: Ayan De Date: Fri, 5 Jun 2026 13:36:00 +0530 Subject: [PATCH 43/49] feat: project-based session storage structure Sessions now stored at ~/.freecode/sessions/{projectDir}/{sessionId}/ Adds projectDir parameter to all session store methods. Adds getMetaBySessionId for formatted dir lookup. Co-Authored-By: Claude Opus 4.6 --- apps/core/src/session/store.ts | 143 +++++++++++++++++++++------------ apps/core/src/store/index.ts | 9 ++- 2 files changed, 100 insertions(+), 52 deletions(-) diff --git a/apps/core/src/session/store.ts b/apps/core/src/session/store.ts index 8cfb12ce..865a8db5 100644 --- a/apps/core/src/session/store.ts +++ b/apps/core/src/session/store.ts @@ -1,12 +1,14 @@ // ============================================================================= // SessionStore - JSONL-based file operations for session persistence // PRIMARY: Provides file-based session storage at ~/.freecode/sessions/ -// STORAGE: Sessions stored at {baseDir}/sessions/{sessionId}/ with meta.json + messages.jsonl +// STORAGE: Sessions stored at {baseDir}/sessions/{projectDir}/{sessionId}/ with meta.json + messages.jsonl +// PROJECT DIR: Project path is formatted using path-formatter (e.g., /home/ayande/Project → home__ayande__Project) // ============================================================================= import { mkdir, readFile, writeFile, readdir } from 'fs/promises' import { join } from 'path' import { randomUUID } from 'crypto' +import { formatSessionDirName } from '../store/path-formatter.js' // ============================================================================ // Types @@ -49,18 +51,19 @@ export interface CreateSessionOptions { } export interface SessionStore { - createSession(opts: CreateSessionOptions): Promise - getMeta(sessionId: string): Promise - updateMeta(sessionId: string, updates: Partial): Promise - updateStatus(sessionId: string, status: SessionMeta['status']): Promise - deleteSession(sessionId: string): Promise + createSession(opts: CreateSessionOptions, forcedId?: string): Promise + getMeta(sessionId: string, projectPath?: string): Promise + getMetaBySessionId(formattedProjDir: string, sessionId: string): Promise + updateMeta(sessionId: string, updates: Partial, projectPath?: string): Promise + updateStatus(sessionId: string, status: SessionMeta['status'], projectPath?: string): Promise + deleteSession(sessionId: string, projectPath?: string): Promise - appendMessage(sessionId: string, message: SerializedMessage): Promise - getMessages(sessionId: string): Promise - markInterrupted(sessionId: string, messageId: string): Promise + appendMessage(sessionId: string, message: SerializedMessage, projectPath?: string): Promise + getMessages(sessionId: string, projectPath?: string): Promise + markInterrupted(sessionId: string, messageId: string, projectPath?: string): Promise list(filter?: { status?: SessionMeta['status']; projectPath?: string }): Promise - fork(sessionId: string): Promise + fork(sessionId: string, newProjectPath?: string): Promise getInterruptedSession(): Promise<{ sessionId: string; messageId: string } | null> } @@ -112,18 +115,40 @@ export async function createSessionStore(baseDir: string): Promise // ============================================================================ class SessionStoreImpl implements SessionStore { - constructor(private baseDir: string) {} + constructor(private baseDir: string, private projectDir?: string) {} - private sessionDir(sessionId: string): string { - return join(this.baseDir, SESSION_DIR, sessionId) + private getProjectDir(projectPath: string): string { + return formatSessionDirName(projectPath) } - private metaPath(sessionId: string): string { - return join(this.sessionDir(sessionId), META_FILE) + private sessionDir(sessionId: string, projectPath?: string): string { + const projDir = projectPath ? this.getProjectDir(projectPath) : this.projectDir + if (!projDir) throw new Error('Project path required') + return join(this.baseDir, SESSION_DIR, projDir, sessionId) } - private messagesPath(sessionId: string): string { - return join(this.sessionDir(sessionId), MESSAGES_FILE) + private sessionDirFromFormatted(sessionId: string, formattedProjDir: string): string { + return join(this.baseDir, SESSION_DIR, formattedProjDir, sessionId) + } + + private metaPath(sessionId: string, projectPath?: string): string { + return join(this.sessionDir(sessionId, projectPath), META_FILE) + } + + private metaPathFromFormatted(sessionId: string, formattedProjDir: string): string { + return join(this.sessionDirFromFormatted(sessionId, formattedProjDir), META_FILE) + } + + private messagesPath(sessionId: string, projectPath?: string): string { + return join(this.sessionDir(sessionId, projectPath), MESSAGES_FILE) + } + + private messagesPathFromFormatted(sessionId: string, formattedProjDir: string): string { + return join(this.sessionDirFromFormatted(sessionId, formattedProjDir), MESSAGES_FILE) + } + + private projectSessionsDir(projectPath: string): string { + return join(this.baseDir, SESSION_DIR, this.getProjectDir(projectPath)) } async createSession(opts: CreateSessionOptions, forcedId?: string): Promise { @@ -141,85 +166,101 @@ class SessionStoreImpl implements SessionStore { lastTurnAt: now, turnCount: 0, } - await ensureDir(this.sessionDir(id)) - await writeJson(this.metaPath(id), meta) - await writeFile(this.messagesPath(id), '', 'utf-8') + const projDir = this.getProjectDir(opts.projectPath) + await ensureDir(join(this.baseDir, SESSION_DIR, projDir)) + await ensureDir(this.sessionDir(id, opts.projectPath)) + await writeJson(this.metaPath(id, opts.projectPath), meta) + await writeFile(this.messagesPath(id, opts.projectPath), '', 'utf-8') return id } - async getMeta(sessionId: string): Promise { - return readJson(this.metaPath(sessionId)) + async getMeta(sessionId: string, projectPath?: string): Promise { + return readJson(this.metaPath(sessionId, projectPath)) } - async updateMeta(sessionId: string, updates: Partial): Promise { - const meta = await this.getMeta(sessionId) + async getMetaBySessionId(formattedProjDir: string, sessionId: string): Promise { + return readJson(this.metaPathFromFormatted(sessionId, formattedProjDir)) + } + + async updateMeta(sessionId: string, updates: Partial, projectPath?: string): Promise { + const meta = await this.getMeta(sessionId, projectPath) if (!meta) return const updated = { ...meta, ...updates, updatedAt: Date.now() } - await writeJson(this.metaPath(sessionId), updated) + await writeJson(this.metaPath(sessionId, projectPath), updated) } - async updateStatus(sessionId: string, status: SessionMeta['status']): Promise { - await this.updateMeta(sessionId, { status }) + async updateStatus(sessionId: string, status: SessionMeta['status'], projectPath?: string): Promise { + await this.updateMeta(sessionId, { status }, projectPath) } - async deleteSession(sessionId: string): Promise { - await this.updateStatus(sessionId, 'deleted') + async deleteSession(sessionId: string, projectPath?: string): Promise { + await this.updateStatus(sessionId, 'deleted', projectPath) } - async appendMessage(sessionId: string, message: SerializedMessage): Promise { + async appendMessage(sessionId: string, message: SerializedMessage, projectPath?: string): Promise { const line = JSON.stringify(message) + '\n' - await writeFile(this.messagesPath(sessionId), line, { flag: 'a' }) + await writeFile(this.messagesPath(sessionId, projectPath), line, { flag: 'a' }) } - async getMessages(sessionId: string): Promise { - const content = await readFile(this.messagesPath(sessionId), 'utf-8').catch(() => '') + async getMessages(sessionId: string, projectPath?: string): Promise { + const content = await readFile(this.messagesPath(sessionId, projectPath), 'utf-8').catch(() => '') if (!content.trim()) return [] return content.trim().split('\n').map(line => JSON.parse(line) as SerializedMessage) } - async markInterrupted(sessionId: string, messageId: string): Promise { - const messages = await this.getMessages(sessionId) + async markInterrupted(sessionId: string, messageId: string, projectPath?: string): Promise { + const messages = await this.getMessages(sessionId, projectPath) const idx = messages.findIndex(m => m.id === messageId) if (idx !== -1) { messages[idx] = { ...messages[idx], interrupted: true } } const content = messages.map(m => JSON.stringify(m)).join('\n') + '\n' - await writeFile(this.messagesPath(sessionId), content, 'utf-8') - await this.updateStatus(sessionId, 'interrupted') + await writeFile(this.messagesPath(sessionId, projectPath), content, 'utf-8') + await this.updateStatus(sessionId, 'interrupted', projectPath) } async list(filter?: { status?: SessionMeta['status']; projectPath?: string }): Promise { const sessionsDir = join(this.baseDir, SESSION_DIR) - let entries: string[] + let projectDirs: string[] try { - entries = await readdir(sessionsDir) + projectDirs = await readdir(sessionsDir) } catch { return [] } const metas: SessionMeta[] = [] - for (const id of entries) { - const meta = await this.getMeta(id) - if (!meta) continue - if (filter?.status && meta.status !== filter.status) continue - if (filter?.projectPath && meta.projectPath !== filter.projectPath) continue - metas.push(meta) + for (const projDir of projectDirs) { + const projPath = join(sessionsDir, projDir) + let sessionIds: string[] + try { + sessionIds = await readdir(projPath) + } catch { + continue + } + for (const id of sessionIds) { + const meta = await this.getMetaBySessionId(projDir, id) + if (!meta) continue + if (filter?.status && meta.status !== filter.status) continue + if (filter?.projectPath && meta.projectPath !== filter.projectPath) continue + metas.push(meta) + } } return metas.sort((a, b) => b.lastTurnAt - a.lastTurnAt) } - async fork(sessionId: string): Promise { + async fork(sessionId: string, newProjectPath?: string): Promise { const meta = await this.getMeta(sessionId) if (!meta) throw new Error('Session not found') + const targetProjectPath = newProjectPath || meta.projectPath const newId = await this.createSession({ title: meta.title + ' (fork)', - projectPath: meta.projectPath, + projectPath: targetProjectPath, provider: meta.provider, model: meta.model, }) - await this.updateMeta(newId, { parentId: sessionId, turnCount: meta.turnCount }) - const messages = await this.getMessages(sessionId) + await this.updateMeta(newId, { parentId: sessionId, turnCount: meta.turnCount }, targetProjectPath) + const messages = await this.getMessages(sessionId, meta.projectPath) for (const msg of messages) { - await this.appendMessage(newId, msg) + await this.appendMessage(newId, msg, targetProjectPath) } return newId } @@ -228,7 +269,7 @@ class SessionStoreImpl implements SessionStore { const all = await this.list({ status: 'interrupted' }) if (all.length === 0) return null const session = all[0] - const messages = await this.getMessages(session.id) + const messages = await this.getMessages(session.id, session.projectPath) const last = messages[messages.length - 1] return last ? { sessionId: session.id, messageId: last.id } : null } diff --git a/apps/core/src/store/index.ts b/apps/core/src/store/index.ts index a73a4e9a..8c269807 100644 --- a/apps/core/src/store/index.ts +++ b/apps/core/src/store/index.ts @@ -31,4 +31,11 @@ export { JsonThreadStoreImpl, createJsonThreadStore } from "./json-store" export { SqliteThreadStoreImpl } from "./sqlite-store" // Remote Session Sync -export { RemoteSessionSync, getRemoteSync, type ExportedSession, type RemoteSessionConfig } from "./remote" \ No newline at end of file +export { RemoteSessionSync, getRemoteSync, type ExportedSession, type RemoteSessionConfig } from "./remote" + +// Path Formatter +export { + formatSessionDirName, + parseSessionDirName, + isSessionDirName, +} from "./path-formatter" \ No newline at end of file From 6aafa10ca5cf64db99a015348e90fb7f19f8b452 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Fri, 5 Jun 2026 13:36:12 +0530 Subject: [PATCH 44/49] fix: use project path from list() for session operations SessionManager now uses list() to find meta then uses projectPath directly for subsequent calls. Fixes resume/switch/fork failures with new project-based storage structure. Co-Authored-By: Claude Opus 4.6 --- apps/core/src/session/manager.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/core/src/session/manager.ts b/apps/core/src/session/manager.ts index 9ab9f375..88a73c87 100644 --- a/apps/core/src/session/manager.ts +++ b/apps/core/src/session/manager.ts @@ -67,12 +67,16 @@ export class SessionManager { // Resume an existing session async resume(sessionId: string): Promise { - const meta = await this.sessionStore.getMeta(sessionId) + // List all sessions to find the one we want and get its projectPath + const allMetas = await this.sessionStore.list() + const meta = allMetas.find(m => m.id === sessionId) if (!meta) { throw new Error(`Session not found: ${sessionId}`) } - let messages = await this.sessionStore.getMessages(sessionId) + // Use getMetaBySessionId with the formatted project dir (already stored in list results) + const formattedProjDir = this.sessionStore.list ? undefined : undefined // not needed - meta already has projectPath + let messages = await this.sessionStore.getMessages(sessionId, meta.projectPath) // Detect interrupted state → inject resume marker if (meta.status === "interrupted") { @@ -107,7 +111,8 @@ export class SessionManager { // Switch to a different session (make it current) async switch(sessionId: string): Promise { - const meta = await this.sessionStore.getMeta(sessionId) + const allMetas = await this.sessionStore.list() + const meta = allMetas.find(m => m.id === sessionId) if (!meta) { throw new Error(`Session not found: ${sessionId}`) } @@ -116,7 +121,12 @@ export class SessionManager { // Fork session at current point async fork(sessionId: string): Promise { - const forkId = await this.sessionStore.fork(sessionId) + const allMetas = await this.sessionStore.list() + const meta = allMetas.find(m => m.id === sessionId) + if (!meta) { + throw new Error(`Session not found: ${sessionId}`) + } + const forkId = await this.sessionStore.fork(sessionId, meta.projectPath) this.currentSessionId = forkId return forkId } From 6adb337b815f3542e60b51001ebca82d07ce8e0e Mon Sep 17 00:00:00 2001 From: Ayan De Date: Fri, 5 Jun 2026 13:36:18 +0530 Subject: [PATCH 45/49] feat: add projectPath to SessionState Adds projectPath field to SessionState interface for use in session store operations throughout the agent loop. Co-Authored-By: Claude Opus 4.6 --- apps/core/src/agent/types.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/core/src/agent/types.ts b/apps/core/src/agent/types.ts index 51ad8b51..23866fc0 100644 --- a/apps/core/src/agent/types.ts +++ b/apps/core/src/agent/types.ts @@ -128,6 +128,7 @@ export interface LoopAction { export interface SessionState { status: "idle" | "starting" | "running" | "error" | "stopped" sessionId: string + projectPath: string turnCount: number iterationCount: number loopHealth: LoopHealth @@ -135,10 +136,11 @@ export interface SessionState { activeToolChain?: string[] // For compaction awareness } -export function createInitialSessionState(sessionId: string): SessionState { +export function createInitialSessionState(sessionId: string, projectPath: string): SessionState { return { status: "idle", sessionId, + projectPath, turnCount: 0, iterationCount: 0, loopHealth: { From 8a0f0cb529e2d40799dcd46d92bd0cbec9d31944 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Fri, 5 Jun 2026 13:36:30 +0530 Subject: [PATCH 46/49] fix: pass projectPath to session store message methods AgentLoop now passes this.state.projectPath to all appendMessage calls in session store. Ensures messages are written to correct project-based directory. Co-Authored-By: Claude Opus 4.6 --- apps/core/src/agent/loop.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/core/src/agent/loop.ts b/apps/core/src/agent/loop.ts index ca04e740..f9ff8941 100644 --- a/apps/core/src/agent/loop.ts +++ b/apps/core/src/agent/loop.ts @@ -71,7 +71,7 @@ export class AgentLoop { private fileStateHash: string = "" constructor(sessionId: string, config?: { maxIterations?: number; heuristics?: Partial; hooks?: HookRuntime; recorder?: RolloutRecorder; sessionStore?: SessionStore }) { - this.state = createInitialSessionState(sessionId) + this.state = createInitialSessionState(sessionId, "") // projectPath set in run() this.config = { maxIterations: config?.maxIterations ?? 100, heuristics: { ...DEFAULT_LOOP_HEURISTICS, ...config?.heuristics }, @@ -87,7 +87,7 @@ export class AgentLoop { // Main execution entry point - runs the continuous loop until completion // =========================================================================== async run(input: UserInput): Promise { - this.state = { ...this.state, status: "starting" } + this.state = { ...this.state, status: "starting", projectPath: input.projectPath } try { this.state = { ...this.state, status: "running" } @@ -854,7 +854,7 @@ Based on this task, which files do you need to read to understand the codebase a parts: [{ type: "text", content }], timestamp: Date.now(), } - await this.sessionStore.appendMessage(this.state.sessionId, message) + await this.sessionStore.appendMessage(this.state.sessionId, message, this.state.projectPath) } private async appendAssistantMessage(content: string): Promise { @@ -866,7 +866,7 @@ Based on this task, which files do you need to read to understand the codebase a parts: [{ type: "text", content }], timestamp: Date.now(), } - await this.sessionStore.appendMessage(this.state.sessionId, message) + await this.sessionStore.appendMessage(this.state.sessionId, message, this.state.projectPath) // Set this message as the interrupt target so Ctrl+C marks it getInterruptHandler().setActive(this.state.sessionId, id) return id @@ -884,7 +884,7 @@ Based on this task, which files do you need to read to understand the codebase a }], timestamp: Date.now(), } - await this.sessionStore.appendMessage(this.state.sessionId, message) + await this.sessionStore.appendMessage(this.state.sessionId, message, this.state.projectPath) } // =========================================================================== From c99d939c59b2632b2a163a5cc53efa10addae6f1 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Fri, 5 Jun 2026 13:36:37 +0530 Subject: [PATCH 47/49] feat: extract title from prompt, use randomUUID for session IDs - Replaces LLM-based title generation with generateTitleFromPrompt() using stop-word filtering (no extra API call) - Session IDs now use randomUUID() instead of counter - Uses SESSION_BASE_DIR constant Co-Authored-By: Claude Opus 4.6 --- apps/core/src/server.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/core/src/server.ts b/apps/core/src/server.ts index f9de8db0..851216bc 100644 --- a/apps/core/src/server.ts +++ b/apps/core/src/server.ts @@ -18,7 +18,9 @@ import { getSessionManager, type SessionContext } from "./session/index.js"; import { createSessionStore, type SessionStore } from "./session/store.js"; import { getRemoteSync, type ExportedSession, type RemoteSessionConfig } from "./store/index.js"; import { getInterruptHandler } from "./session/interrupt.js"; +import { generateTitleFromPrompt } from "./agent/title-generator.js"; import { homedir } from "os"; +import { randomUUID } from 'crypto' import { join } from "path"; const SESSION_BASE_DIR = join(homedir(), ".freecode"); @@ -55,10 +57,9 @@ interface SessionInfo { } const sessions: Map = new Map(); -let sessionCounter = 0; function createSession(config: SessionConfig): SessionInfo { - const id = `session-${++sessionCounter}`; + const id = randomUUID(); const session: SessionInfo = { id, projectPath: config.projectPath, @@ -116,7 +117,7 @@ const methodHandlers: Record< const store = await getSessionStore(); // Use the same session ID that was created in createSession() await store.createSession({ - title: config.title || `Session ${session.id}`, + title: `Session ${session.id}`, projectPath: session.projectPath, provider: session.provider, model: session.model, @@ -165,6 +166,13 @@ const methodHandlers: Record< // Emit done event emitEvent({ type: "done", content: result.message || "Done" }); + // Extract session title from first response (no extra API call) + if (result.success && result.turnCount > 0 && result.content) { + const titleMatch = result.content.match(/SESSION_TITLE:\s*(.+)/i) + const title = titleMatch ? titleMatch[1].trim() : generateTitleFromPrompt(message) + await store.updateMeta(sessionId, { title }, session.projectPath) + } + return result; }, From aca0822199fd15d52a304cb32d1829828441cf18 Mon Sep 17 00:00:00 2001 From: Ayan De Date: Fri, 5 Jun 2026 13:36:45 +0530 Subject: [PATCH 48/49] feat: add title-generator with stop-word filtering New module for session title generation: - generateTitleFromPrompt(): Uses stop-word filtering on first prompt - generateSessionTitle(): LLM-based approach (kept for future Haiku option) - extractSmallModel(): Helper to extract fast model names No extra API call needed - titles extracted from prompt directly. Co-Authored-By: Claude Opus 4.6 --- apps/core/src/agent/title-generator.ts | 141 +++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 apps/core/src/agent/title-generator.ts diff --git a/apps/core/src/agent/title-generator.ts b/apps/core/src/agent/title-generator.ts new file mode 100644 index 00000000..d9f9dd87 --- /dev/null +++ b/apps/core/src/agent/title-generator.ts @@ -0,0 +1,141 @@ +// ============================================================================= +// Session Title Generator +// PRIMARY: Generate a concise title from the first user prompt +// Uses a fast/small model (Haiku-tier) for title generation +// ============================================================================= + +import { getProvider } from "../providers/index.js" +import type { ProviderId } from "../providers/index.js" + +const TITLE_PROMPT_TEMPLATE = `Generate a concise title (3-5 words) that captures the main topic or goal of this coding session. + +Requirements: +- Use sentence-case (only first word capitalized) +- 3-5 words maximum +- Focus on the action or subject, not generic words like "session" or "coding" +- No quotes or special formatting + +Examples: +- "Add user authentication" (not "User Authentication" or "Add user authentication to the app") +- "Fix memory leak in agent loop" +- "Implement session resume feature" +- "Refactor provider adapter" + +Session prompt: +""" + +{prompt} +""" + +Title:` + +interface GenerateTitleOptions { + prompt: string + provider: string + model?: string +} + +/** + * Generate a session title from the first user prompt. + * Uses the provider's fastest model (Haiku-tier equivalent). + */ +export async function generateSessionTitle(opts: GenerateTitleOptions): Promise { + try { + const provider = getProvider(opts.provider as ProviderId) + if (!provider) { + return generateTitleFromPrompt(opts.prompt) + } + + // Use a fast, cheap model for title generation + const titleModel = opts.model ? extractSmallModel(opts.model) : undefined + + const result = await provider.execute({ + prompt: TITLE_PROMPT_TEMPLATE.replace("{prompt}", opts.prompt.slice(0, 500)), + system: "You are a helpful assistant that generates concise session titles.", + model: titleModel, + maxTokens: 20, + }) + + const title = result.content.trim() + if (title && title.length >= 3 && title.length <= 60) { + return normalizeTitle(title) + } + + return generateTitleFromPrompt(opts.prompt) + } catch (error) { + console.warn("[TitleGenerator] Failed to generate title:", error) + return generateTitleFromPrompt(opts.prompt) + } +} + +/** + * Extract a smaller/faster model name from a model string. + * For example, "MiniMax-M2" stays "MiniMax-M2" but could map to "MiniMax-Haiku" + */ +function extractSmallModel(model: string): string | undefined { + // If the model already indicates a small/fast variant, use it + if (model.toLowerCase().includes("haiku") || model.toLowerCase().includes("mini")) { + return model + } + // Otherwise, append -haiku or similar to indicate smaller model + // This is provider-specific and may need adjustment + return undefined // Let provider decide default fast model +} + +/** + * Normalize the generated title. + * - Trim whitespace + * - Remove quotes + * - Ensure sentence case + */ +function normalizeTitle(title: string): string { + let cleaned = title.trim() + // Remove surrounding quotes if present + if ((cleaned.startsWith('"') && cleaned.endsWith('"')) || + (cleaned.startsWith("'") && cleaned.endsWith("'"))) { + cleaned = cleaned.slice(1, -1) + } + // Remove any newlines or weird formatting + cleaned = cleaned.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim() + // Ensure sentence case (first letter capital, rest lowercase) + if (cleaned.length > 0) { + cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1) + } + // Truncate if too long + if (cleaned.length > 50) { + cleaned = cleaned.slice(0, 47) + "..." + } + return cleaned +} + +/** + * Generate a fallback title from the prompt when LLM doesn't provide one. + * Filters out stop words and returns first 5 meaningful words. + */ +export function generateTitleFromPrompt(prompt: string): string { + const stopWords = new Set([ + "please", + "can", + "could", + "would", + "help", + "me", + "with", + "the", + "a", + "an", + "to", + "for", + "and", + "in", + "on", + ]) + + const words = prompt + .replace(/[^\w\s-]/g, "") + .split(/\s+/) + .filter(word => word.length > 2) + .filter(word => !stopWords.has(word.toLowerCase())) + + return words.slice(0, 5).join(" ") +} \ No newline at end of file From 0eec950adbc9b43f2168e8a0108144638de8413f Mon Sep 17 00:00:00 2001 From: Ayan De Date: Fri, 5 Jun 2026 15:13:54 +0530 Subject: [PATCH 49/49] feat: add support for streaming thinking content in AgentLoop and UI components --- apps/core/src/agent/loop.ts | 20 ++++++++++++++------ apps/core/src/agent/types.ts | 1 + apps/core/src/providers/anthropic.ts | 1 + apps/core/src/providers/gemini.ts | 1 + apps/core/src/providers/minimax.ts | 9 ++++++--- apps/core/src/providers/openai.ts | 1 + apps/core/src/providers/types.ts | 1 + apps/tui/src/components/index.ts | 8 ++++++++ apps/tui/src/components/message-row.ts | 13 ++++++++++++- apps/tui/src/components/message-types.ts | 2 +- apps/tui/src/index.ts | 7 +++++++ packages/shared/src/ipc/protocol.ts | 1 + 12 files changed, 54 insertions(+), 11 deletions(-) diff --git a/apps/core/src/agent/loop.ts b/apps/core/src/agent/loop.ts index f9ff8941..91197604 100644 --- a/apps/core/src/agent/loop.ts +++ b/apps/core/src/agent/loop.ts @@ -64,6 +64,7 @@ export class AgentLoop { private recorder: RolloutRecorder private sessionStore: SessionStore | undefined private onToolEvent: ((event: StreamEvent) => void) | undefined + private lastThinking: string | undefined // Loop health tracking state private recentToolCalls: Array<{ tool: string; args: string }> = [] private recentReasoning: string[] = [] @@ -145,7 +146,7 @@ export class AgentLoop { // No tool calls means we're done if (turnResult.toolResults.length === 0) { - return this.complete("Done", turnResult.responseText, { inputTokens: totalInputTokens, outputTokens: totalOutputTokens }) + return this.complete("Done", turnResult.responseText, turnResult.thinking, { inputTokens: totalInputTokens, outputTokens: totalOutputTokens }) } // Build continuation prompt for next iteration @@ -178,7 +179,7 @@ export class AgentLoop { model: string | undefined, context: { name: string; projectPath: string; tree: string }, previousToolResults?: ToolResult[] - ): Promise<{ success: boolean; toolResults: ToolResult[]; responseText?: string; error?: string; usage?: { inputTokens: number; outputTokens: number } }> { + ): Promise<{ success: boolean; toolResults: ToolResult[]; responseText?: string; thinking?: string; error?: string; usage?: { inputTokens: number; outputTokens: number } }> { try { // TWO-PHASE CONTEXT COLLECTION // Phase 1: Ask model which files it needs to complete the task @@ -214,6 +215,12 @@ ${memoryContext ? `Session context:\n${memoryContext}\n\n` : ""}Task: ${prompt}` console.log("[AgentLoop] Sending prompt to provider...") const providerResult = await this.sendToProvider(modifiedPrompt, provider, model, previousToolResults) + // Emit thinking content if present (for UI to display as streaming reasoning) + if (providerResult.thinking) { + this.lastThinking = providerResult.thinking + this.onToolEvent?.({ type: "thinking", content: providerResult.thinking }) + } + // Record turn.started event this.recorder.recordTurnStarted(`turn-${this.state.turnCount}`) @@ -252,7 +259,7 @@ ${memoryContext ? `Session context:\n${memoryContext}\n\n` : ""}Task: ${prompt}` } } } - return { success: true, toolResults: [], responseText: providerResult.content, usage: providerResult.usage } + return { success: true, toolResults: [], responseText: providerResult.content, thinking: providerResult.thinking, usage: providerResult.usage } } // Execute each tool sequentially (as per spec: sequential tools run one at a time) @@ -417,7 +424,7 @@ Based on this task, which files do you need to read to understand the codebase a provider: string, model: string | undefined, toolResults?: ToolResult[] - ): Promise<{ content: string; toolCalls?: Array<{ name: string; args: Record; id: string }>; usage?: { inputTokens: number; outputTokens: number } }> { + ): Promise<{ content: string; thinking?: string; toolCalls?: Array<{ name: string; args: Record; id: string }>; usage?: { inputTokens: number; outputTokens: number } }> { const aiProvider = getProvider(provider as any) const tools = listTools().map(t => { const toolDef = getTool(t.id) @@ -438,7 +445,7 @@ Based on this task, which files do you need to read to understand the codebase a })), model, }) - return { content: result.content, toolCalls: result.toolCalls, usage: result.usage } + return { content: result.content, thinking: result.thinking, toolCalls: result.toolCalls, usage: result.usage } } // =========================================================================== @@ -827,13 +834,14 @@ Based on this task, which files do you need to read to understand the codebase a } } - private complete(message: string, content?: string, usage?: { inputTokens: number; outputTokens: number }): LoopResult { + private complete(message: string, content?: string, thinking?: string, usage?: { inputTokens: number; outputTokens: number }): LoopResult { // Emit session.updated event BusEvents.sessionUpdated(this.state.sessionId) return { success: true, message, content, + thinking: thinking ?? this.lastThinking, turnCount: this.state.turnCount, iterationCount: this.state.iterationCount, finalState: this.state, diff --git a/apps/core/src/agent/types.ts b/apps/core/src/agent/types.ts index 23866fc0..a98dd2ad 100644 --- a/apps/core/src/agent/types.ts +++ b/apps/core/src/agent/types.ts @@ -188,6 +188,7 @@ export interface LoopResult { success: boolean message?: string content?: string + thinking?: string // Extended thinking content from provider turnCount: number iterationCount: number finalState: SessionState diff --git a/apps/core/src/providers/anthropic.ts b/apps/core/src/providers/anthropic.ts index bdfe4072..b3cc1a6a 100644 --- a/apps/core/src/providers/anthropic.ts +++ b/apps/core/src/providers/anthropic.ts @@ -51,6 +51,7 @@ function createAnthropicProvider(_apiKey: string): AIProvider { return { content, + thinking: undefined, // V3 SDK doesn't expose thinking blocks toolCalls: toolCalls?.length ? toolCalls : undefined, usage: result.usage ? { inputTokens: result.usage.inputTokens ?? 0, diff --git a/apps/core/src/providers/gemini.ts b/apps/core/src/providers/gemini.ts index e68057b1..779765d3 100644 --- a/apps/core/src/providers/gemini.ts +++ b/apps/core/src/providers/gemini.ts @@ -48,6 +48,7 @@ function createGeminiProvider(_apiKey: string): AIProvider { return { content: result.text || "", + thinking: undefined, // Gemini doesn't expose thinking blocks toolCalls: toolCalls?.length ? toolCalls : undefined, usage: result.usage ? { inputTokens: result.usage.inputTokens ?? 0, diff --git a/apps/core/src/providers/minimax.ts b/apps/core/src/providers/minimax.ts index bf040bf5..566bf63d 100644 --- a/apps/core/src/providers/minimax.ts +++ b/apps/core/src/providers/minimax.ts @@ -95,9 +95,11 @@ function createMiniMaxProvider(_apiKey: string): AIProvider { stop_reason: string } - // Extract text content, filter out thinking blocks - const textParts = data.content?.filter(c => c.type === "text" || c.type === "thinking") - const content = textParts?.map(c => c.text || c.thinking || "").join("\n").trim() || "" + // Extract text content and thinking blocks separately + const textParts = data.content?.filter(c => c.type === "text") + const thinkingParts = data.content?.filter(c => c.type === "thinking") + const content = textParts?.map(c => c.text || "").join("\n").trim() || "" + const thinking = thinkingParts?.map(c => c.thinking || "").join("\n").trim() || undefined // Extract tool calls from tool_use content blocks const toolCalls = data.content @@ -119,6 +121,7 @@ function createMiniMaxProvider(_apiKey: string): AIProvider { return { content, + thinking, toolCalls: toolCalls.length > 0 ? toolCalls : undefined, usage: data.usage ? { inputTokens: data.usage.input_tokens, diff --git a/apps/core/src/providers/openai.ts b/apps/core/src/providers/openai.ts index f61c42b2..7f909f1a 100644 --- a/apps/core/src/providers/openai.ts +++ b/apps/core/src/providers/openai.ts @@ -48,6 +48,7 @@ function createOpenAIProvider(_apiKey: string): AIProvider { return { content: result.text || "", + thinking: undefined, // OpenAI doesn't have extended thinking in same way toolCalls: toolCalls?.length ? toolCalls : undefined, usage: result.usage ? { inputTokens: result.usage.inputTokens ?? 0, diff --git a/apps/core/src/providers/types.ts b/apps/core/src/providers/types.ts index 4f6dd1db..cdf097f6 100644 --- a/apps/core/src/providers/types.ts +++ b/apps/core/src/providers/types.ts @@ -25,6 +25,7 @@ export interface ExecuteOptions { export interface ExecuteResult { content: string + thinking?: string // Extended thinking/reasoning content toolCalls?: Array<{ name: string; args: Record; id: string }> usage?: { inputTokens: number; outputTokens: number } stopReason: "stop" | "tool_use" | "max_tokens" | "unknown" diff --git a/apps/tui/src/components/index.ts b/apps/tui/src/components/index.ts index d18d1ed5..b802c828 100644 --- a/apps/tui/src/components/index.ts +++ b/apps/tui/src/components/index.ts @@ -107,6 +107,14 @@ export function createToolResultMessage( return addMessage("tool", toolName, component); } +/** + * Create a thinking message - dimmed cyan text showing LLM reasoning + */ +export function createThinkingMessage(content: string): MessageInstance { + const component = createMessageComponent("thinking", content); + return addMessage("thinking", content, component); +} + /** * Get the current in-progress message, if any */ diff --git a/apps/tui/src/components/message-row.ts b/apps/tui/src/components/message-row.ts index bedbed60..44953825 100644 --- a/apps/tui/src/components/message-row.ts +++ b/apps/tui/src/components/message-row.ts @@ -128,8 +128,17 @@ export function createAssistantMessageComponent(content: string): Component { } /** - * Create a system message component — dimmed text + * Create a thinking message component — dimmed cyan text with thinking prefix */ +export function createThinkingMessageComponent(content: string): Component { + const box = new Box(1, 1) + const lines = content.split("\n") + for (const line of lines) { + const text = new Text(chalk.dim.cyan(`Thinking: ${line}`), 1, 1) + box.addChild(text) + } + return new WidthBounded(box) +} export function createSystemMessageComponent(content: string): Component { const displayContent = stripPrefix(content); @@ -166,6 +175,8 @@ export function createMessageComponent( return createAssistantMessageComponent(content); case "system": return createSystemMessageComponent(content); + case "thinking": + return createThinkingMessageComponent(content); case "in_progress": return createInProgressMessageComponent( content, diff --git a/apps/tui/src/components/message-types.ts b/apps/tui/src/components/message-types.ts index 129fb230..5a243dab 100644 --- a/apps/tui/src/components/message-types.ts +++ b/apps/tui/src/components/message-types.ts @@ -1,6 +1,6 @@ import type { Component } from "@earendil-works/pi-tui"; -export type MessageType = "user" | "assistant" | "system" | "in_progress" | "tool"; +export type MessageType = "user" | "assistant" | "system" | "in_progress" | "tool" | "thinking"; export interface MessageInstance { id: number; diff --git a/apps/tui/src/index.ts b/apps/tui/src/index.ts index 9ce9abe0..46d06f4c 100644 --- a/apps/tui/src/index.ts +++ b/apps/tui/src/index.ts @@ -35,6 +35,7 @@ import { onMessagesChange, createToolProgressMessage, createToolResultMessage, + createThinkingMessage, ToolProgressMessage, type MessageInstance, loadSessionMessages, @@ -396,6 +397,12 @@ function handleToolEvent(event: StreamEvent): void { ); break; } + case "thinking": { + // Create or update thinking message - dimmed cyan stream + const thinkingComponent = createThinkingMessage(event.content); + tui.requestRender(); + break; + } } } diff --git a/packages/shared/src/ipc/protocol.ts b/packages/shared/src/ipc/protocol.ts index eaa5b511..e7a7e6ac 100644 --- a/packages/shared/src/ipc/protocol.ts +++ b/packages/shared/src/ipc/protocol.ts @@ -31,6 +31,7 @@ export type StreamEvent = | { type: "tool_start"; toolCallId: string; toolName: string; args: Record } | { type: "tool_output"; toolCallId: string; content: string } | { type: "tool_complete"; toolCallId: string; toolName: string; result: string; success: boolean; duration_ms?: number } + | { type: "thinking"; content: string } // Streaming thinking/reasoning | { type: "text"; content: string } | { type: "done"; content: string } | { type: "error"; content: string };