summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-01 05:23:00 -0600
committerAdam <[email protected]>2026-01-01 05:23:07 -0600
commit6e7fc30f9407f48074852415e2c35958b82b78ed (patch)
tree9d2c0d8bddac44d80e4a6f51df4182e94e84c895
parent03733b0505989cf23538cdbb4f1644fae109b4b7 (diff)
downloadopencode-6e7fc30f9407f48074852415e2c35958b82b78ed.tar.gz
opencode-6e7fc30f9407f48074852415e2c35958b82b78ed.zip
feat(app): context window window
-rw-r--r--packages/app/src/components/session-context-usage.tsx100
-rw-r--r--packages/app/src/context/layout.tsx62
-rw-r--r--packages/app/src/pages/session.tsx397
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,