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
26 changes: 19 additions & 7 deletions packages/opencode/src/cli/ink/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,21 @@ export const App = (): ReactElement => {
const [state, dispatch] = useReducer(appReducer, initialState)
const [uiMode, setUIMode] = useState(state.ui.mode)
const sessionRef = useRef(state.session)
const hasStartedRef = useRef(false)
const [showWelcome, setShowWelcome] = useState(true)

useEffect(() => {
sessionRef.current = state.session
}, [state.session])

// Hide welcome banner after first message - use ref to survive re-renders
useEffect(() => {
if (!hasStartedRef.current && (state.messages.length > 0 || state.streaming.text)) {
hasStartedRef.current = true
setShowWelcome(false)
}
}, [state.messages.length, state.streaming.text])

// Initialize session on mount
useEffect(() => {
const initSession = async () => {
Expand Down Expand Up @@ -85,13 +95,15 @@ export const App = (): ReactElement => {

return (
<Box flexDirection="column">
{/* Welcome message - always visible */}
<Box marginBottom={1}>
<Text color="cyan" bold>
oclite v0.1
</Text>
<Text dimColor> - Lightweight OpenCode TUI</Text>
</Box>
{/* Welcome message - only show once */}
{showWelcome && (
<Box marginBottom={1}>
<Text color="cyan" bold>
oclite v0.1
</Text>
<Text dimColor> - Lightweight OpenCode TUI</Text>
</Box>
)}

{/* Completed messages */}
<MessageList messages={state.messages} />
Expand Down
53 changes: 28 additions & 25 deletions packages/opencode/src/cli/ink/hooks/useSDKEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export function useSDKEvents(sessionId: string | null, dispatch: Dispatch<Action
const [isStreaming, setIsStreaming] = useState(false)
const currentAssistantMessageIdRef = useRef<string | null>(null)
const streamingLockRef = useRef(false)
const seenTextLengthRef = useRef<Map<string, number>>(new Map())

useEffect(() => {
if (!sessionId) {
Expand All @@ -52,7 +53,15 @@ export function useSDKEvents(sessionId: string | null, dispatch: Dispatch<Action
)

for await (const event of events.stream) {
handleEvent(event, dispatch, sessionId, setIsStreaming, currentAssistantMessageIdRef, streamingLockRef)
handleEvent(
event,
dispatch,
sessionId,
setIsStreaming,
currentAssistantMessageIdRef,
streamingLockRef,
seenTextLengthRef,
)
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
Expand Down Expand Up @@ -124,29 +133,8 @@ function handleEvent(
setIsStreaming: (value: boolean) => void,
currentAssistantMessageIdRef: { current: string | null },
streamingLockRef: { current: boolean },
seenTextLengthRef: { current: Map<string, number> },
) {
// 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 All @@ -159,8 +147,19 @@ function handleEvent(
return
}

// Use delta for incremental updates, fallback to full text
const content = event.properties.delta ?? part.text
// Prefer delta for incremental updates, compute delta from full text as fallback
let content = event.properties.delta
if (content === undefined && typeof part.text === "string") {
const messageKey = `${part.sessionID}:${part.messageID}`
const seenLength = seenTextLengthRef.current.get(messageKey) ?? 0

// Defensive check: only slice if text grew (prevent loss on truncation)
if (part.text.length >= seenLength) {
content = part.text.slice(seenLength)
seenTextLengthRef.current.set(messageKey, part.text.length)
}
}

if (typeof content === "string" && content.length > 0) {
dispatch({
type: "STREAM_TEXT",
Expand Down Expand Up @@ -234,6 +233,10 @@ function handleEvent(
if (event.properties.info.sessionID !== sessionId) return
setIsStreaming(false)

// Clean up text length tracking for this message (always, not just for current message)
const messageKey = `${event.properties.info.sessionID}:${event.properties.info.id}`
seenTextLengthRef.current.delete(messageKey)

// Clear the tracked message ID if this is the expected assistant message
if (currentAssistantMessageIdRef.current === event.properties.info.id) {
currentAssistantMessageIdRef.current = null
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/cli/ink/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export function startInkTUI() {
stdin: process.stdin,
stdout: process.stdout,
exitOnCtrlC: true, // Let Ink handle Ctrl+C exit
patchConsole: true, // Prevent console.log/error from breaking Ink rendering
})

return instance.waitUntilExit()
Expand Down