feat(tui): add session switcher plugin (#29861)
This commit is contained in:
@@ -47,6 +47,7 @@ export const Flag = {
|
|||||||
|
|
||||||
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
|
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
|
||||||
OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
||||||
|
OPENCODE_EXPERIMENTAL_SESSION_SWITCHER: enabledByExperimental("OPENCODE_EXPERIMENTAL_SESSION_SWITCHER"),
|
||||||
|
|
||||||
// Evaluated at access time (not module load) because tests, the CLI, and
|
// Evaluated at access time (not module load) because tests, the CLI, and
|
||||||
// external tooling set these env vars at runtime.
|
// external tooling set these env vars at runtime.
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import { useDialog } from "@tui/ui/dialog"
|
||||||
|
import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "@tui/ui/dialog-select"
|
||||||
|
import { useRoute } from "@tui/context/route"
|
||||||
|
import { useSync } from "@tui/context/sync"
|
||||||
|
import { useProject } from "@tui/context/project"
|
||||||
|
import { useTheme } from "@tui/context/theme"
|
||||||
|
import { useSDK } from "@tui/context/sdk"
|
||||||
|
import { useLocal } from "@tui/context/local"
|
||||||
|
import { useToast } from "@tui/ui/toast"
|
||||||
|
import { useCommandShortcut } from "@tui/keymap"
|
||||||
|
import { createEffect, createMemo, createResource, createSignal, on, onMount, untrack } from "solid-js"
|
||||||
|
import { Spinner } from "@tui/component/spinner"
|
||||||
|
import { DialogSessionRename } from "@tui/component/dialog-session-rename"
|
||||||
|
import { DialogSessionDeleteFailed } from "@tui/component/dialog-session-delete-failed"
|
||||||
|
import {
|
||||||
|
openWorkspaceSelect,
|
||||||
|
type WorkspaceSelection,
|
||||||
|
warpWorkspaceSession,
|
||||||
|
} from "@tui/component/dialog-workspace-create"
|
||||||
|
import { createDebouncedSignal } from "@tui/util/signal"
|
||||||
|
import { errorMessage } from "@/util/error"
|
||||||
|
import { SessionPreviewPane, createLeadingTrailingSignal } from "./preview-pane"
|
||||||
|
import { relativeTime } from "./util"
|
||||||
|
|
||||||
|
export function SessionSwitcherDialog() {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const route = useRoute()
|
||||||
|
const sync = useSync()
|
||||||
|
const project = useProject()
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const sdk = useSDK()
|
||||||
|
const local = useLocal()
|
||||||
|
const toast = useToast()
|
||||||
|
const [toDelete, setToDelete] = createSignal<string>()
|
||||||
|
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||||
|
const deleteHint = useCommandShortcut("session.delete")
|
||||||
|
const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
|
||||||
|
const quickSwitch9 = useCommandShortcut("session.quick_switch.9")
|
||||||
|
let select: DialogSelectRef<string> | undefined
|
||||||
|
|
||||||
|
const [searchResults, { refetch }] = createResource(
|
||||||
|
() => ({ query: search(), filter: sync.session.query() }),
|
||||||
|
async (input) => {
|
||||||
|
if (!input.query) return undefined
|
||||||
|
const result = await sdk.client.session.list({ search: input.query, limit: 30, ...input.filter })
|
||||||
|
return result.data ?? []
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||||
|
const sessions = createMemo(() => searchResults() ?? sync.data.session)
|
||||||
|
const [focusedSession, setFocusedSession, scheduleFocused] = createLeadingTrailingSignal<string | undefined>(
|
||||||
|
undefined,
|
||||||
|
150,
|
||||||
|
)
|
||||||
|
const focusedSessionInfo = createMemo(() => {
|
||||||
|
const id = focusedSession()
|
||||||
|
if (!id) return undefined
|
||||||
|
return sessions().find((session) => session.id === id) ?? sync.data.session.find((session) => session.id === id)
|
||||||
|
})
|
||||||
|
|
||||||
|
function recoverFailed(session: NonNullable<ReturnType<typeof sessions>[number]>) {
|
||||||
|
const workspace = project.workspace.get(session.workspaceID!)
|
||||||
|
const list = () => dialog.replace(() => <SessionSwitcherDialog />)
|
||||||
|
const warp = async (selection: WorkspaceSelection) => {
|
||||||
|
const workspaceID = await (async () => {
|
||||||
|
if (selection.type === "none") return null
|
||||||
|
if (selection.type === "existing") return selection.workspaceID
|
||||||
|
const result = await sdk.client.experimental.workspace
|
||||||
|
.create({ type: selection.workspaceType, branch: null })
|
||||||
|
.catch(() => undefined)
|
||||||
|
const created = result?.data
|
||||||
|
if (!created) {
|
||||||
|
toast.show({
|
||||||
|
message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await project.workspace.sync()
|
||||||
|
return created.id
|
||||||
|
})()
|
||||||
|
if (workspaceID === undefined) return
|
||||||
|
await warpWorkspaceSession({
|
||||||
|
dialog,
|
||||||
|
sdk,
|
||||||
|
sync,
|
||||||
|
project,
|
||||||
|
toast,
|
||||||
|
sourceWorkspaceID: session.workspaceID,
|
||||||
|
workspaceID,
|
||||||
|
sessionID: session.id,
|
||||||
|
copyChanges: false,
|
||||||
|
done: list,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
dialog.replace(() => (
|
||||||
|
<DialogSessionDeleteFailed
|
||||||
|
session={session.title}
|
||||||
|
workspace={workspace?.name ?? session.workspaceID!}
|
||||||
|
onDone={list}
|
||||||
|
onDelete={async () => {
|
||||||
|
const current = currentSessionID()
|
||||||
|
const info = current ? sync.data.session.find((item) => item.id === current) : undefined
|
||||||
|
const result = await sdk.client.experimental.workspace.remove({ id: session.workspaceID! })
|
||||||
|
if (result.error) {
|
||||||
|
toast.show({
|
||||||
|
variant: "error",
|
||||||
|
title: "Failed to delete workspace",
|
||||||
|
message: errorMessage(result.error),
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
await project.workspace.sync()
|
||||||
|
await sync.session.refresh()
|
||||||
|
if (search()) await refetch()
|
||||||
|
if (info?.workspaceID === session.workspaceID) {
|
||||||
|
route.navigate({ type: "home" })
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}}
|
||||||
|
onRestore={() => {
|
||||||
|
void openWorkspaceSelect({
|
||||||
|
dialog,
|
||||||
|
sdk,
|
||||||
|
sync,
|
||||||
|
project,
|
||||||
|
toast,
|
||||||
|
onSelect: (selection) => {
|
||||||
|
void warp(selection)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderByRecency(sessionsList: NonNullable<ReturnType<typeof sessions>>) {
|
||||||
|
return sessionsList
|
||||||
|
.filter((x) => x.parentID === undefined)
|
||||||
|
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||||
|
.map((x) => x.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [browseOrder] = createSignal<string[]>(orderByRecency(sync.data.session))
|
||||||
|
|
||||||
|
const quickSwitchHint = createMemo(() => {
|
||||||
|
const first = quickSwitch1()
|
||||||
|
const last = quickSwitch9()
|
||||||
|
if (!first || !last) return undefined
|
||||||
|
return quickSwitchRange(first, last)
|
||||||
|
})
|
||||||
|
const quickSwitchFooterHints = createMemo(() => {
|
||||||
|
const hint = quickSwitchHint()
|
||||||
|
return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : []
|
||||||
|
})
|
||||||
|
|
||||||
|
const options = createMemo<DialogSelectOption<string>[]>(() => {
|
||||||
|
const today = new Date().toDateString()
|
||||||
|
const sessionMap = new Map(
|
||||||
|
sessions()
|
||||||
|
.filter((x) => x.parentID === undefined)
|
||||||
|
.map((x) => [x.id, x]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const searchResult = searchResults()
|
||||||
|
const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder()
|
||||||
|
|
||||||
|
const pinned = local.session.pinned().filter((id) => sessionMap.has(id))
|
||||||
|
const pinnedSet = new Set(pinned)
|
||||||
|
const slotByID = new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
|
||||||
|
|
||||||
|
function buildOption(id: string, category: string): DialogSelectOption<string> | undefined {
|
||||||
|
const x = sessionMap.get(id)
|
||||||
|
if (!x) return undefined
|
||||||
|
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
|
||||||
|
|
||||||
|
const footer = relativeTime(x.time.updated)
|
||||||
|
const isWorktree = workspace?.type === "worktree"
|
||||||
|
|
||||||
|
const isDeleting = toDelete() === x.id
|
||||||
|
const status = sync.data.session_status?.[x.id]
|
||||||
|
const isWorking = status?.type === "busy" || status?.type === "retry"
|
||||||
|
const slot = slotByID.get(x.id)
|
||||||
|
const gutter = isWorking
|
||||||
|
? () => <Spinner />
|
||||||
|
: slot !== undefined
|
||||||
|
? () => <text fg={theme.accent}>{slot}</text>
|
||||||
|
: undefined
|
||||||
|
const titleText = isDeleting ? `Press ${deleteHint()} again to confirm` : isWorktree ? `⎇ ${x.title}` : x.title
|
||||||
|
return {
|
||||||
|
title: titleText,
|
||||||
|
bg: isDeleting ? theme.error : undefined,
|
||||||
|
value: x.id,
|
||||||
|
category,
|
||||||
|
footer,
|
||||||
|
gutter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = displayOrder
|
||||||
|
.filter((id) => !pinnedSet.has(id))
|
||||||
|
.map((id) => {
|
||||||
|
const x = sessionMap.get(id)
|
||||||
|
if (!x) return undefined
|
||||||
|
const label = new Date(x.time.updated).toDateString()
|
||||||
|
return buildOption(id, label === today ? "Today" : label)
|
||||||
|
})
|
||||||
|
.filter((x): x is DialogSelectOption<string> => x !== undefined)
|
||||||
|
|
||||||
|
return [
|
||||||
|
...pinned.map((id) => buildOption(id, "Pinned")).filter((x): x is DialogSelectOption<string> => x !== undefined),
|
||||||
|
...remaining,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on([options, currentSessionID], ([items, current]) => {
|
||||||
|
const selected = untrack(() => select?.selected)
|
||||||
|
const selectedID = selected && items.some((item) => item.value === selected.value) ? selected.value : undefined
|
||||||
|
const currentID = current && items.some((item) => item.value === current) ? current : undefined
|
||||||
|
setFocusedSession(selectedID ?? currentID ?? items[0]?.value)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
dialog.setSize("xlarge")
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = (
|
||||||
|
<DialogSelect
|
||||||
|
ref={(value) => (select = value)}
|
||||||
|
title="Sessions"
|
||||||
|
options={options()}
|
||||||
|
skipFilter={true}
|
||||||
|
current={currentSessionID()}
|
||||||
|
onFilter={setSearch}
|
||||||
|
onMove={(option) => {
|
||||||
|
setToDelete(undefined)
|
||||||
|
scheduleFocused(option.value)
|
||||||
|
}}
|
||||||
|
onSelect={(option) => {
|
||||||
|
route.navigate({
|
||||||
|
type: "session",
|
||||||
|
sessionID: option.value,
|
||||||
|
})
|
||||||
|
dialog.clear()
|
||||||
|
}}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
command: "session.pin.toggle",
|
||||||
|
title: "pin/unpin",
|
||||||
|
onTrigger: (option: { value: string }) => {
|
||||||
|
local.session.togglePin(option.value)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: "session.delete",
|
||||||
|
title: "delete",
|
||||||
|
onTrigger: async (option) => {
|
||||||
|
if (toDelete() === option.value) {
|
||||||
|
const session = sessions().find((item) => item.id === option.value)
|
||||||
|
const status = session?.workspaceID ? project.workspace.status(session.workspaceID) : undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sdk.client.session.delete({
|
||||||
|
sessionID: option.value,
|
||||||
|
})
|
||||||
|
if (result.error) {
|
||||||
|
if (session?.workspaceID) {
|
||||||
|
recoverFailed(session)
|
||||||
|
} else {
|
||||||
|
toast.show({
|
||||||
|
variant: "error",
|
||||||
|
title: "Failed to delete session",
|
||||||
|
message: errorMessage(result.error),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setToDelete(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (session?.workspaceID) {
|
||||||
|
recoverFailed(session)
|
||||||
|
} else {
|
||||||
|
toast.show({
|
||||||
|
variant: "error",
|
||||||
|
title: "Failed to delete session",
|
||||||
|
message: errorMessage(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setToDelete(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (status && status !== "connected") {
|
||||||
|
await sync.session.refresh()
|
||||||
|
}
|
||||||
|
if (search()) await refetch()
|
||||||
|
setToDelete(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setToDelete(option.value)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: "session.rename",
|
||||||
|
title: "rename",
|
||||||
|
onTrigger: async (option) => {
|
||||||
|
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
footerHints={quickSwitchFooterHints()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="row" width="100%">
|
||||||
|
<box flexBasis={68} flexShrink={0}>
|
||||||
|
{list}
|
||||||
|
</box>
|
||||||
|
<box width={1} flexShrink={0} border={["left"]} borderColor={theme.borderSubtle} />
|
||||||
|
<box flexGrow={1} flexShrink={1} flexDirection="column">
|
||||||
|
<SessionPreviewPane sessionID={focusedSession} session={focusedSessionInfo} />
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function quickSwitchRange(first: string, last: string) {
|
||||||
|
const prefix = first.slice(0, -1)
|
||||||
|
if (first.endsWith("1") && last === `${prefix}9`) return `${prefix}1-9`
|
||||||
|
return `${first} through ${last}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { TuiPlugin } from "@opencode-ai/plugin/tui"
|
||||||
|
import type { InternalTuiPlugin } from "../../plugin/internal"
|
||||||
|
import { SessionSwitcherDialog } from "./dialog"
|
||||||
|
|
||||||
|
const id = "internal:session-switcher"
|
||||||
|
|
||||||
|
const tui: TuiPlugin = async (api) => {
|
||||||
|
api.keymap.registerLayer({
|
||||||
|
priority: 1000,
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
name: "session.list",
|
||||||
|
title: "Switch session",
|
||||||
|
category: "Session",
|
||||||
|
namespace: "palette",
|
||||||
|
suggested: () => api.state.session.count() > 0,
|
||||||
|
slashName: "sessions",
|
||||||
|
slashAliases: ["resume", "continue"],
|
||||||
|
run() {
|
||||||
|
api.ui.dialog.replace(() => <SessionSwitcherDialog />)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin: InternalTuiPlugin = {
|
||||||
|
id,
|
||||||
|
tui,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default plugin
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
import { createResource, Show, createMemo, createSignal, onMount, type Accessor, type JSX } from "solid-js"
|
||||||
|
import { TextAttributes, type RGBA } from "@opentui/core"
|
||||||
|
import { useTerminalDimensions } from "@opentui/solid"
|
||||||
|
import { debounce, leadingAndTrailing } from "@solid-primitives/scheduled"
|
||||||
|
import type { Message, Part, Session as SdkSession, SnapshotFileDiff } from "@opencode-ai/sdk/v2"
|
||||||
|
import { useTheme } from "@tui/context/theme"
|
||||||
|
import { useSDK } from "@tui/context/sdk"
|
||||||
|
import { useSync } from "@tui/context/sync"
|
||||||
|
import { Locale } from "@/util/locale"
|
||||||
|
import { Spinner } from "@tui/component/spinner"
|
||||||
|
import { extractMessageMarkdown, extractMessageText, formatDiffSummary, relativeTime, shortModelLabel } from "./util"
|
||||||
|
|
||||||
|
type WithParts = { info: Message; parts: Part[] }
|
||||||
|
|
||||||
|
type Sdk = ReturnType<typeof useSDK>
|
||||||
|
type Sync = ReturnType<typeof useSync>
|
||||||
|
|
||||||
|
const messageCache = new Map<string, Promise<WithParts[]>>()
|
||||||
|
const diffCache = new Map<string, Promise<SnapshotFileDiff[]>>()
|
||||||
|
|
||||||
|
function cacheKey(sessionID: string, version: number) {
|
||||||
|
return `${sessionID}:${version}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateFromSync(sync: Sync, sessionID: string): WithParts[] | undefined {
|
||||||
|
const infos = sync.data.message[sessionID]
|
||||||
|
if (!infos || infos.length === 0) return undefined
|
||||||
|
return infos.map((info) => ({ info, parts: sync.data.part[info.id] ?? [] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMessages(sdk: Sdk, sessionID: string, version: number): Promise<WithParts[]> {
|
||||||
|
const key = cacheKey(sessionID, version)
|
||||||
|
const cached = messageCache.get(key)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const promise = sdk.client.session
|
||||||
|
.messages({ sessionID, limit: 50 })
|
||||||
|
.then((res) => {
|
||||||
|
if (res.error) messageCache.delete(key)
|
||||||
|
return (res.data as WithParts[] | undefined) ?? []
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
messageCache.delete(key)
|
||||||
|
return [] as WithParts[]
|
||||||
|
})
|
||||||
|
messageCache.set(key, promise)
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDiff(sdk: Sdk, sessionID: string, version: number): Promise<SnapshotFileDiff[]> {
|
||||||
|
const key = cacheKey(sessionID, version)
|
||||||
|
const cached = diffCache.get(key)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const promise = sdk.client.session
|
||||||
|
.diff({ sessionID })
|
||||||
|
.then((res) => {
|
||||||
|
if (res.error) diffCache.delete(key)
|
||||||
|
return (res.data as SnapshotFileDiff[] | undefined) ?? []
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
diffCache.delete(key)
|
||||||
|
return [] as SnapshotFileDiff[]
|
||||||
|
})
|
||||||
|
diffCache.set(key, promise)
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prefetchPreviews(sdk: Sdk, sync: Sync, sessionIDs: readonly string[]) {
|
||||||
|
for (const id of sessionIDs) {
|
||||||
|
const version = sync.data.session.find((session) => session.id === id)?.time.updated ?? 0
|
||||||
|
if (!hydrateFromSync(sync, id)) loadMessages(sdk, id, version).catch(() => {})
|
||||||
|
if (!sync.data.session_diff[id]?.length) loadDiff(sdk, id, version).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLeadingTrailingSignal<T>(initial: T, ms: number): [Accessor<T>, (v: T) => void, (v: T) => void] {
|
||||||
|
const [get, set] = createSignal(initial)
|
||||||
|
const setNow = (v: T) => set(() => v)
|
||||||
|
const schedule = leadingAndTrailing(debounce, setNow, ms)
|
||||||
|
return [get, setNow, schedule]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionPreviewPane(props: {
|
||||||
|
sessionID: Accessor<string | undefined>
|
||||||
|
session?: Accessor<SdkSession | undefined>
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const sdk = useSDK()
|
||||||
|
const sync = useSync()
|
||||||
|
const dimensions = useTerminalDimensions()
|
||||||
|
|
||||||
|
const maxHeight = createMemo(() => Math.max(8, Math.floor(dimensions().height / 2) - 4))
|
||||||
|
const session = createMemo(() => {
|
||||||
|
const provided = props.session?.()
|
||||||
|
if (provided) return provided
|
||||||
|
const id = props.sessionID()
|
||||||
|
if (!id) return undefined
|
||||||
|
return sync.data.session.find((s) => s.id === id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const status = createMemo(() => {
|
||||||
|
const id = props.sessionID()
|
||||||
|
if (!id) return undefined
|
||||||
|
return sync.data.session_status?.[id]?.type
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const top = sync.data.session
|
||||||
|
.filter((s) => s.parentID === undefined)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.time.updated - a.time.updated)
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((s) => s.id)
|
||||||
|
prefetchPreviews(sdk, sync, top)
|
||||||
|
})
|
||||||
|
|
||||||
|
const syncedMessages = createMemo(() => {
|
||||||
|
const id = props.sessionID()
|
||||||
|
if (!id) return undefined
|
||||||
|
return hydrateFromSync(sync, id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const syncedDiff = createMemo(() => {
|
||||||
|
const id = props.sessionID()
|
||||||
|
if (!id) return undefined
|
||||||
|
const diff = sync.data.session_diff[id]
|
||||||
|
return diff && diff.length > 0 ? (diff as SnapshotFileDiff[]) : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const [fetchedMessages] = createResource(
|
||||||
|
() => {
|
||||||
|
const id = props.sessionID()
|
||||||
|
if (!id || syncedMessages()) return undefined
|
||||||
|
return { sessionID: id, version: session()?.time.updated ?? 0 }
|
||||||
|
},
|
||||||
|
async (input) => loadMessages(sdk, input.sessionID, input.version),
|
||||||
|
)
|
||||||
|
|
||||||
|
const [fetchedDiff] = createResource(
|
||||||
|
() => {
|
||||||
|
const id = props.sessionID()
|
||||||
|
if (!id || syncedDiff()) return undefined
|
||||||
|
return { sessionID: id, version: session()?.time.updated ?? 0 }
|
||||||
|
},
|
||||||
|
async (input) => loadDiff(sdk, input.sessionID, input.version),
|
||||||
|
)
|
||||||
|
|
||||||
|
const messages = createMemo(() => syncedMessages() ?? fetchedMessages() ?? [])
|
||||||
|
const diff = createMemo(() => syncedDiff() ?? fetchedDiff() ?? [])
|
||||||
|
|
||||||
|
const diffSummary = createMemo(() => {
|
||||||
|
const live = diff()
|
||||||
|
if (live && live.length > 0) {
|
||||||
|
let additions = 0
|
||||||
|
let deletions = 0
|
||||||
|
for (const file of live) {
|
||||||
|
additions += file.additions ?? 0
|
||||||
|
deletions += file.deletions ?? 0
|
||||||
|
}
|
||||||
|
return formatDiffSummary({ additions, deletions, files: live.length })
|
||||||
|
}
|
||||||
|
return formatDiffSummary(session()?.summary)
|
||||||
|
})
|
||||||
|
|
||||||
|
const exchange = createMemo(() => {
|
||||||
|
const items = messages()
|
||||||
|
if (!items || items.length === 0) return undefined
|
||||||
|
const sorted = items.toSorted((a, b) => messageCreated(a) - messageCreated(b))
|
||||||
|
const user = sorted.findLast((item) => messageRole(item) === "user")
|
||||||
|
const assistant = user
|
||||||
|
? sorted.findLast((item) => messageRole(item) === "assistant" && messageParentID(item) === user.info.id)
|
||||||
|
: sorted.findLast((item) => messageRole(item) === "assistant")
|
||||||
|
return { user, assistant }
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = createMemo(() => (fetchedMessages.loading || fetchedDiff.loading) && !exchange())
|
||||||
|
|
||||||
|
const statusLabel = createMemo(() => {
|
||||||
|
const s = status()
|
||||||
|
if (s === "busy") return { text: "working", color: theme.warning }
|
||||||
|
if (s === "retry") return { text: "retrying", color: theme.warning }
|
||||||
|
return { text: "idle", color: theme.textMuted }
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
paddingLeft={2}
|
||||||
|
paddingRight={2}
|
||||||
|
paddingTop={1}
|
||||||
|
paddingBottom={1}
|
||||||
|
gap={1}
|
||||||
|
maxHeight={maxHeight()}
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={session()}
|
||||||
|
fallback={
|
||||||
|
<text fg={theme.textMuted} wrapMode="word">
|
||||||
|
No session selected
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(s) => (
|
||||||
|
<>
|
||||||
|
<Header session={s()} statusLabel={statusLabel()} diff={diffSummary()} />
|
||||||
|
<Show when={loading()}>
|
||||||
|
<Spinner>loading preview...</Spinner>
|
||||||
|
</Show>
|
||||||
|
<Show
|
||||||
|
when={exchange()}
|
||||||
|
fallback={
|
||||||
|
<Show when={!loading()}>
|
||||||
|
<text fg={theme.textMuted} wrapMode="word">
|
||||||
|
No messages yet
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(ex) => <Exchange exchange={ex()} />}
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageRole(item: WithParts) {
|
||||||
|
return (item.info as { role?: string }).role
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageCreated(item: WithParts) {
|
||||||
|
return (item.info.time as { created?: number }).created ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageParentID(item: WithParts) {
|
||||||
|
return (item.info as { parentID?: string }).parentID
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROW_WIDTH = 40
|
||||||
|
|
||||||
|
function Header(props: {
|
||||||
|
session: SdkSession
|
||||||
|
statusLabel: { text: string; color: RGBA }
|
||||||
|
diff: { additions: number; deletions: number; files: number } | undefined
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const title = createMemo(() => Locale.truncate(props.session.title, ROW_WIDTH))
|
||||||
|
const modelAgent = createMemo(() => {
|
||||||
|
const m = shortModelLabel(props.session.model)
|
||||||
|
const a = props.session.agent ?? ""
|
||||||
|
if (m && a) return Locale.truncate(`${m} · ${a}`, ROW_WIDTH)
|
||||||
|
if (m) return Locale.truncate(m, ROW_WIDTH)
|
||||||
|
if (a) return Locale.truncate(a, ROW_WIDTH)
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
const statusRest = createMemo(() => {
|
||||||
|
const joined = ` · ${relativeTime(props.session.time.updated)}`
|
||||||
|
return Locale.truncate(joined, Math.max(0, ROW_WIDTH - props.statusLabel.text.length))
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={0} flexShrink={0}>
|
||||||
|
<Row height={1}>
|
||||||
|
<text fg={theme.text} attributes={TextAttributes.BOLD} wrapMode="none" overflow="hidden">
|
||||||
|
{title()}
|
||||||
|
</text>
|
||||||
|
</Row>
|
||||||
|
<Show when={modelAgent()}>
|
||||||
|
<Row height={1}>
|
||||||
|
<text fg={theme.text} wrapMode="none" overflow="hidden">
|
||||||
|
{modelAgent()}
|
||||||
|
</text>
|
||||||
|
</Row>
|
||||||
|
</Show>
|
||||||
|
<Row height={1}>
|
||||||
|
<text fg={theme.textMuted} wrapMode="none" overflow="hidden">
|
||||||
|
<span style={{ fg: props.statusLabel.color }}>{props.statusLabel.text}</span>
|
||||||
|
<span>{statusRest()}</span>
|
||||||
|
</text>
|
||||||
|
</Row>
|
||||||
|
<Show when={props.diff}>{(d) => <DiffRow diff={d()} />}</Show>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row(props: { height: number; children: JSX.Element }) {
|
||||||
|
return (
|
||||||
|
<box height={props.height} flexShrink={0} overflow="hidden">
|
||||||
|
{props.children}
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiffRow(props: { diff: { additions: number; deletions: number; files: number } }) {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const showAdds = () => props.diff.additions > 0
|
||||||
|
const showDels = () => props.diff.deletions > 0
|
||||||
|
if (!showAdds() && !showDels()) return null
|
||||||
|
return (
|
||||||
|
<Row height={1}>
|
||||||
|
<text wrapMode="none" overflow="hidden">
|
||||||
|
<Show when={showAdds()}>
|
||||||
|
<span style={{ fg: theme.diffAdded }}>+{props.diff.additions}</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={showAdds() && showDels()}>
|
||||||
|
<span> </span>
|
||||||
|
</Show>
|
||||||
|
<Show when={showDels()}>
|
||||||
|
<span style={{ fg: theme.diffRemoved }}>−{props.diff.deletions}</span>
|
||||||
|
</Show>
|
||||||
|
</text>
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROMPT_MAX_CHARS = 240
|
||||||
|
const REPLY_MAX_LINES = 12
|
||||||
|
const REPLY_MAX_CHARS = 800
|
||||||
|
|
||||||
|
function Exchange(props: { exchange: { user?: WithParts; assistant?: WithParts } }) {
|
||||||
|
const { theme, syntax } = useTheme()
|
||||||
|
const userText = createMemo(() =>
|
||||||
|
props.exchange.user ? extractMessageText(props.exchange.user.parts, PROMPT_MAX_CHARS) : undefined,
|
||||||
|
)
|
||||||
|
const assistantMarkdown = createMemo(() =>
|
||||||
|
props.exchange.assistant
|
||||||
|
? extractMessageMarkdown(props.exchange.assistant.parts, REPLY_MAX_LINES, REPLY_MAX_CHARS)
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<Show when={userText()}>
|
||||||
|
<text fg={theme.textMuted} wrapMode="word">
|
||||||
|
<span style={{ fg: theme.textMuted }}>› </span>
|
||||||
|
{userText()!}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={assistantMarkdown()}>
|
||||||
|
<markdown
|
||||||
|
content={assistantMarkdown()!}
|
||||||
|
syntaxStyle={syntax()}
|
||||||
|
streaming={false}
|
||||||
|
internalBlockMode="top-level"
|
||||||
|
tableOptions={{ style: "columns" }}
|
||||||
|
conceal={false}
|
||||||
|
fg={theme.markdownText}
|
||||||
|
bg={theme.backgroundPanel}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<Show when={!userText() && !assistantMarkdown()}>
|
||||||
|
<NonTextHint exchange={props.exchange} />
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NonTextHint(props: { exchange: { user?: WithParts; assistant?: WithParts } }) {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const summary = createMemo(() => {
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
for (const item of [props.exchange.user, props.exchange.assistant]) {
|
||||||
|
if (!item) continue
|
||||||
|
for (const part of item.parts) {
|
||||||
|
counts[part.type] = (counts[part.type] ?? 0) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.entries(counts)
|
||||||
|
.map(([k, n]) => `${n} ${k}`)
|
||||||
|
.join(", ")
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<text fg={theme.textMuted} wrapMode="word">
|
||||||
|
<Show when={summary()} fallback="No text content in the latest messages">
|
||||||
|
Latest exchange has no text content ({summary()})
|
||||||
|
</Show>
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { Part } from "@opencode-ai/sdk/v2"
|
||||||
|
import { Locale } from "@/util/locale"
|
||||||
|
|
||||||
|
export function relativeTime(timestamp: number): string {
|
||||||
|
const diff = Date.now() - timestamp
|
||||||
|
if (diff < 0) return "just now"
|
||||||
|
const seconds = Math.floor(diff / 1000)
|
||||||
|
if (seconds < 60) return "just now"
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
if (minutes < 60) return `${minutes}m ago`
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours}h ago`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
if (days < 7) return `${days}d ago`
|
||||||
|
const d = new Date(timestamp)
|
||||||
|
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractMessageText(parts: readonly Part[], maxLength: number): string {
|
||||||
|
const joined = collectTextParts(parts).join(" ").replace(/\s+/g, " ").trim()
|
||||||
|
return Locale.truncate(joined, maxLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractMessageMarkdown(parts: readonly Part[], maxLines: number, maxChars: number): string {
|
||||||
|
const joined = collectTextParts(parts).join("\n\n").trim()
|
||||||
|
if (!joined) return joined
|
||||||
|
|
||||||
|
let truncated = joined
|
||||||
|
const lines = truncated.split("\n")
|
||||||
|
if (lines.length > maxLines) {
|
||||||
|
truncated = lines.slice(0, maxLines).join("\n")
|
||||||
|
}
|
||||||
|
if (truncated.length > maxChars) {
|
||||||
|
truncated = truncated.slice(0, maxChars).trimEnd()
|
||||||
|
}
|
||||||
|
if (truncated.length === joined.length) return joined
|
||||||
|
// Close any unterminated fenced code block so the renderer doesn't keep
|
||||||
|
// the rest of the panel in "code mode".
|
||||||
|
const fences = (truncated.match(/^```/gm) ?? []).length
|
||||||
|
if (fences % 2 === 1) truncated += "\n```"
|
||||||
|
return truncated + "\n\n…"
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectTextParts(parts: readonly Part[]): string[] {
|
||||||
|
const chunks: string[] = []
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.type !== "text") continue
|
||||||
|
const p = part as Part & { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
|
||||||
|
if (p.synthetic || p.ignored) continue
|
||||||
|
if (!p.text) continue
|
||||||
|
chunks.push(p.text)
|
||||||
|
}
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDiffSummary(summary: { additions: number; deletions: number; files: number } | undefined):
|
||||||
|
| { additions: number; deletions: number; files: number }
|
||||||
|
| undefined {
|
||||||
|
if (!summary) return undefined
|
||||||
|
if (!summary.additions && !summary.deletions && !summary.files) return undefined
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shortModelLabel(model: { id: string; providerID?: string; variant?: string } | undefined): string {
|
||||||
|
if (!model) return ""
|
||||||
|
const id = model.id ?? ""
|
||||||
|
const stripped = model.providerID && id.startsWith(`${model.providerID}/`) ? id.slice(model.providerID.length + 1) : id
|
||||||
|
return model.variant ? `${stripped} (${model.variant})` : stripped
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import Notifications from "../feature-plugins/system/notifications"
|
|||||||
import SessionV2Debug from "../feature-plugins/system/session-v2"
|
import SessionV2Debug from "../feature-plugins/system/session-v2"
|
||||||
import WhichKey from "../feature-plugins/system/which-key"
|
import WhichKey from "../feature-plugins/system/which-key"
|
||||||
import DiffViewer from "../feature-plugins/system/diff-viewer"
|
import DiffViewer from "../feature-plugins/system/diff-viewer"
|
||||||
|
import SessionSwitcher from "../feature-plugins/session"
|
||||||
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||||
import type { RuntimeFlags } from "@/effect/runtime-flags"
|
import type { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
|
|
||||||
@@ -35,5 +37,6 @@ export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalE
|
|||||||
WhichKey,
|
WhichKey,
|
||||||
DiffViewer,
|
DiffViewer,
|
||||||
...(flags.experimentalEventSystem ? [SessionV2Debug] : []),
|
...(flags.experimentalEventSystem ? [SessionV2Debug] : []),
|
||||||
|
...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHER ? [SessionSwitcher] : []),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export interface DialogSelectOption<T = any> {
|
|||||||
export type DialogSelectRef<T> = {
|
export type DialogSelectRef<T> = {
|
||||||
filter: string
|
filter: string
|
||||||
filtered: DialogSelectOption<T>[]
|
filtered: DialogSelectOption<T>[]
|
||||||
|
selected: DialogSelectOption<T> | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||||
@@ -337,6 +338,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
get filtered() {
|
get filtered() {
|
||||||
return filtered()
|
return filtered()
|
||||||
},
|
},
|
||||||
|
get selected() {
|
||||||
|
return selected()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
props.ref?.(ref)
|
props.ref?.(ref)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user