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
13 changes: 12 additions & 1 deletion packages/opencode/src/cli/ink/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Box, Text } from "ink"
import { appReducer, initialState } from "./state/reducer"
import { theme } from "./theme"
import { InputLine } from "./components/InputLine"
import { MessageList } from "./components/MessageList"
import { isCommand, executeCommand } from "./commands"
import { Session } from "../../session"
import { Instance } from "../../project/instance"
Expand Down Expand Up @@ -92,7 +93,17 @@ export const App = (): ReactElement => {
<Text dimColor> - Lightweight OpenCode TUI</Text>
</Box>

{/* Streaming content */}
{/* Completed messages */}
<MessageList messages={state.messages} />

{/* User message being sent */}
{state.streaming.userMessage && (
<Box>
<Text color="blue">❯ {state.streaming.userMessage}</Text>
</Box>
)}

{/* AI streaming response */}
{state.streaming.text && (
<Box>
<Text>{state.streaming.text}</Text>
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/cli/ink/commands/handlers/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ const VALID_AGENTS = ["build", "debug", "test", "general", "default"]

export async function agentHandler(args: string[], context: CommandContext): Promise<void> {
if (args.length === 0 || !args[0]) {
context.dispatch({ type: "STREAM_TEXT", payload: "Invalid agent. Use: build, debug, test, general, or default\n" })
return
}

const agentName = args[0]

if (!VALID_AGENTS.includes(agentName)) {
context.dispatch({ type: "STREAM_TEXT", payload: "Invalid agent. Use: build, debug, test, general, or default\n" })
return
}

Expand All @@ -22,7 +24,9 @@ export async function agentHandler(args: string[], context: CommandContext): Pro
model: context.session.model,
},
})
context.dispatch({ type: "STREAM_TEXT", payload: `Agent switched to: ${agentName}\n` })
} catch (error) {
console.error("Failed to set agent:", error)
context.dispatch({ type: "STREAM_TEXT", payload: "Failed to set agent\n" })
}
}
11 changes: 10 additions & 1 deletion packages/opencode/src/cli/ink/commands/handlers/clear.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import type { CommandContext } from "../types"
import { clear, cursor } from "../../../lite/terminal"

export async function clearHandler(_args: string[], context: CommandContext): Promise<void> {
context.dispatch({ type: "CLEAR_STREAMING" })
// Clear terminal screen using ANSI codes (only in TTY environments)
// Guard against non-TTY contexts (CI/CD, pipes, redirects)
if (process.stdout.isTTY) {
process.stdout.write(clear.screen + cursor.home)
}

// Clear all messages and streaming state in Ink's React state
// This prevents old content from reappearing when Ink re-renders
context.dispatch({ type: "CLEAR_ALL" })
}
4 changes: 4 additions & 0 deletions packages/opencode/src/cli/ink/commands/handlers/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ const VALID_MODEL_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-]{1,}\/[a-zA-Z0-9][a-zA-Z0-9

export async function modelHandler(args: string[], context: CommandContext): Promise<void> {
if (args.length === 0 || !args[0]) {
context.dispatch({ type: "STREAM_TEXT", payload: "Invalid model format. Use: /model provider/model-name\n" })
return
}

const modelName = args[0]

if (!VALID_MODEL_PATTERN.test(modelName)) {
context.dispatch({ type: "STREAM_TEXT", payload: "Invalid model format. Use: /model provider/model-name\n" })
return
}

Expand All @@ -22,7 +24,9 @@ export async function modelHandler(args: string[], context: CommandContext): Pro
model: modelName,
},
})
context.dispatch({ type: "STREAM_TEXT", payload: `Model set to: ${modelName}\n` })
} catch (error) {
console.error("Failed to set model:", error)
context.dispatch({ type: "STREAM_TEXT", payload: "Failed to set model\n" })
}
}
9 changes: 9 additions & 0 deletions packages/opencode/src/cli/ink/commands/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@ const SESSION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-_]{2,63}$/

export async function sessionHandler(args: string[], context: CommandContext): Promise<void> {
if (args.length === 0 || !args[0]) {
context.dispatch({ type: "STREAM_TEXT", payload: "Invalid session ID. Use: /session <id>\n" })
return
}

const sessionId = args[0]

if (!SESSION_ID_PATTERN.test(sessionId)) {
context.dispatch({
type: "STREAM_TEXT",
payload: "Invalid session ID format. Must be 3-64 alphanumeric characters, hyphens, or underscores.\n",
})
return
}

Expand All @@ -22,8 +27,10 @@ export async function sessionHandler(args: string[], context: CommandContext): P
model: context.session.model,
},
})
context.dispatch({ type: "STREAM_TEXT", payload: `Session switched to: ${sessionId}\n` })
} catch (error) {
console.error("Failed to set session:", error)
context.dispatch({ type: "STREAM_TEXT", payload: "Failed to switch session\n" })
}
}

Expand Down Expand Up @@ -67,7 +74,9 @@ export async function newSessionHandler(_args: string[], context: CommandContext
model: context.session.model,
},
})
context.dispatch({ type: "STREAM_TEXT", payload: `New session created: ${sessionId}\n` })
} catch (error) {
console.error("Failed to set new session:", error)
context.dispatch({ type: "STREAM_TEXT", payload: "Failed to create new session\n" })
}
}
6 changes: 6 additions & 0 deletions packages/opencode/src/cli/ink/commands/handlers/submodel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,19 @@ const VALID_MODEL_PATTERN = /^[a-zA-Z](?:[a-zA-Z0-9]+-)*[a-zA-Z0-9]+\/[a-zA-Z0-9

export async function submodelHandler(args: string[], context: CommandContext): Promise<void> {
if (args.length === 0) {
context.dispatch({ type: "STREAM_TEXT", payload: "Invalid model format. Use: /submodel provider/model-name\n" })
return
}

const modelName = args[0]

if (modelName.length > MAX_TOTAL_LENGTH) {
context.dispatch({ type: "STREAM_TEXT", payload: "Invalid model format. Model name too long.\n" })
return
}

if (!VALID_MODEL_PATTERN.test(modelName)) {
context.dispatch({ type: "STREAM_TEXT", payload: "Invalid model format. Use: /submodel provider/model-name\n" })
return
}

Expand All @@ -26,6 +29,7 @@ export async function submodelHandler(args: string[], context: CommandContext):
const model = parts[1]

if (!provider || !model || provider.length > MAX_PROVIDER_LENGTH || model.length > MAX_MODEL_LENGTH) {
context.dispatch({ type: "STREAM_TEXT", payload: "Invalid model format. Provider or model name invalid.\n" })
return
}

Expand All @@ -34,7 +38,9 @@ export async function submodelHandler(args: string[], context: CommandContext):
type: "SET_SUBAGENT_MODEL",
payload: modelName,
})
context.dispatch({ type: "STREAM_TEXT", payload: `Subagent model set to: ${modelName}\n` })
} catch (error) {
console.error("Failed to set subagent model:", error)
context.dispatch({ type: "STREAM_TEXT", payload: "Failed to set subagent model\n" })
}
}
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/ink/components/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { Message } from "../state/types"
import { theme } from "../theme"

interface MessageListProps {
messages: Message[]
messages: readonly Message[]
}

