Просмотр исходного кода

feat(app): context window window

Adam 3 месяцев назад
Родитель
Сommit
6e7fc30f94

+ 70 - 30
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>
   )
 }

+ 41 - 21
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) {

+ 391 - 6
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,