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"
2025-11-09 01:17:46 +00:00
import { RouteProvider , useRoute } from "@tui/context/route"
2025-11-09 01:21:02 +00:00
import { Switch , Match , createEffect , untrack , ErrorBoundary , createSignal , onMount , batch } from "solid-js"
2025-10-31 19:07:36 +00:00
import { Installation } from "@/installation"
import { Global } from "@/global"
import { DialogProvider , useDialog } from "@tui/ui/dialog"
import { SDKProvider , useSDK } from "@tui/context/sdk"
2025-11-09 01:17:46 +00:00
import { SyncProvider , useSync } from "@tui/context/sync"
2025-10-31 19:07:36 +00:00
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"
2025-11-10 17:27:19 +00:00
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 { Session as SessionApi } from "@/session"
import { TuiEvent } from "./event"
2025-10-31 20:12:58 +00:00
import { KVProvider , useKV } from "./context/kv"
2025-11-09 01:17:46 +00:00
import { Provider } from "@/provider/provider"
import { ArgsProvider , useArgs , type Args } from "./context/args"
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-11-09 01:17:46 +00:00
export function tui ( input : { url : string ; args : Args ; onExit ? : ( ) = > Promise < void > } ) {
2025-10-31 19:07:36 +00:00
// 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 onExit = async ( ) = > {
await input . onExit ? . ( )
resolve ( )
}
render (
( ) = > {
return (
2025-11-08 01:59:02 +00:00
< ErrorBoundary fallback = { ( error , reset ) = > < ErrorComponent error = { error } reset = { reset } onExit = { onExit } / > } >
2025-11-09 01:17:46 +00:00
< ArgsProvider { ...input.args } >
< ExitProvider onExit = { onExit } >
< KVProvider >
< ToastProvider >
< RouteProvider >
< SDKProvider url = { input . url } >
< SyncProvider >
< ThemeProvider mode = { mode } >
< LocalProvider >
< KeybindProvider >
< DialogProvider >
< CommandProvider >
< PromptHistoryProvider >
< App / >
< / PromptHistoryProvider >
< / CommandProvider >
< / DialogProvider >
< / KeybindProvider >
< / LocalProvider >
< / ThemeProvider >
< / SyncProvider >
< / SDKProvider >
< / RouteProvider >
< / ToastProvider >
< / KVProvider >
< / ExitProvider >
< / ArgsProvider >
2025-10-31 19:07:36 +00:00
< / 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 toast = useToast ( )
2025-11-01 17:54:01 +00:00
const { theme , mode , setMode } = useTheme ( )
2025-11-09 01:17:46 +00:00
const sync = useSync ( )
2025-11-01 16:13:10 +00:00
const exit = useExit ( )
2025-10-31 19:07:36 +00:00
createEffect ( ( ) = > {
console . log ( JSON . stringify ( route . data ) )
} )
2025-11-09 01:17:46 +00:00
const args = useArgs ( )
onMount ( ( ) = > {
batch ( ( ) = > {
if ( args . agent ) local . agent . set ( args . agent )
if ( args . model ) {
const { providerID , modelID } = Provider . parseModel ( args . model )
if ( ! providerID || ! modelID )
return toast . show ( {
variant : "warning" ,
message : ` Invalid model format: ${ args . model } ` ,
duration : 3000 ,
} )
local . model . set ( { providerID , modelID } , { recent : true } )
}
if ( args . sessionID ) {
route . navigate ( {
type : "session" ,
sessionID : args.sessionID ,
} )
}
} )
} )
2025-11-09 01:53:11 +00:00
createEffect ( ( ) = > {
if ( sync . status !== "complete" ) return
if ( args . continue ) {
const match = sync . data . session . at ( 0 ) ? . id
if ( match ) {
route . navigate ( {
type : "session" ,
sessionID : match ,
} )
}
}
} )
2025-10-31 19:07:36 +00:00
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-11-06 02:13:35 +00:00
{
title : "Toggle debug panel" ,
2025-11-06 02:26:58 +00:00
category : "System" ,
2025-11-06 02:13:35 +00:00
value : "app.debug" ,
onSelect : ( dialog ) = > {
renderer . toggleDebugOverlay ( )
dialog . clear ( )
} ,
} ,
{
title : "Toggle console" ,
2025-11-06 02:26:58 +00:00
category : "System" ,
2025-11-06 02:13:35 +00:00
value : "app.fps" ,
onSelect : ( dialog ) = > {
renderer . console . toggle ( )
dialog . clear ( )
} ,
} ,
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 ) {
2025-11-04 17:33:03 +00:00
dialog . clear ( )
2025-10-31 19:07:36 +00:00
route . navigate ( { type : "home" } )
toast . show ( {
variant : "info" ,
message : "The current session was deleted" ,
} )
}
} )
2025-11-04 23:29:27 +00:00
event . on ( SessionApi . Event . Error . type , ( evt ) = > {
const error = evt . properties . error
const message = ( ( ) = > {
if ( ! error ) return "An error occured"
if ( typeof error === "object" ) {
const data = error . data
if ( "message" in data && typeof data . message === "string" ) {
return data . message
}
}
return String ( error )
} ) ( )
toast . show ( {
variant : "error" ,
message ,
duration : 5000 ,
} )
} )
2025-11-10 16:28:28 +00:00
event . on ( Installation . Event . Updated . type , ( evt ) = > {
toast . show ( {
variant : "success" ,
title : "Update Complete" ,
message : ` OpenCode updated to v ${ evt . properties . version } ` ,
duration : 5000 ,
} )
} )
2025-10-31 19:07:36 +00:00
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 >
2025-11-05 20:33:23 +00:00
< Match when = { route . data . type === "session" } >
2025-10-31 19:07:36 +00:00
< Session / >
< / Match >
< / Switch >
< / box >
< box
height = { 1 }
backgroundColor = { theme . backgroundPanel }
flexDirection = "row"
justifyContent = "space-between"
flexShrink = { 0 }
>
< box flexDirection = "row" >
2025-11-08 01:59:02 +00:00
< box flexDirection = "row" backgroundColor = { theme . backgroundElement } paddingLeft = { 1 } paddingRight = { 1 } >
2025-10-31 19:07:36 +00:00
< 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 >
2025-11-08 01:59:02 +00:00
< text bg = { local . agent . color ( local . agent . current ( ) . name ) } fg = { theme . background } 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
}
2025-11-12 16:40:48 +00:00
issueURL . searchParams . set ( "opencode-version" , Installation . VERSION )
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
)
}