mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-14 15:32:52 -04:00
Compare commits
2 Commits
v2
..
turn-end-flash
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cb7fcc49a | |||
| c49f71185d |
@@ -171,7 +171,7 @@ export interface SlotMap {
|
||||
readonly "prompt.footer.file": PromptFooterInput
|
||||
readonly "session.composer.top": { readonly sessionID: string }
|
||||
readonly "sidebar.content": { readonly sessionID: string }
|
||||
readonly "sidebar.footer": { readonly sessionID: string }
|
||||
readonly "sidebar.footer": Readonly<Record<string, never>>
|
||||
}
|
||||
export type SlotPath = keyof SlotMap
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -70,7 +70,6 @@ import {
|
||||
import { DialogImagePreview } from "../dialog-image-preview"
|
||||
import { useDirectoryRecents } from "../../prompt/directory-recents"
|
||||
import { directoryRecentValue } from "../../prompt/directory-completion"
|
||||
import { useWorkingDirectoryActions } from "../../ui/working-directory-actions"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
@@ -1540,25 +1539,21 @@ export function Prompt(props: PromptProps) {
|
||||
const width = dimensions().width < 44 ? dimensions().width - 5 : Math.min(75, dimensions().width - 4) - 5
|
||||
return Locale.takeWidth(value, Math.max(1, width)).trimEnd()
|
||||
})
|
||||
const footerLocation = createMemo(() => {
|
||||
const locationLabel = createMemo(() => {
|
||||
if (!props.sessionID) {
|
||||
// No session yet: show where the next session will be created.
|
||||
return currentLocation.ref ?? data.location.default()
|
||||
const location = currentLocation.ref ?? data.location.default()
|
||||
const directory = abbreviateHome(location.directory, paths.home)
|
||||
const branch = data.location.vcs.info(location)?.branch.current
|
||||
return branch ? `${directory}:${branch}` : directory
|
||||
}
|
||||
if (status() !== "idle") return
|
||||
return data.session.get(props.sessionID)?.location
|
||||
})
|
||||
const locationLabel = createMemo(() => {
|
||||
const location = footerLocation()
|
||||
const location = data.session.get(props.sessionID)?.location
|
||||
if (!location) return
|
||||
const directory = abbreviateHome(location.directory, paths.home)
|
||||
const branch = data.location.vcs.info(location)?.branch.current
|
||||
return branch ? `${directory}:${branch}` : directory
|
||||
})
|
||||
const locationActions = useWorkingDirectoryActions({
|
||||
directory: () => footerLocation()?.directory,
|
||||
onMove: () => void move.open(),
|
||||
})
|
||||
|
||||
const spinnerDef = createMemo(() => {
|
||||
const agent = status() === "running" ? local.agent.current() : local.agent.current()
|
||||
@@ -1879,17 +1874,7 @@ export function Prompt(props: PromptProps) {
|
||||
<Match when={true}>
|
||||
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
|
||||
{(location) => (
|
||||
<text
|
||||
id="prompt.footer.location"
|
||||
fg={locationActions.hovered() ? theme.text.default : theme.text.subdued}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
onMouseOver={locationActions.onMouseOver}
|
||||
onMouseOut={locationActions.onMouseOut}
|
||||
onMouseUp={locationActions.onMouseUp}
|
||||
>
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
{location()}
|
||||
</text>
|
||||
)}
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
import { useWorkingDirectoryActions } from "../../ui/working-directory-actions"
|
||||
import { usePromptMove } from "../../component/prompt/move"
|
||||
|
||||
function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
const move = usePromptMove({
|
||||
projectID: () => props.context.data.session.get(props.sessionID)?.projectID,
|
||||
sessionID: () => props.sessionID,
|
||||
})
|
||||
const actions = useWorkingDirectoryActions({
|
||||
directory: () => props.context.location?.directory,
|
||||
onMove: () => void move.open(),
|
||||
})
|
||||
function View(props: { context: Plugin.Context }) {
|
||||
const directory = createMemo(() => {
|
||||
if (!props.context.location) return undefined
|
||||
const value = props.context.ui.format.path(props.context.location.directory)
|
||||
@@ -21,20 +11,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
})
|
||||
return (
|
||||
<Show when={directory()}>
|
||||
{(value) => (
|
||||
<box
|
||||
id="sidebar.footer.location"
|
||||
onMouseOver={actions.onMouseOver}
|
||||
onMouseOut={actions.onMouseOut}
|
||||
onMouseUp={actions.onMouseUp}
|
||||
>
|
||||
<FilePath
|
||||
value={value()}
|
||||
maxWidth={38}
|
||||
fg={actions.hovered() ? props.context.theme.text.default : props.context.theme.text.subdued}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
{(value) => <FilePath value={value()} maxWidth={38} fg={props.context.theme.text.subdued} />}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -44,9 +21,6 @@ export default Plugin.define({
|
||||
setup(context) {
|
||||
// Append keeps the path open to additive plugin claims; an external
|
||||
// replace still takes the boundary over.
|
||||
context.ui.slot({
|
||||
append: "sidebar.footer",
|
||||
render: (props) => <View context={context} sessionID={props.sessionID} />,
|
||||
})
|
||||
context.ui.slot({ append: "sidebar.footer", render: () => <View context={context} /> })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
<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>
|
||||
@@ -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 }) {
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
@@ -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 } : {}) })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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()))
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -57,7 +57,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
</scrollbox>
|
||||
|
||||
<box flexShrink={0} gap={1} paddingTop={1}>
|
||||
<Slot path="sidebar.footer" input={{ sessionID: props.sessionID }} />
|
||||
<Slot path="sidebar.footer" />
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import open from "open"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { useDialog } from "./dialog"
|
||||
import { DialogSelect } from "./dialog-select"
|
||||
import { useToast } from "./toast"
|
||||
|
||||
export function useWorkingDirectoryActions(input: { directory: () => string | undefined; onMove?: () => void }) {
|
||||
const clipboard = useClipboard()
|
||||
const dialog = useDialog()
|
||||
const renderer = useRenderer()
|
||||
const toast = useToast()
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
|
||||
function openMenu() {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
const directory = input.directory()
|
||||
if (!directory) return
|
||||
dialog.replace(() => (
|
||||
<DialogSelect
|
||||
title="Working directory"
|
||||
renderFilter={false}
|
||||
options={[
|
||||
{
|
||||
title: "Copy path",
|
||||
value: "location.copy",
|
||||
description: directory,
|
||||
onSelect: (dialog) => {
|
||||
void clipboard.write(directory).then(() => {
|
||||
dialog.clear()
|
||||
toast.show({ message: "Path copied to clipboard", variant: "info" })
|
||||
}, toast.error)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Open folder",
|
||||
value: "location.open",
|
||||
description: "in system file manager",
|
||||
onSelect: (dialog) => {
|
||||
dialog.clear()
|
||||
void open(directory).catch(toast.error)
|
||||
},
|
||||
},
|
||||
...(input.onMove
|
||||
? [
|
||||
{
|
||||
title: "Move session",
|
||||
value: "session.move",
|
||||
description: "to another working directory",
|
||||
onSelect: () => void input.onMove?.(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
return {
|
||||
hovered,
|
||||
onMouseOver: () => setHovered(true),
|
||||
onMouseOut: () => setHovered(false),
|
||||
onMouseUp: openMenu,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user