diff --git a/packages/opencode/src/cli/ink/App.tsx b/packages/opencode/src/cli/ink/App.tsx index f71d06eceb9..e4a7c608361 100644 --- a/packages/opencode/src/cli/ink/App.tsx +++ b/packages/opencode/src/cli/ink/App.tsx @@ -87,7 +87,7 @@ export const App = (): ReactElement => { {/* Welcome message - always visible */} - oclite v1.0 + oclite v0.1 - Lightweight OpenCode TUI diff --git a/packages/opencode/src/cli/ink/LiteApp.tsx b/packages/opencode/src/cli/ink/LiteApp.tsx index 567129cf558..c3878e161b7 100644 --- a/packages/opencode/src/cli/ink/LiteApp.tsx +++ b/packages/opencode/src/cli/ink/LiteApp.tsx @@ -25,7 +25,7 @@ export const LiteApp = (): ReactElement => { {/* Header */} - oclite v1.0 + oclite v0.1 - Lightweight OpenCode TUI diff --git a/packages/opencode/src/cli/ink/commands/handlers/agent.ts b/packages/opencode/src/cli/ink/commands/handlers/agent.ts index da90f74bc27..9a4d078e3b1 100644 --- a/packages/opencode/src/cli/ink/commands/handlers/agent.ts +++ b/packages/opencode/src/cli/ink/commands/handlers/agent.ts @@ -13,12 +13,16 @@ export async function agentHandler(args: string[], context: CommandContext): Pro return } - context.dispatch({ - type: "SET_SESSION", - payload: { - id: context.session.id ?? "default", - agent: agentName, - model: context.session.model, - }, - }) + try { + context.dispatch({ + type: "SET_SESSION", + payload: { + id: context.session.id || "default", + agent: agentName, + model: context.session.model, + }, + }) + } catch (error) { + console.error("Failed to set agent:", error) + } } diff --git a/packages/opencode/src/cli/ink/commands/handlers/compact.ts b/packages/opencode/src/cli/ink/commands/handlers/compact.ts index 27bd66b54ae..a722985b517 100644 --- a/packages/opencode/src/cli/ink/commands/handlers/compact.ts +++ b/packages/opencode/src/cli/ink/commands/handlers/compact.ts @@ -1,5 +1,12 @@ import type { CommandContext } from "../types" -export async function compactHandler(_args: string[], _context: CommandContext): Promise { - // Compact mode toggle implementation pending +export async function compactHandler(_args: string[], context: CommandContext): Promise { + try { + context.dispatch({ + type: "STREAM_TEXT", + payload: "\nCompact mode not yet implemented.\n\n", + }) + } catch (error) { + console.error("Failed to dispatch compact message:", error) + } } diff --git a/packages/opencode/src/cli/ink/commands/handlers/help.ts b/packages/opencode/src/cli/ink/commands/handlers/help.ts index 526bc318f3d..1719aa1e069 100644 --- a/packages/opencode/src/cli/ink/commands/handlers/help.ts +++ b/packages/opencode/src/cli/ink/commands/handlers/help.ts @@ -1,5 +1,29 @@ import type { CommandContext } from "../types" +import { getAllCommands } from "../registry" -export async function helpHandler(_args: string[], _context: CommandContext): Promise { - // Help display implementation pending - requires UI component +const MAX_COMMANDS = 100 + +export async function helpHandler(_args: string[], context: CommandContext): Promise { + const allCommands = getAllCommands() + const commands = allCommands.slice(0, MAX_COMMANDS) + + let helpText = "\nAvailable commands:\n\n" + + for (const cmd of commands) { + helpText += ` /${cmd.name.padEnd(12)} - ${cmd.description}\n` + } + + helpText += "\nKeyboard shortcuts:\n" + helpText += " Ctrl+C - Exit\n" + helpText += " Enter - Submit message\n" + helpText += "\n" + + try { + context.dispatch({ + type: "STREAM_TEXT", + payload: helpText, + }) + } catch (error) { + console.error("Failed to dispatch help text:", error) + } } diff --git a/packages/opencode/src/cli/ink/commands/handlers/model.ts b/packages/opencode/src/cli/ink/commands/handlers/model.ts index 82cda2cfcd1..04c2f1a60d8 100644 --- a/packages/opencode/src/cli/ink/commands/handlers/model.ts +++ b/packages/opencode/src/cli/ink/commands/handlers/model.ts @@ -13,12 +13,16 @@ export async function modelHandler(args: string[], context: CommandContext): Pro return } - context.dispatch({ - type: "SET_SESSION", - payload: { - id: context.session.id ?? "default", - agent: context.session.agent, - model: modelName, - }, - }) + try { + context.dispatch({ + type: "SET_SESSION", + payload: { + id: context.session.id || "default", + agent: context.session.agent, + model: modelName, + }, + }) + } catch (error) { + console.error("Failed to set model:", error) + } } diff --git a/packages/opencode/src/cli/ink/commands/handlers/session.ts b/packages/opencode/src/cli/ink/commands/handlers/session.ts index cc9d730ff06..cf3dd88df8d 100644 --- a/packages/opencode/src/cli/ink/commands/handlers/session.ts +++ b/packages/opencode/src/cli/ink/commands/handlers/session.ts @@ -13,28 +13,61 @@ export async function sessionHandler(args: string[], context: CommandContext): P return } - context.dispatch({ - type: "SET_SESSION", - payload: { - id: sessionId, - agent: context.session.agent, - model: context.session.model, - }, - }) + try { + context.dispatch({ + type: "SET_SESSION", + payload: { + id: sessionId, + agent: context.session.agent, + model: context.session.model, + }, + }) + } catch (error) { + console.error("Failed to set session:", error) + } } -export async function sessionsHandler(_args: string[], _context: CommandContext): Promise { - // Sessions list implementation pending +export async function sessionsHandler(_args: string[], context: CommandContext): Promise { + const currentId = context.session.id || "(no session)" + const helpText = `\nCurrent session: ${currentId}\n\nSession commands:\n /session - Switch to session\n /new - Create new session\n\n` + + try { + context.dispatch({ + type: "STREAM_TEXT", + payload: helpText, + }) + } catch (error) { + console.error("Failed to dispatch session info:", error) + } } export async function newSessionHandler(_args: string[], context: CommandContext): Promise { - const sessionId = crypto.randomUUID() - context.dispatch({ - type: "SET_SESSION", - payload: { - id: sessionId, - agent: context.session.agent, - model: context.session.model, - }, - }) + let sessionId: string + try { + sessionId = crypto.randomUUID() + } catch (error) { + console.error("Failed to generate session ID:", error) + try { + context.dispatch({ + type: "STREAM_TEXT", + payload: "\nFailed to create session: crypto.randomUUID() unavailable\n\n", + }) + } catch (dispatchError) { + console.error("Failed to dispatch error message:", dispatchError) + } + return + } + + try { + context.dispatch({ + type: "SET_SESSION", + payload: { + id: sessionId, + agent: context.session.agent, + model: context.session.model, + }, + }) + } catch (error) { + console.error("Failed to set new session:", error) + } } diff --git a/packages/opencode/src/cli/ink/commands/handlers/submodel.ts b/packages/opencode/src/cli/ink/commands/handlers/submodel.ts index 682b12c7ae5..723bde18a91 100644 --- a/packages/opencode/src/cli/ink/commands/handlers/submodel.ts +++ b/packages/opencode/src/cli/ink/commands/handlers/submodel.ts @@ -29,8 +29,12 @@ export async function submodelHandler(args: string[], context: CommandContext): return } - context.dispatch({ - type: "SET_SUBAGENT_MODEL", - payload: modelName, - }) + try { + context.dispatch({ + type: "SET_SUBAGENT_MODEL", + payload: modelName, + }) + } catch (error) { + console.error("Failed to set subagent model:", error) + } } diff --git a/packages/opencode/src/cli/ink/entry.ts b/packages/opencode/src/cli/ink/entry.ts index c79c45f07a5..eab0aadabad 100644 --- a/packages/opencode/src/cli/ink/entry.ts +++ b/packages/opencode/src/cli/ink/entry.ts @@ -6,6 +6,9 @@ import { Instance } from "@/project/instance" import { InstanceBootstrap } from "@/project/bootstrap" import { Installation } from "@/installation" import { startInkTUI } from "./index.tsx" +import { createSpinner } from "./spinner" +import { Provider } from "@/provider/provider" +import { Agent } from "@/agent/agent" async function main() { try { @@ -37,14 +40,37 @@ async function main() { }) }) + // Show spinner during bootstrap + const spinner = createSpinner("Starting oclite...") + // Initialize Instance for SDK integration - await Instance.provide({ - directory: process.cwd(), - init: InstanceBootstrap, - fn: async () => { - await startInkTUI() - }, - }) + try { + await Instance.provide({ + directory: process.cwd(), + init: InstanceBootstrap, + fn: async () => { + // ISSUE FIX #6: Add timeout to bootstrap + const bootstrap = Promise.all([Provider.list(), Agent.list()]) + const timeout = new Promise((_, reject) => { + setTimeout(() => reject(new Error("Bootstrap timeout after 30s")), 30000) + }) + + // CRITICAL FIX #1: Error handling with timeout + await Promise.race([bootstrap, timeout]) + + spinner.stop(true) + + // CRITICAL FIX #2: Prevent race condition with stdout flush + await new Promise((resolve) => setTimeout(resolve, 100)) + + await startInkTUI() + }, + }) + } catch (error) { + // CRITICAL FIX #1: Ensure cursor restoration on error + spinner.stop(false) + throw error + } } catch (error) { console.error("Failed to start oclite:", error) process.exit(1) diff --git a/packages/opencode/src/cli/ink/hooks/useSDKEvents.ts b/packages/opencode/src/cli/ink/hooks/useSDKEvents.ts index f84b7a0d7c3..c60cfb29fb0 100644 --- a/packages/opencode/src/cli/ink/hooks/useSDKEvents.ts +++ b/packages/opencode/src/cli/ink/hooks/useSDKEvents.ts @@ -85,6 +85,7 @@ export function useSDKEvents(sessionId: string | null, dispatch: Dispatch {} } // Noop for non-TTY + } + + const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + let frame = 0 + let interval: Timer | null = null + let stopped = false // CRITICAL FIX #3: Prevent double-stop + const start = Date.now() + + process.stdout.write("\x1B[?25l") // hide cursor + + interval = setInterval(() => { + const elapsed = Math.floor((Date.now() - start) / 1000) + const time = elapsed > 0 ? ` ${elapsed}s` : "" + process.stdout.write(`\r\x1B[K\x1B[36m${frames[frame]}\x1B[0m ${text}${time}`) + frame = (frame + 1) % frames.length + }, 50) + + // ISSUE FIX #5: Cleanup on signals + const cleanup = () => { + if (!stopped && interval) { + clearInterval(interval) + process.stdout.write("\x1B[?25h") + stopped = true + } + } + process.on("SIGINT", cleanup) + process.on("SIGTERM", cleanup) + + return { + stop: (success = true) => { + // CRITICAL FIX #3: Guard against double stop + if (stopped) return + stopped = true + + if (interval) clearInterval(interval) + const icon = success ? "\x1B[32m✓\x1B[0m" : "\x1B[31m✗\x1B[0m" + process.stdout.write(`\r\x1B[K${icon} ${text}\n`) + process.stdout.write("\x1B[?25h") // show cursor + + // Remove signal handlers + process.off("SIGINT", cleanup) + process.off("SIGTERM", cleanup) + }, + } +} diff --git a/packages/opencode/src/cli/ink/state/reducer.ts b/packages/opencode/src/cli/ink/state/reducer.ts index eb0c5dd6a57..a877b2f13a2 100644 --- a/packages/opencode/src/cli/ink/state/reducer.ts +++ b/packages/opencode/src/cli/ink/state/reducer.ts @@ -6,6 +6,7 @@ export type Action = | { type: "SET_SESSION"; payload: { id: string; agent: string; model: string | null } } | { type: "SET_SUBAGENT_MODEL"; payload: string } | { type: "STREAM_TEXT"; payload: string } + | { type: "ADD_USER_MESSAGE"; payload: string } | { type: "TOOL_START" payload: { id: string; name: string; input: Record } @@ -21,6 +22,7 @@ export const initialState: AppState = { messages: [], streaming: { text: "", + userMessage: null, tools: new Map(), tasks: new Map(), }, @@ -64,6 +66,15 @@ export function appReducer(state: AppState, action: Action): AppState { }, } + case "ADD_USER_MESSAGE": + return { + ...state, + streaming: { + ...state.streaming, + userMessage: action.payload, + }, + } + case "TOOL_START": { const tools = new Map(state.streaming.tools) tools.set(action.payload.id, { @@ -166,6 +177,7 @@ export function appReducer(state: AppState, action: Action): AppState { messages: [...state.messages, message], streaming: { text: "", + userMessage: null, tools: new Map(), tasks: new Map(), }, @@ -192,6 +204,7 @@ export function appReducer(state: AppState, action: Action): AppState { ...state, streaming: { text: "", + userMessage: null, tools: new Map(), tasks: new Map(), }, diff --git a/packages/opencode/src/cli/ink/state/types.ts b/packages/opencode/src/cli/ink/state/types.ts index 477d52c8477..a7bb2b25e87 100644 --- a/packages/opencode/src/cli/ink/state/types.ts +++ b/packages/opencode/src/cli/ink/state/types.ts @@ -59,6 +59,7 @@ export interface Task { export interface StreamingState { text: string + userMessage: string | null tools: Map tasks: Map } @@ -84,6 +85,7 @@ export interface AppState { readonly messages: readonly Message[] streaming: { readonly text: string + readonly userMessage: string | null readonly tools: ReadonlyMap readonly tasks: ReadonlyMap } diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 29e958fe357..e4191ea3ceb 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -160,6 +160,13 @@ export namespace MCP { return typeof entry === "object" && entry !== null && "type" in entry } + // Track initialization state per instance + const initState = Instance.state(() => ({ + promise: undefined as Promise | undefined, + initialized: false, + failed: false, + })) + const state = Instance.state( async () => { const cfg = await Config.get() @@ -209,6 +216,25 @@ export namespace MCP { }, ) + // Initialize MCP in background without blocking + // Uses atomic check-and-set to prevent race conditions + function initializeInBackground() { + const init = initState() + if (init.initialized || init.failed || init.promise) return + + // Atomic: set promise immediately before any async operations + init.promise = Promise.resolve() + .then(() => state()) + .then(() => { + init.initialized = true + log.info("MCP initialization complete") + }) + .catch((error) => { + init.failed = true + log.error("MCP initialization failed", { error }) + }) + } + // Helper function to fetch prompts for a specific client async function fetchPromptsForClient(clientName: string, client: Client) { const prompts = await client.listPrompts().catch((e) => { @@ -494,6 +520,12 @@ export namespace MCP { } export async function status() { + // Start initialization in background if not already started + const init = initState() + if (!init.initialized) { + initializeInBackground() + } + const s = await state() const cfg = await Config.get() const config = cfg.mcp ?? {} @@ -509,6 +541,13 @@ export namespace MCP { } export async function clients() { + // Start initialization in background if not already started + const init = initState() + if (!init.initialized) { + initializeInBackground() + // Return empty clients if not ready yet + return {} + } return state().then((state) => state.clients) } @@ -564,6 +603,21 @@ export namespace MCP { } export async function tools() { + // Start initialization in background if not already started + const init = initState() + if (!init.initialized && !init.failed) { + initializeInBackground() + // Return empty tools on first call - MCP will be available on subsequent calls + log.info("MCP not ready yet, returning empty tools (initializing in background)") + return {} + } + + // If initialization failed, return empty tools (no MCP available) + if (init.failed) { + log.debug("MCP initialization failed, returning empty tools") + return {} + } + const result: Record = {} const s = await state() const cfg = await Config.get() diff --git a/packages/opencode/test/cli/ink/state/reducer.test.ts b/packages/opencode/test/cli/ink/state/reducer.test.ts index cdc39921777..2d8c2d6dd48 100644 --- a/packages/opencode/test/cli/ink/state/reducer.test.ts +++ b/packages/opencode/test/cli/ink/state/reducer.test.ts @@ -78,6 +78,20 @@ describe("appReducer", () => { }) }) + describe("ADD_USER_MESSAGE", () => { + it("stores user message on ADD_USER_MESSAGE", () => { + const action: Action = { type: "ADD_USER_MESSAGE", payload: "hello" } + const state = appReducer(initialState, action) + expect(state.streaming.userMessage).toBe("hello") + }) + + it("replaces previous user message", () => { + let state = appReducer(initialState, { type: "ADD_USER_MESSAGE", payload: "first" }) + state = appReducer(state, { type: "ADD_USER_MESSAGE", payload: "second" }) + expect(state.streaming.userMessage).toBe("second") + }) + }) + describe("TOOL_START", () => { it("adds tool to active set", () => { const action: Action = { @@ -390,5 +404,20 @@ describe("appReducer", () => { expect(state.streaming.tools.size).toBe(0) expect(state.streaming.tasks.size).toBe(0) }) + + it("clears user message on CLEAR_STREAMING", () => { + let state = appReducer(initialState, { type: "ADD_USER_MESSAGE", payload: "hello" }) + state = appReducer(state, { type: "CLEAR_STREAMING" }) + expect(state.streaming.userMessage).toBeNull() + }) + }) + + describe("MESSAGE_COMPLETE with user message", () => { + it("clears user message on MESSAGE_COMPLETE", () => { + let state = appReducer(initialState, { type: "ADD_USER_MESSAGE", payload: "hello" }) + state = appReducer(state, { type: "STREAM_TEXT", payload: "response" }) + state = appReducer(state, { type: "MESSAGE_COMPLETE", payload: { id: "msg-1" } }) + expect(state.streaming.userMessage).toBeNull() + }) }) })