Skip to content

Commit 4f8c7b1

Browse files
authored
opentui: add toast & port CLI flags (-c, -s, -m --agent) (#3447)
1 parent e6d2149 commit 4f8c7b1

File tree

8 files changed

+352
-108
lines changed

8 files changed

+352
-108
lines changed

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 51 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
22
import { Clipboard } from "@tui/util/clipboard"
33
import { TextAttributes } from "@opentui/core"
4-
import { RouteProvider, useRoute } from "@tui/context/route"
5-
import { Switch, Match, createEffect, untrack, ErrorBoundary } from "solid-js"
4+
import { RouteProvider, useRoute, type Route } from "@tui/context/route"
5+
import { Switch, Match, createEffect, untrack, ErrorBoundary, createMemo, createSignal } from "solid-js"
66
import { Installation } from "@/installation"
77
import { Global } from "@/global"
88
import { DialogProvider, useDialog } from "@tui/ui/dialog"
99
import { SDKProvider, useSDK } from "@tui/context/sdk"
10-
import { SyncProvider } from "@tui/context/sync"
10+
import { SyncProvider, useSync } from "@tui/context/sync"
1111
import { LocalProvider, useLocal } from "@tui/context/local"
1212
import { DialogModel } from "@tui/component/dialog-model"
1313
import { DialogStatus } from "@tui/component/dialog-status"
@@ -20,31 +20,41 @@ import { Home } from "@tui/routes/home"
2020
import { Session } from "@tui/routes/session"
2121
import { PromptHistoryProvider } from "./component/prompt/history"
2222
import { DialogAlert } from "./ui/dialog-alert"
23+
import { ToastProvider, useToast } from "./ui/toast"
2324
import { ExitProvider } from "./context/exit"
25+
import type { SessionRoute } from "./context/route"
2426

25-
export async function tui(input: { url: string; onExit?: () => Promise<void> }) {
27+
export async function tui(input: { url: string; sessionID?: string; model?: string; agent?: string; onExit?: () => Promise<void> }) {
28+
const routeData: Route | undefined = input.sessionID
29+
? {
30+
type: "session",
31+
sessionID: input.sessionID,
32+
}
33+
: undefined
2634
await render(
2735
() => {
2836
return (
2937
<ErrorBoundary fallback={<text>Something went wrong</text>}>
3038
<ExitProvider onExit={input.onExit}>
31-
<RouteProvider>
32-
<SDKProvider url={input.url}>
33-
<SyncProvider>
34-
<LocalProvider>
35-
<KeybindProvider>
36-
<DialogProvider>
37-
<CommandProvider>
38-
<PromptHistoryProvider>
39-
<App />
40-
</PromptHistoryProvider>
41-
</CommandProvider>
42-
</DialogProvider>
43-
</KeybindProvider>
44-
</LocalProvider>
45-
</SyncProvider>
46-
</SDKProvider>
47-
</RouteProvider>
39+
<ToastProvider>
40+
<RouteProvider data={routeData}>
41+
<SDKProvider url={input.url}>
42+
<SyncProvider>
43+
<LocalProvider initialModel={input.model} initialAgent={input.agent}>
44+
<KeybindProvider>
45+
<DialogProvider>
46+
<CommandProvider>
47+
<PromptHistoryProvider>
48+
<App />
49+
</PromptHistoryProvider>
50+
</CommandProvider>
51+
</DialogProvider>
52+
</KeybindProvider>
53+
</LocalProvider>
54+
</SyncProvider>
55+
</SDKProvider>
56+
</RouteProvider>
57+
</ToastProvider>
4858
</ExitProvider>
4959
</ErrorBoundary>
5060
)
@@ -67,6 +77,9 @@ function App() {
6777
const local = useLocal()
6878
const command = useCommandDialog()
6979
const { event } = useSDK()
80+
const sync = useSync()
81+
const toast = useToast()
82+
const [sessionExists, setSessionExists] = createSignal(false)
7083

7184
useKeyboard(async (evt) => {
7285
if (evt.meta && evt.name === "t") {
@@ -80,6 +93,22 @@ function App() {
8093
}
8194
})
8295

96+
// Make sure session is valid, otherwise redirect to home
97+
createEffect(async () => {
98+
if (route.data.type === "session") {
99+
const data = route.data as SessionRoute
100+
await sync.session.sync(data.sessionID)
101+
.catch(() => {
102+
toast.show({
103+
message: `Session not found: ${data.sessionID}`,
104+
type: "error",
105+
})
106+
return route.navigate({ type: "home" })
107+
})
108+
setSessionExists(true)
109+
}
110+
})
111+
83112
createEffect(() => {
84113
console.log(JSON.stringify(route.data))
85114
})
@@ -195,7 +224,7 @@ function App() {
195224
<Match when={route.data.type === "home"}>
196225
<Home />
197226
</Match>
198-
<Match when={route.data.type === "session"}>
227+
<Match when={route.data.type === "session" && sessionExists()}>
199228
<Session />
200229
</Match>
201230
</Switch>
Lines changed: 109 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,75 @@
11
import { createStore } from "solid-js/store"
2-
import { batch, createEffect, createMemo, createSignal } from "solid-js"
2+
import { batch, createEffect, createMemo, createSignal, onMount } from "solid-js"
33
import { useSync } from "@tui/context/sync"
44
import { Theme } from "@tui/context/theme"
55
import { uniqueBy } from "remeda"
66
import path from "path"
77
import { Global } from "@/global"
88
import { iife } from "@/util/iife"
99
import { createSimpleContext } from "./helper"
10+
import { useToast } from "../ui/toast"
11+
import type { Provider } from "@opencode-ai/sdk"
1012

1113
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
1214
name: "Local",
13-
init: () => {
15+
init: (props: { initialModel?: string; initialAgent?: string }) => {
1416
const sync = useSync()
17+
const toast = useToast()
18+
19+
function isModelValid(model: { providerID: string, modelID: string }) {
20+
const provider = sync.data.provider.find((x) => x.id === model.providerID)
21+
return !!provider?.models[model.modelID]
22+
}
23+
24+
function getFirstValidModel(...modelFns: (() => { providerID: string, modelID: string } | undefined)[]) {
25+
for (const modelFn of modelFns) {
26+
const model = modelFn()
27+
if (!model) continue
28+
if (isModelValid(model))
29+
return model
30+
}
31+
}
32+
33+
// Set initial model if provided
34+
onMount(() => {
35+
batch(() => {
36+
if (props.initialAgent) {
37+
agent.set(props.initialAgent)
38+
}
39+
if (props.initialModel) {
40+
const [providerID, modelID] = props.initialModel.split("/")
41+
if (!providerID || !modelID)
42+
return toast.show({
43+
type: "warning",
44+
message: `Invalid model format: ${props.initialModel}`,
45+
duration: 3000,
46+
})
47+
model.set({ providerID, modelID }, { recent: true })
48+
}
49+
})
50+
})
51+
52+
// Automatically update model when agent changes
53+
createEffect(() => {
54+
const value = agent.current()
55+
if (value.model) {
56+
if (isModelValid(value.model))
57+
model.set({
58+
providerID: value.model.providerID,
59+
modelID: value.model.modelID,
60+
})
61+
else
62+
toast.show({
63+
type: "warning",
64+
message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
65+
duration: 3000,
66+
})
67+
}
68+
})
1569

1670
const agent = iife(() => {
1771
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
18-
const [store, setStore] = createStore<{
72+
const [agentStore, setAgentStore] = createStore<{
1973
current: string
2074
}>({
2175
current: agents()[0].name,
@@ -25,22 +79,25 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
2579
return agents()
2680
},
2781
current() {
28-
return agents().find((x) => x.name === store.current)!
82+
return agents().find((x) => x.name === agentStore.current)!
2983
},
3084
set(name: string) {
31-
setStore("current", name)
85+
if (!agents().some((x) => x.name === name))
86+
return toast.show({
87+
type: "warning",
88+
message: `Agent not found: ${name}`,
89+
duration: 3000,
90+
})
91+
setAgentStore("current", name)
3292
},
3393
move(direction: 1 | -1) {
34-
let next = agents().findIndex((x) => x.name === store.current) + direction
35-
if (next < 0) next = agents().length - 1
36-
if (next >= agents().length) next = 0
37-
const value = agents()[next]
38-
setStore("current", value.name)
39-
if (value.model)
40-
model.set({
41-
providerID: value.model.providerID,
42-
modelID: value.model.modelID,
43-
})
94+
batch(() => {
95+
let next = agents().findIndex((x) => x.name === agentStore.current) + direction
96+
if (next < 0) next = agents().length - 1
97+
if (next >= agents().length) next = 0
98+
const value = agents()[next]
99+
setAgentStore("current", value.name)
100+
})
44101
},
45102
color(name: string) {
46103
const index = agents().findIndex((x) => x.name === name)
@@ -51,7 +108,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
51108
})
52109

53110
const model = iife(() => {
54-
const [store, setStore] = createStore<{
111+
const [modelStore, setModelStore] = createStore<{
55112
ready: boolean
56113
model: Record<
57114
string,
@@ -75,43 +132,35 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
75132
file
76133
.json()
77134
.then((x) => {
78-
setStore("recent", x.recent)
135+
setModelStore("recent", x.recent)
79136
})
80-
.catch(() => {})
137+
.catch(() => { })
81138
.finally(() => {
82-
setStore("ready", true)
139+
setModelStore("ready", true)
83140
})
84141

85142
createEffect(() => {
86143
Bun.write(
87144
file,
88145
JSON.stringify({
89-
recent: store.recent,
146+
recent: modelStore.recent,
90147
}),
91148
)
92149
})
93150

94-
const fallback = createMemo(() => {
95-
function isValid(providerID: string, modelID: string) {
96-
const provider = sync.data.provider.find((x) => x.id === providerID)
97-
if (!provider) return false
98-
const model = provider.models[modelID]
99-
if (!model) return false
100-
return true
101-
}
102-
151+
const fallbackModel = createMemo(() => {
103152
if (sync.data.config.model) {
104153
const [providerID, modelID] = sync.data.config.model.split("/")
105-
if (isValid(providerID, modelID)) {
154+
if (isModelValid({ providerID, modelID })) {
106155
return {
107156
providerID,
108157
modelID,
109158
}
110159
}
111160
}
112161

113-
for (const item of store.recent) {
114-
if (isValid(item.providerID, item.modelID)) {
162+
for (const item of modelStore.recent) {
163+
if (isModelValid(item)) {
115164
return item
116165
}
117166
}
@@ -123,21 +172,25 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
123172
}
124173
})
125174

126-
const current = createMemo(() => {
175+
const currentModel = createMemo(() => {
127176
const a = agent.current()
128-
return store.model[agent.current().name] ?? (a.model ? a.model : fallback())
177+
return getFirstValidModel(
178+
() => modelStore.model[a.name],
179+
() => a.model,
180+
fallbackModel,
181+
)!
129182
})
130183

131184
return {
132-
current,
185+
current: currentModel,
133186
get ready() {
134-
return store.ready
187+
return modelStore.ready
135188
},
136189
recent() {
137-
return store.recent
190+
return modelStore.recent
138191
},
139192
parsed: createMemo(() => {
140-
const value = current()
193+
const value = currentModel()
141194
const provider = sync.data.provider.find((x) => x.id === value.providerID)!
142195
const model = provider.models[value.modelID]
143196
return {
@@ -147,11 +200,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
147200
}),
148201
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
149202
batch(() => {
150-
setStore("model", agent.current().name, model)
203+
if (!isModelValid(model)) {
204+
toast.show({
205+
message: `Model ${model.providerID}/${model.modelID} is not valid`,
206+
type: "warning",
207+
duration: 3000,
208+
})
209+
return
210+
}
211+
212+
setModelStore("model", agent.current().name, model)
151213
if (options?.recent) {
152-
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
214+
const uniq = uniqueBy([model, ...modelStore.recent], (x) => x.providerID + x.modelID)
153215
if (uniq.length > 5) uniq.pop()
154-
setStore("recent", uniq)
216+
setModelStore("recent", uniq)
155217
}
156218
})
157219
},
@@ -160,30 +222,30 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
160222

161223
const kv = iife(() => {
162224
const [ready, setReady] = createSignal(false)
163-
const [store, setStore] = createStore({
225+
const [kvStore, setKvStore] = createStore({
164226
openrouter_warning: false,
165227
})
166228
const file = Bun.file(path.join(Global.Path.state, "kv.json"))
167229

168230
file
169231
.json()
170232
.then((x) => {
171-
setStore(x)
233+
setKvStore(x)
172234
})
173-
.catch(() => {})
235+
.catch(() => { })
174236
.finally(() => {
175237
setReady(true)
176238
})
177239

178240
return {
179241
get data() {
180-
return store
242+
return kvStore
181243
},
182244
get ready() {
183245
return ready()
184246
},
185247
set(key: string, value: any) {
186-
setStore(key as any, value)
248+
setKvStore(key as any, value)
187249
Bun.write(
188250
file,
189251
JSON.stringify({
@@ -204,4 +266,4 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
204266
}
205267
return result
206268
},
207-
})
269+
})

0 commit comments

Comments
 (0)