From 0e3da4b4f1951d58b166a362065f43a7ec2464ac Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 1 Aug 2026 13:10:08 -0400 Subject: [PATCH] feat(tui): add vertical session tabs (#40072) --- AGENTS.md | 7 + package.json | 1 + packages/cli/src/commands/handlers/default.ts | 6 +- packages/tui/src/app.tsx | 14 +- packages/tui/src/component/dialog-config.tsx | 9 + packages/tui/src/component/dialog-open.tsx | 6 +- .../tui/src/component/dialog-session-list.tsx | 8 +- packages/tui/src/component/session-tabs.tsx | 346 +++++++++++- packages/tui/src/component/tab-pulse.tsx | 523 +++++++++++++----- packages/tui/src/config/index.tsx | 3 + .../tui/src/context/session-tabs-model.ts | 5 + packages/tui/src/routes/session/index.tsx | 8 +- packages/tui/src/util/project.ts | 7 + packages/tui/test/config-v2.test.tsx | 2 +- .../test/context/session-tabs-model.test.ts | 7 + 15 files changed, 802 insertions(+), 150 deletions(-) create mode 100644 packages/tui/src/util/project.ts diff --git a/AGENTS.md b/AGENTS.md index 72d635ae33e..749e8ef7131 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,13 @@ - The default branch in this repo is `dev`. - Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. +## Live V2 TUI Testing + +- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode2` background server and live sessions. +- Pass a directory after the script when needed, for example `bun run dev:live /path/to/project`. +- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `next` TUI storage channel so tabs and other client-local state match the installed client. +- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it. + ## Branch Names Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`. diff --git a/package.json b/package.json index de344eac278..857db661052 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "packageManager": "bun@1.3.14", "scripts": { "dev": "bun run --cwd packages/cli --conditions=browser src/index.ts", + "dev:live": "OPENCODE_TUI_CHANNEL=next OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"", "dev:desktop": "bun --cwd packages/desktop dev", "dev:web": "bun --cwd packages/app dev", "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index e0719494605..b016b863f90 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -45,7 +45,11 @@ export default Runtime.handler(Commands, (input) => const runPromise = Effect.runPromiseWith(context) const service = server.service yield* run({ - app: { name: process.env.OPENCODE_CLIENT ?? "cli", version: OPENCODE_VERSION, channel: OPENCODE_CHANNEL }, + app: { + name: process.env.OPENCODE_CLIENT ?? "cli", + version: OPENCODE_VERSION, + channel: process.env.OPENCODE_TUI_CHANNEL ?? OPENCODE_CHANNEL, + }, server: { endpoint: server.endpoint, service: service diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 29864cf7b3a..aa14a2ef0a7 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -519,6 +519,9 @@ function App(props: { pair?: DialogPairCredentials }) { const terminalTitleEnabled = () => config.data.terminal?.title ?? true const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32" const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full" + const tabsVertical = () => config.data.tabs?.vertical ?? false + const tabsVisible = () => + sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin" createEffect(() => { renderer.useMouse = config.data.mouse @@ -1214,16 +1217,13 @@ function App(props: { pair?: DialogPairCredentials }) { onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined} > + + + - 0 || sessionTabs.newTab()) && - route.data.type !== "plugin" - } - > + diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx index 805df0ff2a1..93c0c65128c 100644 --- a/packages/tui/src/component/dialog-config.tsx +++ b/packages/tui/src/component/dialog-config.tsx @@ -100,6 +100,15 @@ export const settings: Setting[] = [ values: ["cwd", "global"], labels: ["current directory", "global"], }, + { + title: "Vertical", + category: "Tabs", + path: ["tabs", "vertical"], + default: false, + values: [false, true], + labels: ["off", "on"], + keywords: ["sidebar", "orientation", "left"], + }, { title: "Layout", category: "Diffs", diff --git a/packages/tui/src/component/dialog-open.tsx b/packages/tui/src/component/dialog-open.tsx index 7eb73c38b3a..1926dabbc4f 100644 --- a/packages/tui/src/component/dialog-open.tsx +++ b/packages/tui/src/component/dialog-open.tsx @@ -1,4 +1,3 @@ -import path from "path" import { createMemo, createResource, createSignal, onMount } from "solid-js" import type { SessionInfo } from "@opencode-ai/client" import { useTerminalDimensions } from "@opentui/solid" @@ -18,6 +17,7 @@ import { truncateFilePath } from "../ui/file-path" import { stringWidth } from "../util/string-width" import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback" import { Spinner } from "./spinner" +import { projectName } from "../util/project" const RECENT_LIMIT = 8 @@ -81,7 +81,7 @@ export function DialogOpen() { .slice(0, RECENT_LIMIT) const sessionOptions = recent.map((session) => { const project = data.project.get(session.projectID) - const name = project?.canonical === "/" ? undefined : project?.name || path.basename(project?.canonical ?? "") + const name = projectName(project) const running = data.session.status(session.id) === "running" || data.session.family(session.id).some((id) => data.session.status(id) === "running") @@ -109,7 +109,7 @@ export function DialogOpen() { return true }) .map((project) => { - const title = project.name ?? path.basename(project.canonical) + const title = projectName(project) ?? project.canonical const footer = abbreviateHome(project.canonical, paths.home) const width = dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title) diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index ff27245da4a..fe05a9ef181 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -20,6 +20,7 @@ import { useSessionTabs } from "../context/session-tabs" import { useStorage } from "../context/storage" import { useConfig } from "../config" import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback" +import { projectName } from "../util/project" export function DialogSessionList() { const dialog = useDialog() @@ -123,8 +124,7 @@ export function DialogSessionList() { const current = data.location.info() if (!current) return "" const project = data.project.get(current.project.id) - if (!project) return "" - return project.name || path.basename(project.canonical) + return projectName(project) ?? "" }) const options = createMemo(() => { @@ -141,9 +141,7 @@ export function DialogSessionList() { const option = (session: SessionInfo, category: string) => { const directory = session.location.directory const project = data.project.get(session.projectID) - const footer = allProjects() - ? Locale.truncate(project?.name || path.basename(project?.canonical ?? directory), 20) - : undefined + const footer = allProjects() ? Locale.truncate(projectName(project, directory) ?? "", 20) : undefined const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id) const deleting = toDelete() === session.id return { diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index 44b769972b6..5d2e0b9b276 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -1,8 +1,9 @@ -import { RGBA, TextAttributes } from "@opentui/core" +import { RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" import { For, Show, createComputed, createEffect, createMemo, createSignal, untrack } from "solid-js" import { useTerminalDimensions } from "@opentui/solid" import { useConfig } from "../config" import { useSessionTabs } from "../context/session-tabs" +import { useData } from "../context/data" import { useTheme, useThemes } from "../context/theme" import { adaptiveSessionTabLayout, @@ -11,6 +12,7 @@ import { sessionTabComplete, seedSessionTabMotion, sessionTabOverflowWidth, + sessionTabVerticalWidth, type SessionTab, type SessionTabUnread, } from "../context/session-tabs-model" @@ -19,6 +21,7 @@ import { Locale } from "../util/locale" import { stringWidth } from "../util/string-width" import { TabPulse, unreadGlowIntensity } from "./tab-pulse" import { tint } from "../theme/color" +import { projectName } from "../util/project" // A long title fades out over its last cells instead of cutting hard. const FADE_WIDTH = 4 @@ -39,8 +42,343 @@ export type SessionTabsController = Pick + tint(base, glow, 0.12 * unreadGlowIntensity(index, width)) -export function SessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) { +export function SessionTabs( + props: { controller?: SessionTabsController; animations?: boolean; orientation?: "horizontal" | "vertical" } = {}, +) { + if (props.orientation === "vertical") + return + return +} + +function VerticalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean }) { + const tabs = props.controller ?? useSessionTabs() + const data = useData() + const dimensions = useTerminalDimensions() + const theme = useTheme("elevated") + const { mode } = useThemes() + const config = useConfig().data + const animations = () => props.animations ?? config.animations ?? true + const width = () => sessionTabVerticalWidth(dimensions().width) + const hueStep = () => (mode() === "light" ? 800 : 200) + const accent = () => theme.hue.accent[hueStep()] + const activeNumber = () => theme.hue.interactive[hueStep()] + const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35) + const [hovered, setHovered] = createSignal() + const [dragging, setDragging] = createSignal() + const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>() + const newTab = () => tabs.newTab?.() ?? false + const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current())) + const ordered = createMemo(() => { + const pending = preview() + if (!pending) return tabs.tabs() + return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index) + }) + const items = createMemo(() => (newTab() ? [...ordered(), NEW_SESSION_TAB] : ordered())) + const statuses = createMemo( + () => + new Map( + items().map((tab) => { + const status = tab === NEW_SESSION_TAB ? EMPTY_SESSION_TAB_STATUS : tabs.status(tab.sessionID) + return [ + tab.sessionID, + { + ...status, + complete: sessionTabComplete(status.unread, status.busy), + runs: status.busy && !status.attention, + glows: + tab.sessionID !== activeID() && (status.attention || (!status.busy && status.unread !== undefined)), + }, + ] as const + }), + ), + ) + const itemStatus = (tab: SessionTab) => statuses().get(tab.sessionID)! + let rail: { screenY: number } | undefined + let scroll: ScrollBoxRenderable | undefined + + createEffect(() => { + const pending = preview() + if (!pending || dragging()) return + const index = tabs.tabs().findIndex((tab) => tab.sessionID === pending.sessionID) + if (index === -1 || index === Math.min(pending.index, tabs.tabs().length - 1)) setPreview(undefined) + }) + + createEffect(() => { + if (!scroll) return + const index = items().findIndex((tab) => tab.sessionID === activeID()) + if (index === -1) return + const top = index * 3 + if (top < scroll.scrollTop) return scroll.scrollTo(top) + if (top + 2 > scroll.scrollTop + scroll.viewport.height) { + scroll.scrollTo(top + 2 - scroll.viewport.height) + } + }) + + return ( + (rail = element)} + width={width()} + height="100%" + flexShrink={0} + flexDirection="column" + paddingTop={1} + backgroundColor={theme.background.default} + > + (scroll = element)} flexGrow={1} scrollbarOptions={{ visible: false }}> + + + {(tab, index) => { + const selected = () => activeID() === tab.sessionID + const status = createMemo(() => itemStatus(tab)) + const [sweepLevel, setSweepLevel] = createSignal(0) + const session = createMemo(() => (tab === NEW_SESSION_TAB ? undefined : data.session.get(tab.sessionID))) + const project = createMemo(() => { + const value = session() + return value ? data.project.get(value.projectID) : undefined + }) + const numberWidth = () => String(index() + 1).length + 1 + const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0)) + const title = () => tab.title ?? "Untitled session" + const visibleTitle = createMemo(() => Locale.takeWidth(title(), titleWidth())) + const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle())) + const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH) + const detail = createMemo(() => { + if (tab === NEW_SESSION_TAB) return Locale.takeWidth("Start a new session", titleWidth()) + const value = session() + return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth()) + }) + const background = createMemo(() => { + if (selected()) return theme.background.action.primary.selected + if (hovered() === tab.sessionID || dragging() === tab.sessionID) + return theme.background.action.primary.hovered + return theme.background.default + }) + const pulseBackground = createMemo(() => tint(theme.background.default, background(), background().a)) + const numberColor = () => { + if (status().attention) return theme.text.feedback.warning.default + if (status().unread === "error") return theme.text.feedback.error.default + const base = + hovered() === tab.sessionID && !selected() + ? foreground() + : tint(idleNumber(), activeNumber(), Number(selected())) + const color = tint(base, accent(), Number(complete())) + return sweepLevel() === 0 ? color : tint(color, theme.text.default, 0.15 * sweepLevel()) + } + const foreground = () => { + if (hovered() === tab.sessionID) return theme.text.default + return selected() ? theme.text.default : theme.text.subdued + } + const complete = () => status().complete + const glowHue = () => { + if (status().attention) return theme.text.feedback.warning.default + if (status().unread === "error") return theme.text.feedback.error.default + return accent() + } + const pulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.25)) + const glowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.45)) + const detailPulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.13)) + const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25)) + const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35)) + const glows = () => status().glows + const previous = createMemo(() => items()[index() - 1]) + const previousStatus = createMemo(() => { + const tab = previous() + return tab + ? itemStatus(tab) + : { ...EMPTY_SESSION_TAB_STATUS, complete: false, runs: false, glows: false } + }) + const previousGlows = () => previousStatus().glows + const runs = () => status().runs + const previousRuns = () => previousStatus().runs + const previousGlowHue = () => { + if (previousStatus().attention) return theme.text.feedback.warning.default + if (previousStatus().unread === "error") return theme.text.feedback.error.default + return accent() + } + const separatorUpperColor = createMemo(() => tint(theme.background.default, previousGlowHue(), 0.1)) + const separatorLowerColor = createMemo(() => tint(theme.background.default, glowHue(), 0.12)) + const separatorUpperPulseColor = createMemo(() => + tint(theme.background.default, theme.text.default, 0.04), + ) + const separatorLowerPulseColor = createMemo(() => + tint(theme.background.default, theme.text.default, 0.05), + ) + const titleColor = (index: number) => { + const color = glows() + ? glowTextColor(foreground(), glowColor(), 1 + numberWidth() + index, width()) + : foreground() + if (!titleFades() || index < visibleTitleParts().length - FADE_WIDTH) return color + const position = index - (visibleTitleParts().length - FADE_WIDTH) + return tint(color, pulseBackground(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1))) + } + const release = () => { + setDragging(undefined) + const pending = preview() + if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index) + if (tab !== NEW_SESSION_TAB) tabs.select(tab.sessionID) + } + return ( + setHovered(tab.sessionID)} + onMouseOut={() => setHovered(undefined)} + onMouseDown={() => setDragging(tab.sessionID)} + onMouseUp={release} + onMouseDrag={(event) => { + if (!rail || tab === NEW_SESSION_TAB) 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} + > + + + + + + + + + {index() + 1} + + + + + {(character, index) => {character}} + + + + { + if (hovered() !== tab.sessionID) return + event.stopPropagation() + tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID) + }} + > + {hovered() === tab.sessionID ? "×" : ""} + + + + + + + + {detail()} + + + + + ) + }} + + + + + ) +} + +function HorizontalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) { const tabs = props.controller ?? useSessionTabs() const dimensions = useTerminalDimensions() const theme = useTheme() @@ -269,9 +607,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati // spatial falloff as the glow itself; characters beyond the tail stay neutral. const characterColor = (index: number) => { const base = foreground() - const color = glows() - ? tint(base, glowColor(), 0.12 * unreadGlowIntensity(1 + numberWidth() + index, width())) - : base + const color = glows() ? glowTextColor(base, glowColor(), 1 + numberWidth() + index, width()) : base if (!titleFades() || index < displayedParts().length - FADE_WIDTH) return color const position = index - (displayedParts().length - FADE_WIDTH) return tint(color, background(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1))) diff --git a/packages/tui/src/component/tab-pulse.tsx b/packages/tui/src/component/tab-pulse.tsx index db978b24805..23a9ce74e71 100644 --- a/packages/tui/src/component/tab-pulse.tsx +++ b/packages/tui/src/component/tab-pulse.tsx @@ -2,16 +2,28 @@ import { OptimizedBuffer, Renderable, RGBA, type RenderableOptions, type RenderC import { extend } from "@opentui/solid" type TabPulseOptions = RenderableOptions & { + edge?: "above" | "below" enabled?: boolean active?: boolean + outerActive?: boolean promptPulse?: number + outerPromptPulse?: number complete?: boolean + outerComplete?: boolean glow?: boolean + outerGlow?: boolean breathe?: boolean + outerBreathe?: boolean color?: RGBA + outerColor?: RGBA glowColor?: RGBA + outerGlowColor?: RGBA + glowTail?: number + outerGlowTail?: number flashColor?: RGBA + outerFlashColor?: RGBA completionColor?: RGBA + outerCompletionColor?: RGBA backgroundColor?: RGBA /** Reports the running sweep's intensity at the tab number's cell, quantized; 0 when idle. */ onLevel?: (level: number) => void @@ -20,6 +32,7 @@ type TabPulseOptions = RenderableOptions & { const clamp = (value: number) => Math.max(0, Math.min(1, value)) 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 const RUN_TAIL = 18 const RUN_FADE_OUT = 500 @@ -58,9 +71,10 @@ const attackDecay = (progress: number, attack: number, peak: number, rest: numbe export const completionPulseOpacity = (progress: number) => attackDecay(progress, COMPLETION_ATTACK, 1, 0) export const glowIgnitionLevel = (progress: number) => attackDecay(progress, GLOW_IGNITION_ATTACK, GLOW_IGNITION_PEAK, 1) -export const unreadGlowIntensity = (index: number, width: number) => { - const tail = Math.min(GLOW_TAIL, Math.max(1, width - 2)) - return smootherstep(clamp(1 - Math.max(0, index - 1) / tail)) +const glowIntensityAt = (index: number, tail: number) => smootherstep(clamp(1 - Math.max(0, index - 1) / tail)) +export const unreadGlowIntensity = (index: number, width: number, maximumTail = GLOW_TAIL) => { + const tail = Math.min(maximumTail, Math.max(1, width - 2)) + return glowIntensityAt(index, tail) } export function blendTabPulseColor( output: RGBA, @@ -131,45 +145,236 @@ class Envelope { // Hoisted so the per-frame liveness check allocates no closure. const envelopeActive = (envelope: Envelope) => envelope.active -class TabPulseRenderable extends Renderable { - private _enabled: boolean - private _active: boolean - private _promptPulse: number - private _complete: boolean - private _glow: boolean - private _breathe: boolean - private _color: RGBA - private _glowColor: RGBA - private _flashColor: RGBA - private _completionColor: RGBA - private _backgroundColor: RGBA +type PulseStateOptions = { + enabled: boolean + active: boolean + promptPulse: number + complete: boolean + glow: boolean + breathe: boolean +} + +class PulseState { + private enabled: boolean + private active: boolean + private promptPulse: number + private complete: boolean + private glow: boolean + private breathe: boolean private clock = 0 private breatheClock = 0 private completionPending = false + private runAttack = new Envelope(RUN_ATTACK, smootherstep) private runFade = new Envelope(RUN_FADE_OUT, fadeOut) private completionPulse = new Envelope(COMPLETION_DURATION, completionPulseOpacity) private edgeFlash = new Envelope(EDGE_FLASH_DURATION, (progress) => attackDecay(progress, EDGE_FLASH_ATTACK, 1, 0)) private ignition = new Envelope(GLOW_IGNITION_DURATION, glowIgnitionLevel) private glowOff = new Envelope(GLOW_FADE_OUT, fadeOut) - private envelopes = [this.runFade, this.completionPulse, this.edgeFlash, this.ignition, this.glowOff] + private envelopes = [this.runAttack, this.runFade, this.completionPulse, this.edgeFlash, this.ignition, this.glowOff] + + constructor(options: PulseStateOptions) { + this.enabled = options.enabled + this.active = options.active + this.promptPulse = options.promptPulse + this.complete = options.complete + this.glow = options.glow + this.breathe = options.breathe + if (this.enabled && this.active) this.runAttack.start() + } + + private get breathing() { + return this.enabled && this.glow && this.breathe + } + + get live() { + return this.active || this.breathing || this.envelopes.some(envelopeActive) + } + + get running() { + if (!this.enabled) return 0 + return this.active ? (this.runAttack.active ? this.runAttack.level() : 1) : this.runFade.level() + } + + get completion() { + return this.completionPulse.level() * COMPLETION_OPACITY + } + + get flash() { + return this.edgeFlash.level() * EDGE_FLASH_OPACITY + } + + get glowLevel() { + if (!this.glow) return this.glowOff.level() + const base = this.ignition.active ? this.ignition.level() : 1 + if (!this.breathing) return base + return ( + base * (1 + GLOW_BREATHE_RISE * 0.5 * (1 - Math.cos((2 * Math.PI * this.breatheClock) / GLOW_BREATHE_PERIOD))) + ) + } + + setEnabled(value: boolean) { + if (value === this.enabled) return false + this.enabled = value + if (!value) { + for (const envelope of this.envelopes) envelope.stop() + this.completionPending = false + this.breatheClock = 0 + } else if (this.active) { + this.runAttack.restart() + } + return true + } + + setActive(value: boolean) { + if (value === this.active) return false + this.active = value + if (!this.enabled) return true + if (value) { + this.clock = 0 + this.runAttack.restart() + this.runFade.stop() + this.completionPulse.stop() + this.completionPending = false + } else { + const level = this.runAttack.active ? this.runAttack.level() : 1 + this.runAttack.stop() + this.runFade.start(level) + this.completionPending = true + } + this.edgeFlash.start() + return true + } + + setPromptPulse(value: number) { + if (value === this.promptPulse) return false + this.promptPulse = value + if (this.enabled) this.edgeFlash.restart(PROMPT_FLASH_SCALE) + return true + } + + setComplete(value: boolean) { + if (value === this.complete) return false + this.complete = value + if (!value) { + this.completionPulse.stop() + this.completionPending = false + } + if (value && this.completionPending) { + this.completionPending = false + if (this.enabled) this.completionPulse.start() + } + return true + } + + setGlow(value: boolean) { + if (value === this.glow) return false + if (this.enabled && !value) this.glowOff.start(this.glowLevel) + this.glow = value + this.ignition.stop() + this.breatheClock = 0 + if (this.enabled && value) { + this.glowOff.stop() + this.ignition.start() + } + return true + } + + setBreathe(value: boolean) { + if (value === this.breathe) return false + this.breathe = value + this.breatheClock = 0 + return true + } + + advance(deltaTime: number) { + if (!this.enabled) return + if (this.active || this.runFade.active) this.clock += deltaTime + if (this.breathing) this.breatheClock += deltaTime + for (const envelope of this.envelopes) envelope.advance(deltaTime) + if (!this.completionPending) return + if (this.complete) { + this.completionPending = false + this.completionPulse.start() + return + } + if (!this.runFade.active) this.completionPending = false + } + + fronts(width: number) { + const cycles = this.clock / RUN_DURATION + const progress = cycles % 1 + const start = -RUN_HEAD + const end = width - 1 + RUN_TAIL + const secondProgress = cycles < 0.5 ? 0 : (cycles + 0.5) % 1 + return [start + coast(progress) * (end - start), start + coast(secondProgress) * (end - start)] as const + } +} + +class TabPulseRenderable extends Renderable { + private _enabled: boolean + private inner: PulseState + private outer: PulseState + private _color: RGBA + private _outerColor: RGBA + private _glowColor: RGBA + private _outerGlowColor: RGBA + private _glowTail: number + private _outerGlowTail: number + private _edge: "above" | "below" | undefined + private _flashColor: RGBA + private _outerFlashColor: RGBA + private _completionColor: RGBA + private _outerCompletionColor: RGBA + private _backgroundColor: RGBA private renderColor = RGBA.fromInts(0, 0, 0) + private outerRenderColor = RGBA.fromInts(0, 0, 0) private _onLevel: ((level: number) => void) | undefined private lastLevel = 0 constructor(ctx: RenderContext, options: TabPulseOptions = {}) { const enabled = options.enabled ?? true const active = options.active ?? false - super(ctx, { ...options, height: 1, live: enabled && active }) + const glow = options.glow ?? false + const breathe = options.breathe ?? false + const edge = options.edge + const outerActive = options.outerActive ?? active + const outerGlow = options.outerGlow ?? glow + const outerBreathe = options.outerBreathe ?? breathe + super(ctx, { + ...options, + height: 1, + live: + enabled && + (active || (glow && breathe) || (edge !== undefined && (outerActive || (outerGlow && outerBreathe)))), + }) this._enabled = enabled - this._active = active - this._promptPulse = options.promptPulse ?? 0 - this._complete = options.complete ?? false - this._glow = options.glow ?? false - this._breathe = options.breathe ?? false + this.inner = new PulseState({ + enabled, + active, + promptPulse: options.promptPulse ?? 0, + complete: options.complete ?? false, + glow, + breathe, + }) + this.outer = new PulseState({ + enabled: enabled && edge !== undefined, + active: outerActive, + promptPulse: options.outerPromptPulse ?? options.promptPulse ?? 0, + complete: options.outerComplete ?? options.complete ?? false, + glow: outerGlow, + breathe: outerBreathe, + }) this._color = options.color ?? RGBA.defaultForeground() + this._outerColor = options.outerColor ?? this._color this._glowColor = options.glowColor ?? this._color + this._outerGlowColor = options.outerGlowColor ?? this._glowColor + this._glowTail = options.glowTail ?? GLOW_TAIL + this._outerGlowTail = options.outerGlowTail ?? this._glowTail + this._edge = edge this._flashColor = options.flashColor ?? this._color + this._outerFlashColor = options.outerFlashColor ?? options.flashColor ?? this._outerColor this._completionColor = options.completionColor ?? this._color + this._outerCompletionColor = options.outerCompletionColor ?? options.completionColor ?? this._outerColor this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground() this._onLevel = options.onLevel } @@ -179,103 +384,64 @@ class TabPulseRenderable extends Renderable { } private emitLevel(value: number) { + if (!this._onLevel) return const quantized = Math.round(value * 32) / 32 if (quantized === this.lastLevel) return this.lastLevel = quantized this._onLevel?.(quantized) } - private get breathing() { - return this._enabled && this._glow && this._breathe - } - - /** Resting glow is 1; ignition overshoots on arrival, breathing swells while pending, glowOff decays after. */ - private glowLevel() { - if (!this._glow) return this.glowOff.level() - const base = this.ignition.active ? this.ignition.level() : 1 - if (!this.breathing) return base - return ( - base * (1 + GLOW_BREATHE_RISE * 0.5 * (1 - Math.cos((2 * Math.PI * this.breatheClock) / GLOW_BREATHE_PERIOD))) - ) - } - set enabled(value: boolean) { if (value === this._enabled) return this._enabled = value - if (!value) { - for (const envelope of this.envelopes) envelope.stop() - this.completionPending = false - this.breatheClock = 0 - this.live = false - } else if (this._active || this.breathing) { - this.live = true - } + this.inner.setEnabled(value) + this.outer.setEnabled(value && this._edge !== undefined) + this.live = this.inner.live || this.outer.live this.requestRender() } set active(value: boolean) { - if (value === this._active) return - this._active = value - if (!this._enabled) return - if (value) { - this.runFade.stop() - this.completionPulse.stop() - this.completionPending = false - } else { - this.runFade.start() - this.completionPending = true - } - // The same neutral edge flash marks both the start and the finish of a run. - this.edgeFlash.start() - this.live = true - this.requestRender() + if (this.inner.setActive(value)) this.changed() + } + + set outerActive(value: boolean) { + if (this.outer.setActive(value)) this.changed() } set promptPulse(value: number) { - if (value === this._promptPulse) return - this._promptPulse = value - if (!this._enabled) return - this.edgeFlash.restart(PROMPT_FLASH_SCALE) - this.live = true - this.requestRender() + if (this.inner.setPromptPulse(value)) this.changed() + } + + set outerPromptPulse(value: number) { + if (this.outer.setPromptPulse(value)) this.changed() } set complete(value: boolean) { - if (value === this._complete) return - this._complete = value - if (!value) { - this.completionPulse.stop() - this.completionPending = false - } - if (value && this.completionPending) { - this.completionPending = false - if (this._enabled) { - this.completionPulse.start() - this.live = true - } - } - this.requestRender() + if (this.inner.setComplete(value)) this.changed() + } + + set outerComplete(value: boolean) { + if (this.outer.setComplete(value)) this.changed() } set glow(value: boolean) { - if (value === this._glow) return - if (this._enabled && !value) this.glowOff.start(this.glowLevel()) - this._glow = value - this.ignition.stop() - this.breatheClock = 0 - if (this._enabled && value) { - this.glowOff.stop() - this.ignition.start() - this.live = true - } - this.requestRender() + if (this.inner.setGlow(value)) this.changed() + } + + set outerGlow(value: boolean) { + if (this.outer.setGlow(value)) this.changed() } set breathe(value: boolean) { - if (value === this._breathe) return - this._breathe = value - this.breatheClock = 0 - if (this.breathing) this.live = true + if (this.inner.setBreathe(value)) this.changed() + } + + set outerBreathe(value: boolean) { + if (this.outer.setBreathe(value)) this.changed() + } + + private changed() { + this.live = this.inner.live || this.outer.live this.requestRender() } @@ -291,18 +457,62 @@ class TabPulseRenderable extends Renderable { this.requestRender() } + set outerColor(value: RGBA) { + if (value.equals(this._outerColor)) return + this._outerColor = value + this.requestRender() + } + + set outerGlowColor(value: RGBA) { + if (value.equals(this._outerGlowColor)) return + this._outerGlowColor = value + this.requestRender() + } + + set glowTail(value: number) { + if (value === this._glowTail) return + this._glowTail = value + this.requestRender() + } + + set outerGlowTail(value: number) { + if (value === this._outerGlowTail) return + this._outerGlowTail = value + this.requestRender() + } + + set edge(value: "above" | "below" | undefined) { + if (value === this._edge) return + this._edge = value + this.outer.setEnabled(this._enabled && value !== undefined) + this.live = this.inner.live || this.outer.live + this.requestRender() + } + set flashColor(value: RGBA) { if (value.equals(this._flashColor)) return this._flashColor = value this.requestRender() } + set outerFlashColor(value: RGBA) { + if (value.equals(this._outerFlashColor)) return + this._outerFlashColor = value + this.requestRender() + } + set completionColor(value: RGBA) { if (value.equals(this._completionColor)) return this._completionColor = value this.requestRender() } + set outerCompletionColor(value: RGBA) { + if (value.equals(this._outerCompletionColor)) return + this._outerCompletionColor = value + this.requestRender() + } + set backgroundColor(value: RGBA) { if (value.equals(this._backgroundColor)) return this._backgroundColor = value @@ -311,42 +521,45 @@ class TabPulseRenderable extends Renderable { protected override onUpdate(deltaTime: number): void { if (!this._enabled) return - if (this._active || this.runFade.active) this.clock += deltaTime - if (this.breathing) this.breatheClock += deltaTime - for (const envelope of this.envelopes) envelope.advance(deltaTime) - if (this.completionPending) { - if (this._complete) { - this.completionPending = false - this.completionPulse.start() - } else if (!this.runFade.active) { - this.completionPending = false - } - } - this.live = this._active || this.breathing || this.envelopes.some(envelopeActive) + this.inner.advance(deltaTime) + this.outer.advance(deltaTime) + this.live = this.inner.live || this.outer.live } protected override renderSelf(buffer: OptimizedBuffer): void { if (!this.visible || this.isDestroyed || this.width <= 0) return - const running = !this._enabled ? 0 : this._active ? 1 : this.runFade.level() - const completion = this.completionPulse.level() * COMPLETION_OPACITY - // The edge flash is a neutral wash on the running stage; the accent completion stage stays reserved for results. - const flash = this.edgeFlash.level() * EDGE_FLASH_OPACITY - const glowLevel = this.glowLevel() - if (glowLevel === 0 && running === 0 && completion === 0 && flash === 0) { + const running = this.inner.running + const completion = this.inner.completion + const flash = this.inner.flash + const glowLevel = this.inner.glowLevel + const outerRunning = this.outer.running + const outerCompletion = this.outer.completion + const outerFlash = this.outer.flash + const outerGlowLevel = this.outer.glowLevel + if ( + glowLevel === 0 && + running === 0 && + completion === 0 && + flash === 0 && + outerGlowLevel === 0 && + outerRunning === 0 && + outerCompletion === 0 && + outerFlash === 0 + ) { this.emitLevel(0) return } - const progress = (this.clock % RUN_DURATION) / RUN_DURATION - const start = -RUN_HEAD - const end = this.width - 1 + RUN_TAIL - const front = start + coast(progress) * (end - start) - const secondFront = start + coast((progress + 0.5) % 1) * (end - start) - this.emitLevel( - running === 0 - ? 0 - : Math.max(intensityAt(1, front, RUN_HEAD, RUN_TAIL), intensityAt(1, secondFront, RUN_HEAD, RUN_TAIL)) * - running, - ) + const [front, secondFront] = this.inner.fronts(this.width) + const [outerFront, outerSecondFront] = this.outer.fronts(this.width) + if (this._onLevel) + this.emitLevel( + running === 0 + ? 0 + : Math.max(intensityAt(1, front, RUN_HEAD, RUN_TAIL), intensityAt(1, secondFront, RUN_HEAD, RUN_TAIL)) * + running, + ) + const glowTail = Math.min(this._glowTail, Math.max(1, this.width - 2)) + const outerGlowTail = Math.min(this._outerGlowTail, Math.max(1, this.width - 2)) for (let index = 0; index < this.width; index++) { // Skip per-cell sweep and glow math when that stage is idle, e.g. a steady breathing glow. const sweep = @@ -358,6 +571,15 @@ class TabPulseRenderable extends Renderable { ) * 0.14 * running + const outerSweep = + outerRunning === 0 + ? 0 + : Math.max( + intensityAt(index, outerFront, RUN_HEAD, RUN_TAIL), + intensityAt(index, outerSecondFront, RUN_HEAD, RUN_TAIL), + ) * + 0.14 * + outerRunning blendTabPulseColor( this.renderColor, this._backgroundColor, @@ -365,12 +587,34 @@ class TabPulseRenderable extends Renderable { this._color, this._flashColor, this._completionColor, - glowLevel === 0 ? 0 : unreadGlowIntensity(index, this.width) * GLOW_OPACITY * glowLevel, + glowLevel === 0 ? 0 : glowIntensityAt(index, glowTail) * GLOW_OPACITY * glowLevel, sweep, flash, completion, ) - buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor) + if (!this._edge) { + buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor) + continue + } + blendTabPulseColor( + this.outerRenderColor, + this._backgroundColor, + this._outerGlowColor, + this._outerColor, + this._outerFlashColor, + this._outerCompletionColor, + outerGlowLevel === 0 ? 0 : glowIntensityAt(index, outerGlowTail) * GLOW_OPACITY * outerGlowLevel, + outerSweep, + outerFlash, + outerCompletion, + ) + buffer.setCell( + this.screenX + index, + this.screenY, + this._edge === "above" ? "▄" : "▀", + this.renderColor, + this.outerRenderColor, + ) } } } @@ -384,34 +628,61 @@ declare module "@opentui/solid" { extend({ tab_pulse: TabPulseRenderable }) export function TabPulse(props: { + top?: number + width?: number + edge?: "above" | "below" enabled?: boolean active: boolean + outerActive?: boolean promptPulse?: number + outerPromptPulse?: number complete?: boolean + outerComplete?: boolean glow?: boolean + outerGlow?: boolean breathe?: boolean + outerBreathe?: boolean color: RGBA + outerColor?: RGBA glowColor?: RGBA + outerGlowColor?: RGBA + glowTail?: number + outerGlowTail?: number flashColor?: RGBA + outerFlashColor?: RGBA completionColor?: RGBA + outerCompletionColor?: RGBA backgroundColor: RGBA onLevel?: (level: number) => void }) { return ( diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index 8556923fe7b..fa561c724ad 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -132,6 +132,9 @@ export const Info = Schema.Struct({ scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({ description: "Share tabs globally or keep a separate set for each working directory", }), + vertical: Schema.optional(Schema.Boolean).annotate({ + description: "Show tabs in a left sidebar instead of a horizontal strip", + }), }), ).annotate({ description: "Tab strip settings" }), mini: Schema.optional( diff --git a/packages/tui/src/context/session-tabs-model.ts b/packages/tui/src/context/session-tabs-model.ts index 33807431188..9e10336c1f1 100644 --- a/packages/tui/src/context/session-tabs-model.ts +++ b/packages/tui/src/context/session-tabs-model.ts @@ -19,6 +19,11 @@ export function sessionTabComplete(unread: SessionTabUnread | undefined, busy: b export const SESSION_TAB_WIDTH = 22 export const SESSION_TAB_MAX_WIDTH = 32 export const SESSION_TAB_MIN_WIDTH = 8 +export const SESSION_TAB_VERTICAL_WIDTH = 30 +export const SESSION_TAB_VERTICAL_MIN_WIDTH = 20 +export function sessionTabVerticalWidth(total: number) { + return Math.min(SESSION_TAB_VERTICAL_WIDTH, Math.max(SESSION_TAB_VERTICAL_MIN_WIDTH, Math.floor(total * 0.3))) +} // Overflow markers reserve one gap cell beside the arrow and count, e.g. "‹12 " and " 12›". export const sessionTabOverflowWidth = (count: number) => String(count).length + 2 diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 9f5a62a9984..31cfe2e2cde 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -64,6 +64,7 @@ import { errorMessage } from "../../util/error" import { useToast } from "../../ui/toast" import stripAnsi from "strip-ansi" import { usePromptRef } from "../../context/prompt" +import { sessionTabVerticalWidth } from "../../context/session-tabs-model" import { projectedPromptInput } from "../../prompt/codec" import { useEpilogue } from "../../context/epilogue" import { normalizePath } from "../../util/path" @@ -199,14 +200,17 @@ export function Session() { const diffWrapMode = createMemo(() => config.diffs?.wrap ?? "word") const groupExploration = createMemo(() => config.session?.grouping !== "none") - const wide = createMemo(() => dimensions().width > 120) + const tabRailWidth = createMemo(() => + config.tabs?.enabled && config.tabs.vertical ? sessionTabVerticalWidth(dimensions().width) : 0, + ) + const wide = createMemo(() => dimensions().width - tabRailWidth() > 120) const sidebarVisible = createMemo(() => { if (session()?.parentID) return false if (sidebarOpen()) return true if (sidebar() === "auto" && wide()) return true return false }) - const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4) + const contentWidth = createMemo(() => dimensions().width - tabRailWidth() - (sidebarVisible() ? 42 : 0) - 4) const models = createMemo(() => data.location.model.list(location()) ?? []) const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) diff --git a/packages/tui/src/util/project.ts b/packages/tui/src/util/project.ts new file mode 100644 index 00000000000..e3c22e67667 --- /dev/null +++ b/packages/tui/src/util/project.ts @@ -0,0 +1,7 @@ +import path from "path" + +export function projectName(project?: { canonical: string; name?: string }, fallback = "") { + const canonical = project?.canonical ?? fallback + if (canonical === "/") return fallback ? path.basename(fallback) : undefined + return project?.name || path.basename(canonical) +} diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx index 9fd60803419..9a16675556c 100644 --- a/packages/tui/test/config-v2.test.tsx +++ b/packages/tui/test/config-v2.test.tsx @@ -17,7 +17,7 @@ test("validates mini replay settings", () => { test("validates the session tabs setting", () => { const decode = Schema.decodeUnknownSync(Info) - expect(decode({ tabs: { enabled: true } })).toEqual({ tabs: { enabled: true } }) + expect(decode({ tabs: { enabled: true, vertical: true } })).toEqual({ tabs: { enabled: true, vertical: true } }) expect(() => decode({ tabs: { enabled: "on" } })).toThrow() }) diff --git a/packages/tui/test/context/session-tabs-model.test.ts b/packages/tui/test/context/session-tabs-model.test.ts index 173ae570ae2..8ef8bc5b348 100644 --- a/packages/tui/test/context/session-tabs-model.test.ts +++ b/packages/tui/test/context/session-tabs-model.test.ts @@ -12,9 +12,16 @@ import { seedSessionTabMotion, sessionTabComplete, sessionTabOverflowWidth, + sessionTabVerticalWidth, } from "../../src/context/session-tabs-model" describe("session tabs", () => { + test("keeps the vertical rail compact while preserving narrow-terminal content", () => { + expect(sessionTabVerticalWidth(140)).toBe(30) + expect(sessionTabVerticalWidth(90)).toBe(27) + expect(sessionTabVerticalWidth(60)).toBe(20) + }) + test("moves a tab to a clamped index and returns the same tabs for no-ops", () => { const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID })) expect(moveSessionTab(tabs, "a", 2).map((tab) => tab.sessionID)).toEqual(["b", "c", "a"])