Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/tui/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# TUI UI Experiments

- Before implementing a visual behavior as an experiment, add a fixture-driven story under `src/feature-plugins/system/storybook` that renders the real production component.
- Put the current treatment and meaningfully different variants in the story. Expose replay and tuning controls in `StoryFooter`, including reset when values are adjustable.
- Let the user choose or tune a variant in the story before selecting production defaults.
- After selection, register the behavior in `src/component/dialog-experiments.tsx` and gate it with `config.data.experimental?.<id> === true`; experiments must not change default behavior.
- Treat tuning-only stories as local scaffolding and remove them and their registration before committing. Commit a story only when the user explicitly wants it retained as a reusable regression fixture.
- Use OpenCode Drive with a simulated LLM for deterministic turn/session behavior. Do not invoke a real model only to verify TUI behavior.
- Run the story with `OPENCODE_STORY=<story-id> bun run dev:live` and exercise relevant wide and narrow terminal sizes.
55 changes: 55 additions & 0 deletions packages/tui/src/component/assistant-summary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { RGBA } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { createEffect, on, onMount, Show } from "solid-js"
import { tint } from "../theme/color"
import { createAnimatable, tween } from "../ui/animation"

export type AssistantSummaryFlash = {
trigger: number
duration: number
intensity: number
}

export function AssistantSummary(props: {
agent: string
model: string
duration?: string
interrupted?: boolean
agentColor: RGBA
subduedColor: RGBA
flashColor: RGBA
animations: boolean
flash?: AssistantSummaryFlash
}) {
const dimensions = useTerminalDimensions()
const flash = createAnimatable(
{ level: 0 },
{
enabled: () => props.animations,
transition: tween({ duration: props.flash?.duration ?? 0.32 }),
},
)
const run = () => {
if (!props.flash || !props.animations || props.flash.trigger === 0) return
flash.jump({ level: props.flash.intensity })
flash.animate({ level: 0 })
}
onMount(run)
createEffect(on(() => props.flash?.trigger, run, { defer: true }))
const color = (resting: RGBA) => tint(resting, props.flashColor, flash.value().level)

return (
<text>
<span style={{ fg: color(props.agentColor) }}>{props.agent}</span>
<Show when={dimensions().width >= 28}>
<span style={{ fg: color(props.subduedColor) }}> · {props.model}</span>
</Show>
<Show when={props.duration && (dimensions().width < 28 || dimensions().width >= 36)}>
<span style={{ fg: color(props.subduedColor) }}> · {props.duration}</span>
</Show>
<Show when={props.interrupted}>
<span style={{ fg: color(props.subduedColor) }}> · interrupted</span>
</Show>
</text>
)
}
5 changes: 5 additions & 0 deletions packages/tui/src/component/dialog-experiments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ export const experiments: Experiment[] = [
title: "Remember tab scroll",
description: "Keep each open tab's reading position and show a shortcut back to the bottom.",
},
{
id: "turn_summary_flash",
title: "Turn summary flash",
description: "Brighten the agent, model, and duration when a turn completes, then fade to their resting colors.",
},
]

export function DialogExperiments() {
Expand Down
35 changes: 18 additions & 17 deletions packages/tui/src/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ import {
type SessionRow,
} from "./rows"
import { switchLabel } from "../../util/model"
import { AssistantSummary } from "../../component/assistant-summary"
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
import { stringWidth } from "../../util/string-width"
import { useArgs } from "../../context/args"
Expand Down Expand Up @@ -1334,7 +1335,7 @@ function SessionRowView(props: SessionRowViewProps) {
<Show when={props.message(row().messageID)}>
{(message) => (
<Show when={message().type === "assistant"}>
<AssistantFooter message={message() as SessionMessageAssistant} />
<AssistantFooter message={message() as SessionMessageAssistant} flash={row().flash} />
</Show>
)}
</Show>
Expand Down Expand Up @@ -1794,11 +1795,10 @@ function SessionGroupView(props: {
)
}

function AssistantFooter(props: { message: SessionMessageAssistant }) {
function AssistantFooter(props: { message: SessionMessageAssistant; flash?: true }) {
const ctx = use()
const data = useData()
const local = useLocal()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const model = createMemo(
() =>
Expand All @@ -1818,20 +1818,21 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
</Show>
<AssistantRetry retry={props.message.retry} />
<box paddingLeft={3} marginTop={props.message.retry || (props.message.error && !interrupted()) ? 1 : 0}>
<text>
<span style={{ fg: props.message.error ? theme.text.subdued : local.agent.color(props.message.agent) }}>
{Locale.titlecase(props.message.agent)}
</span>
<Show when={dimensions().width >= 28}>
<span style={{ fg: theme.text.subdued }}> · {model()}</span>
</Show>
<Show when={duration() && (dimensions().width < 28 || dimensions().width >= 36)}>
<span style={{ fg: theme.text.subdued }}> · {Locale.duration(duration())}</span>
</Show>
<Show when={interrupted()}>
<span style={{ fg: theme.text.subdued }}> · interrupted</span>
</Show>
</text>
<AssistantSummary
agent={Locale.titlecase(props.message.agent)}
model={model()}
duration={duration() ? Locale.duration(duration()) : undefined}
interrupted={interrupted()}
agentColor={props.message.error ? theme.text.subdued : local.agent.color(props.message.agent)}
subduedColor={theme.text.subdued}
flashColor={theme.text.default}
animations={ctx.config.animations ?? true}
flash={
props.flash && ctx.config.experimental?.turn_summary_flash === true
? { trigger: 1, duration: 0.8, intensity: 0.7 }
: undefined
}
/>
</box>
</>
)
Expand Down
10 changes: 5 additions & 5 deletions packages/tui/src/routes/session/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export type SessionRow =
pending: PartRef[]
completed: boolean
}
| { type: "assistant-footer"; messageID: string }
| { type: "assistant-footer"; messageID: string; flash?: true }
| { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage }

export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessionID: string) => void) {
Expand Down Expand Up @@ -176,13 +176,13 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
}),
)

const appendFooter = (messageID: string) =>
const appendFooter = (messageID: string, flash?: true) =>
setRows(
produce((draft) => {
if (draft.some((row) => row.type === "assistant-footer" && row.messageID === messageID)) return
const index = queuedStart(draft)
completePrevious(draft, index)
draft.splice(index, 0, { type: "assistant-footer", messageID })
draft.splice(index, 0, { type: "assistant-footer", messageID, ...(flash ? { flash } : {}) })
}),
)

Expand Down Expand Up @@ -268,12 +268,12 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
}),
data.on("session.step.ended", (event) => {
if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return
appendFooter(event.data.assistantMessageID)
appendFooter(event.data.assistantMessageID, true)
if (turnTokens()) setRows(reconcile(reduce()))
}),
data.on("session.step.failed", (event) => {
if (event.data.sessionID !== sessionID()) return
appendFooter(event.data.assistantMessageID)
appendFooter(event.data.assistantMessageID, true)
if (turnTokens()) setRows(reconcile(reduce()))
}),
]
Expand Down
Loading