diff options
| author | Adam <[email protected]> | 2026-01-01 05:23:00 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-01-01 05:23:07 -0600 |
| commit | 6e7fc30f9407f48074852415e2c35958b82b78ed (patch) | |
| tree | 9d2c0d8bddac44d80e4a6f51df4182e94e84c895 | |
| parent | 03733b0505989cf23538cdbb4f1644fae109b4b7 (diff) | |
| download | opencode-6e7fc30f9407f48074852415e2c35958b82b78ed.tar.gz opencode-6e7fc30f9407f48074852415e2c35958b82b78ed.zip | |
feat(app): context window window
| -rw-r--r-- | packages/app/src/components/session-context-usage.tsx | 100 | ||||
| -rw-r--r-- | packages/app/src/context/layout.tsx | 62 | ||||
| -rw-r--r-- | packages/app/src/pages/session.tsx | 397 |
3 files changed, 502 insertions, 57 deletions
diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index ece1f8695..53e578214 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -1,13 +1,25 @@ -import { createMemo, Show } from "solid-js" +import { Match, Show, Switch, createMemo } from "solid-js" import { Tooltip } from "@opencode-ai/ui/tooltip" import { ProgressCircle } from "@opencode-ai/ui/progress-circle" -import { useSync } from "@/context/sync" +import { Button } from "@opencode-ai/ui/button" import { useParams } from "@solidjs/router" import { AssistantMessage } from "@opencode-ai/sdk/v2/client" -export function SessionContextUsage() { +import { useLayout } from "@/context/layout" +import { useSync } from "@/context/sync" + +interface SessionContextUsageProps { + variant?: "button" | "indicator" +} + +export function SessionContextUsage(props: SessionContextUsageProps) { const sync = useSync() const params = useParams() + const layout = useLayout() + + const variant = createMemo(() => props.variant ?? "button") + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey())) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) const cost = createMemo(() => { @@ -19,7 +31,11 @@ export function SessionContextUsage() { }) const context = createMemo(() => { - const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage + const last = messages().findLast((x) => { + if (x.role !== "assistant") return false + const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write + return total > 0 + }) as AssistantMessage if (!last) return const total = last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write @@ -30,33 +46,57 @@ export function SessionContextUsage() { } }) - return ( - <Show when={context?.()}> - {(ctx) => ( - <Tooltip - value={ - <div class=""> - <div class="flex items-center gap-2"> - <span class="text-text-invert-strong">{ctx().tokens}</span> - <span class="text-text-invert-base">Tokens</span> - </div> - <div class="flex items-center gap-2"> - <span class="text-text-invert-strong">{ctx().percentage ?? 0}%</span> - <span class="text-text-invert-base">Usage</span> - </div> - <div class="flex items-center gap-2"> - <span class="text-text-invert-strong">{cost()}</span> - <span class="text-text-invert-base">Cost</span> - </div> + const openContext = () => { + if (!params.id) return + layout.review.open() + tabs().open("context") + tabs().setActive("context") + } + + const circle = () => ( + <div class="p-1"> + <ProgressCircle size={16} strokeWidth={2} percentage={context()?.percentage ?? 0} /> + </div> + ) + + const tooltipValue = () => ( + <div> + <Show when={context()}> + {(ctx) => ( + <> + <div class="flex items-center gap-2"> + <span class="text-text-invert-strong">{ctx().tokens}</span> + <span class="text-text-invert-base">Tokens</span> + </div> + <div class="flex items-center gap-2"> + <span class="text-text-invert-strong">{ctx().percentage ?? 0}%</span> + <span class="text-text-invert-base">Usage</span> </div> - } - placement="top" - > - <div class="p-1"> - <ProgressCircle size={16} strokeWidth={2} percentage={ctx().percentage ?? 0} /> - </div> - </Tooltip> - )} + </> + )} + </Show> + <div class="flex items-center gap-2"> + <span class="text-text-invert-strong">{cost()}</span> + <span class="text-text-invert-base">Cost</span> + </div> + <Show when={variant() === "button"}> + <div class="text-11-regular text-text-invert-base mt-1">Click to view context</div> + </Show> + </div> + ) + + return ( + <Show when={params.id}> + <Tooltip value={tooltipValue()} placement="top"> + <Switch> + <Match when={variant() === "indicator"}>{circle()}</Match> + <Match when={true}> + <Button type="button" variant="ghost" class="size-6" onClick={openContext}> + {circle()} + </Button> + </Match> + </Switch> + </Tooltip> </Show> ) } diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 156adc4ff..613a0e0c1 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -209,38 +209,58 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, async open(tab: string) { const current = store.sessionTabs[sessionKey] ?? { all: [] } - if (tab !== "review") { - if (!current.all.includes(tab)) { - if (!store.sessionTabs[sessionKey]) { - setStore("sessionTabs", sessionKey, { all: [tab], active: tab }) - } else { - setStore("sessionTabs", sessionKey, "all", [...current.all, tab]) - setStore("sessionTabs", sessionKey, "active", tab) - } + + if (tab === "review") { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [], active: tab }) return } + setStore("sessionTabs", sessionKey, "active", tab) + return } - if (!store.sessionTabs[sessionKey]) { - setStore("sessionTabs", sessionKey, { all: [], active: tab }) - } else { + + if (tab === "context") { + const all = [tab, ...current.all.filter((x) => x !== tab)] + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all, active: tab }) + return + } + setStore("sessionTabs", sessionKey, "all", all) setStore("sessionTabs", sessionKey, "active", tab) + return } + + if (!current.all.includes(tab)) { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [tab], active: tab }) + return + } + setStore("sessionTabs", sessionKey, "all", [...current.all, tab]) + setStore("sessionTabs", sessionKey, "active", tab) + return + } + + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: current.all, active: tab }) + return + } + setStore("sessionTabs", sessionKey, "active", tab) }, close(tab: string) { const current = store.sessionTabs[sessionKey] if (!current) return + + const all = current.all.filter((x) => x !== tab) batch(() => { - setStore( - "sessionTabs", - sessionKey, - "all", - current.all.filter((x) => x !== tab), - ) - if (current.active === tab) { - const index = current.all.findIndex((f) => f === tab) - const previous = current.all[Math.max(0, index - 1)] - setStore("sessionTabs", sessionKey, "active", previous) + setStore("sessionTabs", sessionKey, "all", all) + if (current.active !== tab) return + + const index = current.all.findIndex((f) => f === tab) + if (index <= 0) { + setStore("sessionTabs", sessionKey, "active", undefined) + return } + setStore("sessionTabs", sessionKey, "active", current.all[index - 1]) }) }, move(tab: string, to: number) { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 24d7bb94f..f738fec33 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -17,6 +17,7 @@ import { Dynamic } from "solid-js/web" import { useLocal, type LocalFile } from "@/context/local" import { createStore } from "solid-js/store" import { PromptInput } from "@/components/prompt-input" +import { SessionContextUsage } from "@/components/session-context-usage" import { DateTime } from "luxon" import { FileIcon } from "@opencode-ai/ui/file-icon" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -30,6 +31,10 @@ import { SessionTurn } from "@opencode-ai/ui/session-turn" import { createAutoScroll } from "@opencode-ai/ui/hooks" import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail" import { SessionReview } from "@opencode-ai/ui/session-review" +import { Markdown } from "@opencode-ai/ui/markdown" +import { Accordion } from "@opencode-ai/ui/accordion" +import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" +import { Code } from "@opencode-ai/ui/code" import { DragDropProvider, DragDropSensors, @@ -70,7 +75,7 @@ import { Select } from "@opencode-ai/ui/select" import { TextField } from "@opencode-ai/ui/text-field" import { base64Encode } from "@opencode-ai/util/encode" import { iife } from "@opencode-ai/util/iife" -import { Session } from "@opencode-ai/sdk/v2/client" +import { AssistantMessage, Session, type Message, type Part } from "@opencode-ai/sdk/v2/client" function same<T>(a: readonly T[], b: readonly T[]) { if (a === b) return true @@ -817,7 +822,23 @@ export default function Page() { ) } - const showTabs = createMemo(() => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0)) + const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context")) + const openedTabs = createMemo(() => + tabs() + .all() + .filter((tab) => tab !== "context"), + ) + + const showTabs = createMemo( + () => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0 || contextOpen()), + ) + + const activeTab = createMemo(() => { + const active = tabs().active() + if (active) return active + if (diffs().length > 0) return "review" + return tabs().all()[0] ?? "review" + }) const mobileWorking = createMemo(() => status().type !== "idle") const mobileAutoScroll = createAutoScroll({ @@ -916,6 +937,347 @@ export default function Page() { </Switch> ) + const ContextTab = () => { + const ctx = createMemo(() => { + const last = messages().findLast((x) => { + if (x.role !== "assistant") return false + const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write + return total > 0 + }) as AssistantMessage + if (!last) return + + const provider = sync.data.provider.all.find((x) => x.id === last.providerID) + const model = provider?.models[last.modelID] + const limit = model?.limit.context + + const input = last.tokens.input + const output = last.tokens.output + const reasoning = last.tokens.reasoning + const cacheRead = last.tokens.cache.read + const cacheWrite = last.tokens.cache.write + const total = input + output + reasoning + cacheRead + cacheWrite + const usage = limit ? Math.round((total / limit) * 100) : null + + return { + message: last, + provider, + model, + limit, + input, + output, + reasoning, + cacheRead, + cacheWrite, + total, + usage, + } + }) + + const cost = createMemo(() => { + const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0) + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(total) + }) + + const counts = createMemo(() => { + const all = messages() + const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0) + const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0) + return { + all: all.length, + user, + assistant, + } + }) + + const systemPrompt = createMemo(() => { + const msg = visibleUserMessages().findLast((m) => !!m.system) + const system = msg?.system + if (!system) return + const trimmed = system.trim() + if (!trimmed) return + return trimmed + }) + + const number = (value: number | null | undefined) => { + if (value === undefined) return "—" + if (value === null) return "—" + return value.toLocaleString() + } + + const percent = (value: number | null | undefined) => { + if (value === undefined) return "—" + if (value === null) return "—" + return value.toString() + "%" + } + + const time = (value: number | undefined) => { + if (!value) return "—" + return DateTime.fromMillis(value).toLocaleString(DateTime.DATETIME_MED) + } + + const providerLabel = createMemo(() => { + const c = ctx() + if (!c) return "—" + return c.provider?.name ?? c.message.providerID + }) + + const modelLabel = createMemo(() => { + const c = ctx() + if (!c) return "—" + if (c.model?.name) return c.model.name + return c.message.modelID + }) + + const breakdown = createMemo( + on( + () => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()], + () => { + const c = ctx() + if (!c) return [] + const input = c.input + if (!input) return [] + + const out = { + system: systemPrompt()?.length ?? 0, + user: 0, + assistant: 0, + tool: 0, + } + + for (const msg of messages()) { + const parts = (sync.data.part[msg.id] ?? []) as Part[] + + if (msg.role === "user") { + for (const part of parts) { + if (part.type === "text") out.user += part.text.length + if (part.type === "file") out.user += part.source?.text.value.length ?? 0 + if (part.type === "agent") out.user += part.source?.value.length ?? 0 + } + continue + } + + if (msg.role === "assistant") { + for (const part of parts) { + if (part.type === "text") out.assistant += part.text.length + if (part.type === "reasoning") out.assistant += part.text.length + if (part.type === "tool") { + out.tool += Object.keys(part.state.input).length * 16 + if (part.state.status === "pending") out.tool += part.state.raw.length + if (part.state.status === "completed") out.tool += part.state.output.length + if (part.state.status === "error") out.tool += part.state.error.length + } + } + } + } + + const estimateTokens = (chars: number) => Math.ceil(chars / 4) + const system = estimateTokens(out.system) + const user = estimateTokens(out.user) + const assistant = estimateTokens(out.assistant) + const tool = estimateTokens(out.tool) + const estimated = system + user + assistant + tool + + const pct = (tokens: number) => (tokens / input) * 100 + const pctLabel = (tokens: number) => (Math.round(pct(tokens) * 10) / 10).toString() + "%" + + const build = (tokens: { system: number; user: number; assistant: number; tool: number; other: number }) => { + return [ + { + key: "system", + label: "System", + tokens: tokens.system, + width: pct(tokens.system), + percent: pctLabel(tokens.system), + color: "var(--syntax-info)", + }, + { + key: "user", + label: "User", + tokens: tokens.user, + width: pct(tokens.user), + percent: pctLabel(tokens.user), + color: "var(--syntax-success)", + }, + { + key: "assistant", + label: "Assistant", + tokens: tokens.assistant, + width: pct(tokens.assistant), + percent: pctLabel(tokens.assistant), + color: "var(--syntax-property)", + }, + { + key: "tool", + label: "Tool Calls", + tokens: tokens.tool, + width: pct(tokens.tool), + percent: pctLabel(tokens.tool), + color: "var(--syntax-warning)", + }, + { + key: "other", + label: "Other", + tokens: tokens.other, + width: pct(tokens.other), + percent: pctLabel(tokens.other), + color: "var(--syntax-comment)", + }, + ].filter((x) => x.tokens > 0) + } + + if (estimated <= input) { + return build({ system, user, assistant, tool, other: input - estimated }) + } + + const scale = input / estimated + const scaled = { + system: Math.floor(system * scale), + user: Math.floor(user * scale), + assistant: Math.floor(assistant * scale), + tool: Math.floor(tool * scale), + } + const scaledTotal = scaled.system + scaled.user + scaled.assistant + scaled.tool + return build({ ...scaled, other: Math.max(0, input - scaledTotal) }) + }, + ), + ) + + function Stat(props: { label: string; value: JSX.Element }) { + return ( + <div class="flex flex-col gap-1"> + <div class="text-12-regular text-text-weak">{props.label}</div> + <div class="text-12-medium text-text-strong">{props.value}</div> + </div> + ) + } + + const stats = createMemo(() => { + const c = ctx() + const count = counts() + return [ + { label: "Session", value: info()?.title ?? params.id ?? "—" }, + { label: "Messages", value: count.all.toLocaleString() }, + { label: "Provider", value: providerLabel() }, + { label: "Model", value: modelLabel() }, + { label: "Context Limit", value: number(c?.limit) }, + { label: "Total Tokens", value: number(c?.total) }, + { label: "Usage", value: percent(c?.usage) }, + { label: "Input Tokens", value: number(c?.input) }, + { label: "Output Tokens", value: number(c?.output) }, + { label: "Reasoning Tokens", value: number(c?.reasoning) }, + { label: "Cache Tokens (read/write)", value: `${number(c?.cacheRead)} / ${number(c?.cacheWrite)}` }, + { label: "User Messages", value: count.user.toLocaleString() }, + { label: "Assistant Messages", value: count.assistant.toLocaleString() }, + { label: "Total Cost", value: cost() }, + { label: "Session Created", value: time(info()?.time.created) }, + { label: "Last Activity", value: time(c?.message.time.created) }, + ] satisfies { label: string; value: JSX.Element }[] + }) + + function RawMessageContent(props: { message: Message }) { + const file = createMemo(() => { + const parts = (sync.data.part[props.message.id] ?? []) as Part[] + const contents = JSON.stringify({ message: props.message, parts }, null, 2) + return { + name: `${props.message.role}-${props.message.id}.json`, + contents, + cacheKey: checksum(contents), + } + }) + + return <Code file={file()} overflow="wrap" class="select-text" /> + } + + function RawMessage(props: { message: Message }) { + return ( + <Accordion.Item value={props.message.id}> + <StickyAccordionHeader> + <Accordion.Trigger> + <div class="flex items-center justify-between gap-2 w-full"> + <div class="min-w-0 truncate"> + {props.message.role} <span class="text-text-base">• {props.message.id}</span> + </div> + <div class="flex items-center gap-3"> + <div class="shrink-0 text-12-regular text-text-weak">{time(props.message.time.created)}</div> + <Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" /> + </div> + </div> + </Accordion.Trigger> + </StickyAccordionHeader> + <Accordion.Content class="bg-background-base"> + <div class="p-3"> + <RawMessageContent message={props.message} /> + </div> + </Accordion.Content> + </Accordion.Item> + ) + } + + return ( + <div class="@container h-full overflow-y-auto no-scrollbar pb-10"> + <div class="px-6 pt-4 flex flex-col gap-10"> + <div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4"> + <For each={stats()}>{(stat) => <Stat label={stat.label} value={stat.value} />}</For> + </div> + + <Show when={breakdown().length > 0}> + <div class="flex flex-col gap-2"> + <div class="text-12-regular text-text-weak">Context Breakdown</div> + <div class="h-2 w-full rounded-full bg-surface-base overflow-hidden flex"> + <For each={breakdown()}> + {(segment) => ( + <div + class="h-full" + style={{ + width: `${segment.width}%`, + "background-color": segment.color, + }} + /> + )} + </For> + </div> + <div class="flex flex-wrap gap-x-3 gap-y-1"> + <For each={breakdown()}> + {(segment) => ( + <div class="flex items-center gap-1 text-11-regular text-text-weak"> + <div class="size-2 rounded-sm" style={{ "background-color": segment.color }} /> + <div>{segment.label}</div> + <div class="text-text-weaker">{segment.percent}</div> + </div> + )} + </For> + </div> + <div class="hidden text-11-regular text-text-weaker"> + Approximate breakdown of input tokens. "Other" includes tool definitions and overhead. + </div> + </div> + </Show> + + <Show when={systemPrompt()}> + {(prompt) => ( + <div class="flex flex-col gap-2"> + <div class="text-12-regular text-text-weak">System Prompt</div> + <div class="border border-border-base rounded-md bg-surface-base px-3 py-2"> + <Markdown text={prompt()} class="text-12-regular" /> + </div> + </div> + )} + </Show> + + <div class="flex flex-col gap-2"> + <div class="text-12-regular text-text-weak">Raw messages</div> + <Accordion multiple> + <For each={messages()}>{(message) => <RawMessage message={message} />}</For> + </Accordion> + </div> + </div> + </div> + ) + } + return ( <div class="relative bg-background-base size-full overflow-hidden flex flex-col"> <Header /> @@ -1015,7 +1377,7 @@ export default function Page() { > <DragDropSensors /> <ConstrainDragYAxis /> - <Tabs value={tabs().active() ?? "review"} onChange={tabs().open}> + <Tabs value={activeTab()} onChange={tabs().open}> <div class="sticky top-0 shrink-0 flex"> <Tabs.List> <Show when={diffs().length}> @@ -1035,8 +1397,24 @@ export default function Page() { </div> </Tabs.Trigger> </Show> - <SortableProvider ids={tabs().all() ?? []}> - <For each={tabs().all() ?? []}> + <Show when={contextOpen()}> + <Tabs.Trigger + value="context" + closeButton={ + <Tooltip value="Close tab" placement="bottom"> + <IconButton icon="close" variant="ghost" onClick={() => tabs().close("context")} /> + </Tooltip> + } + hideCloseButton + > + <div class="flex items-center gap-2"> + <SessionContextUsage variant="indicator" /> + <div>Context</div> + </div> + </Tabs.Trigger> + </Show> + <SortableProvider ids={openedTabs()}> + <For each={openedTabs()}> {(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={tabs().close} />} </For> </SortableProvider> @@ -1072,7 +1450,14 @@ export default function Page() { </div> </Tabs.Content> </Show> - <For each={tabs().all()}> + <Show when={contextOpen()}> + <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict"> + <div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> + <ContextTab /> + </div> + </Tabs.Content> + </Show> + <For each={openedTabs()}> {(tab) => { const [file] = createResource( () => tab, |
