Skip to content

Commit d5756e5

Browse files
See USee U
authored andcommitted
feat(tui): polish session rendering and subagent display
Panelize fenced code blocks with language labels, render reasoning as dimmed markdown, unify MCP/status glyphs, add breathing gradient spinner, and give subagents agent-color identity while preserving status-color semantics. Fixes streaming text flicker by keeping a single stable markdown node during output and only segmenting once finalized.
1 parent 8cfcb06 commit d5756e5

22 files changed

Lines changed: 926 additions & 438 deletions

packages/tui/src/component/dialog-mcp.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,17 @@ import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "../
66
import { useTheme } from "../context/theme"
77
import { TextAttributes } from "@opentui/core"
88
import { useSDK } from "../context/sdk"
9+
import { GLYPH } from "../ui/glyphs"
910

1011
function Status(props: { enabled: boolean; loading: boolean }) {
1112
const { theme } = useTheme()
1213
if (props.loading) {
13-
return <span style={{ fg: theme.textMuted }}> Loading</span>
14+
return <span style={{ fg: theme.textMuted }}>{GLYPH.mcp.loading} Loading</span>
1415
}
1516
if (props.enabled) {
16-
return <span style={{ fg: theme.success, attributes: TextAttributes.BOLD }}> Enabled</span>
17+
return <span style={{ fg: theme.success, attributes: TextAttributes.BOLD }}>{GLYPH.mcp.connected} Enabled</span>
1718
}
18-
return <span style={{ fg: theme.textMuted }}> Disabled</span>
19+
return <span style={{ fg: theme.textMuted }}>{GLYPH.mcp.disabled} Disabled</span>
1920
}
2021

2122
export function DialogMcp() {

packages/tui/src/component/dialog-status.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useTheme } from "../context/theme"
44
import { useDialog } from "../ui/dialog"
55
import { useSync } from "../context/sync"
66
import { For, Match, Switch, Show, createMemo } from "solid-js"
7+
import { GLYPH } from "../ui/glyphs"
78

89
export type DialogStatusProps = {}
910

