feat(tui): add inbox tab layout

This commit is contained in:
Ryan Vogel 2026-08-01 10:40:26 -04:00
commit a01d24e908
15 changed files with 693 additions and 27 deletions

View file

@ -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", () => {

View file

@ -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", () => {

View file

@ -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"])

View file

@ -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()
}
})

View file

@ -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()
}
})

View file

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