Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline bf112cb49c fix(tui): open rendered links on click 2026-08-14 17:54:09 +00:00
9 changed files with 112 additions and 190 deletions
-9
View File
@@ -1,9 +0,0 @@
# 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.
+8 -1
View File
@@ -44,6 +44,7 @@ import {
type TuiApp,
} from "./context/runtime"
import { DialogProvider, useDialog } from "./ui/dialog"
import { linkAt } from "./ui/link"
import { DialogIntegration } from "./component/dialog-integration"
import { ErrorComponent } from "./component/error-component"
import { PluginRouteMissing } from "./component/plugin-route-missing"
@@ -1270,7 +1271,13 @@ function App(props: { pair?: DialogPairCredentials }) {
evt.preventDefault()
evt.stopPropagation()
}}
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
onMouseUp={(event) => {
if (copyOnSelectEnabled()) Selection.copy(renderer, toast, clipboard)
if (event.defaultPrevented || event.button !== MouseButton.LEFT || event.isDragging) return
const href = linkAt(renderer.currentRenderBuffer, event.x, event.y)
if (!href) return
open(href).catch(() => {})
}}
>
<box
flexGrow={1}
@@ -1,55 +0,0 @@
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,11 +19,6 @@ 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() {
+43 -95
View File
@@ -307,7 +307,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createTabMarquee(animations)
const hovered = marquee.hovered
// OpenTUI captures the first drag target, which may differ from the tab pressed on a fast move.
const [dragging, setDragging] = createSignal<string>()
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
const [contextMenu, setContextMenu] = createSignal<TabContextMenuState>()
@@ -344,9 +343,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const itemStatus = (tab: SessionTab) => statuses().get(tab.sessionID)!
let rail: { screenX: number; screenY: number } | undefined
let scroll: ScrollBoxRenderable | undefined
let didDrag = false
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
let suppressClick = false
createEffect(() => {
const pending = preview()
@@ -368,29 +364,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
}
})
const release = () => {
const source = dragging()
if (!source) return
if (didDrag) suppressClick = true
setDragging(undefined)
const pending = preview()
if (pending?.sessionID === source) tabs.move(pending.sessionID, pending.index)
tabs.select(source)
}
const drag = (event: MouseEvent) => {
if (!rail) return
const source = dragging()
if (!source) return
didDrag = true
const target = Math.max(
0,
Math.min(tabs.tabs().length - 1, Math.floor((event.y - rail.screenY - 1 + (scroll?.scrollTop ?? 0)) / 3)),
)
const sourceIndex = items().findIndex((item) => item.sessionID === source)
if (target !== sourceIndex && preview()?.index !== target) setPreview({ sessionID: source, index: target })
}
return (
<box
ref={(element) => (rail = element)}
@@ -402,15 +375,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
paddingTop={1}
backgroundColor={theme.background.default}
onMouseOut={marquee.leaveHovered}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
release()
if (!didDrag) return
didDrag = false
queueMicrotask(() => (suppressClick = false))
}}
onMouseDrag={drag}
onMouseDragEnd={release}
>
<scrollbox ref={(element) => (scroll = element)} flexGrow={1} scrollbarOptions={{ visible: false }}>
<box flexShrink={0} flexDirection="column" gap={1}>
@@ -558,6 +522,12 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
: color
return separator ? tint(faded, pulseBackground(), 0.55) : faded
}
const release = () => {
setDragging(undefined)
const pending = preview()
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
tabs.select(tab.sessionID)
}
return (
<box
height={2}
@@ -569,7 +539,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
onMouseOut={() => marquee.leave(tab.sessionID)}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) {
didDrag = false
setDragging(undefined)
if (!rail) return
setContextMenu({
@@ -582,10 +551,26 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
event.stopPropagation()
return
}
didDrag = false
marquee.enter(tab.sessionID, title(), hoveredTitleWidth())
setDragging(tab.sessionID)
}}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
release()
}}
onMouseDrag={(event) => {
if (!rail) return
const target = Math.max(
0,
Math.min(
tabs.tabs().length - 1,
Math.floor((event.y - rail.screenY - 1 + (scroll?.scrollTop ?? 0)) / 3),
),
)
if (target !== index() && preview()?.index !== target)
setPreview({ sessionID: tab.sessionID, index: target })
}}
onMouseDragEnd={release}
>
<TabPulse
top={-1}
@@ -692,14 +677,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
selectable={false}
onMouseOver={() => setCloseHovered(true)}
onMouseOut={() => setCloseHovered(false)}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON || hovered() !== tab.sessionID) return
didDrag = false
event.stopPropagation()
}}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (suppressClick) return
if (hovered() !== tab.sessionID) return
event.stopPropagation()
tabs.close(tab.sessionID)
@@ -758,8 +737,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
onMouseOver={() => setAddHovered(true)}
onMouseOut={() => setAddHovered(false)}
onMouseDown={(event: MouseEvent) => {
didDrag = false
setDragging(undefined)
if (event.button !== RIGHT_MOUSE_BUTTON) return
if (!rail) return
setContextMenu({ x: event.x, y: event.y })
@@ -768,7 +745,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
}}
onMouseUp={(event: MouseEvent) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (suppressClick) return
if (!newTab()) tabs.add?.()
}}
>
@@ -798,7 +774,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
selectable={false}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (suppressClick) return
if (!addHovered()) return
event.stopPropagation()
tabs.close()
@@ -828,7 +803,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createTabMarquee(animations)
const hovered = marquee.hovered
// OpenTUI captures the first drag target, which may differ from the tab pressed on a fast move.
const [dragging, setDragging] = createSignal<string>()
// A drag reorders a local preview and persists one move on release instead of writing
// per slot crossing; the preview holds after release until the store reflects the move,
@@ -836,9 +810,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
const [contextMenu, setContextMenu] = createSignal<TabContextMenuState>()
let strip: { screenX: number; screenY: number } | undefined
let didDrag = false
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
let suppressClick = false
const hueStep = () => (mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
const activeNumber = () => theme.hue.interactive[hueStep()]
@@ -960,29 +931,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
return layout().before + layout().widths.length - 1
}
const release = () => {
const source = dragging()
if (!source) return
if (didDrag) suppressClick = true
setDragging(undefined)
const pending = preview()
if (pending?.sessionID === source) tabs.move(pending.sessionID, pending.index)
if (source === NEW_SESSION_TAB.sessionID) return
tabs.select(source)
}
const drag = (event: MouseEvent) => {
const source = dragging()
if (!source || source === NEW_SESSION_TAB.sessionID) return
didDrag = true
const slot = slotAt(event.x)
const target = slot === undefined ? undefined : Math.min(slot, tabs.tabs().length - 1)
const sourceIndex = items().findIndex((item) => item.sessionID === source)
if (target !== undefined && target !== sourceIndex && preview()?.index !== target) {
setPreview({ sessionID: source, index: target })
}
}
return (
<box
ref={(element) => (strip = element)}
@@ -992,15 +940,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
flexDirection="row"
zIndex={1}
onMouseOut={marquee.leaveHovered}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
release()
if (!didDrag) return
didDrag = false
queueMicrotask(() => (suppressClick = false))
}}
onMouseDrag={drag}
onMouseDragEnd={release}
renderAfter={function (buffer) {
const x = Math.max(0, this.screenX)
const y = this.screenY + this.height
@@ -1112,6 +1051,15 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
}
const bold = () => (selected() || dragged() ? TextAttributes.BOLD : undefined)
const closeColor = () => tint(theme.text.subdued, theme.text.default, 0.6)
// Releasing a drag (or a plain click) selects the tab, matching browser tab strips and
// keeping sloppy clicks indistinguishable from clean ones.
const release = () => {
setDragging(undefined)
const pending = preview()
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
if (tab === NEW_SESSION_TAB) return
tabs.select(tab.sessionID)
}
return (
<box
width={width()}
@@ -1122,7 +1070,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
onMouseOut={() => marquee.leave(tab.sessionID)}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) {
didDrag = false
setDragging(undefined)
setContextMenu({
x: event.x,
@@ -1134,10 +1081,20 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
event.stopPropagation()
return
}
didDrag = false
marquee.enter(tab.sessionID, title(), hoveredTitleWidth())
setDragging(tab.sessionID)
}}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
release()
}}
onMouseDrag={(event) => {
if (tab === NEW_SESSION_TAB) return
const slot = slotAt(event.x)
if (slot !== undefined && slot !== tabNumber() - 1)
setPreview({ sessionID: tab.sessionID, index: slot })
}}
onMouseDragEnd={release}
>
<TabPulse
enabled={animations()}
@@ -1183,14 +1140,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
selectable={false}
onMouseOver={() => setCloseHovered(true)}
onMouseOut={() => setCloseHovered(false)}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON || hovered() !== tab.sessionID) return
didDrag = false
event.stopPropagation()
}}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (suppressClick) return
// The close mark only renders while hovered; without motion events a click can
// land here first, and must select the tab instead of closing it invisibly.
if (hovered() !== tab.sessionID) return
@@ -1219,8 +1170,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
onMouseOver={() => setAddHovered(true)}
onMouseOut={() => setAddHovered(false)}
onMouseDown={(event) => {
didDrag = false
setDragging(undefined)
if (event.button !== RIGHT_MOUSE_BUTTON) return
setContextMenu({ x: event.x, y: event.y })
event.preventDefault()
@@ -1228,7 +1177,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
}}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (suppressClick) return
tabs.add?.()
}}
>
+17 -18
View File
@@ -98,7 +98,6 @@ 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"
@@ -1335,7 +1334,7 @@ function SessionRowView(props: SessionRowViewProps) {
<Show when={props.message(row().messageID)}>
{(message) => (
<Show when={message().type === "assistant"}>
<AssistantFooter message={message() as SessionMessageAssistant} flash={row().flash} />
<AssistantFooter message={message() as SessionMessageAssistant} />
</Show>
)}
</Show>
@@ -1795,10 +1794,11 @@ function SessionGroupView(props: {
)
}
function AssistantFooter(props: { message: SessionMessageAssistant; flash?: true }) {
function AssistantFooter(props: { message: SessionMessageAssistant }) {
const ctx = use()
const data = useData()
const local = useLocal()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const model = createMemo(
() =>
@@ -1818,21 +1818,20 @@ function AssistantFooter(props: { message: SessionMessageAssistant; flash?: true
</Show>
<AssistantRetry retry={props.message.retry} />
<box paddingLeft={3} marginTop={props.message.retry || (props.message.error && !interrupted()) ? 1 : 0}>
<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
}
/>
<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>
</box>
</>
)
+5 -5
View File
@@ -32,7 +32,7 @@ export type SessionRow =
pending: PartRef[]
completed: boolean
}
| { type: "assistant-footer"; messageID: string; flash?: true }
| { type: "assistant-footer"; messageID: string }
| { 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, flash?: true) =>
const appendFooter = (messageID: string) =>
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, ...(flash ? { flash } : {}) })
draft.splice(index, 0, { type: "assistant-footer", messageID })
}),
)
@@ -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, true)
appendFooter(event.data.assistantMessageID)
if (turnTokens()) setRows(reconcile(reduce()))
}),
data.on("session.step.failed", (event) => {
if (event.data.sessionID !== sessionID()) return
appendFooter(event.data.assistantMessageID, true)
appendFooter(event.data.assistantMessageID)
if (turnTokens()) setRows(reconcile(reduce()))
}),
]
+11 -2
View File
@@ -1,5 +1,5 @@
import type { JSX } from "solid-js"
import type { RGBA } from "@opentui/core"
import { getLinkId, type OptimizedBuffer, type RGBA } from "@opentui/core"
import open from "open"
export interface LinkProps {
@@ -24,7 +24,8 @@ export function Link(props: LinkProps) {
bg={props.bg}
width={props.width}
wrapMode={props.wrapMode}
onMouseUp={() => {
onMouseUp={(event) => {
event.stopPropagation()
open(props.href).catch(() => {})
}}
>
@@ -32,3 +33,11 @@ export function Link(props: LinkProps) {
</text>
)
}
export function linkAt(buffer: OptimizedBuffer, x: number, y: number) {
if (x < 0 || x >= buffer.width || y < 0 || y >= buffer.height) return
const id = getLinkId(buffer.buffers.attributes[y * buffer.width + x] ?? 0)
if (!id) return
const lib = buffer.lib as typeof buffer.lib & { linkGetUrl(id: number): string }
return lib.linkGetUrl(id) || undefined
}
+28
View File
@@ -0,0 +1,28 @@
import { expect, test } from "bun:test"
import { StyledText, TextRenderable } from "@opentui/core"
import { createTestRenderer, setRendererCapabilities } from "@opentui/core/testing"
import { linkAt } from "../../src/ui/link"
test("resolves terminal hyperlink metadata at the clicked cell", async () => {
const app = await createTestRenderer({ width: 60, height: 4 })
setRendererCapabilities(app.renderer, { hyperlinks: true })
const href = "file:///tmp/example.ts#L12"
app.renderer.root.add(
new TextRenderable(app.renderer, {
content: new StyledText([{ __isChunk: true, text: "example", link: { url: href } }]),
width: "100%",
height: 1,
}),
)
try {
await app.waitForFrame((frame) => frame.includes("example"))
const buffer = app.renderer.currentRenderBuffer
const links = Array.from({ length: buffer.width * buffer.height }, (_, index) =>
linkAt(buffer, index % buffer.width, Math.floor(index / buffer.width)),
)
expect(links).toContain(href)
} finally {
app.renderer.destroy()
}
})