@@ -70,7 +71,15 @@ export function DialogStatus() {
7071
)[item.status],
7172
}}
7273
>
73-
74+
{(
75+
{
76+
connected: GLYPH.mcp.connected,
77+
failed: GLYPH.mcp.failed,
78+
disabled: GLYPH.mcp.disabled,
79+
needs_auth: GLYPH.mcp.connected,
80+
needs_client_registration: GLYPH.mcp.failed,
81+
} as Record<string, string>
82+
)[item.status] ?? GLYPH.mcp.connected}
7483
</text>
7584
<text fg={theme.text} wrapMode="word">
7685
<b>{key}</b>{" "}
@@ -108,7 +117,7 @@ export function DialogStatus() {
108117
}[item.status],
109118
}}
110119
>
111-
120+
{item.status === "error" ? GLYPH.mcp.failed : GLYPH.mcp.connected}
112121
</text>
113122
<text fg={theme.text} wrapMode="word">
114123
<b>{item.id}</b> <span style={{ fg: theme.textMuted }}>{item.root}</span>
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { createMemo, createSignal, Show } from "solid-js"
2+
import type { JSX } from "@opentui/solid"
3+
import type { RGBA } from "@opentui/core"
4+
import { useTheme } from "../../context/theme"
5+
import { useKV } from "../../context/kv"
6+
import type { Theme } from "../../theme"
7+
import { tint } from "../../theme"
8+
import { BRAILLE_FRAMES, GLYPH, RESULT_PREFIX, collapsedHint, expandedHint } from "../../ui/glyphs"
9+
import { registerOpencodeSpinner } from "../register-spinner"
10+
11+
registerOpencodeSpinner()
12+
13+
export type ToolVisualState = "pending" | "running" | "permission" | "complete" | "error" | "denied"
14+
15+
/**
16+
* Canonical bullet/label colors for every tool state so "done", "running",
17+
* and "failed" read the same everywhere in the message flow.
18+
*/
19+
export function toolStateColor(theme: Theme, state: ToolVisualState): { dot: RGBA; label: RGBA } {
20+
switch (state) {
21+
case "pending":
22+
return { dot: theme.textMuted, label: theme.textMuted }
23+
case "running":
24+
return { dot: theme.primary, label: theme.text }
25+
case "permission":
26+
return { dot: theme.warning, label: theme.text }
27+
case "complete":
28+
return { dot: theme.success, label: theme.text }
29+
case "error":
30+
return { dot: theme.error, label: theme.text }
31+
case "denied":
32+
return { dot: theme.error, label: theme.textMuted }
33+
}
34+
}
35+
36+
/**
37+
* A logical block: fixed 2-column bullet glyph followed by flowing content.
38+
* When `spinner` is set the glyph cell animates instead (braille frames).
39+
*/
40+
export function Bullet(props: {
41+
color: RGBA
42+
glyph?: string
43+
spinner?: boolean
44+
marginTop?: number
45+
children: JSX.Element
46+
}) {
47+
const kv = useKV()
48+
const { theme } = useTheme()
49+
// Breathing gradient: each braille frame nudges the base color toward the
50+
// theme accent on a triangle wave, so the spinner "pulses" instead of sitting
51+
// on one flat hue. Built once per color/accent pair to avoid per-frame churn.
52+
const gradient = createMemo(() => {
53+
const base = props.color
54+
const accent = theme.accent
55+
return (frameIndex: number, _charIndex: number, totalFrames: number) => {
56+
const phase = totalFrames > 1 ? frameIndex / (totalFrames - 1) : 0
57+
const wave = 1 - Math.abs(phase * 2 - 1)
58+
return tint(base, accent, wave * 0.5)
59+
}
60+
})
61+
return (
62+
<box flexDirection="row" marginTop={props.marginTop}>
63+
<box width={2} flexShrink={0}>
64+
<Show
65+
when={props.spinner && kv.get("animations_enabled", true)}
66+
fallback={<text fg={props.color}>{props.spinner ? GLYPH.idleSpinner : (props.glyph ?? GLYPH.bullet)}</text>}
67+
>
68+
<spinner frames={BRAILLE_FRAMES} interval={80} color={gradient()} />
69+
</Show>
70+
</box>
71+
<box flexGrow={1} flexShrink={1}>
72+
{props.children}
73+
</box>
74+
</box>
75+
)
76+
}
77+
78+
/**
79+
* Hanging-indent result line(s) under a Bullet: `⎿ content`.
80+
*/
81+
export function ResultBlock(props: { color?: RGBA; children: JSX.Element }) {
82+
const { theme } = useTheme()
83+
return (
84+
<box flexDirection="row">
85+
<box width={RESULT_PREFIX.length} flexShrink={0}>
86+
<text fg={props.color ?? theme.textMuted}>{GLYPH.result}</text>
87+
</box>
88+
<box flexGrow={1} flexShrink={1}>
89+
{props.children}
90+
</box>
91+
</box>
92+
)
93+
}
94+
95+
/**
96+
* The one true collapse/expand affordance: `… +N lines (<shortcut> expand)`.
97+
*/
98+
export function CollapsedHint(props: {
99+
hidden: number
100+
expanded: boolean
101+
onToggle: () => void
102+
shortcut?: string
103+
color?: RGBA
104+
}) {
105+
const { theme } = useTheme()
106+
const [hover, setHover] = createSignal(false)
107+
return (
108+
<box
109+
flexDirection="row"
110+
onMouseOver={() => setHover(true)}
111+
onMouseOut={() => setHover(false)}
112+
onMouseUp={() => props.onToggle()}
113+
>
114+
<text fg={hover() ? theme.text : (props.color ?? theme.textMuted)}>
115+
{props.expanded ? expandedHint(props.shortcut) : collapsedHint(props.hidden, props.shortcut)}
116+
</text>
117+
</box>
118+
)
119+
}

packages/tui/src/component/prompt/autocomplete.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -753,8 +753,9 @@ export function Autocomplete(props: {
753753
const isAgent = option().display.startsWith("@") && !option().isDirectory && !option().path
754754
const isFile = option().path !== undefined
755755
const isDir = option().isDirectory
756-
const typeIcon = isDir ? "📁" : isFile ? "📄" : isAgent ? "🤖" : isMcpCommand ? "🔌" : isSlashCommand ? "⌘" : ""
756+
const typeIcon = isMcpCommand ? "::" : ""
757757
const typeColor = isMcpCommand ? theme.accent : isAgent ? theme.secondary : isFile ? theme.info : theme.textMuted
758+
const label = isDir && !option().display.endsWith("/") ? option().display + "/" : option().display
758759
return (
759760
<box
760761
paddingLeft={1}
@@ -785,7 +786,7 @@ export function Autocomplete(props: {
785786
attributes={isSlashCommand && !isMcpCommand ? TextAttributes.BOLD : undefined}
786787
flexShrink={0}
787788
>
788-
{option().display}
789+
{label}
789790
</text>
790791
<Show when={option().description}>
791792
<text fg={index === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">

packages/tui/src/component/prompt/index.tsx

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ import { type AutocompleteRef, Autocomplete } from "./autocomplete"
3838
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
3939
import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2"
4040
import { Locale } from "../../util/locale"
41+
import { usageColor } from "../../util/usage"
42+
import { GLYPH } from "../../ui/glyphs"
43+
import { ACTIVITY_VERBS, activityVerb } from "../../ui/activity-verbs"
4144
import { errorMessage } from "../../util/error"
4245

4346
import { useDialog } from "../../ui/dialog"
@@ -249,14 +252,7 @@ export function Prompt(props: PromptProps) {
249252
}
250253
})
251254

252-
// Warn as the context window fills up: >80% is urgent, >60% is caution.
253-
const usageColor = createMemo(() => {
254-
const pct = usage()?.percent
255-
if (pct === undefined) return theme.textMuted
256-
if (pct > 80) return theme.error
257-
if (pct > 60) return theme.warning
258-
return theme.textMuted
259-
})
255+
const usageFg = createMemo(() => usageColor(theme, usage()?.percent))
260256

261257
const [store, setStore] = createStore<{
262258
prompt: PromptInfo
@@ -1310,6 +1306,20 @@ export function Prompt(props: PromptProps) {
13101306
}
13111307
})
13121308

1309+
const [busyElapsed, setBusyElapsed] = createSignal(0)
1310+
const [busyVerb, setBusyVerb] = createSignal<string>(ACTIVITY_VERBS[0])
1311+
createEffect(() => {
1312+
if (status().type === "idle") {
1313+
setBusyElapsed(0)
1314+
return
1315+
}
1316+
const started = Date.now()
1317+
setBusyVerb(activityVerb(started))
1318+
setBusyElapsed(0)
1319+
const timer = setInterval(() => setBusyElapsed(Math.floor((Date.now() - started) / 1000)), 1000)
1320+
onCleanup(() => clearInterval(timer))
1321+
})
1322+
13131323
return (
13141324
<>
13151325
<box ref={(r: BoxRenderable) => (anchor = r)} visible={props.visible !== false} width="100%">
@@ -1441,7 +1451,7 @@ export function Prompt(props: PromptProps) {
14411451
{props.right}
14421452
</Show>
14431453
<Show when={usage()?.context}>
1444-
<text fg={usageColor()} wrapMode="none">
1454+
<text fg={usageFg()} wrapMode="none">
14451455
{usage()!.context}
14461456
</text>
14471457
</Show>
@@ -1452,13 +1462,19 @@ export function Prompt(props: PromptProps) {
14521462
<Show
14531463
when={status().type === "idle"}
14541464
fallback={
1455-
<Show when={animationsEnabled()} fallback={<text fg={theme.textMuted}></text>}>
1465+
<Show when={animationsEnabled()} fallback={<text fg={theme.textMuted}>{GLYPH.bulletFallback}</text>}>
14561466
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
14571467
</Show>
14581468
}
14591469
>
14601470
<text fg={theme.textMuted}>{directory()}</text>
14611471
</Show>
1472+
<Show when={status().type !== "idle"}>
1473+
<text fg={theme.textMuted} wrapMode="none">
1474+
{busyVerb()}
1475+
{GLYPH.ellipsis} ({busyElapsed()}s)
1476+
</text>
1477+
</Show>
14621478
<box flexGrow={1} />
14631479
<Show when={usage()?.cost}>
14641480
<text fg={theme.textMuted} wrapMode="none">{usage()!.cost}</text>

packages/tui/src/component/spinner.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,26 @@ import { useTheme } from "../context/theme"
33
import { useKV } from "../context/kv"
44
import type { JSX } from "@opentui/solid"
55
import type { RGBA } from "@opentui/core"
6+
import { BRAILLE_FRAMES, GLYPH } from "../ui/glyphs"
67
import { registerOpencodeSpinner } from "./register-spinner"
78

89
registerOpencodeSpinner()
910

10-
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
11+
export const SPINNER_FRAMES = BRAILLE_FRAMES
1112

1213
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
1314
const { theme } = useTheme()
1415
const kv = useKV()
1516
const color = () => props.color ?? theme.textMuted
1617
return (
17-
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={color()}>{props.children}</text>}>
18+
<Show
19+
when={kv.get("animations_enabled", true)}
20+
fallback={
21+
<text fg={color()}>
22+
{GLYPH.idleSpinner} {props.children}
23+
</text>
24+
}
25+
>
1826
<box flexDirection="row" gap={1}>
1927
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
2028
<Show when={props.children}>

packages/tui/src/component/todo-item.tsx

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { TextAttributes } from "@opentui/core"
22
import { useTheme } from "../context/theme"
3+
import { GLYPH } from "../ui/glyphs"
34

45
export interface TodoItemProps {
56
status: string
@@ -9,19 +10,19 @@ export interface TodoItemProps {
910
export function TodoItem(props: TodoItemProps) {
1011
const { theme } = useTheme()
1112

12-
const icon = props.status === "completed" ? "✓" : props.status === "in_progress" ? "◐" : props.status === "pending" ? "○" : "•"
13-
const color = props.status === "completed"
14-
? theme.success
15-
: props.status === "in_progress"
16-
? theme.warning
17-
: props.status === "pending"
18-
? theme.borderSubtle
19-
: theme.textMuted
20-
const attrs = props.status === "completed"
21-
? TextAttributes.STRIKETHROUGH
22-
: props.status === "in_progress"
23-
? TextAttributes.BOLD
24-
: undefined
13+
const icon = props.status === "completed" ? GLYPH.todo.completed : GLYPH.todo.pending
14+
const color =
15+
props.status === "completed"
16+
? theme.textMuted
17+
: props.status === "in_progress"
18+
? theme.primary
19+
: theme.text
20+
const attrs =
21+
props.status === "completed"
22+
? TextAttributes.STRIKETHROUGH
23+
: props.status === "in_progress"
24+
? TextAttributes.BOLD
25+
: undefined
2526

2627
return (
2728
<box flexDirection="row" gap={1} alignItems="center">

packages/tui/src/feature-plugins/home/footer.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { TextAttributes } from "@opentui/core"
55
import { abbreviateHome } from "../../runtime"
66
import { useTuiPaths } from "../../context/runtime"
77
import { useHomeSessionDestination } from "../../routes/home/session-destination"
8+
import { GLYPH } from "../../ui/glyphs"
89

910
const id = "internal:home-footer"
1011

@@ -26,7 +27,6 @@ function Directory(props: { api: TuiPluginApi }) {
2627
<Show when={dir()}>
2728
{(value) => (
2829
<box flexDirection="row" gap={1} flexShrink={0} alignItems="center">
29-
<text fg={theme().borderSubtle}>📁</text>
3030
<text fg={theme().textMuted}>{value()}</text>
3131
</box>
3232
)}
@@ -43,9 +43,9 @@ function Mcp(props: { api: TuiPluginApi }) {
4343
const total = createMemo(() => list().length)
4444

4545
const statusIcon = createMemo(() => {
46-
if (err()) return { icon: "✗", color: theme().error }
47-
if (count() > 0) return { icon: "●", color: theme().success }
48-
return { icon: "○", color: theme().textMuted }
46+
if (err()) return { icon: GLYPH.mcp.failed, color: theme().error }
47+
if (count() > 0) return { icon: GLYPH.mcp.connected, color: theme().success }
48+
return { icon: GLYPH.mcp.disabled, color: theme().textMuted }
4949
})
5050

5151
return (
@@ -72,7 +72,6 @@ function SessionCount(props: { api: TuiPluginApi }) {
7272
return (
7373
<Show when={show()}>
7474
<box flexDirection="row" gap={1} flexShrink={0} alignItems="center">
75-
<text fg={theme().borderSubtle}>💬</text>
7675
<text fg={theme().text} attributes={TextAttributes.BOLD}>
7776
{count()}
7877
</text>

packages/tui/src/feature-plugins/home/tips-view.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
22
import { createMemo, For, type Accessor } from "solid-js"
33
import { TextAttributes } from "@opentui/core"
44
import { EmptyBorder } from "../../ui/border"
5+
import { GLYPH } from "../../ui/glyphs"
56
import { DEFAULT_THEMES, useTheme } from "../../context/theme"
67
import { useCommandShortcut } from "../../keymap"
78

@@ -150,7 +151,7 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
150151
return (
151152
<box flexDirection="row" maxWidth="100%" gap={1} alignItems="center">
152153
<box border={["left"]} borderColor={theme.success} customBorderChars={{ ...EmptyBorder, vertical: "│" }} paddingLeft={1} paddingRight={1}>
153-
<text fg={theme.success}></text>
154+
<text fg={theme.success}>{GLYPH.thinking}</text>
154155
</box>
155156
<text flexShrink={0} fg={theme.success} attributes={TextAttributes.BOLD}>{"Tip"}{" "}</text>
156157
<text flexShrink={1} wrapMode="word">

0 commit comments

Comments
 (0)