feat(tui): add inbox tab layout
This commit is contained in:
parent
85ea15e56d
commit
a01d24e908
15 changed files with 693 additions and 27 deletions
|
|
@ -68,6 +68,7 @@ import { DialogAgent } from "./component/dialog-agent"
|
|||
import { DialogSessionList } from "./component/dialog-session-list"
|
||||
import { DialogOpen } from "./component/dialog-open"
|
||||
import { SessionTabs } from "./component/session-tabs"
|
||||
import { SessionInbox } from "./component/session-inbox"
|
||||
import { ThemeErrorToast } from "./component/theme-error-toast"
|
||||
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
|
||||
import { Home } from "./routes/home"
|
||||
|
|
@ -519,6 +520,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
|
||||
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
|
||||
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
|
||||
const inboxTabsEnabled = () => config.data.tabs?.layout === "inbox" && dimensions().width >= 72
|
||||
|
||||
createEffect(() => {
|
||||
renderer.useMouse = config.data.mouse
|
||||
|
|
@ -645,6 +647,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||
category: "Session",
|
||||
slash: { name: "new", aliases: ["clear"] },
|
||||
run: () => {
|
||||
sessionTabs.navigation.blur()
|
||||
route.navigate({
|
||||
type: "home",
|
||||
location:
|
||||
|
|
@ -653,6 +656,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||
: undefined,
|
||||
})
|
||||
dialog.clear()
|
||||
setTimeout(() => promptRef.current?.focus(), 0)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -672,13 +676,35 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||
enabled: () => !sessionTabs.enabled(),
|
||||
run: () => local.session.quickSwitch(i + 1),
|
||||
})),
|
||||
{
|
||||
name: "session.tabs.toggle_layout",
|
||||
title: !sessionTabs.enabled()
|
||||
? "Enable inbox tabs"
|
||||
: config.data.tabs?.layout === "inbox"
|
||||
? "Move tabs to top"
|
||||
: "Move tabs to inbox",
|
||||
category: "Session",
|
||||
slash: { name: "tabs", aliases: ["tab-layout"] },
|
||||
run: () => {
|
||||
void config
|
||||
.update((draft) => {
|
||||
draft.tabs = {
|
||||
...draft.tabs,
|
||||
enabled: true,
|
||||
layout: sessionTabs.enabled() && config.data.tabs?.layout === "inbox" ? "top" : "inbox",
|
||||
}
|
||||
})
|
||||
.catch(toast.error)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "session.tab.next",
|
||||
title: "Next tab",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.cycle(1),
|
||||
run: () => sessionTabs.cycle(1, inboxTabsEnabled() ? "recent" : "tabs"),
|
||||
},
|
||||
{
|
||||
name: "session.tab.previous",
|
||||
|
|
@ -686,7 +712,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.cycle(-1),
|
||||
run: () => sessionTabs.cycle(-1, inboxTabsEnabled() ? "recent" : "tabs"),
|
||||
},
|
||||
{
|
||||
name: "session.tab.next_unread",
|
||||
|
|
@ -694,7 +720,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.cycleUnread(1),
|
||||
run: () => sessionTabs.cycleUnread(1, inboxTabsEnabled() ? "recent" : "tabs"),
|
||||
},
|
||||
{
|
||||
name: "session.tab.previous_unread",
|
||||
|
|
@ -702,7 +728,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.cycleUnread(-1),
|
||||
run: () => sessionTabs.cycleUnread(-1, inboxTabsEnabled() ? "recent" : "tabs"),
|
||||
},
|
||||
{
|
||||
name: "session.tab.close",
|
||||
|
|
@ -724,7 +750,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.selectIndex(i),
|
||||
run: () => sessionTabs.selectIndex(i, inboxTabsEnabled() ? "recent" : "tabs"),
|
||||
})),
|
||||
{
|
||||
name: "model.list",
|
||||
|
|
@ -1214,12 +1240,23 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
|
||||
>
|
||||
<box flexGrow={1} minHeight={0} flexDirection="row">
|
||||
<Show
|
||||
when={
|
||||
sessionTabs.enabled() &&
|
||||
inboxTabsEnabled() &&
|
||||
(sessionTabs.tabs().length > 0 || sessionTabs.newTab()) &&
|
||||
route.data.type !== "plugin"
|
||||
}
|
||||
>
|
||||
<SessionInbox />
|
||||
</Show>
|
||||
<box flexGrow={1} minWidth={0} flexDirection="column">
|
||||
<Show when={plugins.ready()}>
|
||||
<box flexGrow={1} minHeight={0} flexDirection="column">
|
||||
<Show
|
||||
when={
|
||||
sessionTabs.enabled() &&
|
||||
!inboxTabsEnabled() &&
|
||||
(sessionTabs.tabs().length > 0 || sessionTabs.newTab()) &&
|
||||
route.data.type !== "plugin"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ import { useLocation } from "../../context/location"
|
|||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { PluginSlot } from "../../plugin/render"
|
||||
import { useSessionTabs } from "../../context/session-tabs"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
|
|
@ -159,6 +160,7 @@ export function Prompt(props: PromptProps) {
|
|||
const editor = useEditorContext()
|
||||
const route = useRoute()
|
||||
const data = useData()
|
||||
const sessionTabs = useSessionTabs()
|
||||
const keymapCommands = Keymap.useCommands()
|
||||
const currentLocation = useLocation()
|
||||
const config = useConfig().data
|
||||
|
|
@ -589,7 +591,7 @@ export function Prompt(props: PromptProps) {
|
|||
|
||||
createEffect(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
if (props.visible === false || props.disabled || dialog.stack.length > 0) {
|
||||
if (props.visible === false || props.disabled || dialog.stack.length > 0 || sessionTabs.navigation.active()) {
|
||||
if (input.focused) input.blur()
|
||||
return
|
||||
}
|
||||
|
|
@ -810,6 +812,35 @@ export function Prompt(props: PromptProps) {
|
|||
}
|
||||
})
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
priority: 2,
|
||||
target: inputTarget,
|
||||
enabled:
|
||||
inputTarget() !== undefined &&
|
||||
!props.disabled &&
|
||||
store.mode === "normal" &&
|
||||
!auto()?.visible &&
|
||||
config.tabs?.layout === "inbox" &&
|
||||
dimensions().width >= 72 &&
|
||||
sessionTabs.enabled() &&
|
||||
sessionTabs.tabs().length > 0 &&
|
||||
store.prompt.text === "" &&
|
||||
store.prompt.pasted.length === 0 &&
|
||||
(store.prompt.files?.length ?? 0) === 0 &&
|
||||
(store.prompt.agents?.length ?? 0) === 0,
|
||||
commands: [
|
||||
{
|
||||
bind: "left",
|
||||
title: "Focus session inbox",
|
||||
group: "Session",
|
||||
run: () => {
|
||||
if (!sessionTabs.navigation.focus()) return false
|
||||
input.blur()
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => {
|
||||
return {
|
||||
target: inputTarget,
|
||||
|
|
@ -1455,6 +1486,7 @@ export function Prompt(props: PromptProps) {
|
|||
}}
|
||||
onMouseDown={(r: MouseEvent) => {
|
||||
if (props.disabled || r.button !== 0) return
|
||||
sessionTabs.navigation.blur()
|
||||
r.target?.focus()
|
||||
const extmark = input.extmarks
|
||||
.getAtOffset(input.cursorOffset)
|
||||
|
|
|
|||
322
packages/tui/src/component/session-inbox.tsx
Normal file
322
packages/tui/src/component/session-inbox.tsx
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import type { SessionMessageAssistant } from "@opencode-ai/client"
|
||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { useData } from "../context/data"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { usePromptRef } from "../context/prompt"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { sessionInboxGroup, type SessionInboxGroup } from "../context/session-tabs-model"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import { tint } from "../theme/color"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { activityVerb } from "../util/activity-verb"
|
||||
import { Spinner } from "./spinner"
|
||||
import type { SessionTabsStatus } from "./session-tabs"
|
||||
|
||||
const labels: Record<SessionInboxGroup, string> = {
|
||||
running: "Active",
|
||||
today: "Today",
|
||||
yesterday: "Yesterday",
|
||||
earlier: "Earlier",
|
||||
}
|
||||
|
||||
export type SessionInboxRowInfo = {
|
||||
sessionID: string
|
||||
title: string
|
||||
updated: number
|
||||
preview: string
|
||||
status: SessionTabsStatus
|
||||
group: SessionInboxGroup
|
||||
}
|
||||
|
||||
export function SessionInboxRow(props: {
|
||||
row: SessionInboxRowInfo
|
||||
selected?: boolean
|
||||
focused?: boolean
|
||||
pendingDone?: boolean
|
||||
number?: number
|
||||
verb?: string
|
||||
onSelect?: () => void
|
||||
}) {
|
||||
const theme = useTheme("elevated")
|
||||
const themes = useThemes()
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
const hueStep = () => (themes.mode() === "light" ? 800 : 200)
|
||||
const accent = () => theme.hue.accent[hueStep()]
|
||||
const background = () => {
|
||||
if (props.focused) return theme.background.action.primary.hovered
|
||||
if (props.selected) return theme.background.surface.offset
|
||||
if (hovered()) return theme.background.action.primary.hovered
|
||||
return theme.background.default
|
||||
}
|
||||
const feedback = () => {
|
||||
if (props.row.status.attention) return theme.text.feedback.warning.default
|
||||
if (props.row.status.unread === "error") return theme.text.feedback.error.default
|
||||
if (props.row.status.unread) return accent()
|
||||
return theme.text.subdued
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
height={4}
|
||||
id={`session-inbox-${props.row.sessionID}`}
|
||||
flexShrink={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
paddingTop={1}
|
||||
backgroundColor={background()}
|
||||
border={["left"]}
|
||||
borderColor={props.selected || props.focused ? accent() : background()}
|
||||
onMouseOver={() => setHovered(true)}
|
||||
onMouseOut={() => setHovered(false)}
|
||||
onMouseUp={props.onSelect}
|
||||
>
|
||||
<box height={2} flexDirection="row">
|
||||
<Show when={props.number}>
|
||||
{(number) => (
|
||||
<text
|
||||
width={String(number()).length + 1}
|
||||
fg={props.selected || props.focused ? accent() : theme.text.subdued}
|
||||
selectable={false}
|
||||
>
|
||||
{number()}
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<box height={2} flexGrow={1} minWidth={0}>
|
||||
<box height={1} flexDirection="row">
|
||||
<text
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
fg={theme.text.default}
|
||||
attributes={props.selected || props.focused ? TextAttributes.BOLD : undefined}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
selectable={false}
|
||||
>
|
||||
{props.row.title}
|
||||
</text>
|
||||
<Show when={!props.row.status.busy && (props.row.status.unread || props.row.status.attention)}>
|
||||
<text width={2} fg={feedback()} selectable={false}>
|
||||
●
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<box height={1}>
|
||||
<Show
|
||||
when={!props.pendingDone && !props.row.status.busy}
|
||||
fallback={
|
||||
<Show
|
||||
when={props.pendingDone}
|
||||
fallback={
|
||||
<Spinner color={accent()}>
|
||||
<span style={{ fg: accent() }}>{props.verb ?? activityVerb(props.row.sessionID)}</span>
|
||||
</Spinner>
|
||||
}
|
||||
>
|
||||
<text fg={theme.text.feedback.warning.default} wrapMode="none" truncate>
|
||||
Space again to mark done
|
||||
</text>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<text
|
||||
height={1}
|
||||
fg={tint(theme.text.subdued, theme.text.default, props.selected ? 0.25 : 0)}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
selectable={false}
|
||||
>
|
||||
{props.row.preview}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionInbox() {
|
||||
const tabs = useSessionTabs()
|
||||
const data = useData()
|
||||
const route = useRoute()
|
||||
const theme = useTheme("elevated")
|
||||
const themes = useThemes()
|
||||
const config = useConfig().data
|
||||
const prompt = usePromptRef()
|
||||
const dimensions = useTerminalDimensions()
|
||||
let scroll: ScrollBoxRenderable
|
||||
const [verbCycle, setVerbCycle] = createSignal(0)
|
||||
const verbTimer = setInterval(() => setVerbCycle((value) => value + 1), 3_500)
|
||||
onCleanup(() => {
|
||||
clearInterval(verbTimer)
|
||||
tabs.navigation.blur()
|
||||
})
|
||||
const width = createMemo(() => Math.max(28, Math.min(40, Math.floor(dimensions().width * 0.28))))
|
||||
const hueStep = () => (themes.mode() === "light" ? 800 : 200)
|
||||
const accent = () => theme.hue.accent[hueStep()]
|
||||
const rows = createMemo(() => {
|
||||
verbCycle()
|
||||
return tabs
|
||||
.recent()
|
||||
.map((tab) => {
|
||||
const session = data.session.get(tab.sessionID)
|
||||
const status = tabs.status(tab.sessionID)
|
||||
const assistant = data.session.message
|
||||
.list(tab.sessionID)
|
||||
.findLast(
|
||||
(message): message is SessionMessageAssistant =>
|
||||
message.type === "assistant" && (!session?.revert?.messageID || message.id < session.revert.messageID),
|
||||
)
|
||||
const preview = assistant?.content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join(" ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
return {
|
||||
sessionID: tab.sessionID,
|
||||
title: session ? withTimestampedFallback(session) : (tab.title ?? "Loading session…"),
|
||||
updated: session?.time.updated ?? 0,
|
||||
preview: preview || "No assistant response yet",
|
||||
status,
|
||||
group: sessionInboxGroup(session?.time.updated ?? 0, status.busy),
|
||||
}
|
||||
})
|
||||
.toSorted((a, b) => b.updated - a.updated)
|
||||
})
|
||||
const groups = createMemo(() =>
|
||||
(["running", "today", "yesterday", "earlier"] as const)
|
||||
.map((group) => ({ group, rows: rows().filter((row) => row.group === group) }))
|
||||
.filter((group) => group.rows.length > 0),
|
||||
)
|
||||
const order = () => groups().flatMap((group) => group.rows.map((row) => row.sessionID))
|
||||
|
||||
createEffect(() => {
|
||||
if (!tabs.navigation.active()) return
|
||||
const sessionID = tabs.navigation.selected()
|
||||
if (!sessionID || !scroll || scroll.isDestroyed) return
|
||||
const row = scroll.getRenderable(`session-inbox-${sessionID}`)
|
||||
if (!row) return
|
||||
const top = scroll.scrollTop + row.y - scroll.viewport.y
|
||||
const bottom = top + row.height
|
||||
if (top < scroll.scrollTop) scroll.scrollTo(top)
|
||||
else if (bottom > scroll.scrollTop + scroll.viewport.height) scroll.scrollTo(bottom - scroll.viewport.height)
|
||||
})
|
||||
|
||||
const leave = () => {
|
||||
tabs.navigation.blur()
|
||||
prompt.current?.focus()
|
||||
}
|
||||
const newSession = () => {
|
||||
tabs.navigation.blur()
|
||||
route.navigate({
|
||||
type: "home",
|
||||
location: route.data.type === "session" ? data.session.get(route.data.sessionID)?.location : undefined,
|
||||
})
|
||||
setTimeout(() => prompt.current?.focus(), 0)
|
||||
}
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
priority: 2,
|
||||
enabled: tabs.navigation.active,
|
||||
commands: [
|
||||
{
|
||||
bind: "up,shift+tab",
|
||||
title: "Previous session",
|
||||
group: "Session",
|
||||
run: () => tabs.navigation.move(-1, order()),
|
||||
},
|
||||
{
|
||||
bind: "down,tab",
|
||||
title: "Next session",
|
||||
group: "Session",
|
||||
run: () => tabs.navigation.move(1, order()),
|
||||
},
|
||||
{ bind: "return", title: "Open session", group: "Session", run: () => tabs.navigation.select() },
|
||||
{ bind: "space", title: "Mark session done", group: "Session", run: () => tabs.navigation.done(order()) },
|
||||
{ bind: "right", title: "Return to prompt", group: "Session", run: leave },
|
||||
{ bind: "escape", title: "Return to prompt", group: "Session", run: leave },
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box
|
||||
width={width()}
|
||||
height="100%"
|
||||
flexShrink={0}
|
||||
flexDirection="column"
|
||||
backgroundColor={theme.background.default}
|
||||
border={["right"]}
|
||||
borderColor={theme.border.default}
|
||||
>
|
||||
<box height={3} flexShrink={0} paddingLeft={2} paddingRight={1} flexDirection="row" alignItems="center">
|
||||
<text flexGrow={1} fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
Sessions
|
||||
</text>
|
||||
<text
|
||||
fg={theme.text.subdued}
|
||||
onMouseUp={newSession}
|
||||
selectable={false}
|
||||
>
|
||||
+ new
|
||||
</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
ref={(value) => (scroll = value)}
|
||||
flexGrow={1}
|
||||
scrollAcceleration={getScrollAcceleration(config)}
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: theme.background.default,
|
||||
foregroundColor: theme.scrollbar.default,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<box flexShrink={0} paddingBottom={1}>
|
||||
<Show when={groups().length > 0} fallback={<text marginLeft={2} fg={theme.text.subdued}>No open sessions</text>}>
|
||||
<For each={groups()}>
|
||||
{(group) => (
|
||||
<box flexShrink={0}>
|
||||
<box height={2} paddingLeft={2} paddingRight={1} alignItems="center" flexDirection="row">
|
||||
<text flexGrow={1} fg={group.group === "running" ? accent() : theme.text.subdued}>
|
||||
{labels[group.group]}
|
||||
</text>
|
||||
<text fg={theme.text.subdued}>{group.rows.length}</text>
|
||||
</box>
|
||||
<For each={group.rows}>
|
||||
{(row) => (
|
||||
<SessionInboxRow
|
||||
row={row}
|
||||
selected={tabs.current() === row.sessionID}
|
||||
focused={tabs.navigation.active() && tabs.navigation.selected() === row.sessionID}
|
||||
pendingDone={tabs.navigation.pendingDone() === row.sessionID}
|
||||
number={order().indexOf(row.sessionID) + 1}
|
||||
verb={activityVerb(row.sessionID, verbCycle())}
|
||||
onSelect={() => {
|
||||
tabs.navigation.blur()
|
||||
tabs.select(row.sessionID)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
</scrollbox>
|
||||
<box height={2} flexShrink={0} paddingLeft={2} alignItems="center">
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate>
|
||||
{tabs.navigation.active() ? "↑↓/tab choose · enter open · space done" : "← empty prompt · /tabs layout"}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
|
@ -132,6 +132,9 @@ export const Info = Schema.Struct({
|
|||
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
|
||||
description: "Share tabs globally or keep a separate set for each working directory",
|
||||
}),
|
||||
layout: Schema.optional(Schema.Literals(["top", "inbox"])).annotate({
|
||||
description: "Show tabs as a top strip or a grouped inbox rail",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Tab strip settings" }),
|
||||
mini: Schema.optional(
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ export const Definitions = {
|
|||
session_export: keybind("<leader>x", "Export session to editor"),
|
||||
session_copy: keybind("none", "Copy session transcript"),
|
||||
session_move: keybind("none", "Move session"),
|
||||
session_new: keybind("<leader>n", "Create a new session"),
|
||||
session_new: keybind("alt+t,ctrl+t,<leader>n", "Create a new session"),
|
||||
session_list: keybind("<leader>l", "List all sessions"),
|
||||
open_menu: keybind("ctrl+o", "Open recent sessions and projects"),
|
||||
session_tab_next: keybind("ctrl+tab,<leader>right,alt+shift+]", "Switch to next open tab"),
|
||||
|
|
@ -116,15 +116,15 @@ export const Definitions = {
|
|||
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_9: keybind("<leader>9", "Switch to session in quick slot 9"),
|
||||
session_tab_select_1: keybind("<leader>1,ctrl+1", "Switch to tab 1"),
|
||||
session_tab_select_2: keybind("<leader>2,ctrl+2", "Switch to tab 2"),
|
||||
session_tab_select_3: keybind("<leader>3,ctrl+3", "Switch to tab 3"),
|
||||
session_tab_select_4: keybind("<leader>4,ctrl+4", "Switch to tab 4"),
|
||||
session_tab_select_5: keybind("<leader>5,ctrl+5", "Switch to tab 5"),
|
||||
session_tab_select_6: keybind("<leader>6,ctrl+6", "Switch to tab 6"),
|
||||
session_tab_select_7: keybind("<leader>7,ctrl+7", "Switch to tab 7"),
|
||||
session_tab_select_8: keybind("<leader>8,ctrl+8", "Switch to tab 8"),
|
||||
session_tab_select_9: keybind("<leader>9,ctrl+9", "Switch to tab 9"),
|
||||
session_tab_select_1: keybind("<leader>1,ctrl+1,alt+1", "Switch to tab 1"),
|
||||
session_tab_select_2: keybind("<leader>2,ctrl+2,alt+2", "Switch to tab 2"),
|
||||
session_tab_select_3: keybind("<leader>3,ctrl+3,alt+3", "Switch to tab 3"),
|
||||
session_tab_select_4: keybind("<leader>4,ctrl+4,alt+4", "Switch to tab 4"),
|
||||
session_tab_select_5: keybind("<leader>5,ctrl+5,alt+5", "Switch to tab 5"),
|
||||
session_tab_select_6: keybind("<leader>6,ctrl+6,alt+6", "Switch to tab 6"),
|
||||
session_tab_select_7: keybind("<leader>7,ctrl+7,alt+7", "Switch to tab 7"),
|
||||
session_tab_select_8: keybind("<leader>8,ctrl+8,alt+8", "Switch to tab 8"),
|
||||
session_tab_select_9: keybind("<leader>9,ctrl+9,alt+9", "Switch to tab 9"),
|
||||
|
||||
stash_delete: keybind("ctrl+d", "Delete stash entry"),
|
||||
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
|
||||
|
|
|
|||
|
|
@ -22,6 +22,28 @@ export const SESSION_TAB_MIN_WIDTH = 8
|
|||
// Overflow markers reserve one gap cell beside the arrow and count, e.g. "‹12 " and " 12›".
|
||||
export const sessionTabOverflowWidth = (count: number) => String(count).length + 2
|
||||
|
||||
export type SessionInboxGroup = "running" | "today" | "yesterday" | "earlier"
|
||||
|
||||
export function sessionInboxGroup(updated: number, running: boolean, now = Date.now()): SessionInboxGroup {
|
||||
if (running) return "running"
|
||||
const today = new Date(now)
|
||||
today.setHours(0, 0, 0, 0)
|
||||
if (updated >= today.getTime()) return "today"
|
||||
today.setDate(today.getDate() - 1)
|
||||
if (updated >= today.getTime()) return "yesterday"
|
||||
return "earlier"
|
||||
}
|
||||
|
||||
export function orderSessionTabs(
|
||||
tabs: readonly SessionTab[],
|
||||
status: (sessionID: string) => { busy: boolean; updated: number },
|
||||
) {
|
||||
return tabs
|
||||
.map((tab, index) => ({ tab, index, ...status(tab.sessionID) }))
|
||||
.toSorted((a, b) => Number(b.busy) - Number(a.busy) || b.updated - a.updated || a.index - b.index)
|
||||
.map((item) => item.tab)
|
||||
}
|
||||
|
||||
export function openSessionTab(tabs: SessionTab[], tab: SessionTab): SessionTab[] {
|
||||
const index = tabs.findIndex((item) => item.sessionID === tab.sessionID)
|
||||
if (index === -1) return [...tabs, tab]
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
moveSessionTabHistory,
|
||||
NEW_SESSION_TAB_TITLE,
|
||||
openSessionTab,
|
||||
orderSessionTabs,
|
||||
recordClosedSessionTab,
|
||||
recordSessionTabHistory,
|
||||
reopenSessionTab,
|
||||
|
|
@ -61,6 +62,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||
})
|
||||
const fallback = empty()
|
||||
const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({})
|
||||
const [navigationActive, setNavigationActive] = createSignal(false)
|
||||
const [navigationSelection, setNavigationSelection] = createSignal<string>()
|
||||
const [navigationPendingDone, setNavigationPendingDone] = createSignal<string>()
|
||||
let history: SessionTabHistory = { entries: [], index: -1 }
|
||||
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
|
||||
let closedTabs: ClosedSessionTab[] = []
|
||||
|
|
@ -114,6 +118,11 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||
busy: family.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
|
||||
}
|
||||
}
|
||||
const recent = () =>
|
||||
orderSessionTabs(state().tabs, (sessionID) => ({
|
||||
busy: status(sessionID).busy,
|
||||
updated: data.session.get(sessionID)?.time.updated ?? 0,
|
||||
}))
|
||||
|
||||
function markUnread(sessionID: string, unread: SessionTabUnread) {
|
||||
if (!enabled()) return
|
||||
|
|
@ -146,6 +155,20 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!navigationActive()) return
|
||||
const tabs = state().tabs
|
||||
if (tabs.length === 0) {
|
||||
setNavigationActive(false)
|
||||
setNavigationSelection(undefined)
|
||||
setNavigationPendingDone(undefined)
|
||||
return
|
||||
}
|
||||
if (tabs.some((tab) => tab.sessionID === navigationSelection())) return
|
||||
setNavigationSelection(current() ?? recent()[0]?.sessionID)
|
||||
setNavigationPendingDone(undefined)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!enabled()) return
|
||||
const next = normalize(state())
|
||||
|
|
@ -248,6 +271,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||
tabs() {
|
||||
return state().tabs
|
||||
},
|
||||
recent,
|
||||
newTab() {
|
||||
return newTab()
|
||||
},
|
||||
|
|
@ -291,25 +315,77 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||
draft.tabs = moveSessionTab(draft.tabs, session, index)
|
||||
})
|
||||
},
|
||||
cycle(direction: 1 | -1) {
|
||||
cycle(direction: 1 | -1, order: "tabs" | "recent" = "tabs") {
|
||||
if (!enabled()) return
|
||||
const tab = cycleSessionTab(state().tabs, current(), direction)
|
||||
const tab = cycleSessionTab(order === "recent" ? recent() : state().tabs, current(), direction)
|
||||
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||
},
|
||||
cycleUnread(direction: 1 | -1) {
|
||||
cycleUnread(direction: 1 | -1, order: "tabs" | "recent" = "tabs") {
|
||||
if (!enabled()) return
|
||||
const tab = cycleSessionTab(
|
||||
state().tabs.filter((tab) => state().unread[tab.sessionID] || status(tab.sessionID).attention),
|
||||
(order === "recent" ? recent() : state().tabs).filter(
|
||||
(tab) => state().unread[tab.sessionID] || status(tab.sessionID).attention,
|
||||
),
|
||||
current(),
|
||||
direction,
|
||||
)
|
||||
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||
},
|
||||
selectIndex(index: number) {
|
||||
selectIndex(index: number, order: "tabs" | "recent" = "tabs") {
|
||||
if (!enabled()) return
|
||||
const tab = state().tabs[index]
|
||||
const tab = (order === "recent" ? recent() : state().tabs)[index]
|
||||
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||
},
|
||||
navigation: {
|
||||
active: navigationActive,
|
||||
selected: navigationSelection,
|
||||
pendingDone: navigationPendingDone,
|
||||
focus(order: readonly string[] = recent().map((tab) => tab.sessionID)) {
|
||||
if (!enabled() || state().tabs.length === 0) return false
|
||||
setNavigationSelection(current() ?? order.find((sessionID) => state().tabs.some((tab) => tab.sessionID === sessionID)))
|
||||
setNavigationPendingDone(undefined)
|
||||
setNavigationActive(true)
|
||||
return true
|
||||
},
|
||||
blur() {
|
||||
setNavigationActive(false)
|
||||
setNavigationPendingDone(undefined)
|
||||
},
|
||||
move(direction: 1 | -1, order?: readonly string[]) {
|
||||
const tabs = (order ?? state().tabs.map((tab) => tab.sessionID)).filter((sessionID) =>
|
||||
state().tabs.some((tab) => tab.sessionID === sessionID),
|
||||
)
|
||||
if (!navigationActive() || tabs.length === 0) return
|
||||
const index = tabs.findIndex((sessionID) => sessionID === navigationSelection())
|
||||
const start = index === -1 ? (direction === 1 ? -1 : 0) : index
|
||||
setNavigationSelection(tabs[(start + direction + tabs.length) % tabs.length])
|
||||
setNavigationPendingDone(undefined)
|
||||
},
|
||||
select() {
|
||||
const sessionID = navigationSelection()
|
||||
if (!navigationActive() || !sessionID) return
|
||||
setNavigationActive(false)
|
||||
setNavigationPendingDone(undefined)
|
||||
route.navigate({ type: "session", sessionID })
|
||||
},
|
||||
done(order?: readonly string[]) {
|
||||
const sessionID = navigationSelection()
|
||||
if (!navigationActive() || !sessionID) return
|
||||
if (navigationPendingDone() !== sessionID) {
|
||||
setNavigationPendingDone(sessionID)
|
||||
return
|
||||
}
|
||||
const tabs = (order ?? state().tabs.map((tab) => tab.sessionID)).filter((id) =>
|
||||
state().tabs.some((tab) => tab.sessionID === id),
|
||||
)
|
||||
const index = tabs.indexOf(sessionID)
|
||||
const next = tabs[index + 1] ?? tabs[index - 1]
|
||||
setNavigationPendingDone(undefined)
|
||||
setNavigationSelection(next)
|
||||
remove(sessionID, true)
|
||||
if (!next) setNavigationActive(false)
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -281,7 +281,6 @@ export function Session() {
|
|||
return
|
||||
}
|
||||
editor.reconnect(info.location.directory)
|
||||
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
|
||||
setSynced(true)
|
||||
})().catch((error) => {
|
||||
if (route.sessionID !== sessionID) return
|
||||
|
|
@ -915,8 +914,8 @@ export function Session() {
|
|||
bindings: [...baseAndUnfocusedCommands, ...baseCommands()].map((command) => command.id),
|
||||
}))
|
||||
|
||||
// snap to bottom when session changes
|
||||
createEffect(on(() => route.sessionID, toBottom))
|
||||
// The keyed Session remount and stickyStart="bottom" establish the initial position before draw.
|
||||
// A deferred scroll here causes a visible second jump whenever tabs switch.
|
||||
createEffect(
|
||||
on(
|
||||
() => route.sessionID,
|
||||
|
|
|
|||
67
packages/tui/src/util/activity-verb.ts
Normal file
67
packages/tui/src/util/activity-verb.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
export const ACTIVITY_VERBS = [
|
||||
"mogging",
|
||||
"glazing",
|
||||
"larping",
|
||||
"canoodling",
|
||||
"scheming",
|
||||
"fuming",
|
||||
"coping",
|
||||
"seething",
|
||||
"plotting",
|
||||
"yapping",
|
||||
"fumbling",
|
||||
"malding",
|
||||
"grandstanding",
|
||||
"doomscrolling",
|
||||
"clowning",
|
||||
"gremlining",
|
||||
"goblining",
|
||||
"waffling",
|
||||
"dillydallying",
|
||||
"catastrophizing",
|
||||
"overthinking",
|
||||
"underdelivering",
|
||||
"vibecoding",
|
||||
"yak-shaving",
|
||||
"bikeshedding",
|
||||
"nitpicking",
|
||||
"procrastinating",
|
||||
"overengineering",
|
||||
"spaghettiweaving",
|
||||
"bugfarming",
|
||||
"cachepoisoning",
|
||||
"stacktracing",
|
||||
"tokenburning",
|
||||
"contextstuffing",
|
||||
"promptwrangling",
|
||||
"diffdivining",
|
||||
"lintworshipping",
|
||||
"testdodging",
|
||||
"mergebegging",
|
||||
"branchhaunting",
|
||||
"commitcosplaying",
|
||||
"semicolonhoarding",
|
||||
"typesquinting",
|
||||
"regexsummoning",
|
||||
"dependencyjuggling",
|
||||
"logstaring",
|
||||
"packetwhispering",
|
||||
"daemonpoking",
|
||||
"terminalpeacocking",
|
||||
"sandboxrattling",
|
||||
"copypasting",
|
||||
"tabcollecting",
|
||||
"scopecreeping",
|
||||
"deadlinehaunting",
|
||||
"yakstacking",
|
||||
"slopshoveling",
|
||||
"codefermenting",
|
||||
"pixelslandering",
|
||||
"buildgaslighting",
|
||||
"syntaxgrooming",
|
||||
] as const
|
||||
|
||||
export function activityVerb(key: string, offset = 0) {
|
||||
const hash = Array.from(key).reduce((value, character) => (value * 31 + character.charCodeAt(0)) >>> 0, 0)
|
||||
return ACTIVITY_VERBS[(hash + offset) % ACTIVITY_VERBS.length]
|
||||
}
|
||||
|
|
@ -17,8 +17,11 @@ test("validates mini replay settings", () => {
|
|||
test("validates the session tabs setting", () => {
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
expect(decode({ tabs: { enabled: true } })).toEqual({ tabs: { enabled: true } })
|
||||
expect(decode({ tabs: { enabled: true, layout: "inbox" } })).toEqual({
|
||||
tabs: { enabled: true, layout: "inbox" },
|
||||
})
|
||||
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
|
||||
expect(() => decode({ tabs: { layout: "sidebar" } })).toThrow()
|
||||
})
|
||||
|
||||
test("resolves nested config and keybind defaults", () => {
|
||||
|
|
|
|||
|
|
@ -117,9 +117,11 @@ test("navigates session tabs with leader arrows", () => {
|
|||
test("preserves pinned session bindings alongside tab bindings", () => {
|
||||
const config = resolve({}, { terminalSuspend: true })
|
||||
|
||||
expect(config.keybinds.get("session.new")).toMatchObject([{ key: "alt+t,ctrl+t,<leader>n" }])
|
||||
expect(config.keybinds.has("session.toggle.thinking")).toBe(false)
|
||||
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" }])
|
||||
expect(config.keybinds.get("session.tab.select.1")).toMatchObject([{ key: "<leader>1,ctrl+1,alt+1" }])
|
||||
})
|
||||
|
||||
test("disables suspend and assigns ctrl+z to undo when unsupported", () => {
|
||||
|
|
|
|||
|
|
@ -6,15 +6,42 @@ import {
|
|||
moveSessionTab,
|
||||
moveSessionTabHistory,
|
||||
openSessionTab,
|
||||
orderSessionTabs,
|
||||
recordClosedSessionTab,
|
||||
recordSessionTabHistory,
|
||||
reopenSessionTab,
|
||||
seedSessionTabMotion,
|
||||
sessionInboxGroup,
|
||||
sessionTabComplete,
|
||||
sessionTabOverflowWidth,
|
||||
} from "../../src/context/session-tabs-model"
|
||||
|
||||
describe("session tabs", () => {
|
||||
test("orders running sessions first and completed sessions by recent update", () => {
|
||||
const tabs = ["old", "running-old", "new", "running-new"].map((sessionID) => ({ sessionID }))
|
||||
const state = {
|
||||
old: { busy: false, updated: 10 },
|
||||
"running-old": { busy: true, updated: 20 },
|
||||
new: { busy: false, updated: 40 },
|
||||
"running-new": { busy: true, updated: 30 },
|
||||
}
|
||||
|
||||
expect(orderSessionTabs(tabs, (sessionID) => state[sessionID as keyof typeof state]).map((tab) => tab.sessionID)).toEqual([
|
||||
"running-new",
|
||||
"running-old",
|
||||
"new",
|
||||
"old",
|
||||
])
|
||||
})
|
||||
|
||||
test("groups inbox tabs by running state and local calendar day", () => {
|
||||
const now = new Date(2026, 6, 31, 12).getTime()
|
||||
expect(sessionInboxGroup(new Date(2026, 6, 20).getTime(), true, now)).toBe("running")
|
||||
expect(sessionInboxGroup(new Date(2026, 6, 31, 1).getTime(), false, now)).toBe("today")
|
||||
expect(sessionInboxGroup(new Date(2026, 6, 30, 1).getTime(), false, now)).toBe("yesterday")
|
||||
expect(sessionInboxGroup(new Date(2026, 6, 29, 23).getTime(), false, now)).toBe("earlier")
|
||||
})
|
||||
|
||||
test("moves a tab to a clamped index and returns the same tabs for no-ops", () => {
|
||||
const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
|
||||
expect(moveSessionTab(tabs, "a", 2).map((tab) => tab.sessionID)).toEqual(["b", "c", "a"])
|
||||
|
|
|
|||
|
|
@ -248,3 +248,33 @@ test("tracks a temporary new session tab across close and creation", async () =>
|
|||
setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("navigates the inbox without changing sessions and confirms done twice", async () => {
|
||||
const setup = await renderSessionTabs("first")
|
||||
|
||||
try {
|
||||
await wait(() => setup.tabs.current() === "first")
|
||||
setup.route.navigate({ type: "session", sessionID: "second" })
|
||||
await wait(() => setup.tabs.current() === "second" && setup.tabs.tabs().length === 2)
|
||||
setup.route.navigate({ type: "session", sessionID: "first" })
|
||||
await wait(() => setup.tabs.current() === "first")
|
||||
|
||||
expect(setup.tabs.navigation.focus()).toBe(true)
|
||||
expect(setup.tabs.navigation.selected()).toBe("first")
|
||||
setup.tabs.navigation.move(1)
|
||||
expect(setup.tabs.navigation.selected()).toBe("second")
|
||||
expect(setup.tabs.current()).toBe("first")
|
||||
|
||||
setup.tabs.navigation.done()
|
||||
expect(setup.tabs.navigation.pendingDone()).toBe("second")
|
||||
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first", "second"])
|
||||
setup.tabs.navigation.done()
|
||||
await wait(() => setup.tabs.tabs().length === 1)
|
||||
|
||||
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first"])
|
||||
expect(setup.tabs.current()).toBe("first")
|
||||
expect(setup.tabs.navigation.selected()).toBe("first")
|
||||
} finally {
|
||||
setup.destroy()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -139,3 +139,40 @@ test("global commands stay reachable when the mode changes", async () => {
|
|||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("dispatches direct and leader tab-number bindings", async () => {
|
||||
const calls: number[] = []
|
||||
|
||||
function Harness() {
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
commands: Array.from({ length: 2 }, (_, index) => ({
|
||||
id: `session.tab.select.${index + 1}`,
|
||||
run: () => void calls.push(index + 1),
|
||||
})),
|
||||
}))
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
bindings: ["session.tab.select.1", "session.tab.select.2"],
|
||||
}))
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||
<Keymap.Provider>
|
||||
<Harness />
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
))
|
||||
try {
|
||||
app.mockInput.pressKey("1", { ctrl: true })
|
||||
expect(calls).toEqual([])
|
||||
app.mockInput.pressKey("1", { meta: true })
|
||||
app.mockInput.pressKey("x", { ctrl: true })
|
||||
app.mockInput.pressKey("2")
|
||||
expect(calls).toEqual([1, 2])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
9
packages/tui/test/util/activity-verb.test.ts
Normal file
9
packages/tui/test/util/activity-verb.test.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { ACTIVITY_VERBS, activityVerb } from "../../src/util/activity-verb"
|
||||
|
||||
test("rotates through 60 stable activity verbs", () => {
|
||||
expect(ACTIVITY_VERBS).toHaveLength(60)
|
||||
expect(new Set(ACTIVITY_VERBS).size).toBe(60)
|
||||
expect(activityVerb("session-a", 0)).toBe(activityVerb("session-a", 60))
|
||||
expect(activityVerb("session-a", 1)).not.toBe(activityVerb("session-a", 0))
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue