mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-30 07:22:10 +00:00
feat(tui): make session tab status explicit (#45347)
This commit is contained in:
parent
b5cb5e2a55
commit
4ac19d744a
23 changed files with 979 additions and 131 deletions
|
|
@ -94,7 +94,7 @@ async function open(from?: string): Promise<Session> {
|
|||
screenMode: "split-footer",
|
||||
footerHeight: 4,
|
||||
targetFps: 60,
|
||||
useKittyKeyboard: {},
|
||||
useKittyKeyboard: { events: true, allKeysAsEscapes: true, reportText: true },
|
||||
consoleOptions: {
|
||||
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
},
|
||||
"scripts": {
|
||||
"build": "bun run script/build.ts",
|
||||
"test": "bun test --timeout 30000",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -121,6 +121,12 @@ export const DEFAULT_THEME = {
|
|||
$disabled: "$hue.neutral.500",
|
||||
$selected: "$hue.interactive.700",
|
||||
},
|
||||
status: {
|
||||
running: "$hue.interactive.800",
|
||||
question: "$text.feedback.info.default",
|
||||
permission: "$text.feedback.warning.default",
|
||||
unread: "$hue.accent.800",
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
|
||||
warning: { default: "$hue.yellow.800", subdued: "$hue.yellow.700" },
|
||||
|
|
@ -336,6 +342,12 @@ export const DEFAULT_THEME = {
|
|||
$disabled: "$hue.neutral.500",
|
||||
$selected: "$hue.interactive.500",
|
||||
},
|
||||
status: {
|
||||
running: "$hue.interactive.200",
|
||||
question: "$text.feedback.info.default",
|
||||
permission: "$text.feedback.warning.default",
|
||||
unread: "$hue.accent.200",
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$hue.red.300", subdued: "$hue.red.400" },
|
||||
warning: { default: "$hue.yellow.200", subdued: "$hue.yellow.300" },
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import type { ThemeTokensDefinition } from "./index.js"
|
||||
import type { Mode, ThemeTokensDefinition } from "./index.js"
|
||||
import { DEFAULT_THEME } from "./defaults.js"
|
||||
import { ActionVariant, FeedbackKind } from "./schema.js"
|
||||
|
||||
export function fallback(): ThemeTokensDefinition {
|
||||
export function fallback(mode: Mode): ThemeTokensDefinition {
|
||||
const red = "#ff0000"
|
||||
|
||||
return {
|
||||
|
|
@ -9,6 +10,7 @@ export function fallback(): ThemeTokensDefinition {
|
|||
default: red,
|
||||
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
|
||||
formfield: { default: red },
|
||||
status: DEFAULT_THEME[mode].text.status,
|
||||
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
|
||||
},
|
||||
background: {
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export function resolveThemeDocument(document: ThemeDocument, mode?: "light" | "
|
|||
const selected = selectThemeMode(document, mode)
|
||||
const definition = selected.expanded ? selected.theme : expandTheme(selected.theme)
|
||||
const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode))
|
||||
const core = expandTokens(fallback())
|
||||
const core = expandTokens(fallback(selected.mode))
|
||||
const merged = document.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition)
|
||||
if (!merged["hue"]) throw new Error("Standalone themes must provide hues")
|
||||
return resolveExpandedTheme({
|
||||
|
|
|
|||
|
|
@ -108,6 +108,14 @@ const TextDefinition = Schema.Struct({
|
|||
subdued: Schema.optional(ColorValue),
|
||||
action: Schema.optional(ActionColorDefinition),
|
||||
formfield: Schema.optional(StatefulColorDefinition),
|
||||
status: Schema.optional(
|
||||
Schema.Struct({
|
||||
running: Schema.optional(ColorValue),
|
||||
question: Schema.optional(ColorValue),
|
||||
permission: Schema.optional(ColorValue),
|
||||
unread: Schema.optional(ColorValue),
|
||||
}),
|
||||
),
|
||||
feedback: Schema.optional(
|
||||
Schema.Struct({
|
||||
error: Schema.optional(TextFeedbackDefinition),
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@ export type ResolvedThemeTokens = {
|
|||
readonly subdued: RGBA
|
||||
readonly action: Readonly<Record<ActionVariant, StatefulColor>>
|
||||
readonly formfield: FormfieldColor
|
||||
readonly status: {
|
||||
readonly running: RGBA
|
||||
readonly question: RGBA
|
||||
readonly permission: RGBA
|
||||
readonly unread: RGBA
|
||||
}
|
||||
readonly feedback: Readonly<Record<FeedbackKind, { readonly default: RGBA; readonly subdued: RGBA }>>
|
||||
}
|
||||
readonly background: {
|
||||
|
|
|
|||
42
packages/theme/test/status.test.ts
Normal file
42
packages/theme/test/status.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { Schema } from "effect"
|
||||
import { DEFAULT_THEME, ThemeDocument, migrateV1, resolveThemeDocument } from "../src/tui/index.js"
|
||||
import type { ThemeV1Json } from "../src/tui/v1.js"
|
||||
|
||||
test.each(["light", "dark"] as const)("built-in %s themes resolve status colors", async (mode) => {
|
||||
const source: ThemeV1Json = await Bun.file(
|
||||
new URL("../../tui/src/theme/assets/opencode.json", import.meta.url),
|
||||
).json()
|
||||
for (const document of [DEFAULT_THEME, migrateV1(source)]) {
|
||||
const theme = resolveThemeDocument(document, mode)
|
||||
expect(theme.text.status.running.equals(theme.hue.interactive[mode === "light" ? 800 : 200])).toBeTrue()
|
||||
expect(theme.text.status.question.equals(theme.text.feedback.info.default)).toBeTrue()
|
||||
expect(theme.text.status.permission.equals(theme.text.feedback.warning.default)).toBeTrue()
|
||||
expect(theme.text.status.unread.equals(theme.hue.accent[mode === "light" ? 800 : 200])).toBeTrue()
|
||||
expect(theme.contextual.elevated.text.status).toEqual(theme.text.status)
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["light", "dark"] as const)("custom %s themes inherit and override status colors", (mode) => {
|
||||
for (const standalone of [false, true]) {
|
||||
const theme = resolveThemeDocument(
|
||||
Schema.decodeUnknownSync(ThemeDocument)({
|
||||
version: 2,
|
||||
standalone,
|
||||
[mode]: {
|
||||
hue: { ...DEFAULT_THEME[mode].hue, interactive: "$hue.purple", accent: "$hue.orange" },
|
||||
text: {
|
||||
feedback: { warning: { default: "#654321" } },
|
||||
status: { question: "#123456" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
mode,
|
||||
)
|
||||
expect(theme.text.status.running.equals(theme.hue.purple[mode === "light" ? 800 : 200])).toBeTrue()
|
||||
expect(theme.text.status.unread.equals(theme.hue.orange[mode === "light" ? 800 : 200])).toBeTrue()
|
||||
expect(theme.text.status.question.equals(RGBA.fromHex("#123456"))).toBeTrue()
|
||||
expect(theme.text.status.permission.equals(RGBA.fromHex("#654321"))).toBeTrue()
|
||||
}
|
||||
})
|
||||
|
|
@ -230,7 +230,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||
targetFps: 60,
|
||||
gatherStats: false,
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: {},
|
||||
useKittyKeyboard: { events: true, allKeysAsEscapes: true, reportText: true },
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
useMouse: config.mouse,
|
||||
|
|
|
|||
|
|
@ -144,6 +144,15 @@ export const settings: Setting[] = [
|
|||
values: ["horizontal", "vertical"],
|
||||
keywords: ["sidebar", "orientation", "left"],
|
||||
},
|
||||
{
|
||||
title: "Indicators",
|
||||
category: "Tabs",
|
||||
path: ["tabs", "indicators"],
|
||||
default: "status",
|
||||
values: ["status", "numbers"],
|
||||
labels: ["status icons", "always show numbers"],
|
||||
keywords: ["tab numbers", "number mode", "status icons"],
|
||||
},
|
||||
{
|
||||
title: "Layout",
|
||||
category: "Diffs",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { Portal, 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 { useTheme } from "../context/theme"
|
||||
import {
|
||||
adaptiveSessionTabLayout,
|
||||
moveSessionTab,
|
||||
|
|
@ -39,6 +39,26 @@ import { marqueeCycleWidth, marqueeOverflows, marqueeTextParts } from "../util/m
|
|||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSessionRename } from "./dialog-session-rename"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { registerOpencodeSpinner } from "./register-spinner"
|
||||
import { SPINNER_FRAMES } from "./spinner-frames"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
export const TAB_SPINNERS = {
|
||||
dots: { frames: SPINNER_FRAMES, interval: 80 },
|
||||
arcs: { frames: ["◜", "◝", "◞", "◟"], interval: 120 },
|
||||
quadrants: { frames: ["◴", "◷", "◶", "◵"], interval: 120 },
|
||||
line: { frames: ["|", "/", "-", "\\"], interval: 120 },
|
||||
}
|
||||
export type TabSpinner = keyof typeof TAB_SPINNERS
|
||||
|
||||
export const TAB_UNREAD_MARKERS = {
|
||||
"small-dot": "•",
|
||||
dot: "●",
|
||||
square: "▪",
|
||||
"large-square": "■",
|
||||
}
|
||||
export type TabUnreadMarker = keyof typeof TAB_UNREAD_MARKERS
|
||||
|
||||
// A long title fades out over its last cells instead of cutting hard.
|
||||
const FADE_WIDTH = 4
|
||||
|
|
@ -89,6 +109,75 @@ const NEW_SESSION_TAB: SessionTab = { sessionID: "new", title: NEW_SESSION_TAB_T
|
|||
const glowTextColor = (base: RGBA, glow: RGBA, index: number, width: number, level = 1) =>
|
||||
tint(base, glow, 0.12 * unreadGlowIntensity(index, width) * level)
|
||||
|
||||
function tabFeedbackColor(status: SessionTabsStatus, theme: ReturnType<typeof useTheme>) {
|
||||
if (status.attention) return theme.text.status[status.attention]
|
||||
if (status.unread === "error") return theme.text.feedback.error.default
|
||||
return undefined
|
||||
}
|
||||
|
||||
function TabIndicator(props: {
|
||||
status: SessionTabsStatus
|
||||
label: string
|
||||
width: number
|
||||
color: RGBA
|
||||
unreadColor: RGBA
|
||||
backgroundColor: RGBA
|
||||
flashColor: RGBA
|
||||
animations: boolean
|
||||
numbers: boolean
|
||||
spinner?: TabSpinner
|
||||
unreadMarker?: TabUnreadMarker
|
||||
attributes?: number
|
||||
}) {
|
||||
const runs = () => props.status.busy && !props.status.attention
|
||||
const pending = createMemo(() => props.status.busy || Boolean(props.status.attention))
|
||||
const unread = createMemo(() => Boolean(props.status.unread) && !pending())
|
||||
const unreadColor = createMemo<RGBA>((previous) => (unread() ? props.unreadColor : previous), props.unreadColor)
|
||||
const fade = createAnimatable(
|
||||
{ opacity: unread() ? 1 : 0 },
|
||||
{ enabled: () => props.animations, transition: tween({ duration: 0.18 }) },
|
||||
)
|
||||
createComputed(() => {
|
||||
if (unread()) return fade.jump({ opacity: 1 })
|
||||
if (pending()) return fade.jump({ opacity: 0 })
|
||||
fade.animate({ opacity: 0 })
|
||||
})
|
||||
const fading = () => !props.status.unread && fade.value().opacity > 0
|
||||
const color = () => {
|
||||
if (props.numbers) return props.color
|
||||
if (unread()) return props.unreadColor
|
||||
if (!fading()) return props.color
|
||||
const opacity = fade.value().opacity
|
||||
// Brighten during the first fifth, then dissolve into the tab background.
|
||||
const flash = Math.max(0, 1 - Math.abs(opacity - 0.8) / 0.2)
|
||||
return tint(props.backgroundColor, tint(unreadColor(), props.flashColor, flash * 0.3), Math.min(1, opacity / 0.8))
|
||||
}
|
||||
const spinner = () => TAB_SPINNERS[props.spinner ?? "dots"]
|
||||
const label = () => {
|
||||
if (props.numbers) return props.label
|
||||
if (props.status.attention === "permission") return "!"
|
||||
if (props.status.attention === "question") return "?"
|
||||
if (runs()) return spinner().frames[0]
|
||||
if (props.label === "+") return "+"
|
||||
if (props.status.unread || fading()) return TAB_UNREAD_MARKERS[props.unreadMarker ?? "small-dot"]
|
||||
return ""
|
||||
}
|
||||
return (
|
||||
<box width={props.width + 1} flexShrink={0} flexDirection="row" justifyContent="flex-end" paddingRight={1}>
|
||||
<Show
|
||||
when={runs() && props.animations && !props.numbers}
|
||||
fallback={
|
||||
<text fg={color()} selectable={false} attributes={props.attributes}>
|
||||
{label()}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<spinner frames={spinner().frames} interval={spinner().interval} color={props.color} />
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function createPreviewDoubleClick(tabs: SessionTabsController) {
|
||||
let previous: { sessionID: string; time: number } | undefined
|
||||
return (sessionID: string) => {
|
||||
|
|
@ -106,6 +195,15 @@ function createPreviewDoubleClick(tabs: SessionTabsController) {
|
|||
}
|
||||
}
|
||||
|
||||
function createGlowLevel(dimmed: () => boolean, animations: () => boolean) {
|
||||
const motion = createAnimatable(
|
||||
{ level: dimmed() ? 0.7 : 1 },
|
||||
{ enabled: animations, transition: tween({ duration: 0.2 }) },
|
||||
)
|
||||
createEffect(() => motion.animate({ level: dimmed() ? 0.7 : 1 }))
|
||||
return () => motion.value().level
|
||||
}
|
||||
|
||||
function createNumberIgnition(runs: () => boolean, prompt: () => number, animations: () => boolean) {
|
||||
const ignition = createAnimatable({ level: 0 }, { enabled: animations, transition: tween({ duration: 0.7 }) })
|
||||
let wasRunning = runs()
|
||||
|
|
@ -360,35 +458,68 @@ export function SessionTabs(
|
|||
props: {
|
||||
controller?: SessionTabsController
|
||||
animations?: boolean
|
||||
spinner?: TabSpinner
|
||||
unreadMarker?: TabUnreadMarker
|
||||
orientation?: "horizontal" | "vertical"
|
||||
width?: number
|
||||
} = {},
|
||||
) {
|
||||
const keymap = Keymap.use()
|
||||
const config = useConfig().data
|
||||
const [numbers, setNumbers] = createSignal(config.tabs.indicators === "numbers")
|
||||
createEffect(() => {
|
||||
if (config.tabs.indicators === "numbers" || !keymap.control()) {
|
||||
setNumbers(config.tabs.indicators === "numbers")
|
||||
return
|
||||
}
|
||||
// Brief Control chords should not flash the tab numbers.
|
||||
const timeout = setTimeout(() => setNumbers(true), 300)
|
||||
onCleanup(() => clearTimeout(timeout))
|
||||
})
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.orientation === "vertical"}>
|
||||
<VerticalSessionTabs controller={props.controller} animations={props.animations} width={props.width} />
|
||||
<VerticalSessionTabs
|
||||
controller={props.controller}
|
||||
animations={props.animations}
|
||||
spinner={props.spinner}
|
||||
unreadMarker={props.unreadMarker}
|
||||
numbers={numbers()}
|
||||
width={props.width}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<HorizontalSessionTabs controller={props.controller} animations={props.animations} />
|
||||
<HorizontalSessionTabs
|
||||
controller={props.controller}
|
||||
animations={props.animations}
|
||||
spinner={props.spinner}
|
||||
unreadMarker={props.unreadMarker}
|
||||
numbers={numbers()}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function VerticalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean; width?: number }) {
|
||||
function VerticalSessionTabs(props: {
|
||||
controller?: SessionTabsController
|
||||
animations?: boolean
|
||||
numbers: boolean
|
||||
spinner?: TabSpinner
|
||||
unreadMarker?: TabUnreadMarker
|
||||
width?: number
|
||||
}) {
|
||||
const contextTabs = useSessionTabs()
|
||||
const tabs: SessionTabsController = props.controller ?? contextTabs
|
||||
const data = useData()
|
||||
const theme = useTheme("elevated")
|
||||
const { mode } = useThemes()
|
||||
const config = useConfig().data
|
||||
const animations = () => props.animations ?? config.animations ?? true
|
||||
const width = () => props.width ?? SESSION_SIDEBAR_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 unreadColor = () => theme.text.status.unread
|
||||
const activeNumber = () => theme.text.status.running
|
||||
const idleNumber = () => tint(theme.text.formfield.default, theme.background.default, 0.55)
|
||||
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 [addHovered, setAddHovered] = createSignal(false)
|
||||
|
|
@ -422,8 +553,9 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|||
...status,
|
||||
complete: sessionTabComplete(status.unread, status.busy),
|
||||
runs: status.busy && !status.attention,
|
||||
glows:
|
||||
tab.sessionID !== activeID() && (status.attention || (!status.busy && status.unread !== undefined)),
|
||||
glows: Boolean(
|
||||
status.attention || (tab.sessionID !== activeID() && !status.busy && status.unread !== undefined),
|
||||
),
|
||||
},
|
||||
] as const
|
||||
}),
|
||||
|
|
@ -564,7 +696,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|||
hovered() === tab.sessionID && !selected()
|
||||
? foreground()
|
||||
: tint(idleNumber(), tint(theme.text.default, pulseBackground(), 0.25), Number(selected()))
|
||||
const color = tint(base, glowHue(), numberGlow.value().level)
|
||||
const color = tabFeedbackColor(status(), theme) ?? tint(base, glowHue(), numberGlow.value().level)
|
||||
const runningColor = runs() ? activeNumber() : color
|
||||
return sweepLevel() === 0
|
||||
? tint(runningColor, theme.text.default, numberIgnition.value().level)
|
||||
|
|
@ -575,20 +707,21 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|||
return selected() ? theme.text.default : theme.text.subdued
|
||||
}
|
||||
const complete = () => status().complete
|
||||
// Latched so a resolving glow fades out in the hue it lit with instead of snapping to accent.
|
||||
// Latched so a resolving glow fades out in the hue it lit with instead of snapping to the unread color.
|
||||
let lastGlowHue: RGBA | undefined
|
||||
const glowHue = () => {
|
||||
if (status().attention) return (lastGlowHue = theme.text.feedback.warning.default)
|
||||
if (status().unread === "error") return (lastGlowHue = theme.text.feedback.error.default)
|
||||
if (status().unread !== undefined) return (lastGlowHue = accent())
|
||||
return lastGlowHue ?? accent()
|
||||
const feedback = tabFeedbackColor(status(), theme)
|
||||
if (feedback) return (lastGlowHue = feedback)
|
||||
if (status().unread !== undefined) return (lastGlowHue = unreadColor())
|
||||
return lastGlowHue ?? unreadColor()
|
||||
}
|
||||
const pulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.25))
|
||||
const flashColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.7))
|
||||
const glowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.45))
|
||||
const glowLevel = createGlowLevel(() => selected() && Boolean(status().attention), animations)
|
||||
const glowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.45 * glowLevel()))
|
||||
const detailPulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.13))
|
||||
const detailFlashColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.42))
|
||||
const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25))
|
||||
const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25 * glowLevel()))
|
||||
const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35))
|
||||
const detailTextColor = (index: number) =>
|
||||
detailFades()
|
||||
|
|
@ -619,17 +752,24 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|||
})
|
||||
const previousGlows = () => previousStatus().glows
|
||||
const previousRuns = () => previousStatus().runs
|
||||
const previousGlowLevel = createGlowLevel(
|
||||
() => previous()?.sessionID === activeID() && Boolean(previousStatus().attention),
|
||||
animations,
|
||||
)
|
||||
const indicatorWidth = 10
|
||||
let lastPreviousGlowHue: RGBA | undefined
|
||||
const previousGlowHue = () => {
|
||||
if (previousStatus().attention) return (lastPreviousGlowHue = theme.text.feedback.warning.default)
|
||||
if (previousStatus().unread === "error")
|
||||
return (lastPreviousGlowHue = theme.text.feedback.error.default)
|
||||
if (previousStatus().unread !== undefined) return (lastPreviousGlowHue = accent())
|
||||
return lastPreviousGlowHue ?? accent()
|
||||
const feedback = tabFeedbackColor(previousStatus(), theme)
|
||||
if (feedback) return (lastPreviousGlowHue = feedback)
|
||||
if (previousStatus().unread !== undefined) return (lastPreviousGlowHue = unreadColor())
|
||||
return lastPreviousGlowHue ?? unreadColor()
|
||||
}
|
||||
const separatorUpperColor = createMemo(() => tint(theme.background.default, previousGlowHue(), 0.1))
|
||||
const separatorLowerColor = createMemo(() => tint(theme.background.default, glowHue(), 0.12))
|
||||
const separatorUpperColor = createMemo(() =>
|
||||
tint(theme.background.default, previousGlowHue(), 0.1 * previousGlowLevel()),
|
||||
)
|
||||
const separatorLowerColor = createMemo(() =>
|
||||
tint(theme.background.default, glowHue(), 0.12 * glowLevel()),
|
||||
)
|
||||
const titleColor = (index: number, separator: boolean) => {
|
||||
const level = titleGlow.value().level
|
||||
const color =
|
||||
|
|
@ -729,11 +869,11 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|||
outerColor={tint(theme.background.default, theme.text.default, 0.006)}
|
||||
flashColor={tint(theme.background.default, theme.text.default, 0.18)}
|
||||
flashTail={8}
|
||||
glowColor={tint(theme.background.default, glowHue(), 0.1)}
|
||||
glowColor={tint(theme.background.default, glowHue(), 0.1 * glowLevel())}
|
||||
outerGlowColor={theme.background.default}
|
||||
glowTail={8}
|
||||
outerGlowTail={5}
|
||||
completionColor={tint(theme.background.default, glowHue(), 0.1)}
|
||||
completionColor={tint(theme.background.default, glowHue(), 0.1 * glowLevel())}
|
||||
outerCompletionColor={theme.background.default}
|
||||
backgroundColor={theme.background.default}
|
||||
/>
|
||||
|
|
@ -755,14 +895,20 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|||
onLevel={setSweepLevel}
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row" paddingRight={1}>
|
||||
<text
|
||||
width={numberWidth() + 1}
|
||||
fg={numberColor()}
|
||||
selectable={false}
|
||||
<TabIndicator
|
||||
status={status()}
|
||||
label={sessionTabNumberLabel(index())}
|
||||
width={numberWidth()}
|
||||
color={numberColor()}
|
||||
unreadColor={tabFeedbackColor(status(), theme) ?? unreadColor()}
|
||||
backgroundColor={pulseBackground()}
|
||||
flashColor={theme.text.default}
|
||||
animations={animations()}
|
||||
numbers={props.numbers}
|
||||
spinner={props.spinner}
|
||||
unreadMarker={props.unreadMarker}
|
||||
attributes={selected() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{sessionTabNumberLabel(index()).padStart(numberWidth())}
|
||||
</text>
|
||||
/>
|
||||
<text
|
||||
width={titleWidth()}
|
||||
fg={foreground()}
|
||||
|
|
@ -923,11 +1069,16 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|||
)
|
||||
}
|
||||
|
||||
function HorizontalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
|
||||
function HorizontalSessionTabs(props: {
|
||||
controller?: SessionTabsController
|
||||
animations?: boolean
|
||||
spinner?: TabSpinner
|
||||
unreadMarker?: TabUnreadMarker
|
||||
numbers: boolean
|
||||
}) {
|
||||
const tabs = props.controller ?? useSessionTabs()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme()
|
||||
const { mode } = useThemes()
|
||||
const config = useConfig().data
|
||||
const animations = () => props.animations ?? config.animations ?? true
|
||||
const [addHovered, setAddHovered] = createSignal(false)
|
||||
|
|
@ -960,10 +1111,9 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
|||
onCleanup(clearCloseHold)
|
||||
// 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()]
|
||||
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
|
||||
const unreadColor = () => theme.text.status.unread
|
||||
const activeNumber = () => theme.text.status.running
|
||||
const idleNumber = () => tint(theme.text.formfield.default, theme.background.default, 0.55)
|
||||
const newTab = () => tabs.newTab?.() ?? false
|
||||
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
|
||||
const ordered = createMemo(() => {
|
||||
|
|
@ -1211,13 +1361,11 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
|||
// The edge flash washes toward a brighter stop on the same background-to-text ramp,
|
||||
// so it reads as a lift of the pulse color rather than a different hue.
|
||||
const flashColor = () => tint(background(), theme.text.default, 0.65)
|
||||
const feedbackColor = () => {
|
||||
if (status().attention) return theme.text.feedback.warning.default
|
||||
if (status().unread === "error") return theme.text.feedback.error.default
|
||||
return undefined
|
||||
}
|
||||
const glowColor = () => feedbackColor() ?? accent()
|
||||
const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined))
|
||||
const feedbackColor = () => tabFeedbackColor(status(), theme)
|
||||
const glowLevel = createGlowLevel(() => selected() && Boolean(status().attention), animations)
|
||||
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 tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1)
|
||||
const numberWidth = () => Math.max(2, String(items().length).length)
|
||||
|
|
@ -1273,7 +1421,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
|||
hovered() === tab.sessionID && !selected()
|
||||
? foreground()
|
||||
: tint(idleNumber(), tint(theme.text.default, background(), 0.25), selection())
|
||||
const color = feedback ?? (runs() ? activeNumber() : tint(base, accent(), activity()))
|
||||
const color = runs() ? activeNumber() : (feedback ?? tint(base, unreadColor(), activity()))
|
||||
// The number brightens faintly as the running sweep passes beneath it.
|
||||
return tint(color, theme.text.default, Math.max(numberIgnition.value().level, 0.15 * sweepLevel()))
|
||||
}
|
||||
|
|
@ -1327,14 +1475,25 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
|||
color={pulseColor()}
|
||||
glowColor={glowColor()}
|
||||
flashColor={flashColor()}
|
||||
completionColor={accent()}
|
||||
completionColor={unreadColor()}
|
||||
backgroundColor={background()}
|
||||
onLevel={setSweepLevel}
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row">
|
||||
<text width={numberWidth() + 1} fg={numberColor()} selectable={false} attributes={bold()}>
|
||||
{(tab === NEW_SESSION_TAB ? "+" : sessionTabNumberLabel(tabNumber() - 1)).padStart(numberWidth())}
|
||||
</text>
|
||||
<TabIndicator
|
||||
status={status()}
|
||||
label={tab === NEW_SESSION_TAB ? "+" : sessionTabNumberLabel(tabNumber() - 1)}
|
||||
width={numberWidth()}
|
||||
color={numberColor()}
|
||||
unreadColor={feedbackColor() ?? unreadColor()}
|
||||
backgroundColor={background()}
|
||||
flashColor={theme.text.default}
|
||||
animations={animations()}
|
||||
numbers={props.numbers}
|
||||
spinner={props.spinner}
|
||||
unreadMarker={props.unreadMarker}
|
||||
attributes={bold()}
|
||||
/>
|
||||
<text
|
||||
width={availableTitleWidth()}
|
||||
fg={foreground()}
|
||||
|
|
|
|||
|
|
@ -159,6 +159,9 @@ export const Info = Schema.Struct({
|
|||
layout: Schema.optional(Schema.Literals(["horizontal", "vertical"])).annotate({
|
||||
description: "Show tabs in a horizontal strip or vertical sidebar",
|
||||
}),
|
||||
indicators: Schema.optional(Schema.Literals(["status", "numbers"])).annotate({
|
||||
description: "Show status icons with numbers on held Control, or always show tab numbers",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Tab strip settings" }),
|
||||
mini: Schema.optional(
|
||||
|
|
@ -231,6 +234,7 @@ export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader"
|
|||
enabled: boolean
|
||||
scope: "global" | "cwd"
|
||||
layout: "horizontal" | "vertical"
|
||||
indicators: "status" | "numbers"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -283,6 +287,7 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
|
|||
enabled: input.tabs?.enabled ?? true,
|
||||
scope: input.tabs?.scope ?? "cwd",
|
||||
layout: input.tabs?.layout ?? "horizontal",
|
||||
indicators: input.tabs?.indicators ?? "status",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras
|
|||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import { KeymapProvider, useBindings, useKeymapSelector } from "@opentui/keymap/solid"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { createContext, onCleanup, useContext, type Accessor, type ParentProps } from "solid-js"
|
||||
import { createContext, createSignal, onCleanup, useContext, type Accessor, type ParentProps } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { TuiKeybind } from "../config/keybind"
|
||||
|
||||
|
|
@ -46,6 +46,7 @@ const Context = createContext<{
|
|||
readonly keymap: OpenTuiKeymap
|
||||
readonly config: KeymapConfig
|
||||
readonly mode: Mode
|
||||
readonly control: Accessor<boolean>
|
||||
readonly dispatch: (id: string, input?: string) => void
|
||||
readonly input: (id: string) => string | undefined
|
||||
}>()
|
||||
|
|
@ -55,6 +56,23 @@ function Provider(props: ParentProps<{ config?: KeymapConfig }>) {
|
|||
const config: KeymapConfig = props.config ?? useConfig().data
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
const mode = createMode(keymap)
|
||||
const [control, setControl] = createSignal(false)
|
||||
const modifier = (event: KeyEvent) => {
|
||||
if (!/^(?:(left|right)(ctrl|shift|alt|super|hyper|meta)|iso_level[35]_shift)$/.test(event.name)) return
|
||||
if (event.name === "leftctrl" || event.name === "rightctrl") setControl(event.eventType !== "release")
|
||||
// Modifier-only reports are state changes, not input that cancels a selection or pending chord.
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
const blur = () => setControl(false)
|
||||
renderer.keyInput.prependListener("keypress", modifier)
|
||||
renderer.keyInput.prependListener("keyrelease", modifier)
|
||||
renderer.on("blur", blur)
|
||||
onCleanup(() => {
|
||||
renderer.keyInput.off("keypress", modifier)
|
||||
renderer.keyInput.off("keyrelease", modifier)
|
||||
renderer.off("blur", blur)
|
||||
})
|
||||
let invocation: { readonly id: string; readonly input?: string } | undefined
|
||||
const dispatch = (id: string, input?: string) => {
|
||||
const previous = invocation
|
||||
|
|
@ -145,6 +163,7 @@ function Provider(props: ParentProps<{ config?: KeymapConfig }>) {
|
|||
keymap,
|
||||
config,
|
||||
mode,
|
||||
control,
|
||||
dispatch,
|
||||
input: (id) => (invocation?.id === id ? invocation.input : undefined),
|
||||
}}
|
||||
|
|
@ -158,6 +177,8 @@ function Provider(props: ParentProps<{ config?: KeymapConfig }>) {
|
|||
export type { KeymapCommand, KeymapLayer } from "@opencode-ai/plugin/tui/context"
|
||||
|
||||
export interface Keymap {
|
||||
/** Whether Control is held, when the terminal reports modifier events. */
|
||||
readonly control: Accessor<boolean>
|
||||
/** Dispatches a reachable command by ID. */
|
||||
dispatch(id: string, input?: string): void
|
||||
/** Controls mutually exclusive OpenCode input modes. */
|
||||
|
|
@ -182,6 +203,7 @@ function use(): Keymap {
|
|||
value.dispatch(id, input)
|
||||
},
|
||||
mode: value.mode,
|
||||
control: value.control,
|
||||
intercept: value.keymap.intercept.bind(value.keymap),
|
||||
isLeader,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,9 +167,11 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||
? ("error" as const)
|
||||
: ("activity" as const),
|
||||
promptPulse: promptPulses()[session] ?? 0,
|
||||
attention: members.some(
|
||||
(id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0,
|
||||
),
|
||||
attention: members.some((id) => (data.session.permission.list(id)?.length ?? 0) > 0)
|
||||
? ("permission" as const)
|
||||
: members.some((id) => (data.session.form.list(id)?.length ?? 0) > 0)
|
||||
? ("question" as const)
|
||||
: (false as const),
|
||||
busy: members.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,26 +26,23 @@ export function StoryFooter(props: {
|
|||
</box>
|
||||
<Show when={props.status || props.message}>
|
||||
<box height={1} paddingLeft={1} paddingRight={1} flexDirection="row">
|
||||
<text fg={theme.text.default}>{props.status ?? ""}</text>
|
||||
<Show when={props.status && props.message}>
|
||||
<text fg={theme.text.subdued}> · </text>
|
||||
</Show>
|
||||
<text fg={theme.text.subdued}>{props.message ?? ""}</text>
|
||||
<text fg={theme.text.default} wrapMode="none">
|
||||
{props.status ?? ""}
|
||||
<span style={{ fg: theme.text.subdued }}>
|
||||
{props.status && props.message ? " · " : ""}
|
||||
{props.message ?? ""}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<box height={1} paddingLeft={1} paddingRight={1} flexDirection="row">
|
||||
<text fg={theme.text.default} wrapMode="none">
|
||||
<For each={props.controls}>
|
||||
{(control, index) => (
|
||||
<>
|
||||
<Show when={index() > 0}>
|
||||
<span> </span>
|
||||
</Show>
|
||||
{control.shortcut} <span style={{ fg: theme.text.subdued }}>{control.label}</span>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</text>
|
||||
<box paddingLeft={1} paddingRight={1} flexDirection="row" flexWrap="wrap" columnGap={1}>
|
||||
<For each={props.controls}>
|
||||
{(control) => (
|
||||
<text fg={theme.text.default} wrapMode="none" flexShrink={0}>
|
||||
{control.shortcut} <span style={{ fg: theme.text.subdued }}>{control.label}</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
{/* The app-wide feature footer overlays the terminal's final row. */}
|
||||
<box height={1} />
|
||||
|
|
|
|||
|
|
@ -2,7 +2,15 @@ import { Plugin } from "@opencode-ai/plugin/tui"
|
|||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { batch, createSignal, For } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { EMPTY_SESSION_TAB_STATUS, SessionTabs, type SessionTabsController } from "../../../component/session-tabs"
|
||||
import {
|
||||
EMPTY_SESSION_TAB_STATUS,
|
||||
SessionTabs,
|
||||
TAB_SPINNERS,
|
||||
TAB_UNREAD_MARKERS,
|
||||
type SessionTabsController,
|
||||
type TabSpinner,
|
||||
type TabUnreadMarker,
|
||||
} from "../../../component/session-tabs"
|
||||
import { closeSessionTab, cycleSessionTab, moveSessionTab } from "../../../context/session-tabs-model"
|
||||
import { StoryFooter } from "./footer"
|
||||
import type { Story } from "./index"
|
||||
|
|
@ -24,6 +32,15 @@ const FIXTURE_TABS = [
|
|||
{ sessionID: "fixture-12", title: "Prepare review", project: "opencode-drive" },
|
||||
]
|
||||
|
||||
const FIXTURE_STATUSES: Record<string, FixtureStatus> = {
|
||||
"fixture-2": { ...EMPTY_SESSION_TAB_STATUS, busy: true },
|
||||
"fixture-3": { ...EMPTY_SESSION_TAB_STATUS, busy: true, attention: "question" },
|
||||
"fixture-4": { ...EMPTY_SESSION_TAB_STATUS, busy: true, attention: "permission" },
|
||||
"fixture-5": { ...EMPTY_SESSION_TAB_STATUS, unread: "activity" },
|
||||
"fixture-6": { ...EMPTY_SESSION_TAB_STATUS, unread: "error" },
|
||||
}
|
||||
const FIXTURE_OUTCOMES = { "fixture-5": "completed", "fixture-6": "failed" } as const
|
||||
|
||||
// Plausible targets for the fake transcript's tool calls, picked per fixture index.
|
||||
const TRANSCRIPT_FILES = [
|
||||
"packages/tui/src/component/session-tabs.tsx",
|
||||
|
|
@ -45,23 +62,26 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
|||
const setItems = (next: { sessionID: string; title?: string }[]) =>
|
||||
setTabStore("items", reconcile(next, { key: "sessionID" }))
|
||||
const [active, setActive] = createSignal<string | undefined>("fixture-1")
|
||||
const [lastEvent, setLastEvent] = createSignal("press space to start a random tab")
|
||||
const [statuses, setStatuses] = createSignal<Record<string, FixtureStatus>>({})
|
||||
const [lastEvent, setLastEvent] = createSignal("idle / working / question / permission / complete / error")
|
||||
const [statuses, setStatuses] = createSignal<Record<string, FixtureStatus>>(FIXTURE_STATUSES)
|
||||
const [orientation, setOrientation] = createSignal<"horizontal" | "vertical">("vertical")
|
||||
const spinners = Object.keys(TAB_SPINNERS) as TabSpinner[]
|
||||
const [spinner, setSpinner] = createSignal<TabSpinner>("dots")
|
||||
const markers = Object.keys(TAB_UNREAD_MARKERS) as TabUnreadMarker[]
|
||||
const [marker, setMarker] = createSignal<TabUnreadMarker>("small-dot")
|
||||
const [animations, setAnimations] = createSignal(true)
|
||||
// Unread clears on select, so the transcript remembers how each session's last run ended.
|
||||
const [outcomes, setOutcomes] = createSignal<Record<string, "completed" | "failed">>({})
|
||||
const [outcomes, setOutcomes] = createSignal<Record<string, "completed" | "failed">>(FIXTURE_OUTCOMES)
|
||||
const number = (sessionID: string) => tabs().findIndex((tab) => tab.sessionID === sessionID) + 1
|
||||
|
||||
function finishRun(sessionID: string) {
|
||||
function finishRun(sessionID: string, failed = Math.random() >= 0.75) {
|
||||
if (!tabs().some((item) => item.sessionID === sessionID)) return
|
||||
const roll = Math.random()
|
||||
const failed = roll >= 0.75
|
||||
const unread = active() === sessionID ? undefined : failed ? ("error" as const) : ("activity" as const)
|
||||
batch(() => {
|
||||
setOutcomes((current) => ({ ...current, [sessionID]: failed ? "failed" : "completed" }))
|
||||
setStatuses((current) => ({
|
||||
...current,
|
||||
[sessionID]: { ...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS), busy: false, unread },
|
||||
[sessionID]: { ...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS), busy: false, attention: false, unread },
|
||||
}))
|
||||
// An untitled session earns its title after its first completed run, like a real summarization.
|
||||
const index = number(sessionID) - 1
|
||||
|
|
@ -77,8 +97,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
|||
const status = statuses()[sessionID]
|
||||
batch(() => {
|
||||
setActive(sessionID)
|
||||
if (status && (status.unread || status.attention))
|
||||
setStatuses((current) => ({ ...current, [sessionID]: { ...status, unread: undefined, attention: false } }))
|
||||
if (status?.unread) setStatuses((current) => ({ ...current, [sessionID]: { ...status, unread: undefined } }))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -134,7 +153,12 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
|||
const startRun = (sessionID: string) => {
|
||||
setStatuses((current) => ({
|
||||
...current,
|
||||
[sessionID]: { ...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS), busy: true, unread: undefined },
|
||||
[sessionID]: {
|
||||
...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS),
|
||||
busy: true,
|
||||
attention: false,
|
||||
unread: undefined,
|
||||
},
|
||||
}))
|
||||
setOutcomes((current) => {
|
||||
const next = { ...current }
|
||||
|
|
@ -170,7 +194,10 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
|||
return pool[Math.floor(Math.random() * pool.length)]
|
||||
}
|
||||
const randomRunningTab = () => {
|
||||
const candidates = tabs().filter((tab) => controller.status(tab.sessionID).busy)
|
||||
const candidates = tabs().filter((tab) => {
|
||||
const status = controller.status(tab.sessionID)
|
||||
return status.busy && !status.attention
|
||||
})
|
||||
return candidates[Math.floor(Math.random() * candidates.length)]
|
||||
}
|
||||
// A fake transcript for the selected session so tab switches feel like moving between real
|
||||
|
|
@ -190,7 +217,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
|||
{ text: `> ${fixture.title}`, color: theme.text.default },
|
||||
{ text: "", color: theme.text.default },
|
||||
]
|
||||
if (!status.busy && outcome === undefined) {
|
||||
if (!status.busy && !status.attention && outcome === undefined) {
|
||||
lines.push({ text: "no activity yet — press s to run this session", color: theme.text.subdued })
|
||||
return lines
|
||||
}
|
||||
|
|
@ -202,7 +229,11 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
|||
{ text: ` ✱ Bash bun run test`, color: theme.text.subdued },
|
||||
{ text: "", color: theme.text.default },
|
||||
)
|
||||
if (status.busy) lines.push({ text: "● Working…", color: theme.text.subdued })
|
||||
if (status.attention === "question")
|
||||
lines.push({ text: "? Which approach should I take?", color: theme.text.status.question })
|
||||
else if (status.attention === "permission")
|
||||
lines.push({ text: "! Waiting for permission to run the command", color: theme.text.status.permission })
|
||||
else if (status.busy) lines.push({ text: "● Working…", color: theme.text.status.running })
|
||||
else if (outcome === "failed")
|
||||
lines.push({
|
||||
text: `✗ bun run test failed — 3 tests failing in ${file}`,
|
||||
|
|
@ -218,9 +249,26 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
|||
|
||||
const stateSummary = () => {
|
||||
const values = tabs().map((tab) => controller.status(tab.sessionID))
|
||||
const running = values.filter((status) => status.busy).length
|
||||
const running = values.filter((status) => status.busy && !status.attention).length
|
||||
const waiting = values.filter((status) => status.attention).length
|
||||
const unread = values.filter((status) => status.unread !== undefined).length
|
||||
return [`selected ${number(active() ?? "")}`, `${running} running`, `${unread} unread`].join(" · ")
|
||||
return [`selected ${number(active() ?? "")}`, `${running} running`, `${waiting} waiting`, `${unread} unread`].join(
|
||||
" · ",
|
||||
)
|
||||
}
|
||||
|
||||
const reset = (showcase = false) => {
|
||||
batch(() => {
|
||||
setItems(FIXTURE_TABS.slice(0, 6).map((tab) => ({ ...tab })))
|
||||
setStatuses(showcase ? FIXTURE_STATUSES : {})
|
||||
setOutcomes(showcase ? FIXTURE_OUTCOMES : {})
|
||||
setActive("fixture-1")
|
||||
setSpinner("dots")
|
||||
setMarker("small-dot")
|
||||
setAnimations(true)
|
||||
setOrientation("vertical")
|
||||
})
|
||||
setLastEvent(showcase ? "all six states are visible" : "reset; all tabs idle")
|
||||
}
|
||||
|
||||
props.context.keymap.layer(() => ({
|
||||
|
|
@ -236,7 +284,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
|||
{ bind: "up,k,left,h", title: "Previous tab", group: "Storybook", run: () => cycle(-1) },
|
||||
{ bind: "down,j,right,l", title: "Next tab", group: "Storybook", run: () => cycle(1) },
|
||||
...Array.from({ length: 10 }, (_, index) => ({
|
||||
bind: String((index + 1) % 10),
|
||||
bind: `${(index + 1) % 10},ctrl+${(index + 1) % 10}`,
|
||||
title: `Select tab ${index + 1}`,
|
||||
group: "Storybook",
|
||||
run() {
|
||||
|
|
@ -289,13 +337,64 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
|||
run() {
|
||||
const current = active()
|
||||
if (!current) return
|
||||
if (controller.status(current).busy) {
|
||||
if (controller.status(current).busy && !controller.status(current).attention) {
|
||||
setLastEvent(`tab ${number(current)} is already running`)
|
||||
return
|
||||
}
|
||||
startRun(current)
|
||||
},
|
||||
},
|
||||
...(
|
||||
[
|
||||
{ bind: "q", title: "Ask a question", attention: "question" },
|
||||
{ bind: "a", title: "Request permission", attention: "permission" },
|
||||
{ bind: "i", title: "Set idle", attention: false },
|
||||
] as const
|
||||
).map((state) => ({
|
||||
bind: state.bind,
|
||||
title: state.title,
|
||||
group: "Storybook",
|
||||
run() {
|
||||
const current = active()
|
||||
if (!current) return
|
||||
startRun(current)
|
||||
setStatuses((statuses) => ({
|
||||
...statuses,
|
||||
[current]: { ...EMPTY_SESSION_TAB_STATUS, busy: Boolean(state.attention), attention: state.attention },
|
||||
}))
|
||||
setLastEvent(`tab ${number(current)} ${state.attention || "idle"}`)
|
||||
},
|
||||
})),
|
||||
...(
|
||||
[
|
||||
{ bind: "f", title: "Complete selected tab", failed: false },
|
||||
{ bind: "x", title: "Fail selected tab", failed: true },
|
||||
] as const
|
||||
).map((state) => ({
|
||||
bind: state.bind,
|
||||
title: state.title,
|
||||
group: "Storybook",
|
||||
run() {
|
||||
const current = active()
|
||||
if (!current) return
|
||||
// Leave the result unread so its indicator can be inspected before selecting it again.
|
||||
cycle(1)
|
||||
finishRun(current, state.failed)
|
||||
},
|
||||
})),
|
||||
{
|
||||
bind: "c",
|
||||
title: "Cycle spinner shape",
|
||||
group: "Storybook",
|
||||
run: () => setSpinner((value) => spinners[(spinners.indexOf(value) + 1) % spinners.length]),
|
||||
},
|
||||
{
|
||||
bind: "u",
|
||||
title: "Cycle unread marker",
|
||||
group: "Storybook",
|
||||
run: () => setMarker((value) => markers[(markers.indexOf(value) + 1) % markers.length]),
|
||||
},
|
||||
{ bind: "m", title: "Toggle animations", group: "Storybook", run: () => setAnimations((value) => !value) },
|
||||
{ bind: "t", title: "Add tab", group: "Storybook", run: addTab },
|
||||
{ bind: "d", title: "Close tab", group: "Storybook", run: () => controller.close() },
|
||||
{
|
||||
|
|
@ -306,20 +405,8 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
|||
setOrientation((value) => (value === "vertical" ? "horizontal" : "vertical"))
|
||||
},
|
||||
},
|
||||
{
|
||||
bind: "r",
|
||||
title: "Reset",
|
||||
group: "Storybook",
|
||||
run() {
|
||||
batch(() => {
|
||||
setItems(FIXTURE_TABS.slice(0, 6).map((tab) => ({ ...tab })))
|
||||
setStatuses({})
|
||||
setOutcomes({})
|
||||
setActive("fixture-1")
|
||||
})
|
||||
setLastEvent("reset; press space to start a random tab")
|
||||
},
|
||||
},
|
||||
{ bind: "r,shift+r", title: "Reset to idle", group: "Storybook", run: () => reset() },
|
||||
{ bind: "v", title: "Show all states", group: "Storybook", run: () => reset(true) },
|
||||
],
|
||||
}))
|
||||
|
||||
|
|
@ -331,7 +418,13 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
|||
backgroundColor={theme.background.default}
|
||||
>
|
||||
<box flexGrow={1} flexDirection={orientation() === "vertical" ? "row" : "column"}>
|
||||
<SessionTabs controller={controller} orientation={orientation()} />
|
||||
<SessionTabs
|
||||
controller={controller}
|
||||
orientation={orientation()}
|
||||
spinner={spinner()}
|
||||
unreadMarker={marker()}
|
||||
animations={animations()}
|
||||
/>
|
||||
<box flexGrow={1} paddingLeft={2} paddingRight={2} paddingTop={1} flexDirection="column">
|
||||
<For each={transcript()}>
|
||||
{(line) => (
|
||||
|
|
@ -345,16 +438,28 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
|||
<StoryFooter
|
||||
context={props.context}
|
||||
title="storybook / tabs"
|
||||
details={[orientation() === "vertical" ? "left rail" : "top strip"]}
|
||||
details={[
|
||||
orientation() === "vertical" ? "left rail" : "top strip",
|
||||
spinner(),
|
||||
`${TAB_UNREAD_MARKERS[marker()]} ${marker()}`,
|
||||
animations() ? "animated" : "still",
|
||||
]}
|
||||
status={stateSummary()}
|
||||
message={lastEvent()}
|
||||
controls={[
|
||||
{ shortcut: "space", label: "start" },
|
||||
{ shortcut: "p", label: "prompt" },
|
||||
{ shortcut: "e", label: "end" },
|
||||
{ shortcut: "s", label: "work" },
|
||||
{ shortcut: "q", label: "question" },
|
||||
{ shortcut: "a", label: "permission" },
|
||||
{ shortcut: "i", label: "idle" },
|
||||
{ shortcut: "f/x", label: "complete/fail" },
|
||||
{ shortcut: "c", label: "spinner" },
|
||||
{ shortcut: "u", label: "unread marker" },
|
||||
{ shortcut: "hold ctrl", label: "numbers" },
|
||||
{ shortcut: "m", label: "motion" },
|
||||
{ shortcut: "↑/↓", label: "select" },
|
||||
{ shortcut: "o", label: "layout" },
|
||||
{ shortcut: "r", label: "reset" },
|
||||
{ shortcut: "r", label: "reset idle" },
|
||||
{ shortcut: "v", label: "all states" },
|
||||
{ shortcut: "esc", label: "back" },
|
||||
]}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -177,11 +177,15 @@ export function createPluginContext(input: {
|
|||
tabs: {
|
||||
enabled: host.sessionTabs.enabled,
|
||||
list: () =>
|
||||
host.sessionTabs.tabs().map((tab) => ({
|
||||
...tab,
|
||||
active: host.sessionTabs.current() === tab.sessionID,
|
||||
...host.sessionTabs.status(tab.sessionID),
|
||||
})),
|
||||
host.sessionTabs.tabs().map((tab) => {
|
||||
const status = host.sessionTabs.status(tab.sessionID)
|
||||
return {
|
||||
...tab,
|
||||
active: host.sessionTabs.current() === tab.sessionID,
|
||||
...status,
|
||||
attention: Boolean(status.attention),
|
||||
}
|
||||
}),
|
||||
open(sessionID) {
|
||||
if (!host.sessionTabs.enabled()) return false
|
||||
host.sessionTabs.select(sessionID)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ test("releasing a transcript selection over tab controls does not activate them"
|
|||
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<box flexDirection="column">
|
||||
<SessionTabs controller={controller} animations={false} />
|
||||
<Keymap.Provider>
|
||||
<SessionTabs controller={controller} animations={false} />
|
||||
</Keymap.Provider>
|
||||
<text>selectable transcript text</text>
|
||||
</box>
|
||||
</ThemeProvider>
|
||||
|
|
@ -145,7 +147,9 @@ test("double-clicking a preview tab keeps it open without promoting permanent ta
|
|||
<TestTuiContexts>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<SessionTabs controller={controller} animations={false} />
|
||||
<Keymap.Provider>
|
||||
<SessionTabs controller={controller} animations={false} />
|
||||
</Keymap.Provider>
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
|
|
@ -190,7 +194,9 @@ test("middle-click closes a session tab without selecting it", async () => {
|
|||
<TestTuiContexts>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<SessionTabs controller={controller} animations={false} />
|
||||
<Keymap.Provider>
|
||||
<SessionTabs controller={controller} animations={false} />
|
||||
</Keymap.Provider>
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
|
|
@ -238,7 +244,9 @@ test("keeps consecutive close controls fixed across overflow window changes", as
|
|||
<TestTuiContexts>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<SessionTabs controller={controller} animations={false} />
|
||||
<Keymap.Provider>
|
||||
<SessionTabs controller={controller} animations={false} />
|
||||
</Keymap.Provider>
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
|
|
@ -290,7 +298,9 @@ test("reflows held tabs when the pointer leaves the strip", async () => {
|
|||
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<box flexDirection="column">
|
||||
<SessionTabs controller={controller} animations={false} />
|
||||
<Keymap.Provider>
|
||||
<SessionTabs controller={controller} animations={false} />
|
||||
</Keymap.Provider>
|
||||
<text>outside</text>
|
||||
</box>
|
||||
</ThemeProvider>
|
||||
|
|
|
|||
254
packages/tui/test/component/session-tabs-status.test.tsx
Normal file
254
packages/tui/test/component/session-tabs-status.test.tsx
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
/** @jsxImportSource @opentui/solid */
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { batch, createSignal } from "solid-js"
|
||||
import { ConfigProvider, useConfig, type Info } from "../../src/config"
|
||||
import {
|
||||
EMPTY_SESSION_TAB_STATUS,
|
||||
SessionTabs,
|
||||
type SessionTabsController,
|
||||
type SessionTabsStatus,
|
||||
} from "../../src/component/session-tabs"
|
||||
import { SPINNER_FRAMES } from "../../src/component/spinner-frames"
|
||||
import { ClientProvider } from "../../src/context/client"
|
||||
import { DataProvider } from "../../src/context/data"
|
||||
import { LocationProvider } from "../../src/context/location"
|
||||
import { Keymap } from "../../src/context/keymap"
|
||||
import { RouteProvider } from "../../src/context/route"
|
||||
import { TuiAppProvider } from "../../src/context/runtime"
|
||||
import { SessionTabsProvider } from "../../src/context/session-tabs"
|
||||
import { StorageProvider } from "../../src/context/storage"
|
||||
import { ThemeProvider, useTheme } from "../../src/context/theme"
|
||||
import { emptyThemeSource, tmpdir } from "../fixture/fixture"
|
||||
import { createApi, createEventStream, createFetch } from "../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
|
||||
|
||||
for (const orientation of ["horizontal", "vertical"] as const) {
|
||||
test(`${orientation} tabs replace ordinals with status without moving titles`, async () => {
|
||||
await using temporary = await tmpdir()
|
||||
const [status, setStatus] = createSignal<SessionTabsStatus>(EMPTY_SESSION_TAB_STATUS)
|
||||
const [active, setActive] = createSignal("second")
|
||||
const [animations, setAnimations] = createSignal(false)
|
||||
const [newTab, setNewTab] = createSignal(false)
|
||||
const settings: Info = { tabs: { enabled: true } }
|
||||
let config!: ReturnType<typeof useConfig>
|
||||
let theme!: ReturnType<typeof useTheme>
|
||||
function Colors() {
|
||||
config = useConfig()
|
||||
theme = orientation === "vertical" ? useTheme("elevated") : useTheme()
|
||||
return null
|
||||
}
|
||||
const controller = {
|
||||
tabs: () => [
|
||||
{ sessionID: "first", title: "First" },
|
||||
{ sessionID: "second", title: "Second" },
|
||||
],
|
||||
current: active,
|
||||
newTab,
|
||||
select(sessionID: string) {
|
||||
batch(() => {
|
||||
setActive(sessionID)
|
||||
if (sessionID === "first") setStatus((current) => ({ ...current, unread: undefined }))
|
||||
})
|
||||
},
|
||||
close() {},
|
||||
move() {},
|
||||
detail: () => "project",
|
||||
status: (sessionID: string) => (sessionID === "first" ? status() : EMPTY_SESSION_TAB_STATUS),
|
||||
} satisfies SessionTabsController
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts paths={{ state: temporary.path }}>
|
||||
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
|
||||
<StorageProvider>
|
||||
<ConfigProvider
|
||||
config={createTuiResolvedConfig(settings)}
|
||||
service={{
|
||||
get: async () => settings,
|
||||
update: async (update) => {
|
||||
update(settings)
|
||||
return settings
|
||||
},
|
||||
}}
|
||||
>
|
||||
<RouteProvider initialRoute={{ type: "home" }}>
|
||||
<ClientProvider api={createApi(createFetch(undefined, createEventStream()).fetch)}>
|
||||
<DataProvider directory={temporary.path}>
|
||||
<LocationProvider>
|
||||
<SessionTabsProvider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<Colors />
|
||||
<Keymap.Provider>
|
||||
<SessionTabs
|
||||
controller={controller}
|
||||
orientation={orientation}
|
||||
animations={animations()}
|
||||
/>
|
||||
</Keymap.Provider>
|
||||
</ThemeProvider>
|
||||
</SessionTabsProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</RouteProvider>
|
||||
</ConfigProvider>
|
||||
</StorageProvider>
|
||||
</TuiAppProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 60, height: 10, kittyKeyboard: true },
|
||||
)
|
||||
|
||||
try {
|
||||
app.renderer.start()
|
||||
await app.waitForFrame((frame) => frame.includes(" First") && frame.includes(" Second"))
|
||||
const pressed = performance.now()
|
||||
await app.mockInput.pressKeys(["\x1b[57442;5u"])
|
||||
await app.waitForFrame((frame) => frame.includes("1 First") && frame.includes("2 Second"))
|
||||
expect(performance.now() - pressed).toBeGreaterThanOrEqual(300)
|
||||
await app.mockInput.pressKeys(["\x1b[57442;1:3u"])
|
||||
await app.waitForFrame((frame) => frame.includes(" First"))
|
||||
|
||||
await app.mockInput.pressKeys(["\x1b[57442;5u"])
|
||||
await app.renderOnce()
|
||||
await app.mockInput.pressKeys(["\x1b[57442;1:3u"])
|
||||
await Bun.sleep(325)
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain(" First")
|
||||
|
||||
const titleColumn = app
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.find((line) => line.includes("First"))!
|
||||
.indexOf("First")
|
||||
const states: { status: Partial<SessionTabsStatus>; label: string }[] = [
|
||||
{ status: { busy: true }, label: SPINNER_FRAMES[0] },
|
||||
{ status: { busy: true, attention: "question" }, label: "?" },
|
||||
{ status: { busy: true, attention: "permission" }, label: "!" },
|
||||
{ status: { unread: "activity" }, label: "\u2022" },
|
||||
{ status: { unread: "error" }, label: "\u2022" },
|
||||
{ status: {}, label: "" },
|
||||
]
|
||||
for (const state of states) {
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, ...state.status })
|
||||
await app.renderOnce()
|
||||
await app.waitForFrame((frame) => frame.includes(`${state.label.padStart(2)} First`))
|
||||
const rows = app.captureCharFrame().split("\n")
|
||||
expect(rows.find((line) => line.includes("First"))!.indexOf("First")).toBe(titleColumn)
|
||||
expect(rows[orientation === "vertical" ? 2 : 1]?.trim()).toBe(orientation === "vertical" ? "project" : "")
|
||||
}
|
||||
|
||||
for (const attention of ["question", "permission"] as const) {
|
||||
setAnimations(false)
|
||||
setActive("second")
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true, attention })
|
||||
await app.renderOnce()
|
||||
const glow = () => {
|
||||
const colors = app
|
||||
.captureSpans()
|
||||
.lines[
|
||||
orientation === "vertical" ? 1 : 0
|
||||
]!.spans.flatMap((span) => Array.from({ length: span.width }, () => span.bg))
|
||||
return (
|
||||
Math.abs(colors[1]!.r - colors[18]!.r) +
|
||||
Math.abs(colors[1]!.g - colors[18]!.g) +
|
||||
Math.abs(colors[1]!.b - colors[18]!.b)
|
||||
)
|
||||
}
|
||||
const full = glow()
|
||||
expect(full).toBeGreaterThan(0)
|
||||
setActive("first")
|
||||
await app.renderOnce()
|
||||
const dim = glow()
|
||||
expect(dim).toBeGreaterThan(0)
|
||||
expect(dim).toBeLessThan(full)
|
||||
setActive("second")
|
||||
await app.renderOnce()
|
||||
setAnimations(true)
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.root.liveCount).toBe(0)
|
||||
|
||||
setActive("first")
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.root.liveCount).toBe(0)
|
||||
await app.waitForFrame(() => glow() > dim && glow() < full)
|
||||
await app.waitForFrame(() => glow() === dim, { maxPasses: 60 })
|
||||
setActive("second")
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.root.liveCount).toBe(0)
|
||||
await app.waitForFrame(() => glow() > dim && glow() < full)
|
||||
await app.waitForFrame(() => glow() === full, { maxPasses: 60 })
|
||||
|
||||
setStatus(EMPTY_SESSION_TAB_STATUS)
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.root.liveCount).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
const glyph = "\u2022"
|
||||
for (const unread of ["activity", "error"] as const) {
|
||||
setAnimations(false)
|
||||
setActive("second")
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
|
||||
await app.renderOnce()
|
||||
setAnimations(true)
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, unread })
|
||||
await app.renderOnce()
|
||||
const color = () =>
|
||||
app
|
||||
.captureSpans()
|
||||
.lines.flatMap((line) => line.spans)
|
||||
.find((span) => span.text.trim() === glyph)?.fg
|
||||
expect(color()?.toInts()).toEqual(
|
||||
(unread === "error" ? theme.text.feedback.error.default : theme.text.status.unread).toInts(),
|
||||
)
|
||||
const brightness = () => {
|
||||
const value = color()
|
||||
return value ? value.r + value.g + value.b : undefined
|
||||
}
|
||||
const initial = brightness()!
|
||||
await app.mockMouse.click(1, orientation === "vertical" ? 1 : 0)
|
||||
await app.renderOnce()
|
||||
expect(active()).toBe("first")
|
||||
expect(status().unread).toBeUndefined()
|
||||
expect(app.captureCharFrame()).toContain(`${glyph} First`)
|
||||
await app.waitForFrame((frame) => frame.includes(`${glyph} First`) && (brightness() ?? -1) > initial)
|
||||
const peak = brightness()!
|
||||
await app.waitForFrame((frame) => frame.includes(`${glyph} First`) && (brightness() ?? Infinity) < peak)
|
||||
await app.waitForFrame((frame) => frame.includes(" First"), { maxPasses: 60 })
|
||||
}
|
||||
|
||||
setAnimations(false)
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, unread: "activity" })
|
||||
await app.renderOnce()
|
||||
setAnimations(true)
|
||||
await app.mockMouse.click(1, orientation === "vertical" ? 1 : 0)
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
|
||||
await app.waitForFrame((frame) => SPINNER_FRAMES.slice(1).some((glyph) => frame.includes(`${glyph} First`)))
|
||||
await app.mockInput.pressKeys(["\x1b[57442;5u"])
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true, attention: "question" })
|
||||
await app.waitForFrame((frame) => frame.includes("1 First"))
|
||||
app.renderer.emit("blur")
|
||||
await app.waitForFrame((frame) => frame.includes("? First"))
|
||||
|
||||
await config.update((draft) => {
|
||||
draft.tabs.indicators = "numbers"
|
||||
})
|
||||
await app.waitForFrame((frame) => frame.includes("1 First") && frame.includes("2 Second"))
|
||||
await app.mockInput.pressKeys(["\x1b[57442;5u", "\x1b[57442;1:3u"])
|
||||
app.renderer.emit("blur")
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("1 First")
|
||||
await config.update((draft) => {
|
||||
draft.tabs.indicators = "status"
|
||||
})
|
||||
await app.waitForFrame((frame) => SPINNER_FRAMES.some((glyph) => frame.includes(`${glyph} First`)))
|
||||
|
||||
setNewTab(true)
|
||||
await app.waitForFrame((frame) => frame.includes("+ New session"))
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -20,9 +20,10 @@ test("validates mini replay settings", () => {
|
|||
test("validates the session tabs setting", () => {
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
expect(decode({ tabs: { enabled: true, layout: "vertical" } })).toEqual({
|
||||
tabs: { enabled: true, layout: "vertical" },
|
||||
expect(decode({ tabs: { enabled: true, layout: "vertical", indicators: "numbers" } })).toEqual({
|
||||
tabs: { enabled: true, layout: "vertical", indicators: "numbers" },
|
||||
})
|
||||
expect(() => decode({ tabs: { indicators: "unknown" } })).toThrow()
|
||||
expect(() => decode({ tabs: { layout: true } })).toThrow()
|
||||
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
|
||||
expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
|
||||
|
|
@ -49,7 +50,7 @@ test("resolves nested config and keybind defaults", () => {
|
|||
expect(config.scroll).toEqual({ speed: 2, acceleration: true })
|
||||
expect(config.diffs).toEqual({ view: "split" })
|
||||
expect(config.debug).toEqual({ devtools: true })
|
||||
expect(config.tabs).toEqual({ enabled: true, scope: "cwd", layout: "horizontal" })
|
||||
expect(config.tabs).toEqual({ enabled: true, scope: "cwd", layout: "horizontal", indicators: "status" })
|
||||
expect(config.session.new_location).toBe("launch")
|
||||
expect(config.session.tps).toBe(true)
|
||||
})
|
||||
|
|
@ -58,6 +59,10 @@ test("shows resolved tab defaults in settings", () => {
|
|||
expect(settings.find((setting) => setting.path.join(".") === "tabs.enabled")?.default).toBe(true)
|
||||
expect(settings.find((setting) => setting.path.join(".") === "tabs.scope")?.default).toBe("cwd")
|
||||
expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal")
|
||||
expect(settings.find((setting) => setting.path.join(".") === "tabs.indicators")).toMatchObject({
|
||||
default: "status",
|
||||
values: ["status", "numbers"],
|
||||
})
|
||||
})
|
||||
|
||||
test("shows the new session location default in settings", () => {
|
||||
|
|
|
|||
|
|
@ -753,6 +753,64 @@ test("ignores subagent unread state on the root tab", async () => {
|
|||
}
|
||||
})
|
||||
|
||||
test("distinguishes family questions and permissions without clearing them on selection", async () => {
|
||||
const setup = await renderSessionTabs("root", {
|
||||
home: true,
|
||||
persisted: ["root"],
|
||||
sessionParents: { child: "root" },
|
||||
})
|
||||
try {
|
||||
await wait(() => setup.data.session.get("child") !== undefined)
|
||||
expect(setup.tabs.status("root").attention).toBe(false)
|
||||
|
||||
setup.emit({
|
||||
id: "evt_question",
|
||||
created: 1,
|
||||
type: "form.created",
|
||||
data: {
|
||||
form: {
|
||||
id: "frm_question",
|
||||
sessionID: "child",
|
||||
title: "Choose an approach",
|
||||
fields: [{ key: "approach", type: "string", title: "Approach" }],
|
||||
},
|
||||
},
|
||||
})
|
||||
await wait(() => setup.tabs.status("root").attention === "question")
|
||||
|
||||
setup.tabs.select("root")
|
||||
await wait(() => setup.tabs.current() === "root")
|
||||
expect(setup.tabs.status("root").attention).toBe("question")
|
||||
|
||||
setup.emit({
|
||||
id: "evt_permission",
|
||||
created: 2,
|
||||
type: "permission.asked",
|
||||
data: { id: "per_command", sessionID: "root", action: "shell", resources: ["bun run test"] },
|
||||
})
|
||||
await wait(() => setup.tabs.status("root").attention === "permission")
|
||||
expect(setup.tabs.status("child").attention).toBe("permission")
|
||||
|
||||
setup.emit({
|
||||
id: "evt_permission_reply",
|
||||
created: 3,
|
||||
type: "permission.replied",
|
||||
data: { sessionID: "root", requestID: "per_command", reply: "once" },
|
||||
})
|
||||
await wait(() => setup.tabs.status("root").attention === "question")
|
||||
|
||||
setup.emit({
|
||||
id: "evt_question_reply",
|
||||
created: 4,
|
||||
type: "form.replied",
|
||||
data: { sessionID: "child", id: "frm_question", answer: {} },
|
||||
})
|
||||
await wait(() => setup.tabs.status("root").attention === false)
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("concurrent TUIs do not alternate shared tab titles from divergent session caches", async () => {
|
||||
await using temporary = await tmpdir()
|
||||
const state = temporary.path
|
||||
|
|
|
|||
133
packages/tui/test/keymap-kitty.test.tsx
Normal file
133
packages/tui/test/keymap-kitty.test.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/** @jsxImportSource @opentui/solid */
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { testRender, useRenderer } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { onCleanup, type JSX } from "solid-js"
|
||||
import { ConfigProvider } from "../src/config"
|
||||
import { Keymap } from "../src/context/keymap"
|
||||
import { handleSelectionKey } from "../src/util/selection"
|
||||
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
|
||||
|
||||
function renderKeymap(Content: () => JSX.Element) {
|
||||
return testRender(
|
||||
() => (
|
||||
<ConfigProvider config={createTuiResolvedConfig({ keybinds: { leader: "ctrl+x" } })}>
|
||||
<Keymap.Provider>
|
||||
<Content />
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
),
|
||||
{ width: 30, height: 5, useKittyKeyboard: { events: true, allKeysAsEscapes: true, reportText: true } },
|
||||
)
|
||||
}
|
||||
|
||||
test("Kitty Ctrl down preserves transcript selection for Ctrl+C instead of exiting", async () => {
|
||||
const writes: string[] = []
|
||||
let exits = 0
|
||||
const app = await renderKeymap(() => {
|
||||
const renderer = useRenderer()
|
||||
const keymap = Keymap.use()
|
||||
onCleanup(
|
||||
keymap.intercept(
|
||||
"key",
|
||||
({ event }) =>
|
||||
handleSelectionKey(renderer, { show() {}, error() {} }, event, {
|
||||
read: async () => undefined,
|
||||
async write(text) {
|
||||
writes.push(text)
|
||||
},
|
||||
}),
|
||||
{ priority: 1 },
|
||||
),
|
||||
)
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
commands: [{ id: "app.exit", run: () => void exits++ }],
|
||||
}))
|
||||
return <text>alpha beta gamma</text>
|
||||
})
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
await app.mockMouse.click(6, 0)
|
||||
await app.mockMouse.click(6, 0)
|
||||
expect(app.renderer.getSelection()?.getSelectedText()).toBe("beta")
|
||||
|
||||
await app.mockInput.pressKeys(["\x1b[57442;5u"])
|
||||
expect(app.renderer.getSelection()?.getSelectedText()).toBe("beta")
|
||||
await app.mockInput.pressKeys(["\x1b[99;5u", "\x1b[99;5:3u", "\x1b[57442;1:3u"])
|
||||
expect(writes).toEqual(["beta"])
|
||||
expect(exits).toBe(0)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("Kitty modifier-only events preserve a pending shifted leader sequence", async () => {
|
||||
let calls = 0
|
||||
let leader = () => false
|
||||
const app = await renderKeymap(() => {
|
||||
leader = Keymap.useLeaderActive()
|
||||
Keymap.createLayer(() => ({
|
||||
commands: [{ bind: "<leader>shift+r", run: () => void calls++ }],
|
||||
}))
|
||||
return <box />
|
||||
})
|
||||
|
||||
try {
|
||||
await app.mockInput.pressKeys(["\x1b[57442;5u", "\x1b[120;5u"])
|
||||
expect(leader()).toBeTrue()
|
||||
for (const code of [57453, 57454]) {
|
||||
await app.mockInput.pressKeys([`\x1b[${code}u`, `\x1b[${code};1:3u`])
|
||||
expect(leader()).toBeTrue()
|
||||
}
|
||||
await app.mockInput.pressKeys(["\x1b[120;5:3u", "\x1b[57442;1:3u", "\x1b[57441;2u"])
|
||||
expect(leader()).toBeTrue()
|
||||
await app.mockInput.pressKeys(["\x1b[114:82;2u", "\x1b[114:82;2:3u", "\x1b[57441;1:3u"])
|
||||
expect(calls).toBe(1)
|
||||
expect(leader()).toBeFalse()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("Kitty associated text preserves Caps Lock and Shift+Caps Lock casing in a textarea", async () => {
|
||||
let textarea!: TextareaRenderable
|
||||
const app = await renderKeymap(() => <textarea ref={textarea} focused={true} />)
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
await app.mockInput.pressKeys(["\x1b[97;65;65u", "\x1b[97;65:3u"])
|
||||
expect(textarea.plainText).toBe("A")
|
||||
await app.mockInput.pressKeys(["\x1b[57441;66u", "\x1b[97;66;97u", "\x1b[97;66:3u", "\x1b[57441;65:3u"])
|
||||
expect(textarea.plainText).toBe("Aa")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("Control state follows either Kitty Control key, ignores chord releases, and clears on blur", async () => {
|
||||
let keymap!: Keymap
|
||||
const app = await renderKeymap(() => {
|
||||
keymap = Keymap.use()
|
||||
return <box />
|
||||
})
|
||||
|
||||
try {
|
||||
expect(keymap.control()).toBeFalse()
|
||||
for (const code of [57442, 57448]) {
|
||||
await app.mockInput.pressKeys([`\x1b[${code};5u`])
|
||||
expect(keymap.control()).toBeTrue()
|
||||
await app.mockInput.pressKeys(["\x1b[49;5u", "\x1b[49;5:3u"])
|
||||
expect(keymap.control()).toBeTrue()
|
||||
await app.mockInput.pressKeys([`\x1b[${code};1:3u`])
|
||||
expect(keymap.control()).toBeFalse()
|
||||
await app.mockInput.pressKeys([`\x1b[${code};5u`])
|
||||
expect(keymap.control()).toBeTrue()
|
||||
app.renderer.emit("blur")
|
||||
expect(keymap.control()).toBeFalse()
|
||||
}
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
|
@ -156,6 +156,20 @@ Configure diff presentation:
|
|||
| `single` | boolean | Shows only the selected file patch. |
|
||||
| `view` | `auto`, `split`, or `unified` | Sets the layout. `auto` chooses based on the available width. |
|
||||
|
||||
## Tabs
|
||||
|
||||
Choose **Tabs > Indicators** in settings, or set `tabs.indicators` in `cli.json`:
|
||||
|
||||
```json title="cli.json"
|
||||
{
|
||||
"tabs": {
|
||||
"indicators": "numbers"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`status` (the default) shows status icons and reveals tab numbers while Control is held. `numbers` always shows tab numbers, retaining their status colors and background animations. Both modes work with horizontal and vertical tabs.
|
||||
|
||||
## Terminal
|
||||
|
||||
Configure terminal integration:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue