mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-31 22:44:32 +00:00
fix(tui): animate automatic session renames (#45957)
This commit is contained in:
parent
da57b27277
commit
0bb6cf37be
9 changed files with 572 additions and 21 deletions
|
|
@ -41,6 +41,7 @@ import { DialogSessionRename } from "./dialog-session-rename"
|
|||
import { Keymap } from "../context/keymap"
|
||||
import { registerOpencodeSpinner } from "./register-spinner"
|
||||
import { SPINNER_FRAMES } from "./spinner-frames"
|
||||
import "./title-shimmer"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
|
|
@ -96,6 +97,7 @@ export const EMPTY_SESSION_TAB_STATUS: SessionTabsStatus = {
|
|||
promptPulse: 0,
|
||||
attention: false,
|
||||
busy: false,
|
||||
renaming: false,
|
||||
}
|
||||
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close" | "move"> & {
|
||||
newTab?: () => boolean
|
||||
|
|
@ -648,7 +650,7 @@ function VerticalSessionTabs(props: {
|
|||
const restingTitleWidth = () => Math.max(1, width() - numberWidth() - 2)
|
||||
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 1)
|
||||
const titleWidth = () => (hovered() === tab.sessionID ? hoveredTitleWidth() : restingTitleWidth())
|
||||
const title = () => tab.title ?? "Untitled session"
|
||||
const title = () => (props.controller ? undefined : session()?.title) ?? tab.title ?? "Untitled session"
|
||||
const scrolling = () => marquee.active() === tab.sessionID
|
||||
const visibleTitleParts = createMemo(() =>
|
||||
scrolling()
|
||||
|
|
@ -907,14 +909,21 @@ function VerticalSessionTabs(props: {
|
|||
unreadMarker={props.unreadMarker}
|
||||
attributes={selected() ? TextAttributes.BOLD : undefined}
|
||||
/>
|
||||
<text
|
||||
<title_shimmer
|
||||
width={titleWidth()}
|
||||
height={1}
|
||||
fg={foreground()}
|
||||
rename={{ pending: status().renaming, title: title() }}
|
||||
enabled={animations()}
|
||||
backdrop={pulseBackground()}
|
||||
wrapMode="none"
|
||||
selectable={false}
|
||||
attributes={
|
||||
(selected() ? TextAttributes.BOLD : 0) |
|
||||
(tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
|
||||
(status().renaming && !animations()
|
||||
? TextAttributes.DIM
|
||||
: selected()
|
||||
? TextAttributes.BOLD
|
||||
: 0) | (tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
|
||||
}
|
||||
>
|
||||
<Show
|
||||
|
|
@ -927,7 +936,7 @@ function VerticalSessionTabs(props: {
|
|||
)}
|
||||
</Index>
|
||||
</Show>
|
||||
</text>
|
||||
</title_shimmer>
|
||||
<text
|
||||
position="absolute"
|
||||
right={1}
|
||||
|
|
@ -1075,6 +1084,7 @@ function HorizontalSessionTabs(props: {
|
|||
numbers: boolean
|
||||
}) {
|
||||
const tabs = props.controller ?? useSessionTabs()
|
||||
const data = props.controller ? undefined : useData()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme()
|
||||
const config = useConfig().data
|
||||
|
|
@ -1364,7 +1374,7 @@ function HorizontalSessionTabs(props: {
|
|||
const glowColor = createMemo(() => tint(background(), feedbackColor() ?? unreadColor(), glowLevel()))
|
||||
const glows = () =>
|
||||
Boolean(status().attention || (!selected() && !status().busy && status().unread !== undefined))
|
||||
const title = () => tab.title ?? "Untitled session"
|
||||
const title = () => data?.session.get(tab.sessionID)?.title ?? tab.title ?? "Untitled session"
|
||||
const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1)
|
||||
const numberWidth = () => Math.max(2, String(items().length).length)
|
||||
// Hovering reveals the close mark, so the title's right bound shifts left of it.
|
||||
|
|
@ -1492,13 +1502,18 @@ function HorizontalSessionTabs(props: {
|
|||
unreadMarker={props.unreadMarker}
|
||||
attributes={bold()}
|
||||
/>
|
||||
<text
|
||||
<title_shimmer
|
||||
width={availableTitleWidth()}
|
||||
height={1}
|
||||
fg={foreground()}
|
||||
rename={{ pending: status().renaming, title: title() }}
|
||||
enabled={animations()}
|
||||
backdrop={background()}
|
||||
wrapMode="none"
|
||||
selectable={false}
|
||||
attributes={
|
||||
(bold() ?? 0) | (tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
|
||||
(status().renaming && !animations() ? TextAttributes.DIM : (bold() ?? 0)) |
|
||||
(tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
|
||||
}
|
||||
>
|
||||
<Show when={scrolling() || glows() || titleFades()} fallback={visibleTitle()}>
|
||||
|
|
@ -1508,7 +1523,7 @@ function HorizontalSessionTabs(props: {
|
|||
)}
|
||||
</Index>
|
||||
</Show>
|
||||
</text>
|
||||
</title_shimmer>
|
||||
<text
|
||||
position="absolute"
|
||||
right={1}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
|
|||
}
|
||||
|
||||
const clamp = (value: number) => Math.max(0, Math.min(1, value))
|
||||
const smootherstep = (value: number) => value * value * value * (value * (value * 6 - 15) + 10)
|
||||
export const smootherstep = (value: number) => value * value * value * (value * (value * 6 - 15) + 10)
|
||||
const RUN_DURATION = 2_800
|
||||
const RUN_ATTACK = 450
|
||||
const RUN_HEAD = 4
|
||||
|
|
@ -53,11 +53,11 @@ const GLOW_RELEASE_PEAK = 1.25
|
|||
const GLOW_TAIL = 12
|
||||
const GLOW_OPACITY = 0.16
|
||||
const DEFAULT_FOREGROUND = RGBA.defaultForeground()
|
||||
const intensityAt = (index: number, front: number, head: number, tail: number) => {
|
||||
export const intensityAt = (index: number, front: number, head: number, tail: number) => {
|
||||
const distance = front - index
|
||||
return distance < 0 ? smootherstep(clamp(1 + distance / head)) : smootherstep(clamp(1 - distance / tail))
|
||||
}
|
||||
const coast = (value: number) => {
|
||||
export const coast = (value: number) => {
|
||||
const ramp = 0.2
|
||||
if (value < ramp) return (value * value) / (2 * ramp * (1 - ramp))
|
||||
if (value > 1 - ramp) return 1 - ((1 - value) * (1 - value)) / (2 * ramp * (1 - ramp))
|
||||
|
|
|
|||
269
packages/tui/src/component/title-shimmer.tsx
Normal file
269
packages/tui/src/component/title-shimmer.tsx
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import {
|
||||
BoxRenderable,
|
||||
OptimizedBuffer,
|
||||
RGBA,
|
||||
TargetChannel,
|
||||
TextRenderable,
|
||||
type RenderContext,
|
||||
type TextOptions,
|
||||
} from "@opentui/core"
|
||||
import { extend } from "@opentui/solid"
|
||||
import { coast, intensityAt, smootherstep } from "./tab-pulse"
|
||||
|
||||
type TitleShimmerOptions = TextOptions & {
|
||||
rename?: { title: string; pending: boolean }
|
||||
enabled?: boolean
|
||||
backdrop?: RGBA
|
||||
}
|
||||
|
||||
const SHIMMER_DURATION = 1200
|
||||
const SHIMMER_FADE = 240
|
||||
const ARRIVAL_DURATION = 450
|
||||
const WIPE_FEATHER = 4
|
||||
const TRANSPARENT = RGBA.fromValues(0, 0, 0, 0)
|
||||
// Native text draws wide glyphs as a head followed by flagged continuation cells.
|
||||
const CONTINUATION = 0xc0000000 | 0
|
||||
|
||||
export class TitleShimmerRenderable extends TextRenderable {
|
||||
private _rename: TitleShimmerOptions["rename"]
|
||||
private _enabled: boolean
|
||||
private _backdrop: RGBA
|
||||
private pendingTitle: string | undefined
|
||||
private elapsed = 0
|
||||
private blend = 0
|
||||
private fresh = true
|
||||
private arrival: number | undefined
|
||||
private scratch: OptimizedBuffer | undefined
|
||||
private previous: OptimizedBuffer | undefined
|
||||
private mask = new Float32Array(0)
|
||||
private matrix = new Float32Array(16)
|
||||
|
||||
constructor(ctx: RenderContext, options: TitleShimmerOptions) {
|
||||
super(ctx, options)
|
||||
this._rename = options.rename
|
||||
this.pendingTitle = options.rename?.title
|
||||
this._enabled = options.enabled ?? true
|
||||
this._backdrop = options.backdrop ?? RGBA.defaultBackground()
|
||||
this.matrix[15] = 1
|
||||
this.updateBackdrop()
|
||||
this.live = this.animating
|
||||
}
|
||||
|
||||
private get animating() {
|
||||
return this._enabled && (this.shimmering || this.arrival !== undefined || this.blend > 0)
|
||||
}
|
||||
|
||||
private get shimmering() {
|
||||
return this._rename?.pending && this._rename.title === this.pendingTitle
|
||||
}
|
||||
|
||||
set rename(value: TitleShimmerOptions["rename"]) {
|
||||
if (value?.title === this._rename?.title && value?.pending === this._rename?.pending) return
|
||||
if (value?.pending && !this._rename?.pending) {
|
||||
if (this.pendingTitle !== value.title) this.blend = 0
|
||||
this.pendingTitle = value.title
|
||||
if (this.blend === 0) this.elapsed = 0
|
||||
this.arrival = undefined
|
||||
this.previous?.destroy()
|
||||
this.previous = undefined
|
||||
}
|
||||
// Only an automatic rename replaces the last painted title with a wipe.
|
||||
if (value?.title !== this._rename?.title) {
|
||||
this.arrival = value && this._rename?.pending && this._enabled && this.previous ? 0 : undefined
|
||||
if (this.arrival === undefined) this.blend = 0
|
||||
}
|
||||
this._rename = value
|
||||
this.changed()
|
||||
}
|
||||
|
||||
set enabled(value: boolean) {
|
||||
if (value === this._enabled) return
|
||||
this._enabled = value
|
||||
if (!value) {
|
||||
this.arrival = undefined
|
||||
this.blend = 0
|
||||
}
|
||||
this.changed()
|
||||
}
|
||||
|
||||
set backdrop(value: RGBA) {
|
||||
if (value.equals(this._backdrop)) return
|
||||
this._backdrop = value
|
||||
this.updateBackdrop()
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
private updateBackdrop() {
|
||||
this.matrix[3] = this._backdrop.r
|
||||
this.matrix[7] = this._backdrop.g
|
||||
this.matrix[11] = this._backdrop.b
|
||||
}
|
||||
|
||||
private changed() {
|
||||
if (!this.live && this.animating) this.fresh = true
|
||||
this.live = this.animating
|
||||
if (!this.animating) {
|
||||
this.previous?.destroy()
|
||||
this.previous = undefined
|
||||
}
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
override render(buffer: OptimizedBuffer, deltaTime: number) {
|
||||
if (!this.visible || this.isDestroyed || !Number.isFinite(this.width) || this.width <= 0 || this.height <= 0) return
|
||||
if (!this.animating) return super.render(buffer, deltaTime)
|
||||
// A newly live title must not inherit time spent idle before its fade started.
|
||||
const delta = this.fresh ? 0 : deltaTime
|
||||
this.fresh = false
|
||||
this.elapsed = (this.elapsed + delta) % SHIMMER_DURATION
|
||||
if (this.arrival !== undefined) {
|
||||
this.arrival += delta
|
||||
if (this.arrival >= ARRIVAL_DURATION) {
|
||||
this.arrival = undefined
|
||||
this.blend = 0
|
||||
}
|
||||
}
|
||||
this.blend = Math.max(
|
||||
0,
|
||||
Math.min(1, this.blend + (this.shimmering || this.arrival !== undefined ? delta : -delta) / SHIMMER_FADE),
|
||||
)
|
||||
this.live = this.animating
|
||||
if (!this.animating) {
|
||||
this.previous?.destroy()
|
||||
this.previous = undefined
|
||||
return super.render(buffer, deltaTime)
|
||||
}
|
||||
if (!this.scratch)
|
||||
this.scratch = OptimizedBuffer.create(this.width, this.height, this._ctx.widthMethod, { respectAlpha: true })
|
||||
if (this.scratch.width !== this.width || this.scratch.height !== this.height)
|
||||
this.scratch.resize(this.width, this.height)
|
||||
|
||||
// Shade locally, then composite: colorMatrix itself does not respect ancestor scissors.
|
||||
this.scratch.clear(TRANSPARENT)
|
||||
// OpenTUI's framebuffer compositor can paint a cut wide glyph. Clip the native text draw first.
|
||||
const clip = {
|
||||
left: Math.max(0, -this.screenX),
|
||||
top: Math.max(0, -this.screenY),
|
||||
right: Math.min(this.width, buffer.width - this.screenX),
|
||||
bottom: Math.min(this.height, buffer.height - this.screenY),
|
||||
}
|
||||
for (let parent = this.parent; parent; parent = parent.parent) {
|
||||
if (parent.overflow === "visible" || parent.width <= 0 || parent.height <= 0) continue
|
||||
const border = parent instanceof BoxRenderable ? parent.border : false
|
||||
const left = Number(border === true || (Array.isArray(border) && border.includes("left")))
|
||||
const top = Number(border === true || (Array.isArray(border) && border.includes("top")))
|
||||
clip.left = Math.max(clip.left, parent.screenX - this.screenX + left)
|
||||
clip.top = Math.max(clip.top, parent.screenY - this.screenY + top)
|
||||
clip.right = Math.min(
|
||||
clip.right,
|
||||
parent.screenX -
|
||||
this.screenX +
|
||||
parent.width -
|
||||
Number(border === true || (Array.isArray(border) && border.includes("right"))),
|
||||
)
|
||||
clip.bottom = Math.min(
|
||||
clip.bottom,
|
||||
parent.screenY -
|
||||
this.screenY +
|
||||
parent.height -
|
||||
Number(border === true || (Array.isArray(border) && border.includes("bottom"))),
|
||||
)
|
||||
}
|
||||
this.scratch.pushScissorRect(
|
||||
clip.left,
|
||||
clip.top,
|
||||
Math.max(0, clip.right - clip.left),
|
||||
Math.max(0, clip.bottom - clip.top),
|
||||
)
|
||||
this.scratch.drawTextBuffer(this.textBufferView, 0, 0)
|
||||
const characters = this.scratch.buffers.char
|
||||
let end = 0
|
||||
for (let row = 0; row < this.height; row++) {
|
||||
let column = this.width
|
||||
while (
|
||||
column > 0 &&
|
||||
(characters[row * this.width + column - 1] === 32 || characters[row * this.width + column - 1] === 0)
|
||||
)
|
||||
column--
|
||||
end = Math.max(end, column)
|
||||
}
|
||||
const wipeFront =
|
||||
this.arrival !== undefined && this.previous
|
||||
? -WIPE_FEATHER +
|
||||
coast(this.arrival / ARRIVAL_DURATION) * (Math.max(end, this.previous.width) + WIPE_FEATHER * 2)
|
||||
: undefined
|
||||
const cut = Math.max(0, Math.min(this.width, Math.round(wipeFront ?? 0)))
|
||||
if (wipeFront !== undefined && this.previous) {
|
||||
this.scratch.clear(TRANSPARENT)
|
||||
this.scratch.pushScissorRect(0, 0, cut, this.height)
|
||||
this.scratch.drawTextBuffer(this.textBufferView, 0, 0)
|
||||
this.scratch.popScissorRect()
|
||||
// Snapshot slices must also end on whole glyphs; framebuffer clipping alone can split them.
|
||||
for (let row = 0; row < Math.min(this.height, this.previous.height); row++) {
|
||||
let left = Math.max(cut, clip.left)
|
||||
let right = Math.min(this.previous.width, clip.right)
|
||||
const offset = row * this.previous.width
|
||||
while (left < right && (this.previous.buffers.char[offset + left] & CONTINUATION) === CONTINUATION) left++
|
||||
while (
|
||||
right > left &&
|
||||
right < this.previous.width &&
|
||||
(this.previous.buffers.char[offset + right] & CONTINUATION) === CONTINUATION
|
||||
)
|
||||
right--
|
||||
if (right > left) this.scratch.drawFrameBuffer(left, row, this.previous, left, row, right - left, 1)
|
||||
}
|
||||
}
|
||||
this.scratch.clearScissorRects()
|
||||
if (this.mask.length !== this.width * this.height * 3) this.mask = new Float32Array(this.width * this.height * 3)
|
||||
if (wipeFront === undefined) {
|
||||
if (!this.previous)
|
||||
this.previous = OptimizedBuffer.create(Math.max(1, end), this.height, this._ctx.widthMethod, {
|
||||
respectAlpha: true,
|
||||
})
|
||||
if (this.previous.width !== Math.max(1, end) || this.previous.height !== this.height)
|
||||
this.previous.resize(Math.max(1, end), this.height)
|
||||
this.previous.clear(TRANSPARENT)
|
||||
this.previous.drawFrameBuffer(0, 0, this.scratch)
|
||||
}
|
||||
const front = -4 + coast(this.elapsed / SHIMMER_DURATION) * ((this.previous?.width ?? end) + 4 + 18)
|
||||
const level = smootherstep(this.blend)
|
||||
let strength = 0
|
||||
for (let cell = 0; cell < characters.length; cell++) {
|
||||
const column = cell % this.width
|
||||
if ((characters[cell] & CONTINUATION) !== CONTINUATION) {
|
||||
const old = wipeFront === undefined || column >= cut
|
||||
let visibility = old ? 1 - 0.6 * level * (1 - intensityAt(column, front, 4, 18)) : 1
|
||||
if (wipeFront !== undefined) {
|
||||
let width = 1
|
||||
while (column + width < this.width && (characters[cell + width] & CONTINUATION) === CONTINUATION) width++
|
||||
const distance = old ? column - wipeFront : wipeFront - (column + width)
|
||||
visibility *= smootherstep(Math.max(0, Math.min(1, distance / WIPE_FEATHER)))
|
||||
}
|
||||
strength = 1 - visibility
|
||||
}
|
||||
this.mask[cell * 3] = column
|
||||
this.mask[cell * 3 + 1] = Math.floor(cell / this.width)
|
||||
this.mask[cell * 3 + 2] = strength
|
||||
}
|
||||
this.scratch.colorMatrix(this.matrix, this.mask, 1, TargetChannel.FG)
|
||||
buffer.drawFrameBuffer(this.screenX, this.screenY, this.scratch)
|
||||
this.markClean()
|
||||
this._ctx.addToHitGrid(this.screenX, this.screenY, this.width, this.height, this.num)
|
||||
}
|
||||
|
||||
override destroy() {
|
||||
this.previous?.destroy()
|
||||
this.previous = undefined
|
||||
this.scratch?.destroy()
|
||||
this.scratch = undefined
|
||||
super.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
extend({ title_shimmer: TitleShimmerRenderable })
|
||||
|
||||
declare module "@opentui/solid" {
|
||||
interface OpenTUIComponents {
|
||||
title_shimmer: typeof TitleShimmerRenderable
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { createData } from "@opencode-ai/client/solid"
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useClient } from "./client"
|
||||
|
||||
|
|
@ -17,6 +18,27 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
directory: props.directory,
|
||||
})
|
||||
data satisfies Plugin.Context["data"]
|
||||
return data
|
||||
const [generatingTitles, setGeneratingTitles] = createStore<Record<string, boolean | undefined>>({})
|
||||
return {
|
||||
...data,
|
||||
session: {
|
||||
...data.session,
|
||||
title: {
|
||||
pending: (sessionID: string) => generatingTitles[sessionID] === true,
|
||||
async generate(sessionID: string) {
|
||||
if (generatingTitles[sessionID]) return
|
||||
setGeneratingTitles(sessionID, true)
|
||||
await client.api.session
|
||||
.rename({ sessionID, title: "" })
|
||||
.then(() => {
|
||||
// The HTTP response can beat the renamed event. Keep pending until the new title is projected locally.
|
||||
data.session.invalidate(sessionID)
|
||||
return data.session.sync(sessionID)
|
||||
})
|
||||
.finally(() => setGeneratingTitles(sessionID, undefined))
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||
? ("question" as const)
|
||||
: (false as const),
|
||||
busy: members.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
|
||||
renaming: data.session.title.pending(session),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -859,9 +859,12 @@ export function Session(props: {
|
|||
slash: { name: "rename", arguments: true as const },
|
||||
run: (input?: string) => {
|
||||
if (input === undefined) return DialogSessionRename.show(dialog, route.sessionID, session()?.title)
|
||||
void client.api.session
|
||||
.rename({ sessionID: route.sessionID, title: input.trim() })
|
||||
.catch((error) => toast.error(error))
|
||||
const title = input.trim()
|
||||
void (
|
||||
title
|
||||
? client.api.session.rename({ sessionID: route.sessionID, title })
|
||||
: data.session.title.generate(route.sessionID)
|
||||
).catch((error) => toast.error(error))
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { useTheme } from "../../context/theme"
|
|||
import { useConfig } from "../../config"
|
||||
import { Slot } from "../../plugin/render"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import "../../component/title-shimmer"
|
||||
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
|
||||
|
|
@ -45,11 +47,24 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
|||
>
|
||||
<box flexShrink={0} gap={1} paddingRight={1}>
|
||||
<box paddingRight={1}>
|
||||
<text fg={theme.text.default}>
|
||||
<b>{withTimestampedFallback(session()!)}</b>
|
||||
</text>
|
||||
<Show when={session()!.location.workspaceID}>
|
||||
<text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>
|
||||
<title_shimmer
|
||||
fg={theme.text.default}
|
||||
rename={{
|
||||
pending: data.session.title.pending(props.sessionID),
|
||||
title: withTimestampedFallback(session()),
|
||||
}}
|
||||
enabled={config.animations ?? true}
|
||||
backdrop={theme.background.default}
|
||||
attributes={
|
||||
data.session.title.pending(props.sessionID) && config.animations === false
|
||||
? TextAttributes.DIM
|
||||
: TextAttributes.BOLD
|
||||
}
|
||||
>
|
||||
{withTimestampedFallback(session())}
|
||||
</title_shimmer>
|
||||
<Show when={session().location.workspaceID}>
|
||||
<text fg={theme.text.subdued}>{session().location.workspaceID}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Slot path="sidebar.content" input={{ sessionID: props.sessionID }} />
|
||||
|
|
|
|||
|
|
@ -223,6 +223,87 @@ test("session title generated while an untitled session is loading remains visib
|
|||
}
|
||||
})
|
||||
|
||||
test("automatic rename refreshes the displayed title before settling, even without a renamed event", async () => {
|
||||
await using state = await tmpdir()
|
||||
const setup = await createTestRenderer({ width: 90, height: 20, useThread: false, kittyKeyboard: true })
|
||||
setup.renderer.start()
|
||||
const events = createEventStream()
|
||||
const response = Promise.withResolvers<Response>()
|
||||
const bodies: unknown[] = []
|
||||
const location = { directory, project: { id: "project", directory, canonical: directory } }
|
||||
const session = {
|
||||
id: "ses_rename",
|
||||
title: "Compiler cleanup",
|
||||
projectID: "project",
|
||||
location: { directory },
|
||||
agent: "build",
|
||||
model: { providerID: "provider", id: "model" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
}
|
||||
const calls = createFetch(async (url, request) => {
|
||||
if (url.pathname === "/api/location") return json(location)
|
||||
if (url.pathname === "/api/agent")
|
||||
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
|
||||
if (url.pathname === "/api/model")
|
||||
return json({ location, data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }] })
|
||||
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "provider", name: "Provider" }] })
|
||||
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
|
||||
if (url.pathname === "/api/session/ses_rename") return json({ data: session })
|
||||
if (/^\/api\/session\/ses_rename\/(message|inbox|permission)$/.test(url.pathname))
|
||||
return json({ data: [], cursor: {} })
|
||||
if (url.pathname === "/api/session/ses_rename/rename") {
|
||||
bodies.push(await request.json())
|
||||
return response.promise
|
||||
}
|
||||
return undefined
|
||||
}, events)
|
||||
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||
|
||||
try {
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: {
|
||||
get: async () => ({
|
||||
tabs: { enabled: true, layout: "vertical" },
|
||||
session: { sidebar: "hide" },
|
||||
}),
|
||||
update: async () => ({}),
|
||||
},
|
||||
packages: { resolve: async () => undefined },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
args: { sessionID: session.id },
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
|
||||
await setup.waitForFrame((frame) => frame.includes(session.title) && frame.includes("Build · Model Provider"))
|
||||
await setup.mockInput.typeText("/rename")
|
||||
setup.mockInput.pressEscape()
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.waitFor(() => bodies.length === 1)
|
||||
await setup.renderOnce()
|
||||
expect(bodies[0]).toEqual({ title: "" })
|
||||
expect(setup.captureCharFrame()).toContain("Compiler cleanup")
|
||||
|
||||
session.title = "Simplify compiler parsing"
|
||||
response.resolve(new Response(null, { status: 204 }))
|
||||
await setup.waitForFrame((frame) => frame.includes(session.title), { maxPasses: 60 })
|
||||
expect(setup.captureCharFrame()).not.toContain("Compiler cleanup")
|
||||
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
} finally {
|
||||
response.resolve(new Response(null, { status: 204 }))
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("session startup prompt is submitted exactly once", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const events = createEventStream()
|
||||
|
|
|
|||
145
packages/tui/test/component/title-shimmer.test.ts
Normal file
145
packages/tui/test/component/title-shimmer.test.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { BoxRenderable, RGBA, TextAttributes, TextRenderable } from "@opentui/core"
|
||||
import { createTestRenderer, ManualClock } from "@opentui/core/testing"
|
||||
import { TitleShimmerRenderable } from "../../src/component/title-shimmer"
|
||||
|
||||
test("shimmer fades in from idle and fades out on unchanged completion", async () => {
|
||||
const clock = new ManualClock()
|
||||
const app = await createTestRenderer({ width: 24, height: 1, useThread: false, clock })
|
||||
const title = new TitleShimmerRenderable(app.renderer, {
|
||||
width: 24,
|
||||
height: 1,
|
||||
content: "Compiler cleanup",
|
||||
fg: "#eeeeee",
|
||||
backdrop: RGBA.fromHex("#111111"),
|
||||
rename: { title: "Compiler cleanup", pending: false },
|
||||
})
|
||||
app.renderer.root.add(title)
|
||||
try {
|
||||
await app.renderOnce()
|
||||
const frame = app.captureCharFrame()
|
||||
const colors = app.captureSpans()
|
||||
clock.advance(2000)
|
||||
title.rename = { title: "Compiler cleanup", pending: true }
|
||||
await app.renderOnce()
|
||||
expect(app.captureSpans()).toEqual(colors)
|
||||
clock.advance(120)
|
||||
await app.renderOnce()
|
||||
const middle = app.captureSpans().lines[0].spans.findLast((span) => span.text.trim())?.fg.r ?? 0
|
||||
expect(middle).toBeLessThan(colors.lines[0].spans[0].fg.r)
|
||||
clock.advance(120)
|
||||
await app.renderOnce()
|
||||
expect(app.captureSpans().lines[0].spans.findLast((span) => span.text.trim())?.fg.r ?? 0).toBeLessThan(middle)
|
||||
expect(app.captureSpans()).not.toEqual(colors)
|
||||
expect(app.captureCharFrame()).toBe(frame)
|
||||
title.rename = { title: "Compiler cleanup", pending: false }
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.root.liveCount).toBe(1)
|
||||
clock.advance(240)
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.root.liveCount).toBe(0)
|
||||
expect(app.captureSpans()).toEqual(colors)
|
||||
title.enabled = false
|
||||
title.rename = { title: "Compiler cleanup", pending: true }
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.root.liveCount).toBe(0)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("a feathered wipe keeps the old shimmer moving without dimming the revealed new title", async () => {
|
||||
const clock = new ManualClock()
|
||||
const app = await createTestRenderer({ width: 16, height: 1, useThread: false, clock })
|
||||
const title = new TitleShimmerRenderable(app.renderer, {
|
||||
width: 16,
|
||||
height: 1,
|
||||
content: "ABCDEFGHIJKLMNOP",
|
||||
fg: "#eeeeee",
|
||||
attributes: TextAttributes.ITALIC,
|
||||
backdrop: RGBA.fromHex("#111111"),
|
||||
rename: { title: "ABCDEFGHIJKLMNOP", pending: true },
|
||||
})
|
||||
app.renderer.root.add(title)
|
||||
try {
|
||||
await app.renderOnce()
|
||||
clock.advance(600)
|
||||
await app.renderOnce()
|
||||
const colors = app.captureSpans()
|
||||
title.content = "abcdefghijklmnop"
|
||||
title.rename = { title: "abcdefghijklmnop", pending: true }
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame().trim()).toBe("ABCDEFGHIJKLMNOP")
|
||||
expect(app.captureSpans()).toEqual(colors)
|
||||
clock.advance(225)
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame().trim()).toBe("abcdefghIJKLMNOP")
|
||||
const spans = app.captureSpans().lines[0].spans
|
||||
expect(spans[0].fg.equals(RGBA.fromHex("#eeeeee"))).toBe(true)
|
||||
expect(spans.some((span) => span.fg.equals(RGBA.fromHex("#111111")))).toBe(true)
|
||||
expect(spans.at(-1)?.fg.toInts()).not.toEqual(colors.lines[0].spans.at(-1)?.fg.toInts())
|
||||
expect(spans.every((span) => Boolean(span.attributes & TextAttributes.ITALIC))).toBe(true)
|
||||
clock.advance(225)
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame().trim()).toBe("abcdefghijklmnop")
|
||||
expect(app.renderer.root.liveCount).toBe(0)
|
||||
|
||||
title.rename = { title: "abcdefghijklmnop", pending: false }
|
||||
title.content = "Manual"
|
||||
title.rename = { title: "Manual", pending: false }
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame().trim()).toBe("Manual")
|
||||
expect(app.renderer.root.liveCount).toBe(0)
|
||||
|
||||
title.rename = { title: "Manual", pending: true }
|
||||
await app.renderOnce()
|
||||
title.content = "Next"
|
||||
title.rename = { title: "Next", pending: false }
|
||||
title.enabled = false
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame().trim()).toBe("Next")
|
||||
title.enabled = true
|
||||
expect(app.renderer.root.liveCount).toBe(0)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("native Unicode clipping and shorter replacement leave no split glyphs or old tail", async () => {
|
||||
const clock = new ManualClock()
|
||||
const app = await createTestRenderer({ width: 24, height: 3, useThread: false, clock })
|
||||
const content = "A\u65e5B \u{1f680} cafe\u0301"
|
||||
const title = new TitleShimmerRenderable(app.renderer, {
|
||||
width: 20,
|
||||
height: 1,
|
||||
content,
|
||||
fg: "#eeeeee",
|
||||
wrapMode: "none",
|
||||
backdrop: RGBA.fromHex("#111111"),
|
||||
rename: { title: content, pending: true },
|
||||
})
|
||||
const plain = new TextRenderable(app.renderer, { width: 20, height: 1, content, wrapMode: "none" })
|
||||
const shadedBox = new BoxRenderable(app.renderer, { width: 6, height: 1, marginLeft: 2, overflow: "hidden" })
|
||||
const plainBox = new BoxRenderable(app.renderer, { width: 6, height: 1, marginLeft: 2, overflow: "hidden" })
|
||||
shadedBox.add(title)
|
||||
plainBox.add(plain)
|
||||
app.renderer.root.add(shadedBox)
|
||||
app.renderer.root.add(plainBox)
|
||||
app.renderer.root.add(new TextRenderable(app.renderer, { content: "untouched" }))
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame().split("\n")[0]).toBe(app.captureCharFrame().split("\n")[1])
|
||||
title.content = "Short"
|
||||
title.rename = { title: "Short", pending: false }
|
||||
clock.advance(200)
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame().split("\n")[0].trim()).toBe("Sh B")
|
||||
expect(app.captureCharFrame().split("\n")[2]).toContain("untouched")
|
||||
clock.advance(250)
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame().split("\n")[0].trim()).toBe("Short")
|
||||
expect(app.renderer.root.liveCount).toBe(0)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue