Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/ink/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export const App = (): ReactElement => {
{/* Welcome message - always visible */}
<Box marginBottom={1}>
<Text color="cyan" bold>
oclite v1.0
oclite v0.1
</Text>
<Text dimColor> - Lightweight OpenCode TUI</Text>
</Box>
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/ink/LiteApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const LiteApp = (): ReactElement => {
{/* Header */}
<Box marginBottom={1}>
<Text color="cyan" bold>
oclite v1.0
oclite v0.1
</Text>
<Text dimColor> - Lightweight OpenCode TUI</Text>
</Box>
Expand Down
20 changes: 12 additions & 8 deletions packages/opencode/src/cli/ink/commands/handlers/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
11 changes: 9 additions & 2 deletions packages/opencode/src/cli/ink/commands/handlers/compact.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import type { CommandContext } from "../types"

export async function compactHandler(_args: string[], _context: CommandContext): Promise<void> {
// Compact mode toggle implementation pending
export async function compactHandler(_args: string[], context: CommandContext): Promise<void> {
try {
context.dispatch({
type: "STREAM_TEXT",
payload: "\nCompact mode not yet implemented.\n\n",
})
} catch (error) {
console.error("Failed to dispatch compact message:", error)
}
}
28 changes: 26 additions & 2 deletions packages/opencode/src/cli/ink/commands/handlers/help.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
import type { CommandContext } from "../types"
import { getAllCommands } from "../registry"

export async function helpHandler(_args: string[], _context: CommandContext): Promise<void> {
// Help display implementation pending - requires UI component
const MAX_COMMANDS = 100

export async function helpHandler(_args: string[], context: CommandContext): Promise<void> {
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)
}
}
20 changes: 12 additions & 8 deletions packages/opencode/src/cli/ink/commands/handlers/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
71 changes: 52 additions & 19 deletions packages/opencode/src/cli/ink/commands/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
// Sessions list implementation pending
export async function sessionsHandler(_args: string[], context: CommandContext): Promise<void> {
const currentId = context.session.id || "(no session)"
const helpText = `\nCurrent session: ${currentId}\n\nSession commands:\n /session <id> - 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<void> {
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)
}
}
12 changes: 8 additions & 4 deletions packages/opencode/src/cli/ink/commands/handlers/submodel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
40 changes: 33 additions & 7 deletions packages/opencode/src/cli/ink/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/cli/ink/hooks/useSDKEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export function useSDKEvents(sessionId: string | null, dispatch: Dispatch<Action
streamingLockRef.current = true

dispatch({ type: "CLEAR_STREAMING" })
dispatch({ type: "ADD_USER_MESSAGE", payload: content })

setIsStreaming(true)

Expand Down
49 changes: 49 additions & 0 deletions packages/opencode/src/cli/ink/spinner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
export function createSpinner(text: string) {
// CRITICAL FIX #4: Defense-in-depth TTY check
if (!process.stdout.isTTY) {
return { stop: () => {} } // 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)
},
}
}
13 changes: 13 additions & 0 deletions packages/opencode/src/cli/ink/state/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ToolInputValue> }
Expand All @@ -21,6 +22,7 @@ export const initialState: AppState = {
messages: [],
streaming: {
text: "",
userMessage: null,
tools: new Map(),
tasks: new Map(),
},
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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(),
},
Expand All @@ -192,6 +204,7 @@ export function appReducer(state: AppState, action: Action): AppState {
...state,
streaming: {
text: "",
userMessage: null,
tools: new Map(),
tasks: new Map(),
},
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/cli/ink/state/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export interface Task {

export interface StreamingState {
text: string
userMessage: string | null
tools: Map<string, Tool>
tasks: Map<string, Task>
}
Expand All @@ -84,6 +85,7 @@ export interface AppState {
readonly messages: readonly Message[]
streaming: {
readonly text: string
readonly userMessage: string | null
readonly tools: ReadonlyMap<string, Tool>
readonly tasks: ReadonlyMap<string, Task>
}
Expand Down
Loading
Loading