From 33a323b2a5174a16c9a9e4b2b7b30af30210a373 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:46:01 +1000 Subject: [PATCH 1/2] refactor(app): extract context usage into an extension --- bun.lock | 21 +++ packages/app/e2e/extensions/context.spec.ts | 43 +++++ packages/app/package.json | 1 + packages/app/src/extensions/provider.tsx | 3 +- packages/app/src/index.css | 1 + packages/app/src/session/commands/export.ts | 75 +------- .../src/session/files/session-side-panel.tsx | 20 --- packages/app/src/session/mobile-view-tabs.tsx | 8 +- packages/app/src/session/screen.tsx | 13 +- .../src/session/timeline/message-timeline.tsx | 2 - .../timeline/session-context-usage.tsx | 163 ------------------ packages/plugin-context-desktop/README.md | 17 ++ packages/plugin-context-desktop/package.json | 28 +++ .../plugin-context-desktop/src/button.tsx | 105 +++++++++++ packages/plugin-context-desktop/src/env.d.ts | 4 + packages/plugin-context-desktop/src/export.ts | 67 +++++++ packages/plugin-context-desktop/src/index.tsx | 54 ++++++ .../src/panel.tsx} | 87 +++------- .../src}/session-context-format.ts | 0 packages/plugin-context-desktop/src/toggle.ts | 16 ++ packages/plugin-context-desktop/src/usage.ts | 32 ++++ .../test/boundary.test.ts | 15 ++ .../plugin-context-desktop/test/usage.test.ts | 57 ++++++ packages/plugin-context-desktop/tsconfig.json | 16 ++ 24 files changed, 524 insertions(+), 324 deletions(-) create mode 100644 packages/app/e2e/extensions/context.spec.ts delete mode 100644 packages/app/src/session/timeline/session-context-usage.tsx create mode 100644 packages/plugin-context-desktop/README.md create mode 100644 packages/plugin-context-desktop/package.json create mode 100644 packages/plugin-context-desktop/src/button.tsx create mode 100644 packages/plugin-context-desktop/src/env.d.ts create mode 100644 packages/plugin-context-desktop/src/export.ts create mode 100644 packages/plugin-context-desktop/src/index.tsx rename packages/{app/src/session/files/session-context-tab.tsx => plugin-context-desktop/src/panel.tsx} (78%) rename packages/{app/src/session/files => plugin-context-desktop/src}/session-context-format.ts (100%) create mode 100644 packages/plugin-context-desktop/src/toggle.ts create mode 100644 packages/plugin-context-desktop/src/usage.ts create mode 100644 packages/plugin-context-desktop/test/boundary.test.ts create mode 100644 packages/plugin-context-desktop/test/usage.test.ts create mode 100644 packages/plugin-context-desktop/tsconfig.json diff --git a/bun.lock b/bun.lock index f74705ab87da..aa30df5b87a5 100644 --- a/bun.lock +++ b/bun.lock @@ -64,6 +64,7 @@ "@kobalte/core": "catalog:", "@opencode/client": "workspace:*", "@opencode/plugin": "workspace:*", + "@opencode/plugin-context-desktop": "workspace:*", "@opencode/plugin-review-desktop": "workspace:*", "@opencode/schema": "workspace:*", "@opencode/session-ui": "workspace:*", @@ -657,6 +658,24 @@ "puppeteer-core": "25.9.0", }, }, + "packages/plugin-context-desktop": { + "name": "@opencode/plugin-context-desktop", + "version": "0.0.0", + "dependencies": { + "@opencode/client": "workspace:*", + "@opencode/plugin": "workspace:*", + "@opencode/session-ui": "workspace:*", + "@opencode/ui": "workspace:*", + "@opencode/util": "workspace:*", + "luxon": "catalog:", + "solid-js": "catalog:", + }, + "devDependencies": { + "@types/bun": "catalog:", + "@types/luxon": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/plugin-review-desktop": { "name": "@opencode/plugin-review-desktop", "version": "0.0.0", @@ -2266,6 +2285,8 @@ "@opencode/plugin-browser-desktop": ["@opencode/plugin-browser-desktop@workspace:packages/plugin-browser-desktop"], + "@opencode/plugin-context-desktop": ["@opencode/plugin-context-desktop@workspace:packages/plugin-context-desktop"], + "@opencode/plugin-review-desktop": ["@opencode/plugin-review-desktop@workspace:packages/plugin-review-desktop"], "@opencode/posts": ["@opencode/posts@workspace:packages/posts"], diff --git a/packages/app/e2e/extensions/context.spec.ts b/packages/app/e2e/extensions/context.spec.ts new file mode 100644 index 000000000000..def5c2b1fee7 --- /dev/null +++ b/packages/app/e2e/extensions/context.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from "@playwright/test" +import { readFile } from "node:fs/promises" +import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture" +import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers" +import { mockOpenCodeServer } from "../utils/mock-server" + +test("context opens independently, exports its session, and restores the closed panel", async ({ page }, info) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await mockOpenCodeServer(page, { + directory: fixture.directory, + project: fixture.project, + sessions: fixture.sessions, + provider: fixture.provider, + pageMessages, + }) + await installStressSessionTabs(page) + await page.goto(stressSessionHref(fixture.sourceID)) + await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible() + const toggle = page.getByRole("button", { name: "View context usage", exact: true }) + await toggle.click() + const context = page.getByRole("tabpanel", { name: "Context", exact: true }) + await expect(context.getByText("Total Tokens", { exact: true })).toBeVisible() + await expect(context.getByText(fixture.expected.sourceTitle, { exact: true })).toBeVisible() + await expect(context.getByText("Raw messages", { exact: true })).toBeVisible() + await page.screenshot({ path: info.outputPath("context-extension.png") }) + const pending = page.waitForEvent("download") + await context.getByRole("button", { name: "Export session", exact: true }).click() + const download = await pending + const exported: { info: { id: string }; messages: { id: string }[] } = JSON.parse( + await readFile((await download.path())!, "utf8"), + ) + expect(exported.info.id).toBe(fixture.sourceID) + expect(exported.messages.length).toBeGreaterThan(0) + expect(new Set(exported.messages.map((message) => message.id)).size).toBe(exported.messages.length) + await toggle.click() + await expect(page.getByRole("tab", { name: "Context", exact: true })).toHaveCount(0) + await expect(page.getByRole("button", { name: "Toggle review", exact: true })).toHaveAttribute( + "aria-expanded", + "false", + ) + await toggle.click() + await expect(context.getByText(fixture.expected.sourceTitle, { exact: true })).toBeVisible() +}) diff --git a/packages/app/package.json b/packages/app/package.json index 72458b52acc3..d9703e0f34ad 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -66,6 +66,7 @@ "@opencode/client": "workspace:*", "@opencode/plugin": "workspace:*", "@opencode/plugin-review-desktop": "workspace:*", + "@opencode/plugin-context-desktop": "workspace:*", "@opencode/schema": "workspace:*", "@opencode/session-ui": "workspace:*", "@opencode/ui": "workspace:*", diff --git a/packages/app/src/extensions/provider.tsx b/packages/app/src/extensions/provider.tsx index 7a6c8821fecc..e11c5c3cfca1 100644 --- a/packages/app/src/extensions/provider.tsx +++ b/packages/app/src/extensions/provider.tsx @@ -33,6 +33,7 @@ import { terminalFontFamily, useSettings } from "@/settings/model" import { extensionTabKey } from "./keys" import { showToast } from "@/shell/notifications/toast" import { ReviewDesktop } from "@opencode/plugin-review-desktop" +import { ContextDesktop } from "@opencode/plugin-context-desktop" import type { SessionServices } from "@opencode/plugin/desktop/workspace" export type Contribution = Claim<{ @@ -98,7 +99,7 @@ function createHost() { const workspaceRemoved = new Set<(value: { serverID: string; directory: string }) => void>() const attempted = new WeakSet() let instanceID = 0 - const builtins = () => [ReviewDesktop, ...(platform.extensionPlugins ?? [])] + const builtins = () => [ReviewDesktop, ContextDesktop, ...(platform.extensionPlugins ?? [])] createEffect(() => { const manager = platform.extensionManager if (!manager) return diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 4f841551bb6b..8b433b18e2c0 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -3,6 +3,7 @@ @import "@opencode/ui/styles/tokens"; @import "tw-animate-css"; @source "../../plugin-review-desktop/src"; +@source "../../plugin-context-desktop/src"; @font-face { font-family: "JetBrainsMono Nerd Font Mono"; diff --git a/packages/app/src/session/commands/export.ts b/packages/app/src/session/commands/export.ts index f6eb0e1b57af..c7f65327b281 100644 --- a/packages/app/src/session/commands/export.ts +++ b/packages/app/src/session/commands/export.ts @@ -1,68 +1,7 @@ -import type { SessionInfo, SessionMessageInfo } from "@opencode/client/promise" -import type { ServerApi } from "@/runtime/server/api" -import type { Platform } from "@/runtime/platform/platform" - -export type SessionExportData = { - info: SessionInfo - messages: SessionMessageInfo[] -} - -export async function fetchSessionExport(input: { - sessionID: string - api: Pick -}): Promise { - const [info, first] = await Promise.all([ - input.api.session.get({ sessionID: input.sessionID }), - input.api.message.list({ sessionID: input.sessionID, limit: 200, order: "asc" }), - ]) - const pages = [first] - - while (pages.at(-1)?.cursor.next) { - pages.push( - await input.api.message.list({ - sessionID: input.sessionID, - limit: 200, - cursor: pages.at(-1)!.cursor.next ?? undefined, - }), - ) - } - - return { - info, - messages: pages.flatMap((page) => page.data), - } -} - -export function sessionExportFilename(session: { id: string; title?: string; slug?: string }) { - const name = session.title || session.slug || session.id - const clean = name - .toLowerCase() - .replace(/[^a-z0-9_-]+/gi, "-") - .replace(/^-+|-+$/g, "") - return `${clean || session.id}.json` -} - -export function downloadSessionExport(filename: string, data: unknown) { - const json = JSON.stringify(data, null, 2) - const blob = new Blob([json], { type: "application/json" }) - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = filename - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) -} - -export async function saveSessionExport( - filename: string, - data: unknown, - platform: Pick, -) { - if (!platform.saveFile) { - downloadSessionExport(filename, data) - return true - } - return platform.saveFile({ defaultPath: filename }, JSON.stringify(data, null, 2)) -} +export { + fetchSessionExport, + saveSessionExport, + sessionExportFilename, + downloadSessionExport, + type SessionExportData, +} from "@opencode/plugin-context-desktop/export" diff --git a/packages/app/src/session/files/session-side-panel.tsx b/packages/app/src/session/files/session-side-panel.tsx index d75de674dcec..f1a5671030bf 100644 --- a/packages/app/src/session/files/session-side-panel.tsx +++ b/packages/app/src/session/files/session-side-panel.tsx @@ -22,7 +22,6 @@ import { createSessionTabs, type Sizing } from "@/session/helpers" import { useFile } from "@/workspaces/files/model" import type { SessionExtensions } from "@/extensions/session" import { ExtensionPanelContent } from "@/extensions/content" -import { SessionContextTab } from "./session-context-tab" export function SessionSidePanel(props: { extensions: SessionExtensions @@ -106,20 +105,6 @@ export function SessionSidePanel(props: {
{extensions.toolbar()}
- - session.tabs().close("context")} - closeButton={ - session.tabs().close("context")} - aria-label={language.t("common.closeTab")} - /> - } - > - {language.t("session.tab.context")} - - {(key) => ( panel.key === key)}> @@ -177,11 +162,6 @@ export function SessionSidePanel(props: { - - - - - diff --git a/packages/app/src/session/mobile-view-tabs.tsx b/packages/app/src/session/mobile-view-tabs.tsx index eef7a9d564f9..dcbb6d07780f 100644 --- a/packages/app/src/session/mobile-view-tabs.tsx +++ b/packages/app/src/session/mobile-view-tabs.tsx @@ -17,7 +17,7 @@ const MobilePanelDrawer = lazy(async () => { export function SessionMobileViewTabs(props: { current: string - items: readonly { id: string; title: string }[] + items: readonly { id: string; title: string; menu?: boolean }[] onSelect(value: string): void details?: (close: () => void) => JSX.Element onDetailsOpenChange?: (open: boolean) => void @@ -33,7 +33,7 @@ export function SessionMobileViewTabs(props: { props.onSelect("session")}> {language.t("session.tab.session")} - + !item.menu)}> {(item) => ( props.onSelect(item.id)}> {item.title} @@ -55,7 +55,9 @@ export function SessionMobileViewTabs(props: { /> - props.onSelect("usage")}>{language.t("session.tab.usage")} + item.menu)}> + {(item) => props.onSelect(item.id)}>{item.title}} + setStore("details", true)}>{language.t("session.summary.title")} diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 79aa5a203b64..3d7b5bdf7b85 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -26,7 +26,6 @@ import { createSessionScreenLayout } from "./screen-layout" import { createSessionSummary } from "./summary" import { SessionMobileViewTabs } from "./mobile-view-tabs" import { SessionSidePanel } from "./files/session-side-panel" -import { SessionContextTab } from "./files/session-context-tab" import { createSessionTimelineInteraction } from "./timeline/interaction" import { createTimelineSearchController } from "./timeline/search-controller" import { TimelineSearchBar } from "./timeline/search-bar" @@ -144,11 +143,16 @@ export function SessionScreen(props: { session: SessionModel }) { const summary = createSessionSummary(session) const mobileView = createMemo(() => { if (screen.terminal.open()) return "terminal" - if (store.mobileTab === "session" || store.mobileTab === "usage") return store.mobileTab + if (store.mobileTab === "session") return store.mobileTab const selected = extensions.panels().find((panel) => panel.key === session.tabs.activeTab()) return selected?.props.group ?? selected?.key ?? store.mobileTab }) - const mobileItems = createMemo(() => Array.from(new Map(extensions.panels().map((panel) => [panel.props.group ?? panel.key, panel])).keys()).map((id) => ({ id, title: extensions.panels().find((panel) => (panel.props.group ?? panel.key) === id)!.props.title }))) + const mobileItems = createMemo(() => + Array.from(new Set(extensions.panels().map((panel) => panel.props.group ?? panel.key))).map((id) => { + const panel = extensions.panels().find((panel) => (panel.props.group ?? panel.key) === id)! + return { id, title: panel.props.title, menu: panel.props.initial === "closed" && !panel.props.group } + }), + ) const conversationVisible = createMemo(() => isDesktop() || mobileView() === "session") createEffect(() => { if (!isDesktop() && screen.terminal.open()) setStore("mobileTerminalCached", true) @@ -254,9 +258,6 @@ export function SessionScreen(props: { session: SessionModel }) { <> - - - item.id === mobileView())}> diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index 074e1dfe620e..fb458aea437f 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -14,7 +14,6 @@ import { ProjectAvatar } from "@opencode/ui/project-avatar" import type { Project } from "@/runtime/server/types" import { getFilename } from "@opencode/util/path" import { Popover } from "@kobalte/core/popover" -import { SessionContextUsage } from "@/session/timeline/session-context-usage" import { useLanguage } from "@/runtime/i18n/language" import { useData, useServer } from "@/runtime/server/current" import { useWorkspaceLocation } from "@/workspaces/location" @@ -795,7 +794,6 @@ function MessageTimelineView( {(id) => (
{props.search} - {props.headerActions} {(project) => ( diff --git a/packages/app/src/session/timeline/session-context-usage.tsx b/packages/app/src/session/timeline/session-context-usage.tsx deleted file mode 100644 index eaa8f5a5cd41..000000000000 --- a/packages/app/src/session/timeline/session-context-usage.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { Show, createMemo, type ComponentProps, type JSX } from "solid-js" -import { ProgressCircle } from "@opencode/ui/progress-circle" -import { IconButton } from "@opencode/ui/icon-button" -import { Tooltip } from "@opencode/ui/tooltip" -import { createMediaQuery } from "@solid-primitives/media" - -import { useFile } from "@/workspaces/files/model" -import { useLayout } from "@/shell/state/layout" -import { useData } from "@/runtime/server/current" -import { useLanguage } from "@/runtime/i18n/language" -import { useProviders } from "@/providers/catalog/providers" -import { useWorkspaceLocation } from "@/workspaces/location" -import { useSessionLayout } from "@/session/session-layout" -import { createSessionTabs } from "@/session/helpers" - -interface SessionContextUsageProps { - variant?: "button" | "indicator" - placement?: ComponentProps["placement"] -} - -function ContextTooltipRow(props: { name: JSX.Element; value: JSX.Element }) { - return ( -
- {props.name} - {props.value} -
- ) -} - -function openSessionContext(args: { - view: ReturnType["view"]> - layout: ReturnType - tabs: ReturnType["tabs"]> -}) { - args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button") - if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all") - void args.tabs.open("context").then(() => args.tabs.setActive("context")) -} - -export function SessionContextUsage(props: SessionContextUsageProps) { - const data = useData() - const file = useFile() - const layout = useLayout() - const language = useLanguage() - const sdk = useWorkspaceLocation() - const providers = useProviders(() => sdk().directory) - const { params, tabs, view } = useSessionLayout() - const isDesktop = createMediaQuery("(min-width: 768px)") - - const variant = createMemo(() => props.variant ?? "button") - const tabState = createSessionTabs({ - tabs, - pathFromTab: file.pathFromTab, - normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), - fileBrowser: () => isDesktop() && !!params.id, - }) - const messages = createMemo(() => (params.id ? data.session.message.list(params.id) : [])) - const info = createMemo(() => (params.id ? data.session.get(params.id) : undefined)) - - const usd = createMemo( - () => - new Intl.NumberFormat(language.intl(), { - style: "currency", - currency: "USD", - }), - ) - - const context = createMemo(() => { - const message = messages().findLast((item) => item.type === "assistant" && !!item.tokens) - if (message?.type !== "assistant" || !message.tokens) return - const model = providers.all().get(message.model.providerID)?.models[message.model.id] - const total = - message.tokens.input + - message.tokens.output + - message.tokens.reasoning + - message.tokens.cache.read + - message.tokens.cache.write - return { - total, - usage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null, - } - }) - const cost = createMemo(() => { - return usd().format(info()?.cost ?? 0) - }) - const contextVisible = createMemo(() => view().reviewPanel.opened() && tabState.activeTab() === "context") - const hasOtherTabs = createMemo(() => - tabs() - .all() - .some((tab) => tab !== "context" && tab !== "review"), - ) - - const openContext = () => { - if (!params.id) return - - const sessionView = view() - if (contextVisible()) { - tabs().close("context") - if (sessionView.reviewPanel.source() === "context-button" && !hasOtherTabs()) sessionView.reviewPanel.close() - return - } - - openSessionContext({ - view: sessionView, - layout, - tabs: tabs(), - }) - } - - const circle = () => ( -
- -
- ) - const compactCircle = () => ( -
- -
- ) - - const tooltipValue = () => ( -
- - - -
- ) - - return ( - - - - } - > - {circle()} - - - - ) -} diff --git a/packages/plugin-context-desktop/README.md b/packages/plugin-context-desktop/README.md new file mode 100644 index 000000000000..8edb32afb3af --- /dev/null +++ b/packages/plugin-context-desktop/README.md @@ -0,0 +1,17 @@ +# Context desktop extension + +Built-in renderer extension for the context-usage button, token and cost statistics, +system-prompt display, raw message inspection, and session export. + +The extension contributes a header action and an initially closed panel. It decides +when to open or close that panel and uses the host's tab-closing policy to distinguish +user-opened panels from pinned defaults. On small screens, its panel appears in the +host's additional-panel menu. + +Session history, provider metadata, and models come from the shared public client +data cache. Export reads all message pages through the public HTTP client. Shared +UI controls, Markdown, and file rendering come from `@opencode/ui` and +`@opencode/session-ui`. There are no imports from App, Desktop, Core, or Server. + +Run `bun typecheck` and `bun test` from this package. App fixtures verify the context +panel, export, resizing, and interaction with other extension panels. diff --git a/packages/plugin-context-desktop/package.json b/packages/plugin-context-desktop/package.json new file mode 100644 index 000000000000..d43408767e79 --- /dev/null +++ b/packages/plugin-context-desktop/package.json @@ -0,0 +1,28 @@ +{ + "name": "@opencode/plugin-context-desktop", + "version": "0.0.0", + "type": "module", + "license": "MIT", + "exports": { + ".": "./src/index.tsx", + "./export": "./src/export.ts" + }, + "scripts": { + "typecheck": "tsgo --noEmit", + "test": "bun test" + }, + "dependencies": { + "@opencode/client": "workspace:*", + "@opencode/plugin": "workspace:*", + "@opencode/session-ui": "workspace:*", + "@opencode/ui": "workspace:*", + "@opencode/util": "workspace:*", + "solid-js": "catalog:", + "luxon": "catalog:" + }, + "devDependencies": { + "@types/bun": "catalog:", + "@types/luxon": "catalog:", + "@typescript/native-preview": "catalog:" + } +} diff --git a/packages/plugin-context-desktop/src/button.tsx b/packages/plugin-context-desktop/src/button.tsx new file mode 100644 index 000000000000..ba3ee5a70d19 --- /dev/null +++ b/packages/plugin-context-desktop/src/button.tsx @@ -0,0 +1,105 @@ +import { Show, createMemo, type ComponentProps, type JSX } from "solid-js" +import { ProgressCircle } from "@opencode/ui/progress-circle" +import { IconButton } from "@opencode/ui/icon-button" +import { Tooltip } from "@opencode/ui/tooltip" +import { usePlugin, type SessionContext } from "@opencode/plugin/desktop" +import { contextUsage } from "./usage" +import { toggleContext } from "./toggle" + +interface SessionContextUsageProps { + session: SessionContext + variant?: "button" | "indicator" + placement?: ComponentProps["placement"] +} + +function ContextTooltipRow(props: { name: JSX.Element; value: JSX.Element }) { + return ( +
+ {props.name} + {props.value} +
+ ) +} + +export function SessionContextUsage(props: SessionContextUsageProps) { + const plugin = usePlugin() + const data = props.session.server.data + const language = plugin.i18n + + const variant = createMemo(() => props.variant ?? "button") + const messages = createMemo(() => data.session.message.list(props.session.sessionID)) + const info = createMemo(() => data.session.get(props.session.sessionID)) + + const usd = createMemo( + () => + new Intl.NumberFormat(language.intl(), { + style: "currency", + currency: "USD", + }), + ) + + const context = createMemo(() => + contextUsage( + messages(), + data.location.model.list(props.session.location) ?? [], + data.location.provider.list(props.session.location) ?? [], + ), + ) + const cost = createMemo(() => { + return usd().format(info()?.cost ?? 0) + }) + + const circle = () => ( +
+ +
+ ) + const compactCircle = () => ( +
+ +
+ ) + + const tooltipValue = () => ( +
+ + + +
+ ) + + return ( + + + toggleContext(props.session)} + aria-label={language.t("context.usage.view")} + /> + } + > + {circle()} + + + + ) +} diff --git a/packages/plugin-context-desktop/src/env.d.ts b/packages/plugin-context-desktop/src/env.d.ts new file mode 100644 index 000000000000..8c1b7acd575c --- /dev/null +++ b/packages/plugin-context-desktop/src/env.d.ts @@ -0,0 +1,4 @@ +declare module "*?worker&url" { + const url: string + export default url +} diff --git a/packages/plugin-context-desktop/src/export.ts b/packages/plugin-context-desktop/src/export.ts new file mode 100644 index 000000000000..38a64276d82a --- /dev/null +++ b/packages/plugin-context-desktop/src/export.ts @@ -0,0 +1,67 @@ +import type { SessionInfo, SessionMessageInfo, OpenCodeClient } from "@opencode/client" +import type { Context } from "@opencode/plugin/desktop" + +export type SessionExportData = { + info: SessionInfo + messages: SessionMessageInfo[] +} + +export async function fetchSessionExport(input: { + sessionID: string + api: Pick +}): Promise { + const [info, first] = await Promise.all([ + input.api.session.get({ sessionID: input.sessionID }), + input.api.message.list({ sessionID: input.sessionID, limit: 200, order: "asc" }), + ]) + const pages = [first] + + while (pages.at(-1)?.cursor.next) { + pages.push( + await input.api.message.list({ + sessionID: input.sessionID, + limit: 200, + cursor: pages.at(-1)!.cursor.next ?? undefined, + }), + ) + } + + return { + info, + messages: pages.flatMap((page) => page.data), + } +} + +export function sessionExportFilename(session: { id: string; title?: string; slug?: string }) { + const name = session.title || session.slug || session.id + const clean = name + .toLowerCase() + .replace(/[^a-z0-9_-]+/gi, "-") + .replace(/^-+|-+$/g, "") + return `${clean || session.id}.json` +} + +export function downloadSessionExport(filename: string, data: unknown) { + const json = JSON.stringify(data, null, 2) + const blob = new Blob([json], { type: "application/json" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +} + +export async function saveSessionExport( + filename: string, + data: unknown, + platform: Partial>, +) { + if (!platform.saveFile) { + downloadSessionExport(filename, data) + return true + } + return platform.saveFile({ defaultPath: filename }, JSON.stringify(data, null, 2)) +} diff --git a/packages/plugin-context-desktop/src/index.tsx b/packages/plugin-context-desktop/src/index.tsx new file mode 100644 index 000000000000..d829bbd1a949 --- /dev/null +++ b/packages/plugin-context-desktop/src/index.tsx @@ -0,0 +1,54 @@ +import { Show, createEffect } from "solid-js" +import { Plugin } from "@opencode/plugin/desktop" +import { Panel } from "@opencode/plugin/desktop/solid" +import { SessionContextTab } from "./panel" +import { SessionContextUsage } from "./button" +import { toggleContext } from "./toggle" + +export const ContextDesktop = Plugin.define({ + id: "opencode.context", + setup(ctx) { + ctx.commands.register(() => { + const session = ctx.sessions.current() + return [ + { + id: "toggle", + title: ctx.i18n.t("context.usage.view"), + enabled: !!session?.services, + run() { + if (session) toggleContext(session) + }, + }, + ] + }) + ctx.ui.slot({ + append: "session.header.actions", + render: ({ session }) => , + }) + ctx.ui.slot({ + append: "session.panel", + render: ({ session }) => { + createEffect(() => { + if (!session.services) return + void session.server.data.location.model.sync(session.location) + void session.server.data.location.provider.sync(session.location) + }) + return ( + + {(services) => ( + } + > + + + )} + + ) + }, + }) + }, +}) diff --git a/packages/app/src/session/files/session-context-tab.tsx b/packages/plugin-context-desktop/src/panel.tsx similarity index 78% rename from packages/app/src/session/files/session-context-tab.tsx rename to packages/plugin-context-desktop/src/panel.tsx index 7e6686cfdde6..e7adcf6af755 100644 --- a/packages/app/src/session/files/session-context-tab.tsx +++ b/packages/plugin-context-desktop/src/panel.tsx @@ -1,8 +1,6 @@ import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" import type { JSX } from "solid-js" -import { useData } from "@/runtime/server/current" import { checksum } from "@opencode/util/encode" -import { same } from "@/runtime/persistence/equality" import { Icon } from "@opencode/ui/icon" import { Button } from "@opencode/ui/button" import { Accordion } from "@opencode/ui/accordion" @@ -11,14 +9,10 @@ import { File } from "@opencode/session-ui/file" import { Markdown } from "@opencode/session-ui/markdown" import { ScrollView } from "@opencode/ui/scroll-view" import type { SessionMessageInfo } from "@opencode/client/promise" -import { showToast } from "@/shell/notifications/toast" -import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "@/session/commands/export" -import { useLanguage } from "@/runtime/i18n/language" -import { usePlatform } from "@/runtime/platform/platform" -import { useProviders } from "@/providers/catalog/providers" -import { useWorkspaceLocation } from "@/workspaces/location" -import { useServerSDK } from "@/runtime/server/client" -import { useSessionLayout } from "@/session/session-layout" +import { usePlugin, type SessionContext } from "@opencode/plugin/desktop" +import type { SessionView } from "@opencode/plugin/desktop/workspace" +import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "./export" +import { contextUsage } from "./usage" import { createSessionContextFormatter } from "./session-context-format" function Stat(props: { label: string; value: JSX.Element }) { @@ -82,26 +76,14 @@ function RawMessage(props: { const emptyMessages: SessionMessageInfo[] = [] -export function SessionContextTab() { - const data = useData() - const language = useLanguage() - const platform = usePlatform() - const sdk = useWorkspaceLocation() - const serverSDK = useServerSDK() - const providers = useProviders(() => sdk().directory) - const { params, view } = useSessionLayout() - - const info = createMemo(() => (params.id ? data.session.get(params.id) : undefined)) - - const messages = createMemo( - () => { - const id = params.id - if (!id) return emptyMessages - return data.session.message.list(id) - }, - emptyMessages, - { equals: same }, - ) +export function SessionContextTab(props: { session: SessionContext; view: SessionView }) { + const plugin = usePlugin() + const data = props.session.server.data + const language = plugin.i18n + const platform = plugin.platform + const view = () => props.view + const info = createMemo(() => data.session.get(props.session.sessionID)) + const messages = createMemo(() => data.session.message.list(props.session.sessionID)) const usd = createMemo( () => @@ -111,28 +93,13 @@ export function SessionContextTab() { }), ) - const ctx = createMemo(() => { - const message = messages().findLast((item) => item.type === "assistant" && !!item.tokens) - if (message?.type !== "assistant" || !message.tokens) return - const provider = providers.all().get(message.model.providerID) - const model = provider?.models[message.model.id] - const total = - message.tokens.input + - message.tokens.output + - message.tokens.reasoning + - message.tokens.cache.read + - message.tokens.cache.write - return { - message, - tokens: message.tokens, - providerLabel: provider?.name ?? message.model.providerID, - modelLabel: model?.name ?? message.model.id, - limit: model?.limit.context, - input: message.tokens.input, - total, - usage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null, - } - }) + const ctx = createMemo(() => + contextUsage( + messages(), + data.location.model.list(props.session.location) ?? [], + data.location.provider.list(props.session.location) ?? [], + ), + ) const formatter = createMemo(() => createSessionContextFormatter(language.intl())) const cost = createMemo(() => { @@ -171,7 +138,7 @@ export function SessionContextTab() { }) const stats = [ - { label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" }, + { label: "context.stats.session", value: () => info()?.title ?? props.session.sessionID }, { label: "context.stats.messages", value: () => counts().all.toLocaleString(language.intl()) }, { label: "context.stats.provider", value: providerLabel }, { label: "context.stats.model", value: modelLabel }, @@ -193,26 +160,24 @@ export function SessionContextTab() { ] satisfies { label: string; value: () => JSX.Element }[] const exportSession = async () => { - const sessionID = params.id - if (!sessionID) return + const sessionID = props.session.sessionID try { const data = await fetchSessionExport({ sessionID, - api: serverSDK.api, + api: props.session.server.client, }) const filename = sessionExportFilename(data.info) if (!(await saveSessionExport(filename, data, platform))) return - showToast({ + plugin.ui.toast.show({ variant: "success", - icon: "circle-check", title: language.t("toast.session.export.success.title"), - description: language.t("toast.session.export.success.description", { filename }), + message: language.t("toast.session.export.success.description", { filename }), }) } catch (err) { - showToast({ + plugin.ui.toast.show({ variant: "error", title: language.t("toast.session.export.failed.title"), - description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"), + message: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"), }) } } diff --git a/packages/app/src/session/files/session-context-format.ts b/packages/plugin-context-desktop/src/session-context-format.ts similarity index 100% rename from packages/app/src/session/files/session-context-format.ts rename to packages/plugin-context-desktop/src/session-context-format.ts diff --git a/packages/plugin-context-desktop/src/toggle.ts b/packages/plugin-context-desktop/src/toggle.ts new file mode 100644 index 000000000000..64fa7996f038 --- /dev/null +++ b/packages/plugin-context-desktop/src/toggle.ts @@ -0,0 +1,16 @@ +import type { SessionContext } from "@opencode/plugin/desktop" + +export function toggleContext(session: SessionContext) { + const view = session.services?.view + if (!view) return + if (view.panel.opened() && view.tabs.active() === "context") { + view.tabs.close("context") + if (view.panel.source() === "context-button" && !view.tabs.all().some((tab) => view.tabs.canClose(tab))) + view.panel.close() + return + } + view.panel.open(view.panel.opened() ? "other" : "context-button") + if (view.sidebar.opened()) view.sidebar.setTab("all") + void view.tabs.open("context") + view.tabs.setActive("context") +} diff --git a/packages/plugin-context-desktop/src/usage.ts b/packages/plugin-context-desktop/src/usage.ts new file mode 100644 index 000000000000..51df26c5f96b --- /dev/null +++ b/packages/plugin-context-desktop/src/usage.ts @@ -0,0 +1,32 @@ +import type { ModelInfo, ProviderInfo, SessionMessageInfo } from "@opencode/client" + +export function contextUsage( + messages: readonly SessionMessageInfo[], + models: readonly Pick[], + providers: readonly Pick[], +) { + const message = messages.findLast((item) => item.type === "assistant" && !!item.tokens) + if (message?.type !== "assistant" || !message.tokens) return + const model = models.find( + (model) => + model.providerID === message.model.providerID && + (model.modelID === message.model.id || model.id === message.model.id), + ) + const provider = providers.find((provider) => provider.id === message.model.providerID) + const total = + message.tokens.input + + message.tokens.output + + message.tokens.reasoning + + message.tokens.cache.read + + message.tokens.cache.write + return { + message, + tokens: message.tokens, + total, + input: message.tokens.input, + providerLabel: provider?.name ?? message.model.providerID, + modelLabel: model?.name ?? message.model.id, + limit: model?.limit.context, + usage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null, + } +} diff --git a/packages/plugin-context-desktop/test/boundary.test.ts b/packages/plugin-context-desktop/test/boundary.test.ts new file mode 100644 index 000000000000..46bcd8c8e20e --- /dev/null +++ b/packages/plugin-context-desktop/test/boundary.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from "bun:test" +import path from "node:path" + +test("context UI imports only public renderer and client surfaces", async () => { + const root = path.join(import.meta.dir, "../src") + const files = await Array.fromAsync(new Bun.Glob("**/*.{ts,tsx}").scan(root)) + const sources = await Promise.all( + files.map(async (file) => ({ file, source: await Bun.file(path.join(root, file)).text() })), + ) + expect( + sources + .filter(({ source }) => /["'](?:@\/|@opencode\/(?:app|desktop|core|server)(?:[\/"']))/.test(source)) + .map(({ file }) => file), + ).toEqual([]) +}) diff --git a/packages/plugin-context-desktop/test/usage.test.ts b/packages/plugin-context-desktop/test/usage.test.ts new file mode 100644 index 000000000000..5f863eeb4269 --- /dev/null +++ b/packages/plugin-context-desktop/test/usage.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from "bun:test" +import type { SessionMessageAssistant } from "@opencode/client" +import { contextUsage } from "../src/usage" + +const message = { + id: "msg_usage", + type: "assistant", + time: { created: 1 }, + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [], + tokens: { input: 100, output: 20, reasoning: 10, cache: { read: 40, write: 30 } }, +} satisfies SessionMessageAssistant + +test("counts all token categories against the matching provider model", () => { + const result = contextUsage( + [message], + [ + { + id: "provider/model", + modelID: "model", + providerID: "provider", + name: "Model", + limit: { context: 1000, output: 100 }, + }, + ], + [{ id: "provider", name: "Provider" }], + ) + expect(result).toMatchObject({ + total: 200, + input: 100, + usage: 20, + limit: 1000, + modelLabel: "Model", + providerLabel: "Provider", + }) +}) + +test("uses the newest metered message and handles missing metadata", () => { + const result = contextUsage( + [ + message, + { ...message, id: "msg_latest", tokens: { ...message.tokens, input: 200 } }, + { ...message, id: "msg_streaming", tokens: undefined }, + ], + [], + [], + ) + expect(result).toMatchObject({ + message: { id: "msg_latest" }, + total: 300, + usage: null, + modelLabel: "model", + providerLabel: "provider", + }) + expect(contextUsage([], [], [])).toBeUndefined() +}) diff --git a/packages/plugin-context-desktop/tsconfig.json b/packages/plugin-context-desktop/tsconfig.json new file mode 100644 index 000000000000..513120fc4b68 --- /dev/null +++ b/packages/plugin-context-desktop/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "preserve", + "jsxImportSource": "solid-js", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["bun"] + }, + "include": ["src", "test"] +} From 4f1dbd81894f6387e3f7e197b1169c22c3c83b59 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:11:01 +1000 Subject: [PATCH 2/2] chore(context): name the built-in extension in settings --- packages/plugin-context-desktop/src/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/plugin-context-desktop/src/index.tsx b/packages/plugin-context-desktop/src/index.tsx index d829bbd1a949..e281103a63e1 100644 --- a/packages/plugin-context-desktop/src/index.tsx +++ b/packages/plugin-context-desktop/src/index.tsx @@ -7,6 +7,7 @@ import { toggleContext } from "./toggle" export const ContextDesktop = Plugin.define({ id: "opencode.context", + name: "session.tab.context", setup(ctx) { ctx.commands.register(() => { const session = ctx.sessions.current()