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()
+ })
})
})