diff --git a/packages/tui/AGENTS.md b/packages/tui/AGENTS.md new file mode 100644 index 000000000000..d3887bde3f01 --- /dev/null +++ b/packages/tui/AGENTS.md @@ -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?. === 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= bun run dev:live` and exercise relevant wide and narrow terminal sizes. diff --git a/packages/tui/src/component/assistant-summary.tsx b/packages/tui/src/component/assistant-summary.tsx new file mode 100644 index 000000000000..c6fe2e86db9f --- /dev/null +++ b/packages/tui/src/component/assistant-summary.tsx @@ -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 ( + + {props.agent} + = 28}> + · {props.model} + + = 36)}> + · {props.duration} + + + · interrupted + + + ) +} diff --git a/packages/tui/src/component/dialog-experiments.tsx b/packages/tui/src/component/dialog-experiments.tsx index 3c5f0e18b463..27aa1beffbe2 100644 --- a/packages/tui/src/component/dialog-experiments.tsx +++ b/packages/tui/src/component/dialog-experiments.tsx @@ -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() { diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 1be96e4cdea7..f8bd87b852df 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -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" @@ -1334,7 +1335,7 @@ function SessionRowView(props: SessionRowViewProps) { {(message) => ( - + )} @@ -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( () => @@ -1818,20 +1818,21 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) { - - - {Locale.titlecase(props.message.agent)} - - = 28}> - · {model()} - - = 36)}> - · {Locale.duration(duration())} - - - · interrupted - - + ) diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index e1e0efe78a2a..66b6d13212e2 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -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, onSynced?: (sessionID: string) => void) { @@ -176,13 +176,13 @@ export function createSessionRows(sessionID: Accessor, 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 } : {}) }) }), ) @@ -268,12 +268,12 @@ export function createSessionRows(sessionID: Accessor, 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())) }), ]