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