feat(tui): add inbox tab layout

This commit is contained in:
Ryan Vogel 2026-08-01 10:40:26 -04:00
commit a01d24e908
15 changed files with 693 additions and 27 deletions

View file

@ -68,6 +68,7 @@ import { DialogAgent } from "./component/dialog-agent"
import { DialogSessionList } from "./component/dialog-session-list" import { DialogSessionList } from "./component/dialog-session-list"
import { DialogOpen } from "./component/dialog-open" import { DialogOpen } from "./component/dialog-open"
import { SessionTabs } from "./component/session-tabs" import { SessionTabs } from "./component/session-tabs"
import { SessionInbox } from "./component/session-inbox"
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"
@ -519,6 +520,7 @@ function App(props: { pair?: DialogPairCredentials }) {
const terminalTitleEnabled = () => config.data.terminal?.title ?? true const terminalTitleEnabled = () => config.data.terminal?.title ?? true
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32" const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full" const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
const inboxTabsEnabled = () => config.data.tabs?.layout === "inbox" && dimensions().width >= 72
createEffect(() => { createEffect(() => {
renderer.useMouse = config.data.mouse renderer.useMouse = config.data.mouse
@ -645,6 +647,7 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session", category: "Session",
slash: { name: "new", aliases: ["clear"] }, slash: { name: "new", aliases: ["clear"] },
run: () => { run: () => {
sessionTabs.navigation.blur()
route.navigate({ route.navigate({
type: "home", type: "home",
location: location:
@ -653,6 +656,7 @@ function App(props: { pair?: DialogPairCredentials }) {
: undefined, : undefined,
}) })
dialog.clear() dialog.clear()
setTimeout(() => promptRef.current?.focus(), 0)
}, },
}, },
{ {
@ -672,13 +676,35 @@ function App(props: { pair?: DialogPairCredentials }) {
enabled: () => !sessionTabs.enabled(), enabled: () => !sessionTabs.enabled(),
run: () => local.session.quickSwitch(i + 1), 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", name: "session.tab.next",
title: "Next tab", title: "Next tab",
category: "Session", category: "Session",
palette: undefined, palette: undefined,
enabled: sessionTabs.enabled, enabled: sessionTabs.enabled,
run: () => sessionTabs.cycle(1), run: () => sessionTabs.cycle(1, inboxTabsEnabled() ? "recent" : "tabs"),
}, },
{ {
name: "session.tab.previous", name: "session.tab.previous",
@ -686,7 +712,7 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session", category: "Session",
palette: undefined, palette: undefined,
enabled: sessionTabs.enabled, enabled: sessionTabs.enabled,
run: () => sessionTabs.cycle(-1), run: () => sessionTabs.cycle(-1, inboxTabsEnabled() ? "recent" : "tabs"),
}, },
{ {
name: "session.tab.next_unread", name: "session.tab.next_unread",
@ -694,7 +720,7 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session", category: "Session",
palette: undefined, palette: undefined,
enabled: sessionTabs.enabled, enabled: sessionTabs.enabled,
run: () => sessionTabs.cycleUnread(1), run: () => sessionTabs.cycleUnread(1, inboxTabsEnabled() ? "recent" : "tabs"),
}, },
{ {
name: "session.tab.previous_unread", name: "session.tab.previous_unread",
@ -702,7 +728,7 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session", category: "Session",
palette: undefined, palette: undefined,
enabled: sessionTabs.enabled, enabled: sessionTabs.enabled,
run: () => sessionTabs.cycleUnread(-1), run: () => sessionTabs.cycleUnread(-1, inboxTabsEnabled() ? "recent" : "tabs"),
}, },
{ {
name: "session.tab.close", name: "session.tab.close",
@ -724,7 +750,7 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session", category: "Session",
palette: undefined, palette: undefined,
enabled: sessionTabs.enabled, enabled: sessionTabs.enabled,
run: () => sessionTabs.selectIndex(i), run: () => sessionTabs.selectIndex(i, inboxTabsEnabled() ? "recent" : "tabs"),
})), })),
{ {
name: "model.list", name: "model.list",
@ -1214,12 +1240,23 @@ function App(props: { pair?: DialogPairCredentials }) {
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined} onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
> >
<box flexGrow={1} minHeight={0} flexDirection="row"> <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"> <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 <Show
when={ when={
sessionTabs.enabled() && sessionTabs.enabled() &&
!inboxTabsEnabled() &&
(sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) &&
route.data.type !== "plugin" route.data.type !== "plugin"
} }

View file

@ -54,6 +54,7 @@ import { useLocation } from "../../context/location"
import { Keymap, type KeymapCommand } from "../../context/keymap" import { Keymap, type KeymapCommand } from "../../context/keymap"
import { abbreviateHome } from "../../runtime" import { abbreviateHome } from "../../runtime"
import { PluginSlot } from "../../plugin/render" import { PluginSlot } from "../../plugin/render"
import { useSessionTabs } from "../../context/session-tabs"
registerOpencodeSpinner() registerOpencodeSpinner()
@ -159,6 +160,7 @@ export function Prompt(props: PromptProps) {
const editor = useEditorContext() const editor = useEditorContext()
const route = useRoute() const route = useRoute()
const data = useData() const data = useData()
const sessionTabs = useSessionTabs()
const keymapCommands = Keymap.useCommands() const keymapCommands = Keymap.useCommands()
const currentLocation = useLocation() const currentLocation = useLocation()
const config = useConfig().data const config = useConfig().data
@ -589,7 +591,7 @@ export function Prompt(props: PromptProps) {
createEffect(() => { createEffect(() => {
if (!input || input.isDestroyed) return 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() if (input.focused) input.blur()
return 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(() => { Keymap.createLayer(() => {
return { return {
target: inputTarget, target: inputTarget,
@ -1455,6 +1486,7 @@ export function Prompt(props: PromptProps) {
}} }}
onMouseDown={(r: MouseEvent) => { onMouseDown={(r: MouseEvent) => {
if (props.disabled || r.button !== 0) return if (props.disabled || r.button !== 0) return
sessionTabs.navigation.blur()
r.target?.focus() r.target?.focus()
const extmark = input.extmarks const extmark = input.extmarks
.getAtOffset(input.cursorOffset) .getAtOffset(input.cursorOffset)

View 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>
)
}

View file

@ -132,6 +132,9 @@ export const Info = Schema.Struct({
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({ scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
description: "Share tabs globally or keep a separate set for each working directory", 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" }), ).annotate({ description: "Tab strip settings" }),
mini: Schema.optional( mini: Schema.optional(

View file

@ -85,7 +85,7 @@ export const Definitions = {
session_export: keybind("<leader>x", "Export session to editor"), session_export: keybind("<leader>x", "Export session to editor"),
session_copy: keybind("none", "Copy session transcript"), session_copy: keybind("none", "Copy session transcript"),
session_move: keybind("none", "Move session"), 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"), session_list: keybind("<leader>l", "List all sessions"),
open_menu: keybind("ctrl+o", "Open recent sessions and projects"), 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"), 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_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 tab 1"), session_tab_select_1: keybind("<leader>1,ctrl+1,alt+1", "Switch to tab 1"),
session_tab_select_2: keybind("<leader>2,ctrl+2", "Switch to tab 2"), session_tab_select_2: keybind("<leader>2,ctrl+2,alt+2", "Switch to tab 2"),
session_tab_select_3: keybind("<leader>3,ctrl+3", "Switch to tab 3"), session_tab_select_3: keybind("<leader>3,ctrl+3,alt+3", "Switch to tab 3"),
session_tab_select_4: keybind("<leader>4,ctrl+4", "Switch to tab 4"), session_tab_select_4: keybind("<leader>4,ctrl+4,alt+4", "Switch to tab 4"),
session_tab_select_5: keybind("<leader>5,ctrl+5", "Switch to tab 5"), session_tab_select_5: keybind("<leader>5,ctrl+5,alt+5", "Switch to tab 5"),
session_tab_select_6: keybind("<leader>6,ctrl+6", "Switch to tab 6"), session_tab_select_6: keybind("<leader>6,ctrl+6,alt+6", "Switch to tab 6"),
session_tab_select_7: keybind("<leader>7,ctrl+7", "Switch to tab 7"), session_tab_select_7: keybind("<leader>7,ctrl+7,alt+7", "Switch to tab 7"),
session_tab_select_8: keybind("<leader>8,ctrl+8", "Switch to tab 8"), session_tab_select_8: keybind("<leader>8,ctrl+8,alt+8", "Switch to tab 8"),
session_tab_select_9: keybind("<leader>9,ctrl+9", "Switch to tab 9"), session_tab_select_9: keybind("<leader>9,ctrl+9,alt+9", "Switch to 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"),

View file

@ -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". // 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 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[] { export function openSessionTab(tabs: SessionTab[], tab: SessionTab): SessionTab[] {
const index = tabs.findIndex((item) => item.sessionID === tab.sessionID) const index = tabs.findIndex((item) => item.sessionID === tab.sessionID)
if (index === -1) return [...tabs, tab] if (index === -1) return [...tabs, tab]

View file

@ -16,6 +16,7 @@ import {
moveSessionTabHistory, moveSessionTabHistory,
NEW_SESSION_TAB_TITLE, NEW_SESSION_TAB_TITLE,
openSessionTab, openSessionTab,
orderSessionTabs,
recordClosedSessionTab, recordClosedSessionTab,
recordSessionTabHistory, recordSessionTabHistory,
reopenSessionTab, reopenSessionTab,
@ -61,6 +62,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
}) })
const fallback = empty() const fallback = empty()
const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({}) 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 } let history: SessionTabHistory = { entries: [], index: -1 }
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned. // User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
let closedTabs: ClosedSessionTab[] = [] 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), 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) { function markUnread(sessionID: string, unread: SessionTabUnread) {
if (!enabled()) return 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(() => { createEffect(() => {
if (!enabled()) return if (!enabled()) return
const next = normalize(state()) const next = normalize(state())
@ -248,6 +271,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
tabs() { tabs() {
return state().tabs return state().tabs
}, },
recent,
newTab() { newTab() {
return newTab() return newTab()
}, },
@ -291,25 +315,77 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
draft.tabs = moveSessionTab(draft.tabs, session, index) draft.tabs = moveSessionTab(draft.tabs, session, index)
}) })
}, },
cycle(direction: 1 | -1) { cycle(direction: 1 | -1, order: "tabs" | "recent" = "tabs") {
if (!enabled()) return 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 }) if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
}, },
cycleUnread(direction: 1 | -1) { cycleUnread(direction: 1 | -1, order: "tabs" | "recent" = "tabs") {
if (!enabled()) return if (!enabled()) return
const tab = cycleSessionTab( 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(), current(),
direction, direction,
) )
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID }) if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
}, },
selectIndex(index: number) { selectIndex(index: number, order: "tabs" | "recent" = "tabs") {
if (!enabled()) return 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 }) 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)
},
},
} }
}, },
}) })

View file

@ -281,7 +281,6 @@ export function Session() {
return return
} }
editor.reconnect(info.location.directory) editor.reconnect(info.location.directory)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
setSynced(true) setSynced(true)
})().catch((error) => { })().catch((error) => {
if (route.sessionID !== sessionID) return if (route.sessionID !== sessionID) return
@ -915,8 +914,8 @@ export function Session() {
bindings: [...baseAndUnfocusedCommands, ...baseCommands()].map((command) => command.id), bindings: [...baseAndUnfocusedCommands, ...baseCommands()].map((command) => command.id),
})) }))
// snap to bottom when session changes // The keyed Session remount and stickyStart="bottom" establish the initial position before draw.
createEffect(on(() => route.sessionID, toBottom)) // A deferred scroll here causes a visible second jump whenever tabs switch.
createEffect( createEffect(
on( on(
() => route.sessionID, () => route.sessionID,

View 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]
}

View file

@ -17,8 +17,11 @@ test("validates mini replay settings", () => {
test("validates the session tabs setting", () => { test("validates the session tabs setting", () => {
const decode = Schema.decodeUnknownSync(Info) 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: { enabled: "on" } })).toThrow()
expect(() => decode({ tabs: { layout: "sidebar" } })).toThrow()
}) })
test("resolves nested config and keybind defaults", () => { test("resolves nested config and keybind defaults", () => {

View file

@ -117,9 +117,11 @@ test("navigates session tabs with leader arrows", () => {
test("preserves pinned session bindings alongside tab bindings", () => { test("preserves pinned session bindings alongside tab bindings", () => {
const config = resolve({}, { terminalSuspend: true }) 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.pin.toggle")).toMatchObject([{ key: "ctrl+f" }])
expect(config.keybinds.get("session.quick_switch.1")).toMatchObject([{ key: "<leader>1" }]) 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", () => { test("disables suspend and assigns ctrl+z to undo when unsupported", () => {

View file

@ -6,15 +6,42 @@ import {
moveSessionTab, moveSessionTab,
moveSessionTabHistory, moveSessionTabHistory,
openSessionTab, openSessionTab,
orderSessionTabs,
recordClosedSessionTab, recordClosedSessionTab,
recordSessionTabHistory, recordSessionTabHistory,
reopenSessionTab, reopenSessionTab,
seedSessionTabMotion, seedSessionTabMotion,
sessionInboxGroup,
sessionTabComplete, sessionTabComplete,
sessionTabOverflowWidth, sessionTabOverflowWidth,
} from "../../src/context/session-tabs-model" } from "../../src/context/session-tabs-model"
describe("session tabs", () => { 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", () => { test("moves a tab to a clamped index and returns the same tabs for no-ops", () => {
const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID })) const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
expect(moveSessionTab(tabs, "a", 2).map((tab) => tab.sessionID)).toEqual(["b", "c", "a"]) expect(moveSessionTab(tabs, "a", 2).map((tab) => tab.sessionID)).toEqual(["b", "c", "a"])

View file

@ -248,3 +248,33 @@ test("tracks a temporary new session tab across close and creation", async () =>
setup.destroy() 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()
}
})

View file

@ -139,3 +139,40 @@ test("global commands stay reachable when the mode changes", async () => {
app.renderer.destroy() 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()
}
})

View 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))
})