From a2885d16621ac8165df1f6f59abbf324eaedbe23 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 28 Jul 2026 19:38:46 -0400 Subject: [PATCH] feat(tui): add session tab history (#39411) --- packages/tui/src/app.tsx | 18 ++++++++ packages/tui/src/config/v1/keybind.ts | 4 ++ .../tui/src/context/session-tabs-model.ts | 30 +++++++++++++ packages/tui/src/context/session-tabs.tsx | 11 +++++ packages/tui/test/config.test.tsx | 2 + .../test/context/session-tabs-model.test.ts | 43 +++++++++++++++++++ 6 files changed, 108 insertions(+) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 86c4395953b..0e964ea4635 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -99,6 +99,8 @@ const appGlobalBindingCommands = ["session.list", "session.new"] as const const sessionTabBindingCommands = [ "session.tab.next", "session.tab.previous", + "session.tab.history.back", + "session.tab.history.forward", "session.tab.next_unread", "session.tab.previous_unread", "session.tab.close", @@ -664,6 +666,22 @@ function App(props: { pair?: DialogPairCredentials }) { enabled: sessionTabs.enabled, run: () => sessionTabs.cycle(-1), }, + { + name: "session.tab.history.back", + title: "Back in session tab history", + category: "Session", + palette: undefined, + enabled: sessionTabs.enabled, + run: () => sessionTabs.history(-1), + }, + { + name: "session.tab.history.forward", + title: "Forward in session tab history", + category: "Session", + palette: undefined, + enabled: sessionTabs.enabled, + run: () => sessionTabs.history(1), + }, { name: "session.tab.next_unread", title: "Next unread session tab", diff --git a/packages/tui/src/config/v1/keybind.ts b/packages/tui/src/config/v1/keybind.ts index ab5f8ac4083..50cf2c3d152 100644 --- a/packages/tui/src/config/v1/keybind.ts +++ b/packages/tui/src/config/v1/keybind.ts @@ -89,6 +89,8 @@ export const Definitions = { session_list: keybind("l", "List all sessions"), session_tab_next: keybind("ctrl+tab,right", "Switch to next open session tab"), session_tab_previous: keybind("ctrl+shift+tab,left", "Switch to previous open session tab"), + session_tab_history_back: keybind("ctrl+o", "Go back in session tab history"), + session_tab_history_forward: keybind("ctrl+i", "Go forward in session tab history"), session_tab_next_unread: keybind("down", "Switch to next unread session tab"), session_tab_previous_unread: keybind("up", "Switch to previous unread session tab"), session_tab_close: keybind("w", "Close current session tab"), @@ -305,6 +307,8 @@ export const CommandMap = { session_list: "session.list", session_tab_next: "session.tab.next", session_tab_previous: "session.tab.previous", + session_tab_history_back: "session.tab.history.back", + session_tab_history_forward: "session.tab.history.forward", session_tab_next_unread: "session.tab.next_unread", session_tab_previous_unread: "session.tab.previous_unread", session_tab_close: "session.tab.close", diff --git a/packages/tui/src/context/session-tabs-model.ts b/packages/tui/src/context/session-tabs-model.ts index eceecc4f819..dc887b92862 100644 --- a/packages/tui/src/context/session-tabs-model.ts +++ b/packages/tui/src/context/session-tabs-model.ts @@ -5,6 +5,11 @@ export type SessionTab = { export type SessionTabUnread = "activity" | "error" +export type SessionTabHistory = { + entries: readonly string[] + index: number +} + export function sessionTabComplete(unread: SessionTabUnread | undefined, busy: boolean) { return unread === "activity" && !busy } @@ -37,6 +42,31 @@ export function cycleSessionTab(tabs: readonly SessionTab[], active: string | un return tabs[(start + direction + tabs.length) % tabs.length] } +export function recordSessionTabHistory(history: SessionTabHistory, sessionID: string): SessionTabHistory { + if (history.entries[history.index] === sessionID) return history + const entries = [...history.entries.slice(0, history.index + 1), sessionID] + return { entries, index: entries.length - 1 } +} + +export function moveSessionTabHistory( + history: SessionTabHistory, + tabs: readonly SessionTab[], + active: string | undefined, + direction: 1 | -1, +) { + if (!active) { + const sessionID = history.entries[history.index] + return tabs.some((tab) => tab.sessionID === sessionID) ? { history, sessionID } : { history, sessionID: undefined } + } + const entries = history.entries.map((sessionID, index) => ({ sessionID, index })) + const candidates = direction === -1 ? entries.slice(0, history.index).reverse() : entries.slice(history.index + 1) + const target = candidates.find( + (entry) => entry.sessionID !== active && tabs.some((tab) => tab.sessionID === entry.sessionID), + ) + if (!target) return { history, sessionID: undefined } + return { history: { ...history, index: target.index }, sessionID: target.sessionID } +} + export function adaptiveSessionTabLayout( tabs: readonly SessionTab[], active: string | undefined, diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 73d892d0330..06af71ab112 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -13,8 +13,11 @@ import { isRecord } from "../util/record" import { closeSessionTab, cycleSessionTab, + moveSessionTabHistory, openSessionTab, + recordSessionTabHistory, type SessionTab, + type SessionTabHistory, type SessionTabUnread, } from "./session-tabs-model" @@ -43,6 +46,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp tabs: [], unread: {}, }) + let history: SessionTabHistory = { entries: [], index: -1 } const root = (sessionID: string) => data.session.root(sessionID) const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined) @@ -155,6 +159,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp const routeSessionID = route.data.sessionID batch(() => { const opened = open(routeSessionID) + history = recordSessionTabHistory(history, opened.sessionID) const changed = clearUnread(opened.sessionID) if (opened.changed || changed) untrack(save) }) @@ -241,6 +246,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp ) if (tab) route.navigate({ type: "session", sessionID: tab.sessionID }) }, + history(direction: 1 | -1) { + if (!enabled()) return + const next = moveSessionTabHistory(history, store.tabs, current(), direction) + history = next.history + if (next.sessionID) route.navigate({ type: "session", sessionID: next.sessionID }) + }, selectIndex(index: number) { if (!enabled()) return const tab = store.tabs[index] diff --git a/packages/tui/test/config.test.tsx b/packages/tui/test/config.test.tsx index 90c63871c84..d1aed94f9fb 100644 --- a/packages/tui/test/config.test.tsx +++ b/packages/tui/test/config.test.tsx @@ -108,6 +108,8 @@ test("navigates session tabs with leader arrows", () => { expect(config.keybinds.get("session.tab.next")).toMatchObject([{ key: "ctrl+tab,right" }]) expect(config.keybinds.get("session.tab.previous")).toMatchObject([{ key: "ctrl+shift+tab,left" }]) + expect(config.keybinds.get("session.tab.history.back")).toMatchObject([{ key: "ctrl+o" }]) + expect(config.keybinds.get("session.tab.history.forward")).toMatchObject([{ key: "ctrl+i" }]) expect(config.keybinds.get("session.tab.next_unread")).toMatchObject([{ key: "down" }]) expect(config.keybinds.get("session.tab.previous_unread")).toMatchObject([{ key: "up" }]) }) diff --git a/packages/tui/test/context/session-tabs-model.test.ts b/packages/tui/test/context/session-tabs-model.test.ts index ce4234c5183..e14db3c3d91 100644 --- a/packages/tui/test/context/session-tabs-model.test.ts +++ b/packages/tui/test/context/session-tabs-model.test.ts @@ -3,7 +3,9 @@ import { adaptiveSessionTabLayout, closeSessionTab, cycleSessionTab, + moveSessionTabHistory, openSessionTab, + recordSessionTabHistory, sessionTabComplete, } from "../../src/context/session-tabs-model" @@ -32,6 +34,47 @@ describe("session tabs", () => { expect(cycleSessionTab(tabs, "b", 1)?.sessionID).toBe("a") }) + test("moves backward and forward through selection history", () => { + const tabs = ["a", "b", "c", "d"].map((sessionID) => ({ sessionID })) + const history = ["a", "b", "c", "d"].reduce(recordSessionTabHistory, { entries: [], index: -1 }) + const backToC = moveSessionTabHistory(history, tabs, "d", -1) + const backToB = moveSessionTabHistory(backToC.history, tabs, "c", -1) + const forwardToC = moveSessionTabHistory(backToB.history, tabs, "b", 1) + const forwardToD = moveSessionTabHistory(forwardToC.history, tabs, "c", 1) + + expect([backToC.sessionID, backToB.sessionID, forwardToC.sessionID, forwardToD.sessionID]).toEqual([ + "c", + "b", + "c", + "d", + ]) + }) + + test("truncates forward history after a new selection", () => { + const tabs = ["a", "b", "c", "d"].map((sessionID) => ({ sessionID })) + const history = ["a", "b", "c"].reduce(recordSessionTabHistory, { entries: [], index: -1 }) + const back = moveSessionTabHistory(history, tabs, "c", -1) + const branched = recordSessionTabHistory(back.history, "d") + + expect(branched).toEqual({ entries: ["a", "b", "d"], index: 2 }) + expect(moveSessionTabHistory(branched, tabs, "d", 1).sessionID).toBeUndefined() + }) + + test("skips closed tabs and duplicate entries for the active tab", () => { + const tabs = ["a", "b"].map((sessionID) => ({ sessionID })) + const history = ["a", "b", "c", "b"].reduce(recordSessionTabHistory, { entries: [], index: -1 }) + + expect(moveSessionTabHistory(history, tabs, "b", -1).sessionID).toBe("a") + expect(recordSessionTabHistory(history, "b")).toBe(history) + }) + + test("returns to the latest history entry when no tab is active", () => { + const tabs = ["a", "b"].map((sessionID) => ({ sessionID })) + const history = ["a", "b"].reduce(recordSessionTabHistory, { entries: [], index: -1 }) + + expect(moveSessionTabHistory(history, tabs, undefined, -1).sessionID).toBe("b") + }) + test("reveals completion activity only after session work becomes idle", () => { expect(sessionTabComplete("activity", true)).toBe(false) expect(sessionTabComplete("activity", false)).toBe(true)