feat(tui): add adaptive session tabs (#39396)
This commit is contained in:
parent
f95d04fea0
commit
37a1b80d5a
19 changed files with 1485 additions and 147 deletions
|
|
@ -51,6 +51,7 @@ import { StartupLoading } from "./component/startup-loading"
|
||||||
import { DevToolsBar } from "./component/devtools-bar"
|
import { DevToolsBar } from "./component/devtools-bar"
|
||||||
import { Reconnecting } from "./component/reconnecting"
|
import { Reconnecting } from "./component/reconnecting"
|
||||||
import { DataProvider, useData } from "./context/data"
|
import { DataProvider, useData } from "./context/data"
|
||||||
|
import { SessionTabsProvider, useSessionTabs } from "./context/session-tabs"
|
||||||
import { LocationProvider, useLocation } from "./context/location"
|
import { LocationProvider, useLocation } from "./context/location"
|
||||||
import { LocalProvider, useLocal } from "./context/local"
|
import { LocalProvider, useLocal } from "./context/local"
|
||||||
import { PermissionProvider } from "./context/permission"
|
import { PermissionProvider } from "./context/permission"
|
||||||
|
|
@ -65,6 +66,7 @@ import { DialogThemeList } from "./component/dialog-theme-list"
|
||||||
import { DialogHelp } from "./ui/dialog-help"
|
import { DialogHelp } from "./ui/dialog-help"
|
||||||
import { DialogAgent } from "./component/dialog-agent"
|
import { DialogAgent } from "./component/dialog-agent"
|
||||||
import { DialogSessionList } from "./component/dialog-session-list"
|
import { DialogSessionList } from "./component/dialog-session-list"
|
||||||
|
import { SessionTabs } from "./component/session-tabs"
|
||||||
import { ThemeErrorToast } from "./component/theme-error-toast"
|
import { ThemeErrorToast } from "./component/theme-error-toast"
|
||||||
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
|
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
|
||||||
import { Home } from "./routes/home"
|
import { Home } from "./routes/home"
|
||||||
|
|
@ -92,9 +94,26 @@ import { AttentionProvider } from "./context/attention"
|
||||||
|
|
||||||
registerOpencodeSpinner()
|
registerOpencodeSpinner()
|
||||||
|
|
||||||
const appGlobalBindingCommands = [
|
const appGlobalBindingCommands = ["session.list", "session.new"] as const
|
||||||
"session.list",
|
|
||||||
"session.new",
|
const sessionTabBindingCommands = [
|
||||||
|
"session.tab.next",
|
||||||
|
"session.tab.previous",
|
||||||
|
"session.tab.next_unread",
|
||||||
|
"session.tab.previous_unread",
|
||||||
|
"session.tab.close",
|
||||||
|
"session.tab.select.1",
|
||||||
|
"session.tab.select.2",
|
||||||
|
"session.tab.select.3",
|
||||||
|
"session.tab.select.4",
|
||||||
|
"session.tab.select.5",
|
||||||
|
"session.tab.select.6",
|
||||||
|
"session.tab.select.7",
|
||||||
|
"session.tab.select.8",
|
||||||
|
"session.tab.select.9",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const pinnedSessionBindingCommands = [
|
||||||
"session.quick_switch.1",
|
"session.quick_switch.1",
|
||||||
"session.quick_switch.2",
|
"session.quick_switch.2",
|
||||||
"session.quick_switch.3",
|
"session.quick_switch.3",
|
||||||
|
|
@ -277,113 +296,115 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
|
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
|
||||||
>
|
>
|
||||||
<TuiPathsProvider
|
<TuiPathsProvider
|
||||||
value={{
|
|
||||||
cwd: process.cwd(),
|
|
||||||
home: global.home,
|
|
||||||
state: global.state,
|
|
||||||
worktree: global.data + "/worktree",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TuiLifecycleProvider
|
|
||||||
value={{
|
value={{
|
||||||
add(finalizer) {
|
cwd: process.cwd(),
|
||||||
finalizers.add(finalizer)
|
home: global.home,
|
||||||
return () => finalizers.delete(finalizer)
|
state: global.state,
|
||||||
},
|
worktree: global.data + "/worktree",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TuiTerminalEnvironmentProvider
|
<TuiLifecycleProvider
|
||||||
value={{
|
value={{
|
||||||
platform: process.platform,
|
add(finalizer) {
|
||||||
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
|
finalizers.add(finalizer)
|
||||||
displayServer: process.env.WAYLAND_DISPLAY
|
return () => finalizers.delete(finalizer)
|
||||||
? "wayland"
|
},
|
||||||
: process.env.DISPLAY
|
|
||||||
? "x11"
|
|
||||||
: undefined,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TuiStartupProvider
|
<TuiTerminalEnvironmentProvider
|
||||||
value={{
|
value={{
|
||||||
initialRoute: process.env.OPENCODE_SCRAP
|
platform: process.platform,
|
||||||
? { type: "plugin", id: "scrap", name: "scrap" }
|
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
|
||||||
: process.env.OPENCODE_ROUTE
|
displayServer: process.env.WAYLAND_DISPLAY
|
||||||
? JSON.parse(process.env.OPENCODE_ROUTE)
|
? "wayland"
|
||||||
|
: process.env.DISPLAY
|
||||||
|
? "x11"
|
||||||
: undefined,
|
: undefined,
|
||||||
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ClipboardProvider>
|
<TuiStartupProvider
|
||||||
<ArgsProvider {...input.args}>
|
value={{
|
||||||
<ConfigProvider
|
initialRoute: process.env.OPENCODE_SCRAP
|
||||||
config={config}
|
? { type: "plugin", id: "scrap", name: "scrap" }
|
||||||
service={input.config}
|
: process.env.OPENCODE_ROUTE
|
||||||
options={{ terminalSuspend: process.platform !== "win32" }}
|
? JSON.parse(process.env.OPENCODE_ROUTE)
|
||||||
>
|
: undefined,
|
||||||
<Keymap.Provider>
|
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
|
||||||
<ToastProvider>
|
}}
|
||||||
<RouteProvider
|
>
|
||||||
initialRoute={
|
<ClipboardProvider>
|
||||||
input.args.continue
|
<ArgsProvider {...input.args}>
|
||||||
? {
|
<ConfigProvider
|
||||||
type: "session",
|
config={config}
|
||||||
sessionID: "dummy",
|
service={input.config}
|
||||||
}
|
options={{ terminalSuspend: process.platform !== "win32" }}
|
||||||
: undefined
|
>
|
||||||
}
|
<Keymap.Provider>
|
||||||
>
|
<ToastProvider>
|
||||||
<PluginRuntimeProvider value={pluginRuntime}>
|
<RouteProvider
|
||||||
<ClientProvider api={api} service={service}>
|
initialRoute={
|
||||||
<PermissionProvider>
|
input.args.continue
|
||||||
<DataProvider>
|
? {
|
||||||
<LocationProvider>
|
type: "session",
|
||||||
<ThemeProvider mode={mode}>
|
sessionID: "dummy",
|
||||||
<ThemeErrorToast />
|
}
|
||||||
<LocalProvider>
|
: undefined
|
||||||
<PromptStashProvider>
|
}
|
||||||
<DialogProvider>
|
>
|
||||||
<FrecencyProvider>
|
<PluginRuntimeProvider value={pluginRuntime}>
|
||||||
<PromptHistoryProvider>
|
<ClientProvider api={api} service={service}>
|
||||||
<PromptRefProvider>
|
<PermissionProvider>
|
||||||
<EditorContextProvider>
|
<DataProvider>
|
||||||
<AttentionProvider>
|
<LocationProvider>
|
||||||
<PluginProvider packages={input.packages}>
|
<SessionTabsProvider>
|
||||||
<App
|
<ThemeProvider mode={mode}>
|
||||||
pair={
|
<ThemeErrorToast />
|
||||||
input.server.endpoint.auth
|
<LocalProvider>
|
||||||
? input.server.endpoint.auth
|
<PromptStashProvider>
|
||||||
: {
|
<DialogProvider>
|
||||||
username: "opencode",
|
<FrecencyProvider>
|
||||||
password: "",
|
<PromptHistoryProvider>
|
||||||
}
|
<PromptRefProvider>
|
||||||
}
|
<EditorContextProvider>
|
||||||
/>
|
<AttentionProvider>
|
||||||
</PluginProvider>
|
<PluginProvider packages={input.packages}>
|
||||||
</AttentionProvider>
|
<App
|
||||||
</EditorContextProvider>
|
pair={
|
||||||
</PromptRefProvider>
|
input.server.endpoint.auth
|
||||||
</PromptHistoryProvider>
|
? input.server.endpoint.auth
|
||||||
</FrecencyProvider>
|
: {
|
||||||
</DialogProvider>
|
username: "opencode",
|
||||||
</PromptStashProvider>
|
password: "",
|
||||||
</LocalProvider>
|
}
|
||||||
</ThemeProvider>
|
}
|
||||||
</LocationProvider>
|
/>
|
||||||
</DataProvider>
|
</PluginProvider>
|
||||||
</PermissionProvider>
|
</AttentionProvider>
|
||||||
</ClientProvider>
|
</EditorContextProvider>
|
||||||
</PluginRuntimeProvider>
|
</PromptRefProvider>
|
||||||
</RouteProvider>
|
</PromptHistoryProvider>
|
||||||
</ToastProvider>
|
</FrecencyProvider>
|
||||||
</Keymap.Provider>
|
</DialogProvider>
|
||||||
</ConfigProvider>
|
</PromptStashProvider>
|
||||||
</ArgsProvider>
|
</LocalProvider>
|
||||||
</ClipboardProvider>
|
</ThemeProvider>
|
||||||
</TuiStartupProvider>
|
</SessionTabsProvider>
|
||||||
</TuiTerminalEnvironmentProvider>
|
</LocationProvider>
|
||||||
</TuiLifecycleProvider>
|
</DataProvider>
|
||||||
</TuiPathsProvider>
|
</PermissionProvider>
|
||||||
|
</ClientProvider>
|
||||||
|
</PluginRuntimeProvider>
|
||||||
|
</RouteProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
</Keymap.Provider>
|
||||||
|
</ConfigProvider>
|
||||||
|
</ArgsProvider>
|
||||||
|
</ClipboardProvider>
|
||||||
|
</TuiStartupProvider>
|
||||||
|
</TuiTerminalEnvironmentProvider>
|
||||||
|
</TuiLifecycleProvider>
|
||||||
|
</TuiPathsProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</TuiAppProvider>
|
</TuiAppProvider>
|
||||||
</EpilogueProvider>
|
</EpilogueProvider>
|
||||||
|
|
@ -419,6 +440,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
|
const sessionTabs = useSessionTabs()
|
||||||
const keymap = Keymap.use()
|
const keymap = Keymap.use()
|
||||||
const event = useEvent()
|
const event = useEvent()
|
||||||
const client = useClient()
|
const client = useClient()
|
||||||
|
|
@ -623,9 +645,55 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||||
title: `Switch to session in quick slot ${i + 1}`,
|
title: `Switch to session in quick slot ${i + 1}`,
|
||||||
category: "Session",
|
category: "Session",
|
||||||
palette: undefined,
|
palette: undefined,
|
||||||
run: () => {
|
enabled: () => !sessionTabs.enabled(),
|
||||||
local.session.quickSwitch(i + 1)
|
run: () => local.session.quickSwitch(i + 1),
|
||||||
},
|
})),
|
||||||
|
{
|
||||||
|
name: "session.tab.next",
|
||||||
|
title: "Next open session tab",
|
||||||
|
category: "Session",
|
||||||
|
palette: undefined,
|
||||||
|
enabled: sessionTabs.enabled,
|
||||||
|
run: () => sessionTabs.cycle(1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "session.tab.previous",
|
||||||
|
title: "Previous open session tab",
|
||||||
|
category: "Session",
|
||||||
|
palette: undefined,
|
||||||
|
enabled: sessionTabs.enabled,
|
||||||
|
run: () => sessionTabs.cycle(-1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "session.tab.next_unread",
|
||||||
|
title: "Next unread session tab",
|
||||||
|
category: "Session",
|
||||||
|
palette: undefined,
|
||||||
|
enabled: sessionTabs.enabled,
|
||||||
|
run: () => sessionTabs.cycleUnread(1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "session.tab.previous_unread",
|
||||||
|
title: "Previous unread session tab",
|
||||||
|
category: "Session",
|
||||||
|
palette: undefined,
|
||||||
|
enabled: sessionTabs.enabled,
|
||||||
|
run: () => sessionTabs.cycleUnread(-1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "session.tab.close",
|
||||||
|
title: "Close current session tab",
|
||||||
|
category: "Session",
|
||||||
|
enabled: sessionTabs.enabled,
|
||||||
|
run: () => sessionTabs.close(),
|
||||||
|
},
|
||||||
|
...Array.from({ length: 9 }, (_, i) => ({
|
||||||
|
name: `session.tab.select.${i + 1}`,
|
||||||
|
title: `Switch to session tab ${i + 1}`,
|
||||||
|
category: "Session",
|
||||||
|
palette: undefined,
|
||||||
|
enabled: sessionTabs.enabled,
|
||||||
|
run: () => sessionTabs.selectIndex(i),
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
name: "model.list",
|
name: "model.list",
|
||||||
|
|
@ -1004,6 +1072,18 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||||
bindings: appGlobalBindingCommands,
|
bindings: appGlobalBindingCommands,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
Keymap.createLayer(() => ({
|
||||||
|
mode: "global",
|
||||||
|
enabled: sessionTabs.enabled,
|
||||||
|
bindings: sessionTabBindingCommands,
|
||||||
|
}))
|
||||||
|
|
||||||
|
Keymap.createLayer(() => ({
|
||||||
|
mode: "global",
|
||||||
|
enabled: () => !sessionTabs.enabled(),
|
||||||
|
bindings: pinnedSessionBindingCommands,
|
||||||
|
}))
|
||||||
|
|
||||||
Keymap.createLayer(() => ({
|
Keymap.createLayer(() => ({
|
||||||
enabled: () => {
|
enabled: () => {
|
||||||
const current = promptRef.current
|
const current = promptRef.current
|
||||||
|
|
@ -1099,16 +1179,15 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||||
evt.preventDefault()
|
evt.preventDefault()
|
||||||
evt.stopPropagation()
|
evt.stopPropagation()
|
||||||
}}
|
}}
|
||||||
onMouseUp={
|
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
|
||||||
copyOnSelectEnabled()
|
|
||||||
? () => Selection.copy(renderer, toast, clipboard)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<box flexGrow={1} minHeight={0} flexDirection="row">
|
<box flexGrow={1} minHeight={0} flexDirection="row">
|
||||||
<box flexGrow={1} minWidth={0} flexDirection="column">
|
<box flexGrow={1} minWidth={0} flexDirection="column">
|
||||||
<Show when={plugins.ready()}>
|
<Show when={plugins.ready()}>
|
||||||
<box flexGrow={1} minHeight={0} flexDirection="column">
|
<box flexGrow={1} minHeight={0} flexDirection="column">
|
||||||
|
<Show when={sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"}>
|
||||||
|
<SessionTabs />
|
||||||
|
</Show>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={route.data.type === "home"}>
|
<Match when={route.data.type === "home"}>
|
||||||
<Home />
|
<Home />
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,14 @@ export const settings: Setting[] = [
|
||||||
values: ["none", "auto"],
|
values: ["none", "auto"],
|
||||||
keywords: ["transcript", "messages"],
|
keywords: ["transcript", "messages"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Tabs",
|
||||||
|
category: "Session",
|
||||||
|
path: ["session", "tabs"],
|
||||||
|
default: false,
|
||||||
|
values: [false, true],
|
||||||
|
labels: ["off", "on"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Layout",
|
title: "Layout",
|
||||||
category: "Diffs",
|
category: "Diffs",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { useToast } from "../ui/toast"
|
||||||
import { DialogSessionRename } from "./dialog-session-rename"
|
import { DialogSessionRename } from "./dialog-session-rename"
|
||||||
import { Spinner } from "./spinner"
|
import { Spinner } from "./spinner"
|
||||||
import { errorMessage } from "../util/error"
|
import { errorMessage } from "../util/error"
|
||||||
|
import { useSessionTabs } from "../context/session-tabs"
|
||||||
|
|
||||||
export function DialogSessionList() {
|
export function DialogSessionList() {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
|
@ -25,6 +26,7 @@ export function DialogSessionList() {
|
||||||
const mode = themes.mode
|
const mode = themes.mode
|
||||||
const client = useClient()
|
const client = useClient()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
|
const sessionTabs = useSessionTabs()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const [filter, setFilter] = createSignal("")
|
const [filter, setFilter] = createSignal("")
|
||||||
const shortcuts = Keymap.useShortcuts()
|
const shortcuts = Keymap.useShortcuts()
|
||||||
|
|
@ -79,6 +81,7 @@ export function DialogSessionList() {
|
||||||
})
|
})
|
||||||
|
|
||||||
const quickSwitchHint = createMemo(() => {
|
const quickSwitchHint = createMemo(() => {
|
||||||
|
if (sessionTabs.enabled()) return
|
||||||
const first = shortcuts.get("session.quick_switch.1")
|
const first = shortcuts.get("session.quick_switch.1")
|
||||||
const last = shortcuts.get("session.quick_switch.9")
|
const last = shortcuts.get("session.quick_switch.9")
|
||||||
if (!first || !last) return
|
if (!first || !last) return
|
||||||
|
|
@ -96,7 +99,7 @@ export function DialogSessionList() {
|
||||||
.filter((session) => !session.parentID)
|
.filter((session) => !session.parentID)
|
||||||
.map((session) => [session.id, session]),
|
.map((session) => [session.id, session]),
|
||||||
)
|
)
|
||||||
const pinned = local.session.pinned().filter((sessionID) => sessionMap.has(sessionID))
|
const pinned = sessionTabs.enabled() ? [] : local.session.pinned().filter((sessionID) => sessionMap.has(sessionID))
|
||||||
const pinnedSet = new Set(pinned)
|
const pinnedSet = new Set(pinned)
|
||||||
const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1]))
|
const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1]))
|
||||||
|
|
||||||
|
|
@ -104,7 +107,7 @@ export function DialogSessionList() {
|
||||||
const directory = session.location.directory
|
const directory = session.location.directory
|
||||||
const footer =
|
const footer =
|
||||||
directory !== data.location.info()?.project.directory ? Locale.truncate(path.basename(directory), 20) : ""
|
directory !== data.location.info()?.project.directory ? Locale.truncate(path.basename(directory), 20) : ""
|
||||||
const slot = slotByID.get(session.id)
|
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
|
||||||
const deleting = toDelete() === session.id
|
const deleting = toDelete() === session.id
|
||||||
return {
|
return {
|
||||||
title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : session.title,
|
title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : session.title,
|
||||||
|
|
@ -164,7 +167,8 @@ export function DialogSessionList() {
|
||||||
{
|
{
|
||||||
command: "session.pin.toggle",
|
command: "session.pin.toggle",
|
||||||
title: "pin/unpin",
|
title: "pin/unpin",
|
||||||
onTrigger: (option: { value: string }) => local.session.togglePin(option.value),
|
hidden: sessionTabs.enabled(),
|
||||||
|
onTrigger: (option) => local.session.togglePin(option.value),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
command: "session.delete",
|
command: "session.delete",
|
||||||
|
|
|
||||||
231
packages/tui/src/component/session-tabs.tsx
Normal file
231
packages/tui/src/component/session-tabs.tsx
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
import { RGBA, TextAttributes } from "@opentui/core"
|
||||||
|
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||||
|
import { useTerminalDimensions } from "@opentui/solid"
|
||||||
|
import { useConfig } from "../config"
|
||||||
|
import { useSessionTabs } from "../context/session-tabs"
|
||||||
|
import { useTheme, useThemes } from "../context/theme"
|
||||||
|
import { adaptiveSessionTabLayout, sessionTabComplete, SESSION_TAB_OVERFLOW_WIDTH } from "../context/session-tabs-model"
|
||||||
|
import { createAnimatable, spring } from "../ui/animation"
|
||||||
|
import { Locale } from "../util/locale"
|
||||||
|
import { TabPulse } from "./tab-pulse"
|
||||||
|
import { tint } from "../theme/color"
|
||||||
|
|
||||||
|
export function SessionTabs() {
|
||||||
|
const tabs = useSessionTabs()
|
||||||
|
const dimensions = useTerminalDimensions()
|
||||||
|
const theme = useTheme()
|
||||||
|
const { mode } = useThemes()
|
||||||
|
const config = useConfig().data
|
||||||
|
const [hovered, setHovered] = createSignal<string>()
|
||||||
|
const hueStep = () => (mode() === "light" ? 800 : 200)
|
||||||
|
const accent = () => theme.hue.accent[hueStep()]
|
||||||
|
const activeNumber = () => tint(theme.hue.interactive[hueStep()], theme.background.default, 0.25)
|
||||||
|
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
|
||||||
|
const activeID = createMemo(tabs.current)
|
||||||
|
const items = tabs.tabs
|
||||||
|
const layout = createMemo((previous: ReturnType<typeof adaptiveSessionTabLayout> | undefined) =>
|
||||||
|
adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start),
|
||||||
|
)
|
||||||
|
const statuses = createMemo(
|
||||||
|
() =>
|
||||||
|
new Map(
|
||||||
|
layout().tabs.map((tab) => {
|
||||||
|
const status = tabs.status(tab.sessionID)
|
||||||
|
return [
|
||||||
|
tab.sessionID,
|
||||||
|
{
|
||||||
|
...status,
|
||||||
|
complete: sessionTabComplete(status.unread, status.busy),
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const targets = createMemo(() => ({
|
||||||
|
widths: layout().widths,
|
||||||
|
selections: layout().tabs.map((tab) => Number(tab.sessionID === activeID())),
|
||||||
|
activities: layout().tabs.map((tab) => Number(statuses().get(tab.sessionID)!.complete)),
|
||||||
|
}))
|
||||||
|
const motion = createAnimatable(targets(), {
|
||||||
|
enabled: () => config.animations ?? true,
|
||||||
|
transition: spring({ visualDuration: 0.1 }),
|
||||||
|
})
|
||||||
|
const identity = createMemo(() =>
|
||||||
|
layout()
|
||||||
|
.tabs.map((tab) => tab.sessionID)
|
||||||
|
.join(":"),
|
||||||
|
)
|
||||||
|
let signature = ""
|
||||||
|
let total = 0
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const next = targets()
|
||||||
|
const nextSignature = identity()
|
||||||
|
const reset = (signature && signature !== nextSignature) || (total && total !== layout().total)
|
||||||
|
signature = nextSignature
|
||||||
|
total = layout().total
|
||||||
|
if (reset) return motion.jump(next)
|
||||||
|
motion.animate(next)
|
||||||
|
})
|
||||||
|
|
||||||
|
const visuals = createMemo(() => {
|
||||||
|
const current = signature === identity() && total === layout().total ? motion.value() : targets()
|
||||||
|
const widths = current.widths.map((width) => Math.max(1, Math.round(width)))
|
||||||
|
const active = layout().tabs.findIndex((tab) => tab.sessionID === activeID())
|
||||||
|
if (active !== -1) widths[active]! += layout().total - widths.reduce((sum, width) => sum + width, 0)
|
||||||
|
return new Map(
|
||||||
|
layout().tabs.map((tab, index) => [
|
||||||
|
tab.sessionID,
|
||||||
|
{
|
||||||
|
width: widths[index]!,
|
||||||
|
selection: current.selections[index] ?? Number(tab.sessionID === activeID()),
|
||||||
|
activity: current.activities[index] ?? Number(statuses().get(tab.sessionID)!.complete),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
height={1}
|
||||||
|
flexShrink={0}
|
||||||
|
position="relative"
|
||||||
|
flexDirection="row"
|
||||||
|
zIndex={1}
|
||||||
|
renderAfter={function (buffer) {
|
||||||
|
const x = Math.max(0, this.screenX)
|
||||||
|
const y = this.screenY + this.height
|
||||||
|
const width = Math.min(this.width, buffer.width - x)
|
||||||
|
if (y < 0 || y >= buffer.height || width <= 0) return
|
||||||
|
buffer.fillRect(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
1,
|
||||||
|
RGBA.fromValues(
|
||||||
|
theme.background.default.r,
|
||||||
|
theme.background.default.g,
|
||||||
|
theme.background.default.b,
|
||||||
|
mode() === "light" ? 0.14 : 0.28,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={layout().before > 0}>
|
||||||
|
<text width={SESSION_TAB_OVERFLOW_WIDTH} fg={theme.text.subdued}>
|
||||||
|
‹{layout().before}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
<For each={layout().tabs}>
|
||||||
|
{(tab) => {
|
||||||
|
const selected = () => activeID() === tab.sessionID
|
||||||
|
const status = () => statuses().get(tab.sessionID)!
|
||||||
|
const width = () => visuals().get(tab.sessionID)?.width ?? 1
|
||||||
|
const selection = () => visuals().get(tab.sessionID)?.selection ?? Number(selected())
|
||||||
|
const activity = () => visuals().get(tab.sessionID)?.activity ?? Number(status().complete)
|
||||||
|
const background = () => {
|
||||||
|
const base =
|
||||||
|
hovered() === tab.sessionID && !selected()
|
||||||
|
? theme.background.action.primary.hovered
|
||||||
|
: theme.background.default
|
||||||
|
return tint(base, theme.raise(theme.background.surface.offset), selection())
|
||||||
|
}
|
||||||
|
const pulseBackground = () => background()
|
||||||
|
const pulseColor = () => tint(pulseBackground(), theme.text.default, 0.45)
|
||||||
|
const title = () => tab.title ?? "Untitled session"
|
||||||
|
const availableTitleWidth = () => Math.max(1, width() - 3)
|
||||||
|
const visibleTitle = createMemo(() => Locale.takeWidth(title(), availableTitleWidth()))
|
||||||
|
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
|
||||||
|
const fadeWidth = () => (hovered() === tab.sessionID ? 6 : 4)
|
||||||
|
const fadedTitleParts = createMemo(() => visibleTitleParts().slice(-fadeWidth()))
|
||||||
|
const titleFades = createMemo(() => visibleTitle() !== title() && availableTitleWidth() > fadeWidth())
|
||||||
|
const foreground = () => {
|
||||||
|
if (hovered() === tab.sessionID) return theme.text.default
|
||||||
|
return tint(theme.text.subdued, theme.text.default, selection())
|
||||||
|
}
|
||||||
|
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(), selection())
|
||||||
|
return tint(base, accent(), activity())
|
||||||
|
}
|
||||||
|
const closeColor = () => tint(theme.text.subdued, theme.text.default, 0.6)
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
width={width()}
|
||||||
|
position="relative"
|
||||||
|
flexDirection="row"
|
||||||
|
backgroundColor={background()}
|
||||||
|
onMouseOver={() => setHovered(tab.sessionID)}
|
||||||
|
onMouseOut={() => setHovered(undefined)}
|
||||||
|
onMouseUp={() => tabs.select(tab.sessionID)}
|
||||||
|
>
|
||||||
|
<TabPulse
|
||||||
|
enabled={config.animations ?? true}
|
||||||
|
active={status().busy}
|
||||||
|
complete={status().complete}
|
||||||
|
color={pulseColor()}
|
||||||
|
completionColor={accent()}
|
||||||
|
backgroundColor={pulseBackground()}
|
||||||
|
/>
|
||||||
|
<box zIndex={1} width="100%" flexDirection="row">
|
||||||
|
<text width={1}> </text>
|
||||||
|
<text width={2} fg={numberColor()} attributes={selected() ? TextAttributes.BOLD : undefined}>
|
||||||
|
{items().findIndex((item) => item.sessionID === tab.sessionID) + 1}
|
||||||
|
</text>
|
||||||
|
<Show
|
||||||
|
when={titleFades()}
|
||||||
|
fallback={
|
||||||
|
<text width={availableTitleWidth()} fg={foreground()} wrapMode="none">
|
||||||
|
{visibleTitle()}
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<text width={availableTitleWidth()} fg={foreground()} wrapMode="none">
|
||||||
|
{visibleTitleParts().slice(0, -fadeWidth()).join("")}
|
||||||
|
<For each={fadedTitleParts()}>
|
||||||
|
{(character, index) => (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fg: tint(
|
||||||
|
foreground(),
|
||||||
|
pulseBackground(),
|
||||||
|
0.2 + 0.72 * (index() / Math.max(1, fadedTitleParts().length - 1)),
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{character}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
<text
|
||||||
|
position="absolute"
|
||||||
|
right={1}
|
||||||
|
zIndex={2}
|
||||||
|
width={1}
|
||||||
|
fg={closeColor()}
|
||||||
|
onMouseUp={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
tabs.close(tab.sessionID)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hovered() === tab.sessionID ? "×" : ""}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
<Show when={layout().after > 0}>
|
||||||
|
<text width={SESSION_TAB_OVERFLOW_WIDTH} fg={theme.text.subdued}>
|
||||||
|
{layout().after}›
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
207
packages/tui/src/component/tab-pulse.tsx
Normal file
207
packages/tui/src/component/tab-pulse.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
import { OptimizedBuffer, Renderable, RGBA, type RenderableOptions, type RenderContext } from "@opentui/core"
|
||||||
|
import { extend } from "@opentui/solid"
|
||||||
|
import { tint } from "../theme/color"
|
||||||
|
|
||||||
|
type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
|
||||||
|
enabled?: boolean
|
||||||
|
active?: boolean
|
||||||
|
complete?: boolean
|
||||||
|
color?: RGBA
|
||||||
|
completionColor?: RGBA
|
||||||
|
backgroundColor?: RGBA
|
||||||
|
}
|
||||||
|
|
||||||
|
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_HEAD = 4
|
||||||
|
const RUN_TAIL = 18
|
||||||
|
const RUN_FADE_OUT = 500
|
||||||
|
const COMPLETION_DURATION = 900
|
||||||
|
const COMPLETION_ATTACK = 0.16
|
||||||
|
const intensityAt = (index: number, front: number, head: number, tail: number) => {
|
||||||
|
const distance = front - index
|
||||||
|
return distance < 0 ? smootherstep(clamp(1 + distance / head)) : smootherstep(clamp(1 - distance / tail))
|
||||||
|
}
|
||||||
|
const coast = (value: number) => {
|
||||||
|
const ramp = 0.2
|
||||||
|
if (value < ramp) return (value * value) / (2 * ramp * (1 - ramp))
|
||||||
|
if (value > 1 - ramp) return 1 - ((1 - value) * (1 - value)) / (2 * ramp * (1 - ramp))
|
||||||
|
return (value - ramp / 2) / (1 - ramp)
|
||||||
|
}
|
||||||
|
export const completionPulseOpacity = (progress: number) =>
|
||||||
|
progress < COMPLETION_ATTACK
|
||||||
|
? smootherstep(clamp(progress / COMPLETION_ATTACK))
|
||||||
|
: 1 - smootherstep(clamp((progress - COMPLETION_ATTACK) / (1 - COMPLETION_ATTACK)))
|
||||||
|
class TabPulseRenderable extends Renderable {
|
||||||
|
private _enabled: boolean
|
||||||
|
private _active: boolean
|
||||||
|
private _complete: boolean
|
||||||
|
private _color: RGBA
|
||||||
|
private _completionColor: RGBA
|
||||||
|
private _backgroundColor: RGBA
|
||||||
|
private clock = 0
|
||||||
|
private fadeClock: number | undefined
|
||||||
|
private completionClock: number | undefined
|
||||||
|
private completionPending = false
|
||||||
|
|
||||||
|
constructor(ctx: RenderContext, options: TabPulseOptions = {}) {
|
||||||
|
const enabled = options.enabled ?? true
|
||||||
|
const active = options.active ?? false
|
||||||
|
super(ctx, { ...options, height: 1, live: enabled && active })
|
||||||
|
this._enabled = enabled
|
||||||
|
this._active = active
|
||||||
|
this._complete = options.complete ?? false
|
||||||
|
this._color = options.color ?? RGBA.defaultForeground()
|
||||||
|
this._completionColor = options.completionColor ?? this._color
|
||||||
|
this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground()
|
||||||
|
}
|
||||||
|
|
||||||
|
set enabled(value: boolean) {
|
||||||
|
if (value === this._enabled) return
|
||||||
|
this._enabled = value
|
||||||
|
if (!value) {
|
||||||
|
this.fadeClock = undefined
|
||||||
|
this.completionClock = undefined
|
||||||
|
this.completionPending = false
|
||||||
|
this.live = false
|
||||||
|
} else if (this._active) {
|
||||||
|
this.live = true
|
||||||
|
}
|
||||||
|
this.requestRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
set active(value: boolean) {
|
||||||
|
if (value === this._active) return
|
||||||
|
this._active = value
|
||||||
|
if (!this._enabled) return
|
||||||
|
if (value) {
|
||||||
|
this.fadeClock = undefined
|
||||||
|
this.completionClock = undefined
|
||||||
|
this.completionPending = false
|
||||||
|
this.live = true
|
||||||
|
} else {
|
||||||
|
this.fadeClock = 0
|
||||||
|
this.completionPending = true
|
||||||
|
this.live = true
|
||||||
|
}
|
||||||
|
this.requestRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
set complete(value: boolean) {
|
||||||
|
if (value === this._complete) return
|
||||||
|
this._complete = value
|
||||||
|
if (!value) {
|
||||||
|
this.completionClock = undefined
|
||||||
|
this.completionPending = false
|
||||||
|
}
|
||||||
|
if (value && this.completionPending) {
|
||||||
|
this.completionClock = 0
|
||||||
|
this.completionPending = false
|
||||||
|
this.live = this._enabled
|
||||||
|
}
|
||||||
|
this.requestRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
set color(value: RGBA) {
|
||||||
|
if (value.equals(this._color)) return
|
||||||
|
this._color = value
|
||||||
|
this.requestRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
set completionColor(value: RGBA) {
|
||||||
|
if (value.equals(this._completionColor)) return
|
||||||
|
this._completionColor = value
|
||||||
|
this.requestRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
set backgroundColor(value: RGBA) {
|
||||||
|
if (value.equals(this._backgroundColor)) return
|
||||||
|
this._backgroundColor = value
|
||||||
|
this.requestRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override onUpdate(deltaTime: number): void {
|
||||||
|
if (!this._enabled) return
|
||||||
|
if (this._active || this.fadeClock !== undefined) this.clock += deltaTime
|
||||||
|
if (this.fadeClock !== undefined) {
|
||||||
|
this.fadeClock += deltaTime
|
||||||
|
if (this.fadeClock >= RUN_FADE_OUT) this.fadeClock = undefined
|
||||||
|
}
|
||||||
|
if (this.completionPending) {
|
||||||
|
if (this._complete) {
|
||||||
|
this.completionClock = 0
|
||||||
|
this.completionPending = false
|
||||||
|
} else if (this.fadeClock === undefined) {
|
||||||
|
this.completionPending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.completionClock !== undefined) {
|
||||||
|
this.completionClock += deltaTime
|
||||||
|
if (this.completionClock >= COMPLETION_DURATION) this.completionClock = undefined
|
||||||
|
}
|
||||||
|
this.live = this._active || this.fadeClock !== undefined || this.completionClock !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override renderSelf(buffer: OptimizedBuffer): void {
|
||||||
|
if (!this.visible || this.isDestroyed || !this._enabled || this.width <= 0) return
|
||||||
|
const runningOpacity = this._active
|
||||||
|
? 1
|
||||||
|
: this.fadeClock === undefined
|
||||||
|
? 0
|
||||||
|
: 1 - smootherstep(clamp(this.fadeClock / RUN_FADE_OUT))
|
||||||
|
const completionOpacity =
|
||||||
|
this.completionClock === undefined ? 0 : completionPulseOpacity(this.completionClock / COMPLETION_DURATION)
|
||||||
|
if (runningOpacity === 0 && completionOpacity === 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)
|
||||||
|
for (let index = 0; index < this.width; index++) {
|
||||||
|
const intensity = Math.max(
|
||||||
|
intensityAt(index, front, RUN_HEAD, RUN_TAIL),
|
||||||
|
intensityAt(index, secondFront, RUN_HEAD, RUN_TAIL),
|
||||||
|
)
|
||||||
|
const running = tint(this._backgroundColor, this._color, intensity * 0.14 * runningOpacity)
|
||||||
|
buffer.setCell(
|
||||||
|
this.screenX + index,
|
||||||
|
this.screenY,
|
||||||
|
" ",
|
||||||
|
RGBA.defaultForeground(),
|
||||||
|
tint(running, this._completionColor, completionOpacity * 0.18),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@opentui/solid" {
|
||||||
|
interface OpenTUIComponents {
|
||||||
|
tab_pulse: typeof TabPulseRenderable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extend({ tab_pulse: TabPulseRenderable })
|
||||||
|
|
||||||
|
export function TabPulse(props: {
|
||||||
|
enabled?: boolean
|
||||||
|
active: boolean
|
||||||
|
complete?: boolean
|
||||||
|
color: RGBA
|
||||||
|
completionColor?: RGBA
|
||||||
|
backgroundColor: RGBA
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<tab_pulse
|
||||||
|
position="absolute"
|
||||||
|
zIndex={0}
|
||||||
|
width="100%"
|
||||||
|
enabled={props.enabled ?? true}
|
||||||
|
active={props.active}
|
||||||
|
complete={props.complete ?? false}
|
||||||
|
color={props.color}
|
||||||
|
completionColor={props.completionColor ?? props.color}
|
||||||
|
backgroundColor={props.backgroundColor}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -120,6 +120,9 @@ export const Info = Schema.Struct({
|
||||||
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
|
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
|
||||||
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
|
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
|
||||||
}),
|
}),
|
||||||
|
tabs: Schema.optional(Schema.Boolean).annotate({
|
||||||
|
description: "Use a persistent session tab strip instead of pinned quick-switch sessions",
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
).annotate({ description: "Session transcript presentation settings" }),
|
).annotate({ description: "Session transcript presentation settings" }),
|
||||||
mini: Schema.optional(
|
mini: Schema.optional(
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,11 @@ export const Definitions = {
|
||||||
session_move: keybind("none", "Move session"),
|
session_move: keybind("none", "Move session"),
|
||||||
session_new: keybind("<leader>n", "Create a new session"),
|
session_new: keybind("<leader>n", "Create a new session"),
|
||||||
session_list: keybind("<leader>l", "List all sessions"),
|
session_list: keybind("<leader>l", "List all sessions"),
|
||||||
|
session_tab_next: keybind("ctrl+tab,<leader>right", "Switch to next open session tab"),
|
||||||
|
session_tab_previous: keybind("ctrl+shift+tab,<leader>left", "Switch to previous open session tab"),
|
||||||
|
session_tab_next_unread: keybind("<leader>down", "Switch to next unread session tab"),
|
||||||
|
session_tab_previous_unread: keybind("<leader>up", "Switch to previous unread session tab"),
|
||||||
|
session_tab_close: keybind("<leader>w", "Close current session tab"),
|
||||||
session_timeline: keybind("<leader>g", "Show session timeline"),
|
session_timeline: keybind("<leader>g", "Show session timeline"),
|
||||||
session_fork: keybind("none", "Fork session from message"),
|
session_fork: keybind("none", "Fork session from message"),
|
||||||
session_rename: keybind("ctrl+r", "Rename session"),
|
session_rename: keybind("ctrl+r", "Rename session"),
|
||||||
|
|
@ -97,7 +102,7 @@ export const Definitions = {
|
||||||
session_background: keybind("ctrl+b", "Background blocking session tools"),
|
session_background: keybind("ctrl+b", "Background blocking session tools"),
|
||||||
session_compact: keybind("<leader>c", "Compact the session"),
|
session_compact: keybind("<leader>c", "Compact the session"),
|
||||||
session_queued_prompts: keybind("<leader>q", "View pending work"),
|
session_queued_prompts: keybind("<leader>q", "View pending work"),
|
||||||
session_child_first: keybind("down,<leader>down", "Toggle subagent picker"),
|
session_child_first: keybind("down", "Toggle subagent picker"),
|
||||||
session_child_cycle: keybind("right", "Go to next child session"),
|
session_child_cycle: keybind("right", "Go to next child session"),
|
||||||
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
|
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
|
||||||
session_parent: keybind("up", "Go to parent session"),
|
session_parent: keybind("up", "Go to parent session"),
|
||||||
|
|
@ -111,6 +116,15 @@ export const Definitions = {
|
||||||
session_quick_switch_7: keybind("<leader>7", "Switch to session in quick slot 7"),
|
session_quick_switch_7: keybind("<leader>7", "Switch to session in quick slot 7"),
|
||||||
session_quick_switch_8: keybind("<leader>8", "Switch to session in quick slot 8"),
|
session_quick_switch_8: keybind("<leader>8", "Switch to session in quick slot 8"),
|
||||||
session_quick_switch_9: keybind("<leader>9", "Switch to session in quick slot 9"),
|
session_quick_switch_9: keybind("<leader>9", "Switch to session in quick slot 9"),
|
||||||
|
session_tab_select_1: keybind("<leader>1,ctrl+1", "Switch to session tab 1"),
|
||||||
|
session_tab_select_2: keybind("<leader>2,ctrl+2", "Switch to session tab 2"),
|
||||||
|
session_tab_select_3: keybind("<leader>3,ctrl+3", "Switch to session tab 3"),
|
||||||
|
session_tab_select_4: keybind("<leader>4,ctrl+4", "Switch to session tab 4"),
|
||||||
|
session_tab_select_5: keybind("<leader>5,ctrl+5", "Switch to session tab 5"),
|
||||||
|
session_tab_select_6: keybind("<leader>6,ctrl+6", "Switch to session tab 6"),
|
||||||
|
session_tab_select_7: keybind("<leader>7,ctrl+7", "Switch to session tab 7"),
|
||||||
|
session_tab_select_8: keybind("<leader>8,ctrl+8", "Switch to session tab 8"),
|
||||||
|
session_tab_select_9: keybind("<leader>9,ctrl+9", "Switch to session tab 9"),
|
||||||
|
|
||||||
stash_delete: keybind("ctrl+d", "Delete stash entry"),
|
stash_delete: keybind("ctrl+d", "Delete stash entry"),
|
||||||
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
|
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
|
||||||
|
|
@ -289,6 +303,11 @@ export const CommandMap = {
|
||||||
session_move: "session.move",
|
session_move: "session.move",
|
||||||
session_new: "session.new",
|
session_new: "session.new",
|
||||||
session_list: "session.list",
|
session_list: "session.list",
|
||||||
|
session_tab_next: "session.tab.next",
|
||||||
|
session_tab_previous: "session.tab.previous",
|
||||||
|
session_tab_next_unread: "session.tab.next_unread",
|
||||||
|
session_tab_previous_unread: "session.tab.previous_unread",
|
||||||
|
session_tab_close: "session.tab.close",
|
||||||
session_timeline: "session.timeline",
|
session_timeline: "session.timeline",
|
||||||
session_fork: "session.fork",
|
session_fork: "session.fork",
|
||||||
session_rename: "session.rename",
|
session_rename: "session.rename",
|
||||||
|
|
@ -313,6 +332,15 @@ export const CommandMap = {
|
||||||
session_quick_switch_7: "session.quick_switch.7",
|
session_quick_switch_7: "session.quick_switch.7",
|
||||||
session_quick_switch_8: "session.quick_switch.8",
|
session_quick_switch_8: "session.quick_switch.8",
|
||||||
session_quick_switch_9: "session.quick_switch.9",
|
session_quick_switch_9: "session.quick_switch.9",
|
||||||
|
session_tab_select_1: "session.tab.select.1",
|
||||||
|
session_tab_select_2: "session.tab.select.2",
|
||||||
|
session_tab_select_3: "session.tab.select.3",
|
||||||
|
session_tab_select_4: "session.tab.select.4",
|
||||||
|
session_tab_select_5: "session.tab.select.5",
|
||||||
|
session_tab_select_6: "session.tab.select.6",
|
||||||
|
session_tab_select_7: "session.tab.select.7",
|
||||||
|
session_tab_select_8: "session.tab.select.8",
|
||||||
|
session_tab_select_9: "session.tab.select.9",
|
||||||
stash_delete: "stash.delete",
|
stash_delete: "stash.delete",
|
||||||
model_provider_list: "model.dialog.provider",
|
model_provider_list: "model.dialog.provider",
|
||||||
model_favorite_toggle: "model.dialog.favorite",
|
model_favorite_toggle: "model.dialog.favorite",
|
||||||
|
|
|
||||||
108
packages/tui/src/context/session-tabs-model.ts
Normal file
108
packages/tui/src/context/session-tabs-model.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
export type SessionTab = {
|
||||||
|
sessionID: string
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionTabUnread = "activity" | "error"
|
||||||
|
|
||||||
|
export function sessionTabComplete(unread: SessionTabUnread | undefined, busy: boolean) {
|
||||||
|
return unread === "activity" && !busy
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SESSION_TAB_WIDTH = 22
|
||||||
|
export const SESSION_TAB_MAX_WIDTH = 32
|
||||||
|
export const SESSION_TAB_MIN_WIDTH = 8
|
||||||
|
export const SESSION_TAB_OVERFLOW_WIDTH = 3
|
||||||
|
|
||||||
|
export function openSessionTab(tabs: SessionTab[], tab: SessionTab): SessionTab[] {
|
||||||
|
const index = tabs.findIndex((item) => item.sessionID === tab.sessionID)
|
||||||
|
if (index === -1) return [...tabs, tab]
|
||||||
|
if (!tab.title || tabs[index]?.title === tab.title) return tabs
|
||||||
|
return tabs.map((item, position) => (position === index ? { ...item, title: tab.title } : item))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeSessionTab(tabs: readonly SessionTab[], sessionID: string) {
|
||||||
|
const index = tabs.findIndex((tab) => tab.sessionID === sessionID)
|
||||||
|
if (index === -1) return { tabs: [...tabs], next: undefined }
|
||||||
|
return {
|
||||||
|
tabs: tabs.filter((tab) => tab.sessionID !== sessionID),
|
||||||
|
next: tabs[index + 1]?.sessionID ?? tabs[index - 1]?.sessionID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cycleSessionTab(tabs: readonly SessionTab[], active: string | undefined, direction: 1 | -1) {
|
||||||
|
if (tabs.length === 0) return
|
||||||
|
const index = tabs.findIndex((tab) => tab.sessionID === active)
|
||||||
|
const start = index === -1 ? (direction === 1 ? -1 : 0) : index
|
||||||
|
return tabs[(start + direction + tabs.length) % tabs.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adaptiveSessionTabLayout(
|
||||||
|
tabs: readonly SessionTab[],
|
||||||
|
active: string | undefined,
|
||||||
|
available: number,
|
||||||
|
previousStart = 0,
|
||||||
|
) {
|
||||||
|
if (tabs.length === 0) return { tabs: [], widths: [], before: 0, after: 0, start: 0, total: 0 }
|
||||||
|
|
||||||
|
const activeIndex = Math.max(
|
||||||
|
0,
|
||||||
|
tabs.findIndex((tab) => tab.sessionID === active),
|
||||||
|
)
|
||||||
|
const fit = (width: number) =>
|
||||||
|
Math.min(tabs.length, Math.max(1, 1 + Math.floor((Math.max(0, width) - SESSION_TAB_WIDTH) / SESSION_TAB_MIN_WIDTH)))
|
||||||
|
const solve = (count: number, start: number, attempts: number): { count: number; start: number } => {
|
||||||
|
const nextStart = Math.min(
|
||||||
|
Math.max(0, activeIndex < start ? activeIndex : activeIndex >= start + count ? activeIndex - count + 1 : start),
|
||||||
|
tabs.length - count,
|
||||||
|
)
|
||||||
|
const markers =
|
||||||
|
(nextStart > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0) +
|
||||||
|
(nextStart + count < tabs.length ? SESSION_TAB_OVERFLOW_WIDTH : 0)
|
||||||
|
const nextCount = fit(available - markers)
|
||||||
|
if (nextCount === count || attempts === 0) return { count, start: nextStart }
|
||||||
|
return solve(nextCount, nextStart, attempts - 1)
|
||||||
|
}
|
||||||
|
const solved = solve(fit(available), previousStart, 3)
|
||||||
|
const visible = tabs.slice(solved.start, solved.start + solved.count)
|
||||||
|
const before = solved.start
|
||||||
|
const after = tabs.length - solved.start - solved.count
|
||||||
|
const contentWidth = Math.max(
|
||||||
|
1,
|
||||||
|
available - (before > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0) - (after > 0 ? SESSION_TAB_OVERFLOW_WIDTH : 0),
|
||||||
|
)
|
||||||
|
const roomy = contentWidth >= SESSION_TAB_WIDTH * visible.length
|
||||||
|
const total = roomy ? Math.min(contentWidth, SESSION_TAB_MAX_WIDTH * visible.length) : contentWidth
|
||||||
|
if (roomy) {
|
||||||
|
const width = Math.floor(total / visible.length)
|
||||||
|
const remainder = total - width * visible.length
|
||||||
|
return {
|
||||||
|
tabs: visible,
|
||||||
|
widths: visible.map((_, index) => width + Number(index < remainder)),
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
start: solved.start,
|
||||||
|
total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const inactiveWidth =
|
||||||
|
visible.length === 1
|
||||||
|
? 0
|
||||||
|
: Math.min(
|
||||||
|
SESSION_TAB_WIDTH,
|
||||||
|
Math.max(
|
||||||
|
SESSION_TAB_MIN_WIDTH,
|
||||||
|
Math.floor((total - Math.min(SESSION_TAB_WIDTH, total)) / (visible.length - 1)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const activeWidth = visible.length === 1 ? total : total - inactiveWidth * (visible.length - 1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
tabs: visible,
|
||||||
|
widths: visible.map((tab) => (tab.sessionID === active ? activeWidth : inactiveWidth)),
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
start: solved.start,
|
||||||
|
total,
|
||||||
|
}
|
||||||
|
}
|
||||||
251
packages/tui/src/context/session-tabs.tsx
Normal file
251
packages/tui/src/context/session-tabs.tsx
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
import { batch, createEffect, onCleanup, untrack } from "solid-js"
|
||||||
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
|
import path from "path"
|
||||||
|
import { isDeepEqual } from "remeda"
|
||||||
|
import { createSimpleContext } from "./helper"
|
||||||
|
import { useData } from "./data"
|
||||||
|
import { useEvent } from "./event"
|
||||||
|
import { useRoute } from "./route"
|
||||||
|
import { useTuiPaths } from "./runtime"
|
||||||
|
import { useConfig } from "../config"
|
||||||
|
import { readJson, writeJsonAtomic } from "../util/persistence"
|
||||||
|
import { isRecord } from "../util/record"
|
||||||
|
import {
|
||||||
|
closeSessionTab,
|
||||||
|
cycleSessionTab,
|
||||||
|
openSessionTab,
|
||||||
|
type SessionTab,
|
||||||
|
type SessionTabUnread,
|
||||||
|
} from "./session-tabs-model"
|
||||||
|
|
||||||
|
type PersistedState = {
|
||||||
|
tabs: SessionTab[]
|
||||||
|
unread: Record<string, SessionTabUnread>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimpleContext({
|
||||||
|
name: "SessionTabs",
|
||||||
|
init: () => {
|
||||||
|
const route = useRoute()
|
||||||
|
const data = useData()
|
||||||
|
const event = useEvent()
|
||||||
|
const config = useConfig().data
|
||||||
|
const filePath = path.join(useTuiPaths().state, "session-tabs.json")
|
||||||
|
const enabled = () => config.session?.tabs ?? false
|
||||||
|
const state: {
|
||||||
|
pending: boolean
|
||||||
|
saving: boolean
|
||||||
|
snapshot: string
|
||||||
|
value?: PersistedState
|
||||||
|
} = { pending: false, saving: false, snapshot: "" }
|
||||||
|
const [store, setStore] = createStore<PersistedState & { ready: boolean }>({
|
||||||
|
ready: false,
|
||||||
|
tabs: [],
|
||||||
|
unread: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const root = (sessionID: string) => data.session.root(sessionID)
|
||||||
|
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
|
||||||
|
const status = (sessionID: string) => {
|
||||||
|
const session = root(sessionID)
|
||||||
|
const members = data.session.family(session)
|
||||||
|
const family = members.length > 0 ? members : [session]
|
||||||
|
return {
|
||||||
|
unread: store.unread[session],
|
||||||
|
attention: family.some(
|
||||||
|
(id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0,
|
||||||
|
),
|
||||||
|
busy: family.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
if (!store.ready) {
|
||||||
|
state.pending = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const value = { tabs: [...store.tabs], unread: { ...store.unread } }
|
||||||
|
const snapshot = JSON.stringify(value)
|
||||||
|
if (snapshot === state.snapshot && !state.saving) return
|
||||||
|
state.value = value
|
||||||
|
state.pending = true
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
function flush() {
|
||||||
|
if (state.saving || !state.pending || !state.value) return
|
||||||
|
const value = state.value
|
||||||
|
const snapshot = JSON.stringify(value)
|
||||||
|
state.pending = false
|
||||||
|
if (snapshot === state.snapshot) return
|
||||||
|
state.saving = true
|
||||||
|
void writeJsonAtomic(filePath, value)
|
||||||
|
.then(() => {
|
||||||
|
state.snapshot = snapshot
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
state.saving = false
|
||||||
|
flush()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(sessionID: string) {
|
||||||
|
const session = root(sessionID)
|
||||||
|
const next = openSessionTab(store.tabs, { sessionID: session, title: data.session.get(session)?.title })
|
||||||
|
if (next === store.tabs) return { sessionID: session, changed: false }
|
||||||
|
setStore("tabs", reconcile(next))
|
||||||
|
return { sessionID: session, changed: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearUnread(sessionID: string) {
|
||||||
|
const session = root(sessionID)
|
||||||
|
if (!store.unread[session]) return false
|
||||||
|
setStore(
|
||||||
|
"unread",
|
||||||
|
produce((draft) => {
|
||||||
|
delete draft[session]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function markUnread(sessionID: string, unread: SessionTabUnread) {
|
||||||
|
if (!enabled()) return
|
||||||
|
const session = root(sessionID)
|
||||||
|
if (current() === session || !store.tabs.some((tab) => tab.sessionID === session)) return
|
||||||
|
if (store.unread[session] === unread) return
|
||||||
|
setStore("unread", session, unread)
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
readJson<unknown>(filePath)
|
||||||
|
.then((value) => {
|
||||||
|
if (!isRecord(value)) return
|
||||||
|
const persisted = value
|
||||||
|
if (Array.isArray(persisted.tabs))
|
||||||
|
setStore(
|
||||||
|
"tabs",
|
||||||
|
persisted.tabs.flatMap((tab) => {
|
||||||
|
if (!isRecord(tab) || typeof tab.sessionID !== "string") return []
|
||||||
|
if ("title" in tab && tab.title !== undefined && typeof tab.title !== "string") return []
|
||||||
|
return [{ sessionID: tab.sessionID, title: typeof tab.title === "string" ? tab.title : undefined }]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (persisted.unread && typeof persisted.unread === "object")
|
||||||
|
setStore(
|
||||||
|
"unread",
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(persisted.unread).filter(
|
||||||
|
(entry): entry is [string, SessionTabUnread] => entry[1] === "activity" || entry[1] === "error",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
setStore("ready", true)
|
||||||
|
if (state.pending) save()
|
||||||
|
else state.snapshot = JSON.stringify({ tabs: store.tabs, unread: store.unread })
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!enabled()) return
|
||||||
|
if (!store.ready || route.data.type !== "session" || route.data.sessionID === "dummy") return
|
||||||
|
const routeSessionID = route.data.sessionID
|
||||||
|
batch(() => {
|
||||||
|
const opened = open(routeSessionID)
|
||||||
|
const changed = clearUnread(opened.sessionID)
|
||||||
|
if (opened.changed || changed) untrack(save)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!enabled() || !store.ready) return
|
||||||
|
const next = store.tabs.reduce<SessionTab[]>((tabs, tab) => {
|
||||||
|
const sessionID = root(tab.sessionID)
|
||||||
|
return openSessionTab(tabs, { sessionID, title: data.session.get(sessionID)?.title ?? tab.title })
|
||||||
|
}, [])
|
||||||
|
const unread = Object.entries(store.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
|
||||||
|
const sessionID = root(entry[0])
|
||||||
|
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
|
||||||
|
return result
|
||||||
|
}, {})
|
||||||
|
if (isDeepEqual(next, store.tabs) && isDeepEqual(unread, store.unread)) return
|
||||||
|
batch(() => {
|
||||||
|
setStore("tabs", reconcile(next))
|
||||||
|
setStore("unread", reconcile(unread))
|
||||||
|
})
|
||||||
|
save()
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity")))
|
||||||
|
onCleanup(event.on("session.execution.interrupted", (evt) => markUnread(evt.data.sessionID, "activity")))
|
||||||
|
onCleanup(event.on("session.execution.failed", (evt) => markUnread(evt.data.sessionID, "error")))
|
||||||
|
onCleanup(
|
||||||
|
event.on("session.error", (evt) => {
|
||||||
|
if (evt.data.sessionID) markUnread(evt.data.sessionID, "error")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
onCleanup(
|
||||||
|
event.on("session.deleted", (evt) => {
|
||||||
|
remove(evt.data.sessionID, enabled())
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function remove(sessionID: string, navigate: boolean) {
|
||||||
|
const target = root(sessionID)
|
||||||
|
const closed = closeSessionTab(store.tabs, target)
|
||||||
|
if (closed.tabs.length === store.tabs.length) return
|
||||||
|
batch(() => {
|
||||||
|
setStore("tabs", reconcile(closed.tabs))
|
||||||
|
clearUnread(target)
|
||||||
|
if (navigate && current() === target)
|
||||||
|
route.navigate(closed.next ? { type: "session", sessionID: closed.next } : { type: "home" })
|
||||||
|
})
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
tabs() {
|
||||||
|
return store.tabs
|
||||||
|
},
|
||||||
|
current,
|
||||||
|
status,
|
||||||
|
select(sessionID: string) {
|
||||||
|
if (!enabled()) return
|
||||||
|
route.navigate({ type: "session", sessionID: root(sessionID) })
|
||||||
|
},
|
||||||
|
close(sessionID?: string) {
|
||||||
|
if (!enabled()) return
|
||||||
|
const target = sessionID ? root(sessionID) : current()
|
||||||
|
if (!target) {
|
||||||
|
const previous = store.tabs.at(-1)
|
||||||
|
if (route.data.type === "home" && previous) route.navigate({ type: "session", sessionID: previous.sessionID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
remove(target, true)
|
||||||
|
},
|
||||||
|
cycle(direction: 1 | -1) {
|
||||||
|
if (!enabled()) return
|
||||||
|
const tab = cycleSessionTab(store.tabs, current(), direction)
|
||||||
|
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||||
|
},
|
||||||
|
cycleUnread(direction: 1 | -1) {
|
||||||
|
if (!enabled()) return
|
||||||
|
const tab = cycleSessionTab(
|
||||||
|
store.tabs.filter((tab) => store.unread[tab.sessionID] || status(tab.sessionID).attention),
|
||||||
|
current(),
|
||||||
|
direction,
|
||||||
|
)
|
||||||
|
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||||
|
},
|
||||||
|
selectIndex(index: number) {
|
||||||
|
if (!enabled()) return
|
||||||
|
const tab = store.tabs[index]
|
||||||
|
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -98,12 +98,6 @@ export function Composer(props: ComposerProps) {
|
||||||
{ bind: "left", title: "Previous tab", group: "Composer", run: () => switchTab(-1) },
|
{ bind: "left", title: "Previous tab", group: "Composer", run: () => switchTab(-1) },
|
||||||
{ bind: "right", title: "Next tab", group: "Composer", run: () => switchTab(1) },
|
{ bind: "right", title: "Next tab", group: "Composer", run: () => switchTab(1) },
|
||||||
{ bind: "escape", title: "Close composer", group: "Composer", run: close },
|
{ bind: "escape", title: "Close composer", group: "Composer", run: close },
|
||||||
{
|
|
||||||
bind: "<leader>down",
|
|
||||||
title: "Toggle composer",
|
|
||||||
group: "Composer",
|
|
||||||
run: close,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
|
||||||
235
packages/tui/src/ui/animation.ts
Normal file
235
packages/tui/src/ui/animation.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
|
||||||
|
|
||||||
|
type AnimatableValue = number | readonly number[]
|
||||||
|
type AnimatableTarget = Record<string, AnimatableValue>
|
||||||
|
type Ease = (progress: number) => number
|
||||||
|
|
||||||
|
type SpringTransition = {
|
||||||
|
type: "spring"
|
||||||
|
visualDuration: number
|
||||||
|
restDelta: number
|
||||||
|
restSpeed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type TweenTransition = {
|
||||||
|
type: "tween"
|
||||||
|
duration: number
|
||||||
|
ease: Ease
|
||||||
|
}
|
||||||
|
|
||||||
|
type Transition = SpringTransition | TweenTransition
|
||||||
|
|
||||||
|
type ValueState = {
|
||||||
|
scalar: boolean
|
||||||
|
value: number[]
|
||||||
|
target: number[]
|
||||||
|
velocity: number[]
|
||||||
|
from: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnimationTask = (now: number) => boolean
|
||||||
|
|
||||||
|
const tasks = new Set<AnimationTask>()
|
||||||
|
let timer: ReturnType<typeof setInterval> | undefined
|
||||||
|
|
||||||
|
const smoothstep = (progress: number) => progress * progress * (3 - 2 * progress)
|
||||||
|
|
||||||
|
export function spring(options: { visualDuration: number; restDelta?: number; restSpeed?: number }): Transition {
|
||||||
|
return {
|
||||||
|
type: "spring",
|
||||||
|
visualDuration: options.visualDuration,
|
||||||
|
restDelta: options.restDelta ?? 0.002,
|
||||||
|
restSpeed: options.restSpeed ?? 0.002,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tween(options: { duration: number; ease?: Ease }): Transition {
|
||||||
|
return {
|
||||||
|
type: "tween",
|
||||||
|
duration: options.duration,
|
||||||
|
ease: options.ease ?? smoothstep,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAnimatable<T extends AnimatableTarget>(
|
||||||
|
initial: T,
|
||||||
|
options: {
|
||||||
|
transition: Transition
|
||||||
|
enabled?: Accessor<boolean>
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const enabled = options.enabled ?? (() => true)
|
||||||
|
const state = new Map<string, ValueState>()
|
||||||
|
const [value, setValue] = createSignal(clone(initial), { equals: false })
|
||||||
|
let target = clone(initial)
|
||||||
|
let started = performance.now()
|
||||||
|
let previous = started
|
||||||
|
|
||||||
|
rebuild(initial)
|
||||||
|
|
||||||
|
const step: AnimationTask = (now) => {
|
||||||
|
if (!enabled()) {
|
||||||
|
jump(target)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = Math.min(0.05, (now - previous) / 1_000)
|
||||||
|
previous = now
|
||||||
|
const moving = options.transition.type === "spring" ? advanceSpring(delta) : advanceTween(now)
|
||||||
|
if (!moving) {
|
||||||
|
jump(target)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
setValue(() => read())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function animate(next: T) {
|
||||||
|
if (sameTarget(next)) return
|
||||||
|
target = clone(next)
|
||||||
|
if (!enabled() || !sameShape(next)) return jump(next)
|
||||||
|
|
||||||
|
started = performance.now()
|
||||||
|
previous = started
|
||||||
|
for (const [key, current] of state) {
|
||||||
|
current.from = [...current.value]
|
||||||
|
current.target = values(next[key]!)
|
||||||
|
}
|
||||||
|
if (settled()) return jump(next)
|
||||||
|
schedule(step)
|
||||||
|
}
|
||||||
|
|
||||||
|
function jump(next: T) {
|
||||||
|
target = clone(next)
|
||||||
|
unschedule(step)
|
||||||
|
rebuild(next)
|
||||||
|
setValue(() => clone(next))
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
unschedule(step)
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuild(next: T) {
|
||||||
|
state.clear()
|
||||||
|
for (const [key, nextValue] of Object.entries(next)) {
|
||||||
|
const value = values(nextValue)
|
||||||
|
state.set(key, {
|
||||||
|
scalar: typeof nextValue === "number",
|
||||||
|
value,
|
||||||
|
target: [...value],
|
||||||
|
velocity: value.map(() => 0),
|
||||||
|
from: [...value],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
started = performance.now()
|
||||||
|
previous = started
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameShape(next: T) {
|
||||||
|
const entries = Object.entries(next)
|
||||||
|
if (entries.length !== state.size) return false
|
||||||
|
return entries.every(([key, nextValue]) => {
|
||||||
|
const current = state.get(key)
|
||||||
|
if (!current || current.scalar !== (typeof nextValue === "number")) return false
|
||||||
|
return current.value.length === values(nextValue).length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameTarget(next: T) {
|
||||||
|
if (!sameShape(next)) return false
|
||||||
|
return Object.entries(next).every(([key, value]) =>
|
||||||
|
values(value).every((part, index) => part === state.get(key)!.target[index]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function settled() {
|
||||||
|
return [...state.values()].every(
|
||||||
|
(current) =>
|
||||||
|
current.value.every((value, index) => value === current.target[index]) &&
|
||||||
|
current.velocity.every((velocity) => velocity === 0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceSpring(delta: number) {
|
||||||
|
const transition = options.transition
|
||||||
|
if (transition.type !== "spring") return false
|
||||||
|
const frequency = (2 * Math.PI) / (Math.max(0.001, transition.visualDuration) * 1.2)
|
||||||
|
let moving = false
|
||||||
|
|
||||||
|
for (const current of state.values()) {
|
||||||
|
current.value.forEach((value, index) => {
|
||||||
|
const target = current.target[index]!
|
||||||
|
const velocity = current.velocity[index]!
|
||||||
|
const offset = value - target
|
||||||
|
const decay = Math.exp(-frequency * delta)
|
||||||
|
const nextVelocity = velocity + frequency * offset
|
||||||
|
current.value[index] = target + (offset + nextVelocity * delta) * decay
|
||||||
|
current.velocity[index] = (velocity - frequency * nextVelocity * delta) * decay
|
||||||
|
moving ||=
|
||||||
|
Math.abs(current.value[index]! - target) > transition.restDelta ||
|
||||||
|
Math.abs(current.velocity[index]!) > transition.restSpeed
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return moving
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceTween(now: number) {
|
||||||
|
const transition = options.transition
|
||||||
|
if (transition.type !== "tween") return false
|
||||||
|
const progress = Math.min(1, (now - started) / 1_000 / Math.max(0.001, transition.duration))
|
||||||
|
const eased = transition.ease(progress)
|
||||||
|
for (const current of state.values())
|
||||||
|
current.value.forEach((_, index) => {
|
||||||
|
const from = current.from[index]!
|
||||||
|
current.value[index] = from + (current.target[index]! - from) * eased
|
||||||
|
current.velocity[index] = 0
|
||||||
|
})
|
||||||
|
return progress < 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function read() {
|
||||||
|
return Object.fromEntries(
|
||||||
|
[...state].map(([key, current]) => [key, current.scalar ? current.value[0]! : [...current.value]]),
|
||||||
|
) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (enabled()) return
|
||||||
|
jump(target)
|
||||||
|
})
|
||||||
|
onCleanup(stop)
|
||||||
|
|
||||||
|
return { value, animate, jump }
|
||||||
|
}
|
||||||
|
|
||||||
|
function values(value: AnimatableValue) {
|
||||||
|
return typeof value === "number" ? [value] : [...value]
|
||||||
|
}
|
||||||
|
|
||||||
|
function clone<T extends AnimatableTarget>(target: T) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(target).map(([key, value]) => [key, typeof value === "number" ? value : [...value]]),
|
||||||
|
) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule(task: AnimationTask) {
|
||||||
|
tasks.add(task)
|
||||||
|
if (timer) return
|
||||||
|
timer = setInterval(tick, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
function unschedule(task: AnimationTask) {
|
||||||
|
tasks.delete(task)
|
||||||
|
if (tasks.size > 0 || !timer) return
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
const now = performance.now()
|
||||||
|
for (const task of tasks) if (!task(now)) tasks.delete(task)
|
||||||
|
if (tasks.size > 0 || !timer) return
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = undefined
|
||||||
|
}
|
||||||
|
|
@ -67,20 +67,30 @@ export function truncate(str: string, len: number): string {
|
||||||
|
|
||||||
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
||||||
|
|
||||||
|
export function graphemes(str: string) {
|
||||||
|
return Array.from(graphemeSegmenter.segment(str), (item) => item.segment)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function takeWidth(str: string, width: number) {
|
||||||
|
if (width <= 0) return ""
|
||||||
|
if (stringWidth(str) <= width) return str
|
||||||
|
|
||||||
|
const result: string[] = []
|
||||||
|
let used = 0
|
||||||
|
for (const segment of graphemes(str)) {
|
||||||
|
const next = stringWidth(segment)
|
||||||
|
if (used + next > width) break
|
||||||
|
result.push(segment)
|
||||||
|
used += next
|
||||||
|
}
|
||||||
|
return result.join("")
|
||||||
|
}
|
||||||
|
|
||||||
export function truncateWidth(str: string, width: number): string {
|
export function truncateWidth(str: string, width: number): string {
|
||||||
if (width <= 0) return ""
|
if (width <= 0) return ""
|
||||||
if (stringWidth(str) <= width) return str
|
if (stringWidth(str) <= width) return str
|
||||||
if (width === 1) return "…"
|
if (width === 1) return "…"
|
||||||
|
return takeWidth(str, width - 1) + "…"
|
||||||
const result: string[] = []
|
|
||||||
let used = 0
|
|
||||||
for (const item of graphemeSegmenter.segment(str)) {
|
|
||||||
const next = stringWidth(item.segment)
|
|
||||||
if (used + next > width - 1) break
|
|
||||||
result.push(item.segment)
|
|
||||||
used += next
|
|
||||||
}
|
|
||||||
return result.join("") + "…"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function truncateLeft(str: string, len: number): string {
|
export function truncateLeft(str: string, len: number): string {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js"
|
import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js"
|
||||||
|
import { createAnimatable, tween } from "../ui/animation"
|
||||||
|
|
||||||
export function createDebouncedSignal<T>(value: T, ms: number): [Accessor<T>, (value: T) => void] {
|
export function createDebouncedSignal<T>(value: T, ms: number): [Accessor<T>, (value: T) => void] {
|
||||||
const [get, set] = createSignal(value)
|
const [get, set] = createSignal(value)
|
||||||
|
|
@ -17,35 +18,32 @@ export function createDebouncedSignal<T>(value: T, ms: number): [Accessor<T>, (v
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFadeIn(show: Accessor<boolean>, enabled: Accessor<boolean>) {
|
export function createFadeIn(show: Accessor<boolean>, enabled: Accessor<boolean>) {
|
||||||
const [alpha, setAlpha] = createSignal(show() ? 1 : 0)
|
const alpha = createAnimatable(
|
||||||
|
{ value: show() ? 1 : 0 },
|
||||||
|
{
|
||||||
|
enabled,
|
||||||
|
transition: tween({ duration: 0.16 }),
|
||||||
|
},
|
||||||
|
)
|
||||||
let revealed = show()
|
let revealed = show()
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on([show, enabled], ([visible, animate]) => {
|
on([show, enabled], ([visible, animate]) => {
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
setAlpha(0)
|
alpha.jump({ value: 0 })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!animate || revealed) {
|
if (!animate || revealed) {
|
||||||
revealed = true
|
revealed = true
|
||||||
setAlpha(1)
|
alpha.jump({ value: 1 })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = performance.now()
|
|
||||||
revealed = true
|
revealed = true
|
||||||
setAlpha(0)
|
alpha.animate({ value: 1 })
|
||||||
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
const progress = Math.min((performance.now() - start) / 160, 1)
|
|
||||||
setAlpha(progress * progress * (3 - 2 * progress))
|
|
||||||
if (progress >= 1) clearInterval(timer)
|
|
||||||
}, 16)
|
|
||||||
|
|
||||||
onCleanup(() => clearInterval(timer))
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
return alpha
|
return () => alpha.value().value
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
packages/tui/test/component/tab-pulse.test.ts
Normal file
10
packages/tui/test/component/tab-pulse.test.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { completionPulseOpacity } from "../../src/component/tab-pulse"
|
||||||
|
|
||||||
|
test("completion pulse rises quickly and fades over the remaining duration", () => {
|
||||||
|
expect(completionPulseOpacity(0)).toBe(0)
|
||||||
|
expect(completionPulseOpacity(0.08)).toBeCloseTo(0.5)
|
||||||
|
expect(completionPulseOpacity(0.16)).toBe(1)
|
||||||
|
expect(completionPulseOpacity(0.58)).toBeCloseTo(0.5)
|
||||||
|
expect(completionPulseOpacity(1)).toBe(0)
|
||||||
|
})
|
||||||
|
|
@ -2,13 +2,7 @@
|
||||||
import { testRender } from "@opentui/solid"
|
import { testRender } from "@opentui/solid"
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import {
|
import { resolve, ConfigProvider, Info, useConfig, type Interface } from "../src/config"
|
||||||
resolve,
|
|
||||||
ConfigProvider,
|
|
||||||
Info,
|
|
||||||
useConfig,
|
|
||||||
type Interface,
|
|
||||||
} from "../src/config"
|
|
||||||
|
|
||||||
test("validates mini replay settings", () => {
|
test("validates mini replay settings", () => {
|
||||||
const decode = Schema.decodeUnknownSync(Info)
|
const decode = Schema.decodeUnknownSync(Info)
|
||||||
|
|
@ -20,6 +14,13 @@ test("validates mini replay settings", () => {
|
||||||
expect(() => decode({ mini: { replay_limit: 1.5 } })).toThrow()
|
expect(() => decode({ mini: { replay_limit: 1.5 } })).toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("validates the session tabs setting", () => {
|
||||||
|
const decode = Schema.decodeUnknownSync(Info)
|
||||||
|
|
||||||
|
expect(decode({ session: { tabs: true } })).toEqual({ session: { tabs: true } })
|
||||||
|
expect(() => decode({ session: { tabs: "on" } })).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
test("resolves nested config and keybind defaults", () => {
|
test("resolves nested config and keybind defaults", () => {
|
||||||
const config = resolve(
|
const config = resolve(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,24 @@ test("resolves message navigation defaults", () => {
|
||||||
test("opens the subagent picker with down", () => {
|
test("opens the subagent picker with down", () => {
|
||||||
const config = resolve({}, { terminalSuspend: true })
|
const config = resolve({}, { terminalSuspend: true })
|
||||||
|
|
||||||
expect(config.keybinds.get("session.child.first")).toMatchObject([{ key: "down,<leader>down" }])
|
expect(config.keybinds.get("session.child.first")).toMatchObject([{ key: "down" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("navigates session tabs with leader arrows", () => {
|
||||||
|
const config = resolve({}, { terminalSuspend: true })
|
||||||
|
|
||||||
|
expect(config.keybinds.get("session.tab.next")).toMatchObject([{ key: "ctrl+tab,<leader>right" }])
|
||||||
|
expect(config.keybinds.get("session.tab.previous")).toMatchObject([{ key: "ctrl+shift+tab,<leader>left" }])
|
||||||
|
expect(config.keybinds.get("session.tab.next_unread")).toMatchObject([{ key: "<leader>down" }])
|
||||||
|
expect(config.keybinds.get("session.tab.previous_unread")).toMatchObject([{ key: "<leader>up" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves pinned session bindings alongside tab bindings", () => {
|
||||||
|
const config = resolve({}, { terminalSuspend: true })
|
||||||
|
|
||||||
|
expect(config.keybinds.get("session.pin.toggle")).toMatchObject([{ key: "ctrl+f" }])
|
||||||
|
expect(config.keybinds.get("session.quick_switch.1")).toMatchObject([{ key: "<leader>1" }])
|
||||||
|
expect(config.keybinds.get("session.tab.select.1")).toMatchObject([{ key: "<leader>1,ctrl+1" }])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("disables suspend and assigns ctrl+z to undo when unsupported", () => {
|
test("disables suspend and assigns ctrl+z to undo when unsupported", () => {
|
||||||
|
|
|
||||||
104
packages/tui/test/context/session-tabs-model.test.ts
Normal file
104
packages/tui/test/context/session-tabs-model.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import {
|
||||||
|
adaptiveSessionTabLayout,
|
||||||
|
closeSessionTab,
|
||||||
|
cycleSessionTab,
|
||||||
|
openSessionTab,
|
||||||
|
sessionTabComplete,
|
||||||
|
} from "../../src/context/session-tabs-model"
|
||||||
|
|
||||||
|
describe("session tabs", () => {
|
||||||
|
test("opens each session once and refreshes its title", () => {
|
||||||
|
const tabs = openSessionTab([{ sessionID: "a", title: "Old" }], { sessionID: "a", title: "New" })
|
||||||
|
expect(tabs).toEqual([{ sessionID: "a", title: "New" }])
|
||||||
|
expect(openSessionTab(tabs, { sessionID: "b" })).toEqual([{ sessionID: "a", title: "New" }, { sessionID: "b" }])
|
||||||
|
expect(openSessionTab(tabs, { sessionID: "a", title: "New" })).toBe(tabs)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("selects the right tab then the left tab after closing", () => {
|
||||||
|
expect(closeSessionTab([{ sessionID: "a" }, { sessionID: "b" }, { sessionID: "c" }], "b")).toEqual({
|
||||||
|
tabs: [{ sessionID: "a" }, { sessionID: "c" }],
|
||||||
|
next: "c",
|
||||||
|
})
|
||||||
|
expect(closeSessionTab([{ sessionID: "a" }, { sessionID: "b" }], "b").next).toBe("a")
|
||||||
|
expect(closeSessionTab([{ sessionID: "a" }], "a").next).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cycles through a filtered tab set in either direction", () => {
|
||||||
|
const tabs = ["a", "c", "e"].map((sessionID) => ({ sessionID }))
|
||||||
|
expect(cycleSessionTab(tabs, "c", 1)?.sessionID).toBe("e")
|
||||||
|
expect(cycleSessionTab(tabs, "c", -1)?.sessionID).toBe("a")
|
||||||
|
expect(cycleSessionTab(tabs, "e", 1)?.sessionID).toBe("a")
|
||||||
|
expect(cycleSessionTab(tabs, "b", 1)?.sessionID).toBe("a")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reveals completion activity only after session work becomes idle", () => {
|
||||||
|
expect(sessionTabComplete("activity", true)).toBe(false)
|
||||||
|
expect(sessionTabComplete("activity", false)).toBe(true)
|
||||||
|
expect(sessionTabComplete("error", false)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("expands the active tab and keeps inactive widths equal", () => {
|
||||||
|
const tabs = ["a", "b", "c", "d", "e", "f", "g"].map((sessionID) => ({ sessionID }))
|
||||||
|
const layout = adaptiveSessionTabLayout(tabs, "d", 76)
|
||||||
|
|
||||||
|
expect(layout).toMatchObject({ before: 0, after: 0, start: 0, total: 76 })
|
||||||
|
expect(layout.widths).toEqual([9, 9, 9, 22, 9, 9, 9])
|
||||||
|
expect(layout.widths.reduce((total, width) => total + width, 0)).toBe(76)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("only swaps old and new active width inside a sticky window", () => {
|
||||||
|
const tabs = ["a", "b", "c", "d", "e", "f", "g"].map((sessionID) => ({ sessionID }))
|
||||||
|
const before = adaptiveSessionTabLayout(tabs, "c", 76)
|
||||||
|
const after = adaptiveSessionTabLayout(tabs, "d", 76, before.start)
|
||||||
|
|
||||||
|
expect(before.start).toBe(after.start)
|
||||||
|
expect(before.widths).toEqual([9, 9, 22, 9, 9, 9, 9])
|
||||||
|
expect(after.widths).toEqual([9, 9, 9, 22, 9, 9, 9])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("shares roomy width equally without changing widths on selection", () => {
|
||||||
|
const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
|
||||||
|
const before = adaptiveSessionTabLayout(tabs, "a", 100)
|
||||||
|
const after = adaptiveSessionTabLayout(tabs, "c", 100, before.start)
|
||||||
|
|
||||||
|
expect(before.widths).toEqual([32, 32, 32])
|
||||||
|
expect(after.widths).toEqual(before.widths)
|
||||||
|
expect(before.total).toBe(96)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("caps a single tab instead of stretching it across the terminal", () => {
|
||||||
|
const layout = adaptiveSessionTabLayout([{ sessionID: "a" }], "a", 100)
|
||||||
|
|
||||||
|
expect(layout.widths).toEqual([32])
|
||||||
|
expect(layout.total).toBe(32)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("fills roomy space equally below the maximum width", () => {
|
||||||
|
const tabs = ["a", "b", "c", "d"].map((sessionID) => ({ sessionID }))
|
||||||
|
|
||||||
|
expect(adaptiveSessionTabLayout(tabs, "b", 100).widths).toEqual([25, 25, 25, 25])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("expands only the active tab under compact pressure", () => {
|
||||||
|
const tabs = ["a", "b", "c", "d", "e"].map((sessionID) => ({ sessionID }))
|
||||||
|
const before = adaptiveSessionTabLayout(tabs, "c", 100)
|
||||||
|
const after = adaptiveSessionTabLayout(tabs, "d", 100, before.start)
|
||||||
|
|
||||||
|
expect(before.widths).toEqual([19, 19, 24, 19, 19])
|
||||||
|
expect(after.widths).toEqual([19, 19, 19, 24, 19])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("moves the window only after selection crosses its edge", () => {
|
||||||
|
const tabs = Array.from({ length: 10 }, (_, index) => ({ sessionID: String(index) }))
|
||||||
|
const initial = adaptiveSessionTabLayout(tabs, "3", 70)
|
||||||
|
const inside = adaptiveSessionTabLayout(tabs, "4", 70, initial.start)
|
||||||
|
const crossed = adaptiveSessionTabLayout(tabs, "7", 70, inside.start)
|
||||||
|
|
||||||
|
expect(initial.start).toBe(0)
|
||||||
|
expect(inside.start).toBe(0)
|
||||||
|
expect(crossed.start).toBeGreaterThan(0)
|
||||||
|
expect(crossed.tabs.some((tab) => tab.sessionID === "7")).toBe(true)
|
||||||
|
expect(crossed.widths.reduce((total, width) => total + width, 0)).toBe(crossed.total)
|
||||||
|
})
|
||||||
|
})
|
||||||
45
packages/tui/test/ui/animation.test.ts
Normal file
45
packages/tui/test/ui/animation.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { createRoot } from "solid-js"
|
||||||
|
import { createAnimatable, spring, tween } from "../../src/ui/animation"
|
||||||
|
|
||||||
|
test("animates numeric objects and arrays to their targets", async () => {
|
||||||
|
let dispose = () => {}
|
||||||
|
const visual = createRoot((nextDispose) => {
|
||||||
|
dispose = nextDispose
|
||||||
|
return createAnimatable(
|
||||||
|
{ widths: [8, 8], selection: 0 },
|
||||||
|
{ transition: tween({ duration: 0.02, ease: (progress) => progress }) },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
visual.animate({ widths: [12, 4], selection: 1 })
|
||||||
|
await Bun.sleep(80)
|
||||||
|
expect(visual.value()).toEqual({ widths: [12, 4], selection: 1 })
|
||||||
|
} finally {
|
||||||
|
dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("retains spring state while retargeting and supports immediate jumps", async () => {
|
||||||
|
let dispose = () => {}
|
||||||
|
const visual = createRoot((nextDispose) => {
|
||||||
|
dispose = nextDispose
|
||||||
|
return createAnimatable({ value: 0 }, { transition: spring({ visualDuration: 0.02 }) })
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
visual.animate({ value: 1 })
|
||||||
|
await Bun.sleep(20)
|
||||||
|
const target = visual.value().value
|
||||||
|
visual.animate({ value: target })
|
||||||
|
await Bun.sleep(20)
|
||||||
|
expect(visual.value().value).not.toBe(target)
|
||||||
|
await Bun.sleep(80)
|
||||||
|
expect(visual.value().value).toBeCloseTo(target)
|
||||||
|
visual.jump({ value: 0 })
|
||||||
|
expect(visual.value().value).toBe(0)
|
||||||
|
} finally {
|
||||||
|
dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -7,3 +7,8 @@ test("truncates text from the right by terminal width", () => {
|
||||||
expect(Locale.truncateWidth("abcdefgh", 1)).toBe("…")
|
expect(Locale.truncateWidth("abcdefgh", 1)).toBe("…")
|
||||||
expect(Locale.truncateWidth("abcdefgh", 0)).toBe("")
|
expect(Locale.truncateWidth("abcdefgh", 0)).toBe("")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("takes whole graphemes within terminal width", () => {
|
||||||
|
expect(Locale.takeWidth("ab界cd", 4)).toBe("ab界")
|
||||||
|
expect(Locale.graphemes("a👨👩👧👦b")).toEqual(["a", "👨👩👧👦", "b"])
|
||||||
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue