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 { 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"
}

View file

@ -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)

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({
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(

View file

@ -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"),

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".
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]

View file

@ -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)
},
},
}
},
})

View file

@ -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,

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", () => {
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", () => {

View file

@ -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", () => {

View file

@ -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"])

View file

@ -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()
}
})

View file

@ -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()
}
})

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