Browse Source

feat(tui): add inbox tab layout

Ryan Vogel 2 tuần trước cách đây
mục cha
commit
a01d24e908

+ 42 - 5
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}
     >
       <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"
                 }

+ 33 - 1
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)

+ 322 - 0
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<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>
+  )
+}

+ 3 - 0
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(

+ 10 - 10
packages/tui/src/config/v1/keybind.ts

@@ -85,7 +85,7 @@ export const Definitions = {
   session_export: keybind("<leader>x", "Export session to editor"),
   session_copy: keybind("none", "Copy session transcript"),
   session_move: keybind("none", "Move session"),
-  session_new: keybind("<leader>n", "Create a new session"),
+  session_new: keybind("alt+t,ctrl+t,<leader>n", "Create a new session"),
   session_list: keybind("<leader>l", "List all sessions"),
   open_menu: keybind("ctrl+o", "Open recent sessions and projects"),
   session_tab_next: keybind("ctrl+tab,<leader>right,alt+shift+]", "Switch to next open tab"),
@@ -116,15 +116,15 @@ export const Definitions = {
   session_quick_switch_7: keybind("<leader>7", "Switch to session in quick slot 7"),
   session_quick_switch_8: keybind("<leader>8", "Switch to session in quick slot 8"),
   session_quick_switch_9: keybind("<leader>9", "Switch to session in quick slot 9"),
-  session_tab_select_1: keybind("<leader>1,ctrl+1", "Switch to tab 1"),
-  session_tab_select_2: keybind("<leader>2,ctrl+2", "Switch to tab 2"),
-  session_tab_select_3: keybind("<leader>3,ctrl+3", "Switch to tab 3"),
-  session_tab_select_4: keybind("<leader>4,ctrl+4", "Switch to tab 4"),
-  session_tab_select_5: keybind("<leader>5,ctrl+5", "Switch to tab 5"),
-  session_tab_select_6: keybind("<leader>6,ctrl+6", "Switch to tab 6"),
-  session_tab_select_7: keybind("<leader>7,ctrl+7", "Switch to tab 7"),
-  session_tab_select_8: keybind("<leader>8,ctrl+8", "Switch to tab 8"),
-  session_tab_select_9: keybind("<leader>9,ctrl+9", "Switch to tab 9"),
+  session_tab_select_1: keybind("<leader>1,ctrl+1,alt+1", "Switch to tab 1"),
+  session_tab_select_2: keybind("<leader>2,ctrl+2,alt+2", "Switch to tab 2"),
+  session_tab_select_3: keybind("<leader>3,ctrl+3,alt+3", "Switch to tab 3"),
+  session_tab_select_4: keybind("<leader>4,ctrl+4,alt+4", "Switch to tab 4"),
+  session_tab_select_5: keybind("<leader>5,ctrl+5,alt+5", "Switch to tab 5"),
+  session_tab_select_6: keybind("<leader>6,ctrl+6,alt+6", "Switch to tab 6"),
+  session_tab_select_7: keybind("<leader>7,ctrl+7,alt+7", "Switch to tab 7"),
+  session_tab_select_8: keybind("<leader>8,ctrl+8,alt+8", "Switch to tab 8"),
+  session_tab_select_9: keybind("<leader>9,ctrl+9,alt+9", "Switch to tab 9"),
 
   stash_delete: keybind("ctrl+d", "Delete stash entry"),
   model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),

+ 22 - 0
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]

+ 82 - 6
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<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)
+        },
+      },
     }
   },
 })

+ 2 - 3
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,

+ 67 - 0
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]
+}

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

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

+ 27 - 0
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"])

+ 30 - 0
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()
+  }
+})

+ 37 - 0
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 <box />
+  }
+
+  const app = await testRender(() => (
+    <ConfigProvider config={createTuiResolvedConfig()}>
+      <Keymap.Provider>
+        <Harness />
+      </Keymap.Provider>
+    </ConfigProvider>
+  ))
+  try {
+    app.mockInput.pressKey("1", { ctrl: true })
+    expect(calls).toEqual([])
+    app.mockInput.pressKey("1", { meta: true })
+    app.mockInput.pressKey("x", { ctrl: true })
+    app.mockInput.pressKey("2")
+    expect(calls).toEqual([1, 2])
+  } finally {
+    app.renderer.destroy()
+  }
+})

+ 9 - 0
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))
+})