diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 329b54a9523..f0bc50cc81b 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -105,6 +105,7 @@ const sessionTabBindingCommands = [ "session.tab.next_unread", "session.tab.previous_unread", "session.tab.close", + "session.tab.reopen", "session.tab.select.1", "session.tab.select.2", "session.tab.select.3", @@ -721,6 +722,13 @@ function App(props: { pair?: DialogPairCredentials }) { enabled: sessionTabs.enabled, run: () => sessionTabs.close(), }, + { + name: "session.tab.reopen", + title: "Reopen closed tab", + category: "Session", + enabled: sessionTabs.enabled, + run: () => sessionTabs.reopen(), + }, ...Array.from({ length: 9 }, (_, i) => ({ name: `session.tab.select.${i + 1}`, title: `Switch to tab ${i + 1}`, diff --git a/packages/tui/src/config/v1/keybind.ts b/packages/tui/src/config/v1/keybind.ts index f958a438986..508099dc760 100644 --- a/packages/tui/src/config/v1/keybind.ts +++ b/packages/tui/src/config/v1/keybind.ts @@ -94,6 +94,7 @@ export const Definitions = { session_tab_next_unread: keybind("down", "Switch to next unread tab"), session_tab_previous_unread: keybind("up", "Switch to previous unread tab"), session_tab_close: keybind("w", "Close current tab"), + session_tab_reopen: keybind("ctrl+shift+t", "Reopen last closed tab"), session_timeline: keybind("g", "Show session timeline"), session_fork: keybind("none", "Fork session from message"), session_rename: keybind("ctrl+r", "Rename session"), @@ -297,6 +298,7 @@ export const CommandMap = { session_tab_next_unread: "session.tab.next_unread", session_tab_previous_unread: "session.tab.previous_unread", session_tab_close: "session.tab.close", + session_tab_reopen: "session.tab.reopen", session_timeline: "session.timeline", session_fork: "session.fork", session_rename: "session.rename", diff --git a/packages/tui/src/context/session-tabs-model.ts b/packages/tui/src/context/session-tabs-model.ts index 433a19150bf..0434da79223 100644 --- a/packages/tui/src/context/session-tabs-model.ts +++ b/packages/tui/src/context/session-tabs-model.ts @@ -36,6 +36,39 @@ export function closeSessionTab(tabs: readonly SessionTab[], sessionID: string) } } +export type ClosedSessionTab = { + tab: SessionTab + index: number +} + +const CLOSED_SESSION_TAB_LIMIT = 10 + +export function recordClosedSessionTab( + stack: readonly ClosedSessionTab[], + tab: SessionTab, + index: number, +): ClosedSessionTab[] { + return [...stack.filter((entry) => entry.tab.sessionID !== tab.sessionID), { tab, index }].slice( + -CLOSED_SESSION_TAB_LIMIT, + ) +} + +/** + * Pop the most recently closed tab that is not already open and restore it at its original + * position. Entries for already-open sessions are consumed so repeated reopens walk the stack. + */ +export function reopenSessionTab(stack: readonly ClosedSessionTab[], tabs: readonly SessionTab[]) { + const remaining = [...stack] + while (remaining.length > 0) { + const entry = remaining.pop()! + if (tabs.some((tab) => tab.sessionID === entry.tab.sessionID)) continue + const next = [...tabs] + next.splice(Math.min(entry.index, tabs.length), 0, entry.tab) + return { stack: remaining, tabs: next, sessionID: entry.tab.sessionID } + } + return { stack: remaining, tabs: undefined, sessionID: undefined } +} + export function moveSessionTab(tabs: SessionTab[], sessionID: string, index: number): SessionTab[] { const from = tabs.findIndex((tab) => tab.sessionID === sessionID) const to = Math.max(0, Math.min(tabs.length - 1, index)) diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 5a6653108cc..096804338f2 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -14,7 +14,10 @@ import { moveSessionTab, moveSessionTabHistory, openSessionTab, + recordClosedSessionTab, recordSessionTabHistory, + reopenSessionTab, + type ClosedSessionTab, type SessionTab, type SessionTabHistory, type SessionTabUnread, @@ -57,6 +60,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp const fallback = empty() const [promptPulses, setPromptPulses] = createSignal>({}) let history: SessionTabHistory = { entries: [], index: -1 } + // User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned. + let closedTabs: ClosedSessionTab[] = [] function state() { if (config.tabs?.scope === "global") return store.global @@ -193,6 +198,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp ) onCleanup( event.on("session.deleted", (evt) => { + const target = root(evt.data.sessionID) + closedTabs = closedTabs.filter((entry) => entry.tab.sessionID !== target) remove(evt.data.sessionID, enabled()) }), ) @@ -244,8 +251,22 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp if (route.data.type === "home" && session) route.navigate({ type: "session", sessionID: session }) return } + const index = state().tabs.findIndex((tab) => tab.sessionID === target) + const tab = state().tabs[index] + if (tab) closedTabs = recordClosedSessionTab(closedTabs, tab, index) remove(target, true) }, + reopen() { + if (!enabled()) return + const result = reopenSessionTab(closedTabs, state().tabs) + closedTabs = result.stack + const tabs = result.tabs + if (!tabs || !result.sessionID) return + update((draft) => { + draft.tabs = tabs + }) + route.navigate({ type: "session", sessionID: result.sessionID }) + }, move(sessionID: string, index: number) { if (!enabled()) return const session = root(sessionID) diff --git a/packages/tui/test/context/session-tabs-model.test.ts b/packages/tui/test/context/session-tabs-model.test.ts index 0c89652e9e9..68375dd57ef 100644 --- a/packages/tui/test/context/session-tabs-model.test.ts +++ b/packages/tui/test/context/session-tabs-model.test.ts @@ -6,7 +6,9 @@ import { moveSessionTab, moveSessionTabHistory, openSessionTab, + recordClosedSessionTab, recordSessionTabHistory, + reopenSessionTab, seedSessionTabMotion, sessionTabComplete, sessionTabOverflowWidth, @@ -136,6 +138,45 @@ describe("session tabs", () => { expect(moveSessionTabHistory(current, closed.tabs, "b", -1).sessionID).toBe("c") }) + test("reopens the most recently closed tab at its original position", () => { + const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID })) + const stack = recordClosedSessionTab([], { sessionID: "b", title: "Middle" }, 1) + const reopened = reopenSessionTab(stack, [{ sessionID: "a" }, { sessionID: "c" }]) + + expect(reopened.sessionID).toBe("b") + expect(reopened.tabs).toEqual([{ sessionID: "a" }, { sessionID: "b", title: "Middle" }, { sessionID: "c" }]) + expect(reopened.stack).toEqual([]) + expect(reopenSessionTab([], tabs)).toEqual({ stack: [], tabs: undefined, sessionID: undefined }) + }) + + test("skips and consumes closed entries that are already open", () => { + const stack = [ + { tab: { sessionID: "a" }, index: 0 }, + { tab: { sessionID: "b" }, index: 1 }, + ] + const reopened = reopenSessionTab(stack, [{ sessionID: "b" }]) + + expect(reopened.sessionID).toBe("a") + expect(reopened.tabs).toEqual([{ sessionID: "a" }, { sessionID: "b" }]) + expect(reopened.stack).toEqual([]) + }) + + test("clamps restored positions and keeps one entry per session", () => { + const twice = recordClosedSessionTab(recordClosedSessionTab([], { sessionID: "a" }, 5), { sessionID: "a" }, 2) + expect(twice).toEqual([{ tab: { sessionID: "a" }, index: 2 }]) + + const reopened = reopenSessionTab(twice, [{ sessionID: "b" }]) + expect(reopened.tabs).toEqual([{ sessionID: "b" }, { sessionID: "a" }]) + + const overflow = Array.from({ length: 12 }, (_, index) => ({ sessionID: String(index) })).reduce( + (stack, tab, index) => recordClosedSessionTab(stack, tab, index), + twice, + ) + expect(overflow).toHaveLength(10) + expect(overflow.at(-1)?.tab.sessionID).toBe("11") + expect(overflow[0]?.tab.sessionID).toBe("2") + }) + test("reveals completion activity only after session work becomes idle", () => { expect(sessionTabComplete("activity", true)).toBe(false) expect(sessionTabComplete("activity", false)).toBe(true)