export const MessageList = ({ messages }: MessageListProps): ReactElement => (
Expand Down
34 changes: 26 additions & 8 deletions packages/opencode/src/cli/ink/hooks/useSDKEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,28 @@ function handleEvent(
currentAssistantMessageIdRef: { current: string | null },
streamingLockRef: { current: boolean },
) {
// Debug logging for event flow
const getSessionId = () => {
const props = event.properties as Record<string, unknown>
if (props.part && typeof props.part === "object" && props.part !== null) {
const part = props.part as Record<string, unknown>
if (typeof part.sessionID === "string") {
return part.sessionID.slice(0, 8)
}
}
if (props.info && typeof props.info === "object" && props.info !== null) {
const info = props.info as Record<string, unknown>
if (typeof info.sessionID === "string") {
return info.sessionID.slice(0, 8)
}
}
if (typeof props.sessionID === "string") {
return props.sessionID.slice(0, 8)
}
return "unknown"
}
console.error(`[EVENT] ${event.type}`, getSessionId())

switch (event.type) {
case "message.part.updated": {
const part = event.properties.part
Expand Down Expand Up @@ -230,14 +252,10 @@ function handleEvent(
if (event.properties.status.type === "idle") {
setIsStreaming(false)
streamingLockRef.current = false

// Fallback: If MESSAGE_COMPLETE never arrived (SDK crash, network error),
// clear streaming state to prevent memory leak
if (currentAssistantMessageIdRef.current !== null) {
currentAssistantMessageIdRef.current = null
dispatch({ type: "CLEAR_STREAMING" })
}
// Otherwise, MESSAGE_COMPLETE already moved content to messages array
currentAssistantMessageIdRef.current = null
// DO NOT dispatch CLEAR_STREAMING here - let MESSAGE_COMPLETE handle it
// The session.status: idle event may arrive BEFORE message.updated,
// causing streaming content to be wiped before it can be displayed.
}
break
}
Expand Down
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 @@ -17,6 +17,7 @@ export type Action =
| { type: "MESSAGE_COMPLETE"; payload: { id: string } }
| { type: "SET_UI_MODE"; payload: UIMode }
| { type: "CLEAR_STREAMING" }
| { type: "CLEAR_ALL" }

export const initialState: AppState = {
messages: [],
Expand Down Expand Up @@ -210,6 +211,18 @@ export function appReducer(state: AppState, action: Action): AppState {
},
}

case "CLEAR_ALL":
return {
...state,
messages: [],
streaming: {
text: "",
userMessage: null,
tools: new Map(),
tasks: new Map(),
},
}

default:
return state
}
Expand Down
54 changes: 51 additions & 3 deletions packages/opencode/test/cli/ink/commands/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,59 @@ describe("cli.ink.commands.handlers", () => {
})

describe("clear handler", () => {
test("dispatches CLEAR_STREAMING action", async () => {
test("clears terminal screen and all state in TTY environment", async () => {
const { clearHandler } = await import("../../../../src/cli/ink/commands/handlers/clear")
const context = createContext()
await clearHandler([], context)
expect(mockDispatch).toHaveBeenCalledWith({ type: "CLEAR_STREAMING" })

// Mock stdout.write to capture ANSI escape codes
const originalWrite = process.stdout.write
const originalIsTTY = process.stdout.isTTY
const writeMock = mock(() => true)

try {
process.stdout.write = writeMock as never
process.stdout.isTTY = true

await clearHandler([], context)

// Verify ANSI escape codes were written using terminal utilities
// Should write clear.screen (\x1b[2J) + cursor.home (\x1b[H)
expect(writeMock).toHaveBeenCalledWith("\x1b[2J\x1b[H")

// Verify all state was cleared (messages + streaming)
expect(mockDispatch).toHaveBeenCalledWith({ type: "CLEAR_ALL" })
} finally {
// Restore original write and isTTY (always executes)
process.stdout.write = originalWrite
process.stdout.isTTY = originalIsTTY
}
})

test("skips ANSI codes in non-TTY environment but clears state", async () => {
const { clearHandler } = await import("../../../../src/cli/ink/commands/handlers/clear")
const context = createContext()

// Mock stdout.write to verify ANSI codes are NOT written
const originalWrite = process.stdout.write
const originalIsTTY = process.stdout.isTTY
const writeMock = mock(() => true)

try {
process.stdout.write = writeMock as never
process.stdout.isTTY = false

await clearHandler([], context)

// Verify ANSI escape codes were NOT written in non-TTY
expect(writeMock).not.toHaveBeenCalled()

// Verify state was still cleared (this always happens)
expect(mockDispatch).toHaveBeenCalledWith({ type: "CLEAR_ALL" })
} finally {
// Restore original write and isTTY (always executes)
process.stdout.write = originalWrite
process.stdout.isTTY = originalIsTTY
}
})
})

Expand Down
Loading
Loading