Skip to content

Commit 63bfe76

Browse files
authored
tui design refinement (anomalyco#4809)
1 parent 99d7ff4 commit 63bfe76

File tree

11 files changed

+387
-265
lines changed

11 files changed

+387
-265
lines changed

.opencode/opencode.jsonc

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"$schema": "https://opencode.ai/config.json",
33
"plugin": ["opencode-openai-codex-auth"],
44
// "enterprise": {
5-
// "url": "http://localhost:3000",
5+
// "url": "https://enterprise.dev.opencode.ai",
66
// },
77
"provider": {
88
"opencode": {
@@ -11,4 +11,10 @@
1111
},
1212
},
1313
},
14+
"mcp": {
15+
"exa": {
16+
"type": "remote",
17+
"url": "https://mcp.exa.ai/mcp",
18+
},
19+
},
1420
}

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

Lines changed: 8 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -452,51 +452,14 @@ function App() {
452452
}
453453
}}
454454
>
455-
<box flexDirection="column" flexGrow={1}>
456-
<Switch>
457-
<Match when={route.data.type === "home"}>
458-
<Home />
459-
</Match>
460-
<Match when={route.data.type === "session"}>
461-
<Session />
462-
</Match>
463-
</Switch>
464-
</box>
465-
<box
466-
height={1}
467-
backgroundColor={theme.backgroundPanel}
468-
flexDirection="row"
469-
justifyContent="space-between"
470-
flexShrink={0}
471-
>
472-
<box flexDirection="row">
473-
<box flexDirection="row" backgroundColor={theme.backgroundElement} paddingLeft={1} paddingRight={1}>
474-
<text fg={theme.textMuted}>open</text>
475-
<text fg={theme.text} attributes={TextAttributes.BOLD}>
476-
code{" "}
477-
</text>
478-
<text fg={theme.textMuted}>v{Installation.VERSION}</text>
479-
</box>
480-
<box paddingLeft={1} paddingRight={1}>
481-
<text fg={theme.textMuted}>
482-
{process.cwd().replace(Global.Path.home, "~")}
483-
{sync.data.vcs?.branch ? `:${sync.data.vcs.branch}` : ""}
484-
</text>
485-
</box>
486-
</box>
487-
<Show when={false}>
488-
<box flexDirection="row" flexShrink={0}>
489-
<text fg={theme.textMuted} paddingRight={1}>
490-
tab
491-
</text>
492-
<text fg={local.agent.color(local.agent.current().name)}>{""}</text>
493-
<text bg={local.agent.color(local.agent.current().name)} fg={theme.background} wrapMode={undefined}>
494-
<span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span>
495-
<span> AGENT </span>
496-
</text>
497-
</box>
498-
</Show>
499-
</box>
455+
<Switch>
456+
<Match when={route.data.type === "home"}>
457+
<Home />
458+
</Match>
459+
<Match when={route.data.type === "session"}>
460+
<Session />
461+
</Match>
462+
</Switch>
500463
</box>
501464
)
502465
}

packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,11 +197,24 @@ function ApiMethod(props: ApiMethodProps) {
197197
const dialog = useDialog()
198198
const sdk = useSDK()
199199
const sync = useSync()
200+
const { theme } = useTheme()
200201

201202
return (
202203
<DialogPrompt
203204
title={props.title}
204205
placeholder="API key"
206+
description={
207+
props.providerID === "opencode" ? (
208+
<box gap={1}>
209+
<text fg={theme.textMuted}>
210+
OpenCode Zen gives you access to all the best coding models at the cheapest prices with a single API key.
211+
</text>
212+
<text>
213+
Go to <span style={{ fg: theme.primary }}>https://opencode.ai/zen</span> to get a key
214+
</text>
215+
</box>
216+
) : undefined
217+
}
205218
onConfirm={async (value) => {
206219
if (!value) return
207220
sdk.client.auth.set({

packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,11 @@ export function Autocomplete(props: {
292292
description: "open editor",
293293
onSelect: () => command.trigger("prompt.editor", "prompt"),
294294
},
295+
{
296+
display: "/connect",
297+
description: "connect to a provider",
298+
onSelect: () => command.trigger("provider.connect"),
299+
},
295300
{
296301
display: "/help",
297302
description: "show help",

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -637,11 +637,7 @@ export function Prompt(props: PromptProps) {
637637
flexGrow={1}
638638
>
639639
<textarea
640-
placeholder={
641-
props.showPlaceholder
642-
? t`${dim(fg(theme.primary)(" → up/down"))} ${dim(fg("#64748b")("history"))} ${dim(fg("#a78bfa")("•"))} ${dim(fg(theme.primary)(keybind.print("input_newline")))} ${dim(fg("#64748b")("newline"))} ${dim(fg("#a78bfa")("•"))} ${dim(fg(theme.primary)(keybind.print("input_submit")))} ${dim(fg("#64748b")("submit"))}`
643-
: undefined
644-
}
640+
placeholder={props.sessionID ? undefined : "Build anything..."}
645641
textColor={theme.text}
646642
focusedTextColor={theme.text}
647643
minHeight={1}
@@ -781,7 +777,12 @@ export function Prompt(props: PromptProps) {
781777
return
782778
}
783779
}}
784-
ref={(r: TextareaRenderable) => (input = r)}
780+
ref={(r: TextareaRenderable) => {
781+
input = r
782+
setTimeout(() => {
783+
input.cursorColor = highlight()
784+
}, 0)
785+
}}
785786
onMouseDown={(r: MouseEvent) => r.target?.focus()}
786787
focusedBackgroundColor={theme.backgroundElement}
787788
cursorColor={highlight()}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { createMemo } from "solid-js"
2+
import { useSync } from "./sync"
3+
import { Global } from "@/global"
4+
5+
export function useDirectory() {
6+
const sync = useSync()
7+
return createMemo(() => {
8+
const result = process.cwd().replace(Global.Path.home, "~")
9+
if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
10+
return result
11+
})
12+
}

packages/opencode/src/cli/cmd/tui/routes/home.tsx

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@ import { Locale } from "@/util/locale"
88
import { useSync } from "../context/sync"
99
import { Toast } from "../ui/toast"
1010
import { useArgs } from "../context/args"
11+
import { Global } from "@/global"
12+
import { useDirectory } from "../context/directory"
1113

1214
// TODO: what is the best way to do this?
1315
let once = false
1416

1517
export function Home() {
1618
const sync = useSync()
1719
const { theme } = useTheme()
20+
const mcp = createMemo(() => Object.keys(sync.data.mcp).length > 0)
1821
const mcpError = createMemo(() => {
1922
return Object.values(sync.data.mcp).some((x) => x.status === "failed")
2023
})
@@ -47,31 +50,36 @@ export function Home() {
4750
once = true
4851
}
4952
})
53+
const directory = useDirectory()
5054

5155
return (
52-
<box flexGrow={1} justifyContent="center" alignItems="center" paddingLeft={2} paddingRight={2} gap={1}>
53-
<Logo />
54-
<box width={39}>
55-
<HelpRow keybind="command_list">Commands</HelpRow>
56-
<HelpRow keybind="session_list">List sessions</HelpRow>
57-
<HelpRow keybind="model_list">Switch model</HelpRow>
58-
<HelpRow keybind="agent_cycle">Switch agent</HelpRow>
56+
<>
57+
<box flexGrow={1} justifyContent="center" alignItems="center" paddingLeft={2} paddingRight={2} gap={1}>
58+
<Logo />
59+
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1}>
60+
<Prompt ref={(r) => (prompt = r)} hint={Hint} />
61+
</box>
62+
<Toast />
5963
</box>
60-
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1}>
61-
<Prompt ref={(r) => (prompt = r)} hint={Hint} />
64+
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0} gap={2}>
65+
<text fg={theme.textMuted}>{directory()}</text>
66+
<box gap={1} flexDirection="row" flexShrink={0}>
67+
<Show when={mcp()}>
68+
<text fg={theme.text}>
69+
<Switch>
70+
<Match when={mcpError()}>
71+
<span style={{ fg: theme.error }}></span>
72+
</Match>
73+
<Match when={true}>
74+
<span style={{ fg: theme.success }}></span>
75+
</Match>
76+
</Switch>
77+
{Object.keys(sync.data.mcp).length} MCP
78+
</text>
79+
<text fg={theme.textMuted}>/status</text>
80+
</Show>
81+
</box>
6282
</box>
63-
<Toast />
64-
</box>
65-
)
66-
}
67-
68-
function HelpRow(props: ParentProps<{ keybind: keyof KeybindsConfig }>) {
69-
const keybind = useKeybind()
70-
const { theme } = useTheme()
71-
return (
72-
<box flexDirection="row" justifyContent="space-between" width="100%">
73-
<text fg={theme.text}>{props.children}</text>
74-
<text fg={theme.primary}>{keybind.print(props.keybind)}</text>
75-
</box>
83+
</>
7684
)
7785
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { createMemo, Match, Show, Switch } from "solid-js"
2+
import { useTheme } from "../../context/theme"
3+
import { useSync } from "../../context/sync"
4+
import { useDirectory } from "../../context/directory"
5+
6+
export function Footer() {
7+
const { theme } = useTheme()
8+
const sync = useSync()
9+
const mcp = createMemo(() => Object.keys(sync.data.mcp))
10+
const mcpError = createMemo(() => Object.values(sync.data.mcp).some((x) => x.status === "failed"))
11+
const lsp = createMemo(() => Object.keys(sync.data.lsp))
12+
const directory = useDirectory()
13+
return (
14+
<box flexDirection="row" justifyContent="space-between" gap={1}>
15+
<text fg={theme.textMuted}>{directory()}</text>
16+
<box gap={2} flexDirection="row" flexShrink={0}>
17+
<text fg={theme.text}>
18+
<span style={{ fg: theme.success }}></span> {lsp().length} LSP
19+
</text>
20+
<Show when={mcp().length}>
21+
<text fg={theme.text}>
22+
<Switch>
23+
<Match when={mcpError()}>
24+
<span style={{ fg: theme.error }}></span>
25+
</Match>
26+
<Match when={true}>
27+
<span style={{ fg: theme.success }}></span>
28+
</Match>
29+
</Switch>
30+
{mcp().length} MCP
31+
</text>
32+
</Show>
33+
<text fg={theme.textMuted}>/status</text>
34+
</box>
35+
</box>
36+
)
37+
}

packages/opencode/src/cli/cmd/tui/routes/session/header.tsx

Lines changed: 61 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@ import { useRouteData } from "@tui/context/route"
33
import { useSync } from "@tui/context/sync"
44
import { pipe, sumBy } from "remeda"
55
import { useTheme } from "@tui/context/theme"
6-
import { SplitBorder } from "@tui/component/border"
6+
import { SplitBorder, EmptyBorder } from "@tui/component/border"
77
import type { AssistantMessage, Session } from "@opencode-ai/sdk"
8+
import { useDirectory } from "../../context/directory"
9+
import { useKeybind } from "../../context/keybind"
810

911
const Title = (props: { session: Accessor<Session> }) => {
1012
const { theme } = useTheme()
1113
return (
1214
<text fg={theme.text}>
13-
<span style={{ bold: true, fg: theme.accent }}>#</span>{" "}
14-
<span style={{ bold: true }}>{props.session().title}</span>
15+
<span style={{ bold: true }}>#</span> <span style={{ bold: true }}>{props.session().title}</span>
1516
</text>
1617
)
1718
}
@@ -53,43 +54,71 @@ export function Header() {
5354
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
5455
let result = total.toLocaleString()
5556
if (model?.limit.context) {
56-
result += "/" + Math.round((total / model.limit.context) * 100) + "%"
57+
result += " " + Math.round((total / model.limit.context) * 100) + "%"
5758
}
5859
return result
5960
})
6061

6162
const { theme } = useTheme()
63+
const keybind = useKeybind()
6264

6365
return (
64-
<box paddingLeft={1} paddingRight={1} {...SplitBorder} borderColor={theme.backgroundElement} flexShrink={0}>
65-
<Show
66-
when={shareEnabled()}
67-
fallback={
68-
<box flexDirection="row" justifyContent="space-between" gap={1}>
69-
<Title session={session} />
70-
<ContextInfo context={context} cost={cost} />
71-
</box>
72-
}
66+
<box flexShrink={0}>
67+
<box
68+
paddingTop={1}
69+
paddingBottom={1}
70+
paddingLeft={2}
71+
paddingRight={1}
72+
{...SplitBorder}
73+
border={["left"]}
74+
borderColor={theme.border}
75+
flexShrink={0}
76+
backgroundColor={theme.backgroundPanel}
7377
>
74-
<Title session={session} />
75-
<box flexDirection="row" justifyContent="space-between" gap={1}>
76-
<box flexGrow={1} flexShrink={1}>
77-
<Switch>
78-
<Match when={session().share?.url}>
79-
<text fg={theme.textMuted} wrapMode="word">
80-
{session().share!.url}
81-
</text>
82-
</Match>
83-
<Match when={true}>
84-
<text fg={theme.text} wrapMode="word">
85-
/share <span style={{ fg: theme.textMuted }}>to create a shareable link</span>
86-
</text>
87-
</Match>
88-
</Switch>
89-
</box>
90-
<ContextInfo context={context} cost={cost} />
91-
</box>
92-
</Show>
78+
<Switch>
79+
<Match when={session()?.parentID}>
80+
<box flexDirection="row" gap={2}>
81+
<text fg={theme.text}>
82+
<b>Subagent session</b>
83+
</text>
84+
<text fg={theme.text}>
85+
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
86+
</text>
87+
<text fg={theme.text}>
88+
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
89+
</text>
90+
<box flexGrow={1} flexShrink={1} />
91+
<ContextInfo context={context} cost={cost} />
92+
</box>
93+
</Match>
94+
<Match when={!shareEnabled()}>
95+
<box flexDirection="row" justifyContent="space-between" gap={1}>
96+
<Title session={session} />
97+
<ContextInfo context={context} cost={cost} />
98+
</box>
99+
</Match>
100+
<Match when={true}>
101+
<Title session={session} />
102+
<box flexDirection="row" justifyContent="space-between" gap={1}>
103+
<box flexGrow={1} flexShrink={1}>
104+
<Switch>
105+
<Match when={session().share?.url}>
106+
<text fg={theme.textMuted} wrapMode="word">
107+
{session().share!.url}
108+
</text>
109+
</Match>
110+
<Match when={true}>
111+
<text fg={theme.text} wrapMode="word">
112+
/share <span style={{ fg: theme.textMuted }}>to create a shareable link</span>
113+
</text>
114+
</Match>
115+
</Switch>
116+
</box>
117+
<ContextInfo context={context} cost={cost} />
118+
</box>
119+
</Match>
120+
</Switch>
121+
</box>
93122
</box>
94123
)
95124
}

0 commit comments

Comments
 (0)