Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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}`,
Expand Down
2 changes: 2 additions & 0 deletions packages/tui/src/config/v1/keybind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export const Definitions = {
session_tab_next_unread: keybind("<leader>down", "Switch to next unread tab"),
session_tab_previous_unread: keybind("<leader>up", "Switch to previous unread tab"),
session_tab_close: keybind("<leader>w", "Close current tab"),
session_tab_reopen: keybind("ctrl+shift+t", "Reopen last closed tab"),
session_timeline: keybind("<leader>g", "Show session timeline"),
session_fork: keybind("none", "Fork session from message"),
session_rename: keybind("ctrl+r", "Rename session"),
Expand Down Expand Up @@ -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",
Expand Down
33 changes: 33 additions & 0 deletions packages/tui/src/context/session-tabs-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
21 changes: 21 additions & 0 deletions packages/tui/src/context/session-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import {
moveSessionTab,
moveSessionTabHistory,
openSessionTab,
recordClosedSessionTab,
recordSessionTabHistory,
reopenSessionTab,
type ClosedSessionTab,
type SessionTab,
type SessionTabHistory,
type SessionTabUnread,
Expand Down Expand Up @@ -57,6 +60,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const fallback = empty()
const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({})
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
Expand Down Expand Up @@ -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())
}),
)
Expand Down Expand Up @@ -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)
Expand Down
41 changes: 41 additions & 0 deletions packages/tui/test/context/session-tabs-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import {
moveSessionTab,
moveSessionTabHistory,
openSessionTab,
recordClosedSessionTab,
recordSessionTabHistory,
reopenSessionTab,
seedSessionTabMotion,
sessionTabComplete,
sessionTabOverflowWidth,
Expand Down Expand Up @@ -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)
Expand Down
Loading