diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context-viewer.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context-viewer.tsx new file mode 100644 index 0000000000..d2c5120e60 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context-viewer.tsx @@ -0,0 +1,200 @@ +import type { TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { Part as SdkPart } from "@opencode-ai/sdk/v2" +import { createMemo, createSignal } from "solid-js" +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" +import { useDialog } from "@tui/ui/dialog" +import { Keybind } from "@/util/keybind" + +type Value = { + sessionID: string + messageID: string + partID: string + kind: string +} + +const money = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", +}) + +function estimate(part: SdkPart): number { + switch (part.type) { + case "text": + return Math.ceil((part.text?.length ?? 0) / 4) + case "tool": { + if (part.state.status !== "completed") return 0 + const raw = JSON.stringify(part.state.input ?? "").length + (part.state.output ?? "").length + return Math.ceil(raw / 4) + } + case "reasoning": + return Math.ceil((part.text?.length ?? 0) / 4) + case "subtask": + return Math.ceil((part.prompt?.length ?? 0) / 4) + default: + return 0 + } +} + +function label(part: SdkPart): string { + switch (part.type) { + case "text": + return (part.text ?? "").slice(0, 60).replace(/\n/g, " ") || "(empty)" + case "tool": { + const icon = + part.state.status === "completed" + ? "✓" + : part.state.status === "error" + ? "✗" + : part.state.status === "running" + ? "…" + : "○" + const flag = part.state.status === "completed" && part.state.time?.compacted ? " [compacted]" : "" + return `[${part.tool}] ${icon}${flag}` + } + case "reasoning": + return "[reasoning] " + (part.text ?? "").slice(0, 40).replace(/\n/g, " ") + case "subtask": + return `[subtask] ${part.description ?? part.prompt?.slice(0, 40) ?? ""}` + case "file": + return `[file] ${part.filename ?? part.url ?? ""}` + case "step-start": + return "[step-start]" + case "step-finish": + return `[step-finish] ${part.reason ?? ""}` + case "patch": + return `[patch] ${part.files?.length ?? 0} files` + case "agent": + return `[@${part.name}]` + case "retry": + return `[retry] #${part.attempt}` + case "compaction": + return "[compaction]" + case "snapshot": + return "[snapshot]" + default: + return `[unknown]` + } +} + +function desc(part: SdkPart): string | undefined { + if (part.type === "tool" && part.state.status === "completed") return part.state.title ?? undefined + return undefined +} + +export function show(api: TuiPluginApi, sessionID: string) { + api.ui.dialog.setSize("xlarge") + api.ui.dialog.replace(() => ) +} + +function Viewer(props: { api: TuiPluginApi; sessionID: string }) { + const dialog = useDialog() + const theme = () => props.api.theme.current + const [pending, setPending] = createSignal() + const msgs = createMemo(() => props.api.state.session.messages(props.sessionID)) + const cost = createMemo(() => msgs().reduce((sum, m) => sum + (m.role === "assistant" ? m.cost : 0), 0)) + + const options = createMemo(() => { + const result: DialogSelectOption[] = [] + const all = msgs() + for (let i = 0; i < all.length; i++) { + const msg = all[i] + const parts = props.api.state.part(msg.id) + const role = msg.role === "user" ? "User" : "Assistant" + let info = "" + if (msg.role === "assistant") { + const t = msg.tokens + const total = t.input + t.output + t.reasoning + t.cache.read + t.cache.write + info = ` · ${total.toLocaleString()} tok` + if (msg.cost > 0) info += ` · ${money.format(msg.cost)}` + } + const category = `#${i + 1} ${role}${info}` + + for (const part of parts) { + const tok = estimate(part) + const deleting = pending() === part.id + result.push({ + title: deleting ? "Press again to confirm delete" : label(part), + value: { + sessionID: props.sessionID, + messageID: msg.id, + partID: part.id, + kind: part.type, + }, + description: deleting ? undefined : desc(part), + footer: tok > 0 ? `~${tok.toLocaleString()} tok` : undefined, + category, + bg: deleting ? theme().error : undefined, + }) + } + + if (parts.length === 0) { + result.push({ + title: "(no parts)", + value: { + sessionID: props.sessionID, + messageID: msg.id, + partID: "", + kind: "empty", + }, + category, + disabled: true, + }) + } + } + return result + }) + + return ( + setPending(undefined)} + keybind={[ + { + keybind: Keybind.parse("alt+c")[0], + title: "compact", + onTrigger: async (opt: DialogSelectOption) => { + if (opt.value.kind !== "tool") return + const parts = props.api.state.part(opt.value.messageID) + const part = parts.find((p) => p.id === opt.value.partID) + if (!part || part.type !== "tool") return + if (part.state.status !== "completed") return + if (part.state.time?.compacted) return + await props.api.client.part.update({ + sessionID: opt.value.sessionID, + messageID: opt.value.messageID, + partID: opt.value.partID, + part: { + ...part, + state: { + ...part.state, + time: { ...part.state.time, compacted: Date.now() }, + }, + }, + }) + }, + }, + { + keybind: Keybind.parse("alt+d")[0], + title: "delete", + onTrigger: async (opt: DialogSelectOption) => { + if (!opt.value.partID) return + if (pending() !== opt.value.partID) { + setPending(opt.value.partID) + return + } + setPending(undefined) + await props.api.client.part.delete({ + sessionID: opt.value.sessionID, + messageID: opt.value.messageID, + partID: opt.value.partID, + }) + }, + }, + ]} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx index 9ffe779791..f92ceb45e0 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx @@ -1,6 +1,7 @@ -import type { AssistantMessage } from "@opencode-ai/sdk/v2" +import type { AssistantMessage, Part } from "@opencode-ai/sdk/v2" import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" import { createMemo } from "solid-js" +import { show as showViewer } from "./context-viewer" const id = "internal:sidebar-context" @@ -9,6 +10,20 @@ const money = new Intl.NumberFormat("en-US", { currency: "USD", }) +function counts(parts: readonly Part[]) { + let tools = 0 + let errors = 0 + let files = 0 + for (const p of parts) { + if (p.type === "tool") { + tools++ + if (p.state.status === "error") errors++ + if (p.state.status === "completed" && (p.tool === "read" || p.tool === "glob" || p.tool === "grep")) files++ + } + } + return { tools, errors, files } +} + function View(props: { api: TuiPluginApi; session_id: string }) { const theme = () => props.api.theme.current const msg = createMemo(() => props.api.state.session.messages(props.session_id)) @@ -32,13 +47,32 @@ function View(props: { api: TuiPluginApi; session_id: string }) { } }) + const stats = createMemo(() => { + const all = msg() + let tools = 0 + let errors = 0 + let files = 0 + for (const m of all) { + const c = counts(props.api.state.part(m.id)) + tools += c.tools + errors += c.errors + files += c.files + } + return { messages: all.length, tools, errors, files } + }) + return ( - + props.api.command.trigger("session.context_panel.toggle")}> Context - {state().tokens.toLocaleString()} tokens - {state().percent ?? 0}% used + + {state().tokens.toLocaleString()} tokens · {state().percent ?? 0}% used + + + {stats().messages} msgs · {stats().tools} tools{stats().errors > 0 ? ` · ${stats().errors} err` : ""} + {stats().files > 0 ? ` · ${stats().files} reads` : ""} + {money.format(cost())} spent ) @@ -53,6 +87,23 @@ const tui: TuiPlugin = async (api) => { }, }, }) + + api.command.register(() => [ + { + title: "View context", + value: "context.viewer", + category: "Session", + description: "Browse session messages and parts with token estimates", + slash: { name: "context" }, + onSelect() { + const route = api.route.current + if (route.name !== "session") return + const sessionID = (route.params as { sessionID?: string }).sessionID + if (!sessionID) return + showViewer(api, sessionID) + }, + }, + ]) } const plugin: TuiPluginModule & { id: string } = { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/context-panel.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/context-panel.tsx new file mode 100644 index 0000000000..7d1767242c --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/context-panel.tsx @@ -0,0 +1,484 @@ +import { useSync } from "@tui/context/sync" +import { useTheme, selectedForeground } from "@tui/context/theme" +import { useKeybind } from "@tui/context/keybind" +import { useSDK } from "@tui/context/sdk" +import { useTuiConfig } from "../../context/tui-config" +import { useToast } from "../../ui/toast" +import { getScrollAcceleration } from "../../util/scroll" +import { Keybind } from "@/util/keybind" +import { Locale } from "@/util/locale" +import type { Part, AssistantMessage } from "@opencode-ai/sdk/v2" +import { batch, createEffect, createMemo, createSignal, For, on, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" +import * as fuzzysort from "fuzzysort" + +const PANEL_WIDTH = 80 + +const money = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", +}) + +function estimate(part: Part): number { + switch (part.type) { + case "text": + return Math.ceil((part.text?.length ?? 0) / 4) + case "tool": { + if (part.state.status !== "completed") return 0 + const raw = JSON.stringify(part.state.input ?? "").length + (part.state.output ?? "").length + return Math.ceil(raw / 4) + } + case "reasoning": + return Math.ceil((part.text?.length ?? 0) / 4) + case "subtask": + return Math.ceil((part.prompt?.length ?? 0) / 4) + default: + return 0 + } +} + +type Item = { + id: string + title: string + description?: string + footer?: string + category: string + sessionID: string + messageID: string + partID: string + kind: string + disabled?: boolean +} + +function items( + sessionID: string, + messages: ReturnType["data"]["message"][string], + parts: ReturnType["data"]["part"], +): Item[] { + const result: Item[] = [] + const all = messages ?? [] + for (let i = 0; i < all.length; i++) { + const msg = all[i] + const msgParts = parts[msg.id] ?? [] + const role = msg.role === "user" ? "User" : "Assistant" + let info = "" + if (msg.role === "assistant") { + const t = (msg as AssistantMessage).tokens + const total = t.input + t.output + t.reasoning + t.cache.read + t.cache.write + info = ` · ${total.toLocaleString()} tok` + if ((msg as AssistantMessage).cost > 0) info += ` · ${money.format((msg as AssistantMessage).cost)}` + } + const category = `#${i + 1} ${role}${info}` + + for (const part of msgParts) { + const tok = estimate(part) + result.push({ + id: part.id, + title: partLabel(part), + description: partDesc(part), + footer: tok > 0 ? `~${tok.toLocaleString()} tok` : undefined, + category, + sessionID, + messageID: msg.id, + partID: part.id, + kind: part.type, + }) + } + + if (msgParts.length === 0) { + result.push({ + id: `empty-${msg.id}`, + title: "(no parts)", + category, + sessionID, + messageID: msg.id, + partID: "", + kind: "empty", + disabled: true, + }) + } + } + return result +} + +function partLabel(part: Part): string { + switch (part.type) { + case "text": + return (part.text ?? "").slice(0, 60).replace(/\n/g, " ") || "(empty)" + case "tool": { + const icon = + part.state.status === "completed" + ? "✓" + : part.state.status === "error" + ? "✗" + : part.state.status === "running" + ? "…" + : "○" + const flag = part.state.status === "completed" && part.state.time?.compacted ? " [compacted]" : "" + return `[${part.tool}] ${icon}${flag}` + } + case "reasoning": + return "[reasoning] " + (part.text ?? "").slice(0, 40).replace(/\n/g, " ") + case "subtask": + return `[subtask] ${part.description ?? part.prompt?.slice(0, 40) ?? ""}` + case "file": + return `[file] ${part.filename ?? part.url ?? ""}` + case "step-start": + return "[step-start]" + case "step-finish": + return `[step-finish] ${part.reason ?? ""}` + case "patch": + return `[patch] ${part.files?.length ?? 0} files` + case "agent": + return `[@${part.name}]` + case "retry": + return `[retry] #${part.attempt}` + case "compaction": + return "[compaction]" + case "snapshot": + return "[snapshot]" + default: + return "[unknown]" + } +} + +function partDesc(part: Part): string | undefined { + if (part.type === "tool" && part.state.status === "completed") return part.state.title ?? undefined + return undefined +} + +export { PANEL_WIDTH } + +export function ContextPanel(props: { sessionID: string }) { + const sync = useSync() + const sdk = useSDK() + const { theme } = useTheme() + const keybind = useKeybind() + const tuiConfig = useTuiConfig() + const toast = useToast() + const dimensions = useTerminalDimensions() + const acceleration = createMemo(() => getScrollAcceleration(tuiConfig)) + + const msgs = createMemo(() => sync.data.message[props.sessionID] ?? []) + const cost = createMemo(() => + msgs().reduce((sum, m) => sum + (m.role === "assistant" ? (m as AssistantMessage).cost : 0), 0), + ) + + const all = createMemo(() => items(props.sessionID, msgs(), sync.data.part)) + + const [store, setStore] = createStore({ + selected: 0, + filter: "", + input: "keyboard" as "keyboard" | "mouse", + }) + + const [pending, setPending] = createSignal() + + const filtered = createMemo(() => { + const opts = all().filter((x) => !x.disabled) + const needle = store.filter.toLowerCase() + if (!needle) return opts + return fuzzysort + .go(needle, opts, { + keys: ["title", "category"], + scoreFn: (r) => r[0].score * 2 + r[1].score, + }) + .map((x) => x.obj) + }) + + const flatten = createMemo(() => store.filter.length > 0) + + const grouped = createMemo<[string, Item[]][]>(() => { + if (flatten()) return [["", filtered()]] + const map = new Map() + for (const item of filtered()) { + const key = item.category + const arr = map.get(key) ?? [] + arr.push(item) + map.set(key, arr) + } + return Array.from(map.entries()) + }) + + const flat = createMemo(() => grouped().flatMap(([, opts]) => opts)) + + const selected = createMemo(() => flat()[store.selected]) + + createEffect( + on( + () => store.filter, + () => { + setTimeout(() => moveTo(0, true), 0) + }, + ), + ) + + createEffect(() => { + filtered() + setStore("input", "keyboard") + }) + + function move(dir: number) { + if (flat().length === 0) return + let next = store.selected + dir + if (next < 0) next = flat().length - 1 + if (next >= flat().length) next = 0 + moveTo(next, true) + } + + function moveTo(next: number, center = false) { + setStore("selected", next) + if (!scroll) return + const target = scroll.getChildren().find((c) => c.id === selected()?.id) + if (!target) return + const y = target.y - scroll.y + if (center) { + scroll.scrollBy(y - Math.floor(scroll.height / 2)) + } else { + if (y >= scroll.height) scroll.scrollBy(y - scroll.height + 1) + if (y < 0) scroll.scrollBy(y) + } + } + + async function compact() { + const s = selected() + if (!s || s.kind !== "tool") return + const parts = sync.data.part[s.messageID] ?? [] + const part = parts.find((p) => p.id === s.partID) + if (!part || part.type !== "tool") return + if (part.state.status !== "completed") return + if (part.state.time?.compacted) return + await sdk.client.part.update({ + sessionID: s.sessionID, + messageID: s.messageID, + partID: s.partID, + part: { + ...part, + state: { + ...part.state, + time: { ...part.state.time, compacted: Date.now() }, + }, + }, + }) + toast.show({ message: "Part compacted", variant: "success" }) + } + + async function del() { + const s = selected() + if (!s || !s.partID) return + if (pending() !== s.partID) { + setPending(s.partID) + return + } + setPending(undefined) + await sdk.client.part.delete({ + sessionID: s.sessionID, + messageID: s.messageID, + partID: s.partID, + }) + } + + async function delSection() { + const s = selected() + if (!s) return + const key = `section:${s.messageID}` + if (pending() !== key) { + setPending(key) + return + } + setPending(undefined) + await sdk.client.session.deleteMessage({ + sessionID: s.sessionID, + messageID: s.messageID, + }) + toast.show({ message: "Message deleted", variant: "success" }) + } + + const compactKey = Keybind.parse("alt+c")[0] + const deleteKey = Keybind.parse("alt+d")[0] + const sectionKey = Keybind.parse("alt+s")[0] + + let input: InputRenderable + let scroll: ScrollBoxRenderable + + useKeyboard((evt) => { + setStore("input", "keyboard") + if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1) + if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1) + if (evt.name === "pageup") move(-10) + if (evt.name === "pagedown") move(10) + if (evt.name === "home") moveTo(0) + if (evt.name === "end") moveTo(flat().length - 1) + + if (compactKey && Keybind.match(compactKey, keybind.parse(evt))) { + evt.preventDefault() + compact() + } + if (deleteKey && Keybind.match(deleteKey, keybind.parse(evt))) { + evt.preventDefault() + del() + } + if (sectionKey && Keybind.match(sectionKey, keybind.parse(evt))) { + evt.preventDefault() + delSection() + } + }) + + const fg = selectedForeground(theme) + const maxTitle = createMemo(() => PANEL_WIDTH - 22) + + return ( + + + + Context · {money.format(cost())} + + {keybind.print("context_panel")} close + + + + { + batch(() => { + setStore("filter", e) + }) + }} + focusedBackgroundColor={theme.backgroundPanel} + cursorColor={theme.primary} + focusedTextColor={theme.textMuted} + ref={(r) => { + input = r + setTimeout(() => { + if (!input || input.isDestroyed) return + input.focus() + }, 1) + }} + placeholder="Filter parts..." + placeholderColor={theme.textMuted} + /> + + + 0} + fallback={ + + No results found + + } + > + (scroll = r)} + flexGrow={1} + paddingTop={1} + scrollAcceleration={acceleration()} + verticalScrollbarOptions={{ + trackOptions: { + backgroundColor: theme.background, + foregroundColor: theme.borderActive, + }, + }} + > + + {([category, opts], index) => ( + <> + + 0 ? 1 : 0}> + + {pending() === `section:${opts[0]?.messageID}` ? "Press again to delete section" : category} + + + + + {(opt) => { + const active = createMemo(() => opt.id === selected()?.id) + const deleting = createMemo(() => pending() === opt.partID) + return ( + setStore("input", "mouse")} + onMouseOver={() => { + if (store.input !== "mouse") return + const idx = flat().findIndex((x) => x.id === opt.id) + if (idx !== -1) moveTo(idx) + }} + onMouseDown={() => { + const idx = flat().findIndex((x) => x.id === opt.id) + if (idx !== -1) moveTo(idx) + }} + backgroundColor={ + active() ? (deleting() ? theme.error : theme.primary) : RGBA.fromInts(0, 0, 0, 0) + } + paddingLeft={1} + paddingRight={1} + > + + {Locale.truncate(deleting() ? "Press again to confirm delete" : opt.title, maxTitle())} + + + {" "} + {opt.description} + + + + + + {opt.footer} + + + + ) + }} + + + )} + + + + + + + + compact{" "} + + alt+c + + + + delete{" "} + + alt+d + + + + section{" "} + + alt+s + + + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 396d756301..6201868230 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -61,6 +61,7 @@ import { DialogTimeline } from "./dialog-timeline" import { DialogForkFromTimeline } from "./dialog-fork-from-timeline" import { DialogSessionRename } from "../../component/dialog-session-rename" import { Sidebar } from "./sidebar" +import { ContextPanel, PANEL_WIDTH as CONTEXT_PANEL_WIDTH } from "./context-panel" import { SubagentFooter } from "./subagent-footer.tsx" import { Flag } from "@/flag/flag" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" @@ -144,6 +145,7 @@ export function Session() { const dimensions = useTerminalDimensions() const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto") const [sidebarOpen, setSidebarOpen] = createSignal(false) + const [contextPanel, setContextPanel] = kv.signal<"hide" | "show">("context_panel", "hide") const [conceal, setConceal] = createSignal(true) const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true) const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide") @@ -155,14 +157,24 @@ export function Session() { const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false) const wide = createMemo(() => dimensions().width > 120) + const contextPanelVisible = createMemo(() => { + if (session()?.parentID) return false + return contextPanel() === "show" + }) const sidebarVisible = createMemo(() => { if (session()?.parentID) return false + if (contextPanelVisible()) return false if (sidebarOpen()) return true if (sidebar() === "auto" && wide()) return true return false }) const showTimestamps = createMemo(() => timestamps() === "show") - const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4) + const rightWidth = createMemo(() => { + if (contextPanelVisible()) return CONTEXT_PANEL_WIDTH + if (sidebarVisible()) return 42 + return 0 + }) + const contentWidth = createMemo(() => dimensions().width - rightWidth() - 4) const providers = createMemo(() => Model.index(sync.data.provider)) const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) @@ -565,6 +577,20 @@ export function Session() { dialog.clear() }, }, + { + title: contextPanelVisible() ? "Hide context panel" : "Show context panel", + value: "session.context_panel.toggle", + keybind: "context_panel", + category: "Session", + slash: { + name: "context-panel", + aliases: ["cp"], + }, + onSelect: (dialog) => { + setContextPanel(() => (contextPanelVisible() ? "hide" : "show")) + dialog.clear() + }, + }, { title: conceal() ? "Disable code concealment" : "Enable code concealment", value: "session.toggle.conceal", @@ -1199,6 +1225,9 @@ export function Session() { + + + ) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index efae2ca551..bbe2faf109 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -614,6 +614,7 @@ export namespace Config { editor_open: z.string().optional().default("e").describe("Open external editor"), theme_list: z.string().optional().default("t").describe("List available themes"), sidebar_toggle: z.string().optional().default("b").describe("Toggle sidebar"), + context_panel: z.string().optional().default("i").describe("Toggle context panel"), scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"), username_toggle: z.string().optional().default("none").describe("Toggle username visibility"), status_view: z.string().optional().default("s").describe("View status"),