diff --git a/packages/opencode/src/cli/ink/App.tsx b/packages/opencode/src/cli/ink/App.tsx
index e4a7c608361..5304812e77d 100644
--- a/packages/opencode/src/cli/ink/App.tsx
+++ b/packages/opencode/src/cli/ink/App.tsx
@@ -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"
@@ -92,7 +93,17 @@ export const App = (): ReactElement => {
- Lightweight OpenCode TUI
- {/* Streaming content */}
+ {/* Completed messages */}
+
+
+ {/* User message being sent */}
+ {state.streaming.userMessage && (
+
+ ❯ {state.streaming.userMessage}
+
+ )}
+
+ {/* AI streaming response */}
{state.streaming.text && (
{state.streaming.text}
diff --git a/packages/opencode/src/cli/ink/commands/handlers/agent.ts b/packages/opencode/src/cli/ink/commands/handlers/agent.ts
index 9a4d078e3b1..b5cd630943c 100644
--- a/packages/opencode/src/cli/ink/commands/handlers/agent.ts
+++ b/packages/opencode/src/cli/ink/commands/handlers/agent.ts
@@ -4,12 +4,14 @@ const VALID_AGENTS = ["build", "debug", "test", "general", "default"]
export async function agentHandler(args: string[], context: CommandContext): Promise {
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
}
@@ -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" })
}
}
diff --git a/packages/opencode/src/cli/ink/commands/handlers/clear.ts b/packages/opencode/src/cli/ink/commands/handlers/clear.ts
index 9d899a4c254..bf0c94cb68d 100644
--- a/packages/opencode/src/cli/ink/commands/handlers/clear.ts
+++ b/packages/opencode/src/cli/ink/commands/handlers/clear.ts
@@ -1,5 +1,14 @@
import type { CommandContext } from "../types"
+import { clear, cursor } from "../../../lite/terminal"
export async function clearHandler(_args: string[], context: CommandContext): Promise {
- 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" })
}
diff --git a/packages/opencode/src/cli/ink/commands/handlers/model.ts b/packages/opencode/src/cli/ink/commands/handlers/model.ts
index 04c2f1a60d8..c8cd3749f0e 100644
--- a/packages/opencode/src/cli/ink/commands/handlers/model.ts
+++ b/packages/opencode/src/cli/ink/commands/handlers/model.ts
@@ -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 {
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
}
@@ -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" })
}
}
diff --git a/packages/opencode/src/cli/ink/commands/handlers/session.ts b/packages/opencode/src/cli/ink/commands/handlers/session.ts
index cf3dd88df8d..b3dfd65936b 100644
--- a/packages/opencode/src/cli/ink/commands/handlers/session.ts
+++ b/packages/opencode/src/cli/ink/commands/handlers/session.ts
@@ -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 {
if (args.length === 0 || !args[0]) {
+ context.dispatch({ type: "STREAM_TEXT", payload: "Invalid session ID. Use: /session \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
}
@@ -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" })
}
}
@@ -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" })
}
}
diff --git a/packages/opencode/src/cli/ink/commands/handlers/submodel.ts b/packages/opencode/src/cli/ink/commands/handlers/submodel.ts
index 723bde18a91..f4b28997da2 100644
--- a/packages/opencode/src/cli/ink/commands/handlers/submodel.ts
+++ b/packages/opencode/src/cli/ink/commands/handlers/submodel.ts
@@ -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 {
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
}
@@ -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
}
@@ -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" })
}
}
diff --git a/packages/opencode/src/cli/ink/components/MessageList.tsx b/packages/opencode/src/cli/ink/components/MessageList.tsx
index c2056009cb7..3d2092b90cf 100644
--- a/packages/opencode/src/cli/ink/components/MessageList.tsx
+++ b/packages/opencode/src/cli/ink/components/MessageList.tsx
@@ -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 => (
diff --git a/packages/opencode/src/cli/ink/hooks/useSDKEvents.ts b/packages/opencode/src/cli/ink/hooks/useSDKEvents.ts
index c60cfb29fb0..affb5d81d3f 100644
--- a/packages/opencode/src/cli/ink/hooks/useSDKEvents.ts
+++ b/packages/opencode/src/cli/ink/hooks/useSDKEvents.ts
@@ -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
+ if (props.part && typeof props.part === "object" && props.part !== null) {
+ const part = props.part as Record
+ 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
+ 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
@@ -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
}
diff --git a/packages/opencode/src/cli/ink/state/reducer.ts b/packages/opencode/src/cli/ink/state/reducer.ts
index a877b2f13a2..2690f75a529 100644
--- a/packages/opencode/src/cli/ink/state/reducer.ts
+++ b/packages/opencode/src/cli/ink/state/reducer.ts
@@ -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: [],
@@ -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
}
diff --git a/packages/opencode/test/cli/ink/commands/handlers.test.ts b/packages/opencode/test/cli/ink/commands/handlers.test.ts
index 7521196df70..a8f0c58f41a 100644
--- a/packages/opencode/test/cli/ink/commands/handlers.test.ts
+++ b/packages/opencode/test/cli/ink/commands/handlers.test.ts
@@ -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
+ }
})
})
diff --git a/packages/opencode/test/cli/ink/commands/submodel.test.ts b/packages/opencode/test/cli/ink/commands/submodel.test.ts
index b107d8048fb..37954240573 100644
--- a/packages/opencode/test/cli/ink/commands/submodel.test.ts
+++ b/packages/opencode/test/cli/ink/commands/submodel.test.ts
@@ -49,7 +49,10 @@ describe("cli.ink.commands.handlers.submodel", () => {
await submodelHandler([], context)
- expect(mockDispatch).not.toHaveBeenCalled()
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: "STREAM_TEXT",
+ payload: "Invalid model format. Use: /submodel provider/model-name\n",
+ })
})
test("rejects invalid model format without slash", async () => {
@@ -59,7 +62,10 @@ describe("cli.ink.commands.handlers.submodel", () => {
await submodelHandler(["invalid-model"], context)
- expect(mockDispatch).not.toHaveBeenCalled()
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: "STREAM_TEXT",
+ payload: "Invalid model format. Use: /submodel provider/model-name\n",
+ })
})
test("rejects model with multiple slashes", async () => {
@@ -69,7 +75,10 @@ describe("cli.ink.commands.handlers.submodel", () => {
await submodelHandler(["provider/model/version"], context)
- expect(mockDispatch).not.toHaveBeenCalled()
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: "STREAM_TEXT",
+ payload: "Invalid model format. Use: /submodel provider/model-name\n",
+ })
})
test("accepts model with hyphens and dots", async () => {
@@ -91,7 +100,10 @@ describe("cli.ink.commands.handlers.submodel", () => {
await submodelHandler([""], context)
- expect(mockDispatch).not.toHaveBeenCalled()
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: "STREAM_TEXT",
+ payload: "Invalid model format. Use: /submodel provider/model-name\n",
+ })
})
test("rejects model with leading hyphen in provider", async () => {
@@ -101,7 +113,10 @@ describe("cli.ink.commands.handlers.submodel", () => {
await submodelHandler(["-provider/model"], context)
- expect(mockDispatch).not.toHaveBeenCalled()
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: "STREAM_TEXT",
+ payload: "Invalid model format. Use: /submodel provider/model-name\n",
+ })
})
test("rejects model with leading hyphen in model name", async () => {
@@ -111,7 +126,10 @@ describe("cli.ink.commands.handlers.submodel", () => {
await submodelHandler(["provider/-model"], context)
- expect(mockDispatch).not.toHaveBeenCalled()
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: "STREAM_TEXT",
+ payload: "Invalid model format. Use: /submodel provider/model-name\n",
+ })
})
test("rejects single character provider or model", async () => {
@@ -121,7 +139,10 @@ describe("cli.ink.commands.handlers.submodel", () => {
await submodelHandler(["a/b"], context)
- expect(mockDispatch).not.toHaveBeenCalled()
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: "STREAM_TEXT",
+ payload: "Invalid model format. Use: /submodel provider/model-name\n",
+ })
})
test("rejects single character model name", async () => {
@@ -131,7 +152,10 @@ describe("cli.ink.commands.handlers.submodel", () => {
await submodelHandler(["provider/a"], context)
- expect(mockDispatch).not.toHaveBeenCalled()
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: "STREAM_TEXT",
+ payload: "Invalid model format. Use: /submodel provider/model-name\n",
+ })
})
test("rejects single digit model name", async () => {
@@ -141,7 +165,10 @@ describe("cli.ink.commands.handlers.submodel", () => {
await submodelHandler(["provider/1"], context)
- expect(mockDispatch).not.toHaveBeenCalled()
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: "STREAM_TEXT",
+ payload: "Invalid model format. Use: /submodel provider/model-name\n",
+ })
})
test("accepts minimum valid format", async () => {
@@ -163,7 +190,10 @@ describe("cli.ink.commands.handlers.submodel", () => {
await submodelHandler(["provider-/model"], context)
- expect(mockDispatch).not.toHaveBeenCalled()
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: "STREAM_TEXT",
+ payload: "Invalid model format. Use: /submodel provider/model-name\n",
+ })
})
test("rejects model with trailing hyphen in model name", async () => {
@@ -173,7 +203,10 @@ describe("cli.ink.commands.handlers.submodel", () => {
await submodelHandler(["provider/model-"], context)
- expect(mockDispatch).not.toHaveBeenCalled()
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: "STREAM_TEXT",
+ payload: "Invalid model format. Use: /submodel provider/model-name\n",
+ })
})
test("rejects model exceeding maximum length", async () => {
@@ -185,7 +218,10 @@ describe("cli.ink.commands.handlers.submodel", () => {
const longModel = "b".repeat(129)
await submodelHandler([`${longProvider}/${longModel}`], context)
- expect(mockDispatch).not.toHaveBeenCalled()
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: "STREAM_TEXT",
+ payload: "Invalid model format. Model name too long.\n",
+ })
})
test("accepts model at maximum length boundary", async () => {
@@ -210,7 +246,10 @@ describe("cli.ink.commands.handlers.submodel", () => {
const provider = "a" + "b".repeat(63) + "c"
await submodelHandler([`${provider}/model`], context)
- expect(mockDispatch).not.toHaveBeenCalled()
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: "STREAM_TEXT",
+ payload: "Invalid model format. Provider or model name invalid.\n",
+ })
})
test("rejects model name exceeding 128 characters", async () => {
@@ -221,7 +260,10 @@ describe("cli.ink.commands.handlers.submodel", () => {
const model = "a" + "b".repeat(127) + "c"
await submodelHandler([`provider/${model}`], context)
- expect(mockDispatch).not.toHaveBeenCalled()
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: "STREAM_TEXT",
+ payload: "Invalid model format. Provider or model name invalid.\n",
+ })
})
test("rejects malicious backtracking input without timeout", async () => {
@@ -234,7 +276,10 @@ describe("cli.ink.commands.handlers.submodel", () => {
await submodelHandler([attack], context)
const duration = Date.now() - start
- expect(mockDispatch).not.toHaveBeenCalled()
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: "STREAM_TEXT",
+ payload: "Invalid model format. Use: /submodel provider/model-name\n",
+ })
expect(duration).toBeLessThan(100)
})
})
diff --git a/packages/opencode/test/cli/ink/state/reducer.test.ts b/packages/opencode/test/cli/ink/state/reducer.test.ts
index 2d8c2d6dd48..143533713e8 100644
--- a/packages/opencode/test/cli/ink/state/reducer.test.ts
+++ b/packages/opencode/test/cli/ink/state/reducer.test.ts
@@ -412,6 +412,33 @@ describe("appReducer", () => {
})
})
+ describe("CLEAR_ALL", () => {
+ it("clears all messages and streaming state", () => {
+ let state = initialState
+ // Add completed messages
+ state = appReducer(state, { type: "ADD_USER_MESSAGE", payload: "hello" })
+ state = appReducer(state, { type: "MESSAGE_COMPLETE", payload: { id: "msg-1" } })
+ state = appReducer(state, { type: "STREAM_TEXT", payload: "response" })
+ state = appReducer(state, { type: "MESSAGE_COMPLETE", payload: { id: "msg-2" } })
+ // Add streaming state
+ state = appReducer(state, { type: "STREAM_TEXT", payload: "streaming..." })
+ state = appReducer(state, {
+ type: "TOOL_START",
+ payload: { id: "tool-1", name: "read", input: {} },
+ })
+
+ // Clear all
+ state = appReducer(state, { type: "CLEAR_ALL" })
+
+ // Verify everything is cleared
+ expect(state.messages).toEqual([])
+ expect(state.streaming.text).toBe("")
+ expect(state.streaming.userMessage).toBeNull()
+ expect(state.streaming.tools.size).toBe(0)
+ expect(state.streaming.tasks.size).toBe(0)
+ })
+ })
+
describe("MESSAGE_COMPLETE with user message", () => {
it("clears user message on MESSAGE_COMPLETE", () => {
let state = appReducer(initialState, { type: "ADD_USER_MESSAGE", payload: "hello" })