mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-30 17:13:35 +00:00
feat(tui): add session tab history (#39411)
This commit is contained in:
parent
38a3dbb4c4
commit
a2885d1662
6 changed files with 108 additions and 0 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -89,6 +89,8 @@ export const Definitions = {
|
|||
session_list: keybind("<leader>l", "List all sessions"),
|
||||
session_tab_next: keybind("ctrl+tab,<leader>right", "Switch to next open session tab"),
|
||||
session_tab_previous: keybind("ctrl+shift+tab,<leader>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("<leader>down", "Switch to next unread session tab"),
|
||||
session_tab_previous_unread: keybind("<leader>up", "Switch to previous unread session tab"),
|
||||
session_tab_close: keybind("<leader>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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -108,6 +108,8 @@ test("navigates session tabs with leader arrows", () => {
|
|||
|
||||
expect(config.keybinds.get("session.tab.next")).toMatchObject([{ key: "ctrl+tab,<leader>right" }])
|
||||
expect(config.keybinds.get("session.tab.previous")).toMatchObject([{ key: "ctrl+shift+tab,<leader>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: "<leader>down" }])
|
||||
expect(config.keybinds.get("session.tab.previous_unread")).toMatchObject([{ key: "<leader>up" }])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue