2025-10-31 19:07:36 +00:00
import { render , useKeyboard , useRenderer , useTerminalDimensions } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import { TextAttributes } from "@opentui/core"
import { RouteProvider , useRoute , type Route } from "@tui/context/route"
import { Switch , Match , createEffect , untrack , ErrorBoundary , createSignal } from "solid-js"
import { Installation } from "@/installation"
import { Global } from "@/global"
import { DialogProvider , useDialog } from "@tui/ui/dialog"
import { SDKProvider , useSDK } from "@tui/context/sdk"
import { SyncProvider , useSync } from "@tui/context/sync"
import { LocalProvider , useLocal } from "@tui/context/local"
import { DialogModel } from "@tui/component/dialog-model"
import { DialogStatus } from "@tui/component/dialog-status"
import { DialogThemeList } from "@tui/component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { CommandProvider , useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { KeybindProvider } from "@tui/context/keybind"
import { ThemeProvider , useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
import { Session } from "@tui/routes/session"
import { PromptHistoryProvider } from "./component/prompt/history"
import { DialogAlert } from "./ui/dialog-alert"
import { ToastProvider , useToast } from "./ui/toast"
2025-11-01 16:13:10 +00:00
import { ExitProvider , useExit } from "./context/exit"
2025-10-31 19:07:36 +00:00
import type { SessionRoute } from "./context/route"
import { Session as SessionApi } from "@/session"
import { TuiEvent } from "./event"
2025-10-31 20:12:58 +00:00
import { KVProvider , useKV } from "./context/kv"
2025-10-31 19:07:36 +00:00
2025-11-01 17:54:01 +00:00
async function getTerminalBackgroundColor ( ) : Promise < "dark" | "light" > {
2025-11-04 04:36:01 +00:00
// can't set raw mode if not a TTY
if ( ! process . stdin . isTTY ) return "dark"
2025-11-01 17:54:01 +00:00
return new Promise ( ( resolve ) = > {
let timeout : NodeJS.Timeout
const cleanup = ( ) = > {
process . stdin . setRawMode ( false )
process . stdin . removeListener ( "data" , handler )
clearTimeout ( timeout )
}
const handler = ( data : Buffer ) = > {
const str = data . toString ( )
const match = str . match ( /\x1b]11;([^\x07\x1b]+)/ )
if ( match ) {
cleanup ( )
const color = match [ 1 ]
// Parse RGB values from color string
// Formats: rgb:RR/GG/BB or #RRGGBB or rgb(R,G,B)
let r = 0 ,
g = 0 ,
b = 0
if ( color . startsWith ( "rgb:" ) ) {
const parts = color . substring ( 4 ) . split ( "/" )
r = parseInt ( parts [ 0 ] , 16 ) >> 8 // Convert 16-bit to 8-bit
g = parseInt ( parts [ 1 ] , 16 ) >> 8 // Convert 16-bit to 8-bit
b = parseInt ( parts [ 2 ] , 16 ) >> 8 // Convert 16-bit to 8-bit
} else if ( color . startsWith ( "#" ) ) {
r = parseInt ( color . substring ( 1 , 3 ) , 16 )
g = parseInt ( color . substring ( 3 , 5 ) , 16 )
b = parseInt ( color . substring ( 5 , 7 ) , 16 )
} else if ( color . startsWith ( "rgb(" ) ) {
const parts = color . substring ( 4 , color . length - 1 ) . split ( "," )
r = parseInt ( parts [ 0 ] )
g = parseInt ( parts [ 1 ] )
b = parseInt ( parts [ 2 ] )
}
// Calculate luminance using relative luminance formula
const luminance = ( 0.299 * r + 0.587 * g + 0.114 * b ) / 255
// Determine if dark or light based on luminance threshold
resolve ( luminance > 0.5 ? "light" : "dark" )
}
}
process . stdin . setRawMode ( true )
process . stdin . on ( "data" , handler )
process . stdout . write ( "\x1b]11;?\x07" )
timeout = setTimeout ( ( ) = > {
cleanup ( )
resolve ( "dark" )
} , 1000 )
} )
}
2025-10-31 19:07:36 +00:00
export function tui ( input : {
url : string
sessionID? : string
model? : string
agent? : string
2025-11-01 15:18:31 +00:00
prompt? : string
2025-10-31 19:07:36 +00:00
onExit ? : ( ) = > Promise < void >
} ) {
// promise to prevent immediate exit
2025-11-01 17:54:01 +00:00
return new Promise < void > ( async ( resolve ) = > {
const mode = await getTerminalBackgroundColor ( )
2025-10-31 19:07:36 +00:00
const routeData : Route | undefined = input . sessionID
? {
type : "session" ,
sessionID : input.sessionID ,
}
: undefined
const onExit = async ( ) = > {
await input . onExit ? . ( )
resolve ( )
}
render (
( ) = > {
return (
2025-10-31 21:53:08 +00:00
< ErrorBoundary
fallback = { ( error , reset ) = > (
< ErrorComponent error = { error } reset = { reset } onExit = { onExit } / >
) }
>
2025-10-31 19:07:36 +00:00
< ExitProvider onExit = { onExit } >
2025-10-31 20:12:58 +00:00
< KVProvider >
< ToastProvider >
< RouteProvider data = { routeData } >
< SDKProvider url = { input . url } >
< SyncProvider >
2025-11-01 17:54:01 +00:00
< ThemeProvider mode = { mode } >
< LocalProvider
initialModel = { input . model }
initialAgent = { input . agent }
initialPrompt = { input . prompt }
>
2025-10-31 20:12:58 +00:00
< KeybindProvider >
< DialogProvider >
< CommandProvider >
< PromptHistoryProvider >
< App / >
< / PromptHistoryProvider >
< / CommandProvider >
< / DialogProvider >
< / KeybindProvider >
< / LocalProvider >
< / ThemeProvider >
< / SyncProvider >
< / SDKProvider >
< / RouteProvider >
< / ToastProvider >
< / KVProvider >
2025-10-31 19:07:36 +00:00
< / ExitProvider >
< / ErrorBoundary >
)
} ,
{
targetFps : 60 ,
gatherStats : false ,
exitOnCtrlC : false ,
2025-11-02 00:45:38 +00:00
useKittyKeyboard : true ,
2025-10-31 19:07:36 +00:00
} ,
)
} )
}
function App() {
const route = useRoute ( )
const dimensions = useTerminalDimensions ( )
const renderer = useRenderer ( )
renderer . disableStdoutInterception ( )
const dialog = useDialog ( )
const local = useLocal ( )
2025-10-31 20:12:58 +00:00
const kv = useKV ( )
2025-10-31 19:07:36 +00:00
const command = useCommandDialog ( )
const { event } = useSDK ( )
const sync = useSync ( )
const toast = useToast ( )
const [ sessionExists , setSessionExists ] = createSignal ( false )
2025-11-01 17:54:01 +00:00
const { theme , mode , setMode } = useTheme ( )
2025-11-01 16:13:10 +00:00
const exit = useExit ( )
2025-10-31 19:07:36 +00:00
useKeyboard ( async ( evt ) = > {
2025-11-03 21:47:08 +00:00
if ( ! Installation . isLocal ( ) ) return
2025-10-31 19:07:36 +00:00
if ( evt . meta && evt . name === "t" ) {
2025-11-03 21:47:08 +00:00
renderer . toggleDebugOverlay ( )
2025-10-31 19:07:36 +00:00
return
}
if ( evt . meta && evt . name === "d" ) {
2025-11-03 21:47:08 +00:00
renderer . console . toggle ( )
2025-10-31 19:07:36 +00:00
return
}
} )
// Make sure session is valid, otherwise redirect to home
createEffect ( async ( ) = > {
if ( route . data . type === "session" ) {
const data = route . data as SessionRoute
await sync . session . sync ( data . sessionID ) . catch ( ( ) = > {
toast . show ( {
message : ` Session not found: ${ data . sessionID } ` ,
variant : "error" ,
} )
return route . navigate ( { type : "home" } )
} )
setSessionExists ( true )
}
} )
createEffect ( ( ) = > {
console . log ( JSON . stringify ( route . data ) )
} )
command . register ( ( ) = > [
{
title : "Switch session" ,
value : "session.list" ,
keybind : "session_list" ,
category : "Session" ,
onSelect : ( ) = > {
dialog . replace ( ( ) = > < DialogSessionList / > )
} ,
} ,
{
title : "New session" ,
value : "session.new" ,
keybind : "session_new" ,
category : "Session" ,
onSelect : ( ) = > {
route . navigate ( {
type : "home" ,
} )
dialog . clear ( )
} ,
} ,
{
title : "Switch model" ,
value : "model.list" ,
keybind : "model_list" ,
category : "Agent" ,
onSelect : ( ) = > {
dialog . replace ( ( ) = > < DialogModel / > )
} ,
} ,
2025-10-31 23:42:27 +00:00
{
title : "Model cycle" ,
value : "model.cycle_recent" ,
keybind : "model_cycle_recent" ,
category : "Agent" ,
onSelect : ( ) = > {
local . model . cycle ( 1 )
} ,
} ,
{
title : "Model cycle reverse" ,
value : "model.cycle_recent_reverse" ,
keybind : "model_cycle_recent_reverse" ,
category : "Agent" ,
onSelect : ( ) = > {
local . model . cycle ( - 1 )
} ,
} ,
2025-10-31 19:07:36 +00:00
{
title : "Switch agent" ,
value : "agent.list" ,
keybind : "agent_list" ,
category : "Agent" ,
onSelect : ( ) = > {
dialog . replace ( ( ) = > < DialogAgent / > )
} ,
} ,
{
title : "Agent cycle" ,
value : "agent.cycle" ,
keybind : "agent_cycle" ,
category : "Agent" ,
disabled : true ,
onSelect : ( ) = > {
local . agent . move ( 1 )
} ,
} ,
{
title : "Agent cycle reverse" ,
value : "agent.cycle.reverse" ,
keybind : "agent_cycle_reverse" ,
category : "Agent" ,
disabled : true ,
onSelect : ( ) = > {
local . agent . move ( - 1 )
} ,
} ,
{
title : "View status" ,
keybind : "status_view" ,
value : "opencode.status" ,
onSelect : ( ) = > {
dialog . replace ( ( ) = > < DialogStatus / > )
} ,
category : "System" ,
} ,
{
title : "Switch theme" ,
value : "theme.switch" ,
onSelect : ( ) = > {
dialog . replace ( ( ) = > < DialogThemeList / > )
} ,
category : "System" ,
} ,
2025-11-01 17:54:01 +00:00
{
title : ` Switch to ${ mode ( ) === "dark" ? "light" : "dark" } mode ` ,
value : "theme.switch_mode" ,
onSelect : ( ) = > {
setMode ( mode ( ) === "dark" ? "light" : "dark" )
} ,
category : "System" ,
} ,
2025-10-31 19:07:36 +00:00
{
title : "Help" ,
value : "help.show" ,
onSelect : ( ) = > {
dialog . replace ( ( ) = > < DialogHelp / > )
} ,
category : "System" ,
} ,
2025-11-01 16:13:10 +00:00
{
title : "Exit the app" ,
value : "app.exit" ,
onSelect : exit ,
category : "System" ,
2025-11-01 17:54:01 +00:00
} ,
2025-10-31 19:07:36 +00:00
] )
createEffect ( ( ) = > {
const providerID = local . model . current ( ) . providerID
2025-11-01 15:40:21 +00:00
if ( providerID === "openrouter" && ! kv . get ( "openrouter_warning" , false ) ) {
2025-10-31 19:07:36 +00:00
untrack ( ( ) = > {
DialogAlert . show (
dialog ,
"Warning" ,
"While openrouter is a convenient way to access LLMs your request will often be routed to subpar providers that do not work well in our testing.\n\nFor reliable access to models check out OpenCode Zen\nhttps://opencode.ai/zen" ,
2025-10-31 20:12:58 +00:00
) . then ( ( ) = > kv . set ( "openrouter_warning" , true ) )
2025-10-31 19:07:36 +00:00
} )
}
} )
event . on ( TuiEvent . CommandExecute . type , ( evt ) = > {
command . trigger ( evt . properties . command )
} )
event . on ( TuiEvent . ToastShow . type , ( evt ) = > {
toast . show ( {
title : evt.properties.title ,
message : evt.properties.message ,
variant : evt.properties.variant ,
duration : evt.properties.duration ,
} )
} )
event . on ( SessionApi . Event . Deleted . type , ( evt ) = > {
if ( route . data . type === "session" && route . data . sessionID === evt . properties . info . id ) {
route . navigate ( { type : "home" } )
toast . show ( {
variant : "info" ,
message : "The current session was deleted" ,
} )
}
} )
return (
< box
width = { dimensions ( ) . width }
height = { dimensions ( ) . height }
backgroundColor = { theme . background }
onMouseUp = { async ( ) = > {
const text = renderer . getSelection ( ) ? . getSelectedText ( )
if ( text && text . length > 0 ) {
const base64 = Buffer . from ( text ) . toString ( "base64" )
const osc52 = ` \ x1b]52;c; ${ base64 } \ x07 `
const finalOsc52 = process . env [ "TMUX" ] ? ` \ x1bPtmux; \ x1b ${ osc52 } \ x1b \\ ` : osc52
/* @ts-expect-error */
renderer . writeOut ( finalOsc52 )
await Clipboard . copy ( text )
2025-11-01 14:34:21 +00:00
. then ( ( ) = > toast . show ( { message : "Copied to clipboard" , variant : "info" } ) )
. catch ( toast . error )
2025-10-31 19:07:36 +00:00
renderer . clearSelection ( )
}
} }
>
< box flexDirection = "column" flexGrow = { 1 } >
< Switch >
< Match when = { route . data . type === "home" } >
< Home / >
< / Match >
< Match when = { route . data . type === "session" && sessionExists ( ) } >
< Session / >
< / Match >
< / Switch >
< / box >
< box
height = { 1 }
backgroundColor = { theme . backgroundPanel }
flexDirection = "row"
justifyContent = "space-between"
flexShrink = { 0 }
>
< box flexDirection = "row" >
< box
flexDirection = "row"
backgroundColor = { theme . backgroundElement }
paddingLeft = { 1 }
paddingRight = { 1 }
>
< text fg = { theme . textMuted } > open < / text >
2025-11-01 17:54:01 +00:00
< text fg = { theme . text } attributes = { TextAttributes . BOLD } >
code { " " }
< / text >
2025-10-31 19:07:36 +00:00
< text fg = { theme . textMuted } > v { Installation . VERSION } < / text >
< / box >
< box paddingLeft = { 1 } paddingRight = { 1 } >
< text fg = { theme . textMuted } > { process . cwd ( ) . replace ( Global . Path . home , "~" ) } < / text >
< / box >
< / box >
< box flexDirection = "row" flexShrink = { 0 } >
< text fg = { theme . textMuted } paddingRight = { 1 } >
tab
< / text >
< text fg = { local . agent . color ( local . agent . current ( ) . name ) } > { "" } < / text >
< text
bg = { local . agent . color ( local . agent . current ( ) . name ) }
fg = { theme . background }
2025-10-31 21:53:08 +00:00
wrapMode = { undefined }
2025-10-31 19:07:36 +00:00
>
< span style = { { bold : true } } > { local . agent . current ( ) . name . toUpperCase ( ) } < / span >
< span > AGENT < / span >
< / text >
< / box >
< / box >
< / box >
)
}
2025-10-31 21:20:32 +00:00
2025-10-31 21:53:08 +00:00
function ErrorComponent ( props : { error : Error ; reset : ( ) = > void ; onExit : ( ) = > Promise < void > } ) {
2025-10-31 21:20:32 +00:00
const term = useTerminalDimensions ( )
useKeyboard ( ( evt ) = > {
if ( evt . ctrl && evt . name === "c" ) {
props . onExit ( )
}
} )
const [ copied , setCopied ] = createSignal ( false )
const issueURL = new URL ( "https://github.com/sst/opencode/issues/new?template=bug-report.yml" )
if ( props . error . message ) {
issueURL . searchParams . set ( "title" , ` opentui: fatal: ${ props . error . message } ` )
}
if ( props . error . stack ) {
2025-10-31 21:53:08 +00:00
issueURL . searchParams . set (
"description" ,
"```\n" + props . error . stack . substring ( 0 , 6000 - issueURL . toString ( ) . length ) + "...\n```" ,
)
2025-10-31 21:20:32 +00:00
}
const copyIssueURL = ( ) = > {
2025-10-31 21:53:08 +00:00
Clipboard . copy ( issueURL . toString ( ) ) . then ( ( ) = > {
setCopied ( true )
} )
2025-10-31 21:20:32 +00:00
}
return (
< box flexDirection = "column" gap = { 1 } >
< box flexDirection = "row" gap = { 1 } alignItems = "center" >
< text attributes = { TextAttributes . BOLD } > Please report an issue . < / text >
< box onMouseUp = { copyIssueURL } backgroundColor = "#565f89" padding = { 1 } >
< text attributes = { TextAttributes . BOLD } > Copy issue URL ( exception info pre - filled ) < / text >
< / box >
{ copied ( ) && < text > Successfully copied < / text > }
< / box >
< box flexDirection = "row" gap = { 2 } alignItems = "center" >
< text > A fatal error occurred ! < / text >
< box onMouseUp = { props . reset } backgroundColor = "#565f89" padding = { 1 } >
< text > Reset TUI < / text >
< / box >
< box onMouseUp = { props . onExit } backgroundColor = "#565f89" padding = { 1 } >
< text > Exit < / text >
< / box >
< / box >
< scrollbox height = { Math . floor ( term ( ) . height * 0.7 ) } >
< text > { props . error . stack } < / text >
< / scrollbox >
< text > { props . error . message } < / text >
2025-10-31 21:53:08 +00:00
< / box >
2025-10-31 21:20:32 +00:00
)
}