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" })