diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx
index 29864cf7b3..6373d70f08 100644
--- a/packages/tui/src/app.tsx
+++ b/packages/tui/src/app.tsx
@@ -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}
>
+ 0 || sessionTabs.newTab()) &&
+ route.data.type !== "plugin"
+ }
+ >
+
+
0 || sessionTabs.newTab()) &&
route.data.type !== "plugin"
}
diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx
index fdbff6af76..4ddf665b9b 100644
--- a/packages/tui/src/component/prompt/index.tsx
+++ b/packages/tui/src/component/prompt/index.tsx
@@ -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)
diff --git a/packages/tui/src/component/session-inbox.tsx b/packages/tui/src/component/session-inbox.tsx
new file mode 100644
index 0000000000..2aeeb99325
--- /dev/null
+++ b/packages/tui/src/component/session-inbox.tsx
@@ -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 = {
+ 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 (
+ setHovered(true)}
+ onMouseOut={() => setHovered(false)}
+ onMouseUp={props.onSelect}
+ >
+
+
+ {(number) => (
+
+ {number()}
+
+ )}
+
+
+
+
+ {props.row.title}
+
+
+
+ ●
+
+
+
+
+
+ {props.verb ?? activityVerb(props.row.sessionID)}
+
+ }
+ >
+
+ Space again to mark done
+
+
+ }
+ >
+
+ {props.row.preview}
+
+
+
+
+
+
+ )
+}
+
+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 (
+
+
+
+ Sessions
+
+
+ + new
+
+
+ (scroll = value)}
+ flexGrow={1}
+ scrollAcceleration={getScrollAcceleration(config)}
+ verticalScrollbarOptions={{
+ trackOptions: {
+ backgroundColor: theme.background.default,
+ foregroundColor: theme.scrollbar.default,
+ },
+ }}
+ >
+
+ 0} fallback={No open sessions}>
+
+ {(group) => (
+
+
+
+ {labels[group.group]}
+
+ {group.rows.length}
+
+
+ {(row) => (
+ {
+ tabs.navigation.blur()
+ tabs.select(row.sessionID)
+ }}
+ />
+ )}
+
+
+ )}
+
+
+
+
+
+
+ {tabs.navigation.active() ? "↑↓/tab choose · enter open · space done" : "← empty prompt · /tabs layout"}
+
+
+
+ )
+}
diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx
index 8556923fe7..202313af0e 100644
--- a/packages/tui/src/config/index.tsx
+++ b/packages/tui/src/config/index.tsx
@@ -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(
diff --git a/packages/tui/src/config/v1/keybind.ts b/packages/tui/src/config/v1/keybind.ts
index b75c2d06a5..9edfa33f8c 100644
--- a/packages/tui/src/config/v1/keybind.ts
+++ b/packages/tui/src/config/v1/keybind.ts
@@ -85,7 +85,7 @@ export const Definitions = {
session_export: keybind("x", "Export session to editor"),
session_copy: keybind("none", "Copy session transcript"),
session_move: keybind("none", "Move session"),
- session_new: keybind("n", "Create a new session"),
+ session_new: keybind("alt+t,ctrl+t,n", "Create a new session"),
session_list: keybind("l", "List all sessions"),
open_menu: keybind("ctrl+o", "Open recent sessions and projects"),
session_tab_next: keybind("ctrl+tab,right,alt+shift+]", "Switch to next open tab"),
@@ -116,15 +116,15 @@ export const Definitions = {
session_quick_switch_7: keybind("7", "Switch to session in quick slot 7"),
session_quick_switch_8: keybind("8", "Switch to session in quick slot 8"),
session_quick_switch_9: keybind("9", "Switch to session in quick slot 9"),
- session_tab_select_1: keybind("1,ctrl+1", "Switch to tab 1"),
- session_tab_select_2: keybind("2,ctrl+2", "Switch to tab 2"),
- session_tab_select_3: keybind("3,ctrl+3", "Switch to tab 3"),
- session_tab_select_4: keybind("4,ctrl+4", "Switch to tab 4"),
- session_tab_select_5: keybind("5,ctrl+5", "Switch to tab 5"),
- session_tab_select_6: keybind("6,ctrl+6", "Switch to tab 6"),
- session_tab_select_7: keybind("7,ctrl+7", "Switch to tab 7"),
- session_tab_select_8: keybind("8,ctrl+8", "Switch to tab 8"),
- session_tab_select_9: keybind("9,ctrl+9", "Switch to tab 9"),
+ session_tab_select_1: keybind("1,ctrl+1,alt+1", "Switch to tab 1"),
+ session_tab_select_2: keybind("2,ctrl+2,alt+2", "Switch to tab 2"),
+ session_tab_select_3: keybind("3,ctrl+3,alt+3", "Switch to tab 3"),
+ session_tab_select_4: keybind("4,ctrl+4,alt+4", "Switch to tab 4"),
+ session_tab_select_5: keybind("5,ctrl+5,alt+5", "Switch to tab 5"),
+ session_tab_select_6: keybind("6,ctrl+6,alt+6", "Switch to tab 6"),
+ session_tab_select_7: keybind("7,ctrl+7,alt+7", "Switch to tab 7"),
+ session_tab_select_8: keybind("8,ctrl+8,alt+8", "Switch to tab 8"),
+ session_tab_select_9: keybind("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"),
diff --git a/packages/tui/src/context/session-tabs-model.ts b/packages/tui/src/context/session-tabs-model.ts
index 3380743118..8e59bbfa0a 100644
--- a/packages/tui/src/context/session-tabs-model.ts
+++ b/packages/tui/src/context/session-tabs-model.ts
@@ -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]
diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx
index 3c8b56af0b..3b55a24ce1 100644
--- a/packages/tui/src/context/session-tabs.tsx
+++ b/packages/tui/src/context/session-tabs.tsx
@@ -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>({})
+ const [navigationActive, setNavigationActive] = createSignal(false)
+ const [navigationSelection, setNavigationSelection] = createSignal()
+ const [navigationPendingDone, setNavigationPendingDone] = createSignal()
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)
+ },
+ },
}
},
})
diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx
index 9f5a62a998..e7aa1f84ed 100644
--- a/packages/tui/src/routes/session/index.tsx
+++ b/packages/tui/src/routes/session/index.tsx
@@ -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,
diff --git a/packages/tui/src/util/activity-verb.ts b/packages/tui/src/util/activity-verb.ts
new file mode 100644
index 0000000000..067e14ccf1
--- /dev/null
+++ b/packages/tui/src/util/activity-verb.ts
@@ -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]
+}
diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx
index 9fd6080341..d0b80e3dbf 100644
--- a/packages/tui/test/config-v2.test.tsx
+++ b/packages/tui/test/config-v2.test.tsx
@@ -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", () => {
diff --git a/packages/tui/test/config.test.tsx b/packages/tui/test/config.test.tsx
index a9d5a007fe..73260285b2 100644
--- a/packages/tui/test/config.test.tsx
+++ b/packages/tui/test/config.test.tsx
@@ -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,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: "1" }])
- expect(config.keybinds.get("session.tab.select.1")).toMatchObject([{ key: "1,ctrl+1" }])
+ expect(config.keybinds.get("session.tab.select.1")).toMatchObject([{ key: "1,ctrl+1,alt+1" }])
})
test("disables suspend and assigns ctrl+z to undo when unsupported", () => {
diff --git a/packages/tui/test/context/session-tabs-model.test.ts b/packages/tui/test/context/session-tabs-model.test.ts
index 173ae570ae..fe13bd0e5b 100644
--- a/packages/tui/test/context/session-tabs-model.test.ts
+++ b/packages/tui/test/context/session-tabs-model.test.ts
@@ -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"])
diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx
index 330cb1443e..9bd17059a1 100644
--- a/packages/tui/test/context/session-tabs.test.tsx
+++ b/packages/tui/test/context/session-tabs.test.tsx
@@ -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()
+ }
+})
diff --git a/packages/tui/test/keymap.test.tsx b/packages/tui/test/keymap.test.tsx
index 11141ddf10..cbf8136a7b 100644
--- a/packages/tui/test/keymap.test.tsx
+++ b/packages/tui/test/keymap.test.tsx
@@ -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
+ }
+
+ const app = await testRender(() => (
+
+
+
+
+
+ ))
+ 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()
+ }
+})
diff --git a/packages/tui/test/util/activity-verb.test.ts b/packages/tui/test/util/activity-verb.test.ts
new file mode 100644
index 0000000000..29be5828a4
--- /dev/null
+++ b/packages/tui/test/util/activity-verb.test.ts
@@ -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))
+})