Compare commits

...

16 Commits

Author SHA1 Message Date
Shantur Rathore
9313b2bd6c Add showUsageMetrics to Prefs schema 2025-11-25 16:06:14 +00:00
Shantur Rathore
d25cb09714 Align selector shortcuts and widen sidebar 2025-11-25 16:04:19 +00:00
Shantur Rathore
0d0d1271c3 Move assistant usage chips 2025-11-25 13:12:54 +00:00
Shantur Rathore
1fd3b2e75c Add toggle for usage metrics 2025-11-25 12:26:38 +00:00
Shantur Rathore
bf32fcf136 Refine session usage tracking 2025-11-25 12:03:33 +00:00
Shantur Rathore
48eb6b8982 bump version 0.2.4 2025-11-25 08:56:51 +00:00
Shantur Rathore
797fafe854 Normalize host when parsing CLI 2025-11-25 00:52:46 +00:00
Shantur Rathore
b342660ed0 Improve welcome mobile layout 2025-11-25 00:50:21 +00:00
Shantur Rathore
169d5ddeb9 Use npx tauri for workspace builds 2025-11-24 20:16:49 +00:00
Shantur Rathore
38642b60e9 add command palette button 2025-11-24 14:37:15 +00:00
Shantur Rathore
01effb8924 refine prompt overlay layout 2025-11-24 14:16:25 +00:00
Shantur Rathore
b434bfd3e9 Ensure tauri bundle includes server deps 2025-11-24 11:20:27 +00:00
Shantur Rathore
ed769911d6 bump to version v0.2.3 2025-11-23 19:37:41 +00:00
Shantur Rathore
dd6efee900 disable SSE body timeouts and ignore workspace-stopped disconnects 2025-11-23 19:34:14 +00:00
Shantur Rathore
48a16a6702 ignore expected workspace stops when showing disconnect modal 2025-11-23 19:17:53 +00:00
Shantur Rathore
841b9daa1f only show disconnect modal on final status 2025-11-23 19:13:22 +00:00
34 changed files with 781 additions and 216 deletions

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "codenomad-workspace",
"version": "0.2.2",
"version": "0.2.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codenomad-workspace",
"version": "0.2.2",
"version": "0.2.4",
"dependencies": {
"7zip-bin": "^5.2.0",
"google-auth-library": "^10.5.0"
@@ -8613,7 +8613,7 @@
},
"packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.2.2",
"version": "0.2.4",
"dependencies": {
"@codenomad/ui": "file:../ui",
"@neuralnomads/codenomad": "file:../server"
@@ -8641,7 +8641,7 @@
},
"packages/server": {
"name": "@neuralnomads/codenomad",
"version": "0.2.2",
"version": "0.2.4",
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",
@@ -8680,14 +8680,14 @@
},
"packages/tauri-app": {
"name": "@codenomad/tauri-app",
"version": "0.2.2",
"version": "0.2.4",
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"
}
},
"packages/ui": {
"name": "@codenomad/ui",
"version": "0.2.2",
"version": "0.2.4",
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",

View File

@@ -1,6 +1,6 @@
{
"name": "codenomad-workspace",
"version": "0.2.2",
"version": "0.2.4",
"private": true,
"description": "CodeNomad monorepo workspace",
"workspaces": {

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.2.2",
"version": "0.2.4",
"description": "CodeNomad - AI coding assistant",
"author": {
"name": "Neural Nomads",

View File

@@ -1,12 +1,12 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.2.2",
"version": "0.2.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
"version": "0.2.2",
"version": "0.2.4",
"dependencies": {
"@fastify/cors": "^8.5.0",
"commander": "^12.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.2.2",
"version": "0.2.4",
"description": "CodeNomad Server",
"author": {
"name": "Neural Nomads",

View File

@@ -16,6 +16,7 @@ const PreferencesSchema = z.object({
diffViewMode: z.enum(["split", "unified"]).default("split"),
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
showUsageMetrics: z.boolean().default(true),
})
const RecentFolderSchema = z.object({

View File

@@ -80,9 +80,11 @@ function parseCliOptions(argv: string[]): CliOptions {
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
const normalizedHost = resolveHost(parsed.host)
return {
port: parsed.port,
host: parsed.host,
host: normalizedHost,
rootDir: resolvedRoot,
configPath: parsed.config,
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
@@ -102,6 +104,13 @@ function parsePort(input: string): number {
return value
}
function resolveHost(input: string | undefined): string {
if (input && input.trim() === "0.0.0.0") {
return "0.0.0.0"
}
return DEFAULT_HOST
}
async function main() {
const options = parseCliOptions(process.argv.slice(2))
const logger = createLogger({ level: options.logLevel, destination: options.logDestination, component: "app" })

View File

@@ -1,10 +1,12 @@
import { fetch } from "undici"
import { Agent, fetch } from "undici"
import { Agent as UndiciAgent } from "undici"
import { EventBus } from "../events/bus"
import { Logger } from "../logger"
import { WorkspaceManager } from "./manager"
import { InstanceStreamEvent, InstanceStreamStatus } from "../api-types"
const INSTANCE_HOST = "127.0.0.1"
const STREAM_AGENT = new UndiciAgent({ bodyTimeout: 0, headersTimeout: 0 })
const RECONNECT_DELAY_MS = 1000
interface InstanceEventBridgeOptions {
@@ -24,8 +26,8 @@ export class InstanceEventBridge {
constructor(private readonly options: InstanceEventBridgeOptions) {
const bus = this.options.eventBus
bus.on("workspace.started", (event) => this.startStream(event.workspace.id))
bus.on("workspace.stopped", (event) => this.stopStream(event.workspaceId))
bus.on("workspace.error", (event) => this.stopStream(event.workspace.id))
bus.on("workspace.stopped", (event) => this.stopStream(event.workspaceId, "workspace stopped"))
bus.on("workspace.error", (event) => this.stopStream(event.workspace.id, "workspace error"))
}
shutdown() {
@@ -59,14 +61,14 @@ export class InstanceEventBridge {
this.streams.set(workspaceId, { controller, task })
}
private stopStream(workspaceId: string) {
private stopStream(workspaceId: string, reason?: string) {
const active = this.streams.get(workspaceId)
if (!active) {
return
}
active.controller.abort()
this.streams.delete(workspaceId)
this.publishStatus(workspaceId, "disconnected")
this.publishStatus(workspaceId, "disconnected", reason)
}
private async runStream(workspaceId: string, signal: AbortSignal) {
@@ -97,6 +99,7 @@ export class InstanceEventBridge {
const response = await fetch(url, {
headers: { Accept: "text/event-stream" },
signal,
dispatcher: STREAM_AGENT,
})
if (!response.ok || !response.body) {

View File

@@ -1,15 +1,15 @@
{
"name": "@codenomad/tauri-app",
"version": "0.2.2",
"version": "0.2.4",
"private": true,
"scripts": {
"dev": "tauri dev",
"dev": "npx --yes @tauri-apps/cli@^2.9.4 dev",
"dev:ui": "npm run dev --workspace @codenomad/ui",
"dev:prep": "node ./scripts/dev-prep.js",
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
"prebuild": "node ./scripts/prebuild.js",
"bundle:server": "npm --workspace @neuralnomads/codenomad run build && npm run prebuild",
"build": "tauri build"
"bundle:server": "npm run prebuild",
"build": "npx --yes @tauri-apps/cli@^2.9.4 build"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"

View File

@@ -13,6 +13,26 @@ const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
const sources = ["dist", "public", "node_modules", "package.json"]
const serverInstallCommand =
"npm install --omit=dev --ignore-scripts --workspaces=false --package-lock=false --install-strategy=shallow --fund=false --audit=false"
const serverDevInstallCommand =
"npm ci --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
const envWithRootBin = {
...process.env,
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
}
const braceExpansionPath = path.join(
serverRoot,
"node_modules",
"@fastify",
"static",
"node_modules",
"brace-expansion",
"package.json",
)
function ensureServerBuild() {
const distPath = path.join(serverRoot, "dist")
const publicPath = path.join(serverRoot, "public")
@@ -24,6 +44,10 @@ function ensureServerBuild() {
execSync("npm --workspace @neuralnomads/codenomad run build", {
cwd: workspaceRoot,
stdio: "inherit",
env: {
...process.env,
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
},
})
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
@@ -48,6 +72,31 @@ function ensureUiBuild() {
}
}
function ensureServerDevDependencies() {
if (fs.existsSync(braceExpansionPath)) {
return
}
console.log("[prebuild] ensuring server build dependencies (with dev)...")
execSync(serverDevInstallCommand, {
cwd: workspaceRoot,
stdio: "inherit",
env: envWithRootBin,
})
}
function ensureServerDependencies() {
if (fs.existsSync(braceExpansionPath)) {
return
}
console.log("[prebuild] ensuring server production dependencies...")
execSync(serverInstallCommand, {
cwd: serverRoot,
stdio: "inherit",
})
}
function copyServerArtifacts() {
fs.rmSync(serverDest, { recursive: true, force: true })
fs.mkdirSync(serverDest, { recursive: true })
@@ -59,7 +108,7 @@ function copyServerArtifacts() {
console.warn(`[prebuild] skipped missing ${from}`)
continue
}
fs.cpSync(from, to, { recursive: true })
fs.cpSync(from, to, { recursive: true, dereference: true })
console.log(`[prebuild] copied ${from} -> ${to}`)
}
}
@@ -83,7 +132,9 @@ function copyUiLoadingAssets() {
console.log(`[prebuild] prepared UI loading assets from ${uiDist}`)
}
ensureServerDevDependencies()
ensureServerBuild()
ensureUiBuild()
ensureServerDependencies()
copyServerArtifacts()
copyUiLoadingAssets()

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
"version": "0.2.2",
"version": "0.2.4",
"private": true,
"type": "module",
"scripts": {

View File

@@ -47,6 +47,7 @@ const App: Component = () => {
preferences,
recordWorkspaceLaunch,
toggleShowThinkingBlocks,
toggleUsageMetrics,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
@@ -205,6 +206,7 @@ const App: Component = () => {
const { commands: paletteCommands, executeCommand } = useCommands({
preferences,
toggleShowThinkingBlocks,
toggleUsageMetrics,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,

View File

@@ -3,7 +3,6 @@ import { For, Show, createEffect, createMemo } from "solid-js"
import { agents, fetchAgents, sessions } from "../stores/sessions"
import { ChevronDown } from "lucide-solid"
import type { Agent } from "../types/session"
import Kbd from "./kbd"
interface AgentSelectorProps {
instanceId: string
@@ -116,9 +115,6 @@ export default function AgentSelector(props: AgentSelectorProps) {
</Select.Content>
</Select.Portal>
</Select>
<span class="hint sidebar-selector-hint">
<Kbd shortcut="cmd+shift+a" />
</span>
</div>
)
}

View File

@@ -231,7 +231,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
>
<div class="mb-6 text-center shrink-0">
<div class="mb-3 flex justify-center">
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
</div>
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
@@ -318,7 +318,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</Show>
<div class="panel shrink-0">
<div class="panel-header">
<div class="panel-header hidden sm:block">
<h2 class="panel-title">Browse for Folder</h2>
<p class="panel-subtitle">Select any folder on your computer</p>
</div>
@@ -354,7 +354,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div>
</div>
<div class="mt-1 panel panel-footer shrink-0">
<div class="mt-1 panel panel-footer shrink-0 hidden sm:block">
<div class="panel-footer-hints">
<Show when={folders().length > 0}>
<div class="flex items-center gap-1.5">

View File

@@ -281,7 +281,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
</div>
</div>
<div class="panel-footer">
<div class="panel-footer hidden sm:block">
<div class="panel-footer-hints">
<div class="flex items-center gap-1.5">
<kbd class="kbd"></kbd>

View File

@@ -14,6 +14,7 @@ import InfoView from "../info-view"
import AgentSelector from "../agent-selector"
import ModelSelector from "../model-selector"
import CommandPalette from "../command-palette"
import Kbd from "../kbd"
import ContextUsagePanel from "../session/context-usage-panel"
import SessionView from "../session/session-view"
@@ -28,7 +29,7 @@ interface InstanceShellProps {
onExecuteCommand: (command: Command) => void
}
const DEFAULT_SESSION_SIDEBAR_WIDTH = 280
const DEFAULT_SESSION_SIDEBAR_WIDTH = 350
const InstanceShell: Component<InstanceShellProps> = (props) => {
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
@@ -114,12 +115,22 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
/>
<div class="sidebar-selector-hints" aria-hidden="true">
<span class="hint sidebar-selector-hint sidebar-selector-hint--left">
<Kbd shortcut="cmd+shift+a" />
</span>
<span class="hint sidebar-selector-hint sidebar-selector-hint--right">
<Kbd shortcut="cmd+shift+m" />
</span>
</div>
<ModelSelector
instanceId={props.instance.id}
sessionId={activeSession().id}
currentModel={activeSession().model}
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
/>
</div>
</>
)}

View File

@@ -1,6 +1,8 @@
import { For, Show } from "solid-js"
import { For, Show, createMemo } from "solid-js"
import type { Message, SDKPart, MessageInfo, ClientPart } from "../types/message"
import { partHasRenderableText } from "../types/message"
import { formatTokenTotal } from "../lib/formatters"
import { preferences } from "../stores/preferences"
import MessagePart from "./message-part"
interface MessageItemProps {
@@ -16,6 +18,7 @@ interface MessageItemProps {
export default function MessageItem(props: MessageItemProps) {
const isUser = () => props.message.type === "user"
const showUsageMetrics = () => preferences().showUsageMetrics ?? true
const timestamp = () => {
const date = new Date(props.message.timestamp)
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
@@ -138,6 +141,44 @@ export default function MessageItem(props: MessageItemProps) {
isUser()
? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]"
: "message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]"
const statChipClass =
"inline-flex items-center gap-1 rounded-full border border-[var(--border-base)] px-2 py-0.5 text-[10px]"
const statLabelClass = "uppercase text-[9px] tracking-wide text-[var(--text-muted)]"
const statValueClass = "font-semibold text-[var(--text-primary)]"
const usageStats = createMemo(() => {
const info = props.messageInfo
if (!info || info.role !== "assistant" || !info.tokens) {
return null
}
if (!showUsageMetrics()) {
return null
}
const tokens = info.tokens
const input = tokens.input ?? 0
const output = tokens.output ?? 0
const reasoning = tokens.reasoning ?? 0
if (input === 0 && output === 0 && reasoning === 0) {
return null
}
return {
input,
output,
reasoning,
cacheRead: tokens.cache?.read ?? 0,
cacheWrite: tokens.cache?.write ?? 0,
cost: info.cost ?? 0,
}
})
const formatCostValue = (value: number) => {
if (!value) return "$0.00"
if (value < 0.01) return `$${value.toPrecision(2)}`
return `$${value.toFixed(2)}`
}
const agentIdentifier = () => {
if (isUser()) return ""
@@ -225,6 +266,7 @@ export default function MessageItem(props: MessageItemProps) {
</For>
</div>
<Show when={fileAttachments().length > 0}>
<div class="message-attachments">
<For each={fileAttachments()}>
@@ -269,8 +311,39 @@ export default function MessageItem(props: MessageItemProps) {
</div>
</Show>
<Show when={usageStats()}>
{(usage) => (
<div class="mt-3 flex flex-wrap items-center gap-1 text-[10px] text-[var(--text-muted)]">
<div class={statChipClass}>
<span class={statLabelClass}>Input</span>
<span class={statValueClass}>{formatTokenTotal(usage().input)}</span>
</div>
<div class={statChipClass}>
<span class={statLabelClass}>Output</span>
<span class={statValueClass}>{formatTokenTotal(usage().output)}</span>
</div>
<div class={statChipClass}>
<span class={statLabelClass}>Reasoning</span>
<span class={statValueClass}>{formatTokenTotal(usage().reasoning)}</span>
</div>
<div class={statChipClass}>
<span class={statLabelClass}>Cache Read</span>
<span class={statValueClass}>{formatTokenTotal(usage().cacheRead)}</span>
</div>
<div class={statChipClass}>
<span class={statLabelClass}>Cache Write</span>
<span class={statValueClass}>{formatTokenTotal(usage().cacheWrite)}</span>
</div>
<div class={statChipClass}>
<span class={statLabelClass}>Cost</span>
<span class={statValueClass}>{formatCostValue(usage().cost)}</span>
</div>
</div>
)}
</Show>
<Show when={props.message.status === "sending"}>
<div class="message-sending">
<span class="generating-spinner"></span> Sending...
</div>

View File

@@ -32,7 +32,9 @@ import { sseManager } from "../lib/sse-manager"
import Kbd from "./kbd"
import { useConfig } from "../stores/preferences"
import { getSessionInfo, computeDisplayParts, sessions, setActiveSession, setActiveParentSession } from "../stores/sessions"
import { formatTokenTotal } from "../lib/formatters"
import { setActiveInstanceId } from "../stores/instances"
import { showCommandPalette } from "../stores/command-palette"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
const SCROLL_OFFSET = 64
@@ -71,26 +73,9 @@ function navigateToTaskSession(location: TaskSessionLocation) {
}
}
// Format tokens like TUI (e.g., "110K", "1.2M")
// Format tokens like session sidebar (comma-separated totals)
function formatTokens(tokens: number): string {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(1)}M`
} else if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(0)}K`
}
return tokens.toString()
}
// Format session info for the session view header
function formatSessionInfo(usageTokens: number, contextWindow: number, usagePercent: number | null): string {
if (contextWindow > 0) {
const windowStr = formatTokens(contextWindow)
const usageStr = formatTokens(usageTokens)
const percent = usagePercent ?? Math.min(100, Math.max(0, Math.round((usageTokens / contextWindow) * 100)))
return `${usageStr} of ${windowStr} (${percent}%)`
}
return formatTokens(usageTokens)
return formatTokenTotal(tokens)
}
interface MessageStreamProps {
@@ -186,6 +171,9 @@ export default function MessageStream(props: MessageStreamProps) {
const scrollStateKey = () => makeScrollKey(props.instanceId, props.sessionId)
const connectionStatus = () => sseManager.getStatus(props.instanceId)
const handleCommandPaletteClick = () => {
showCommandPalette(props.instanceId)
}
function createToolSignature(message: Message, toolPart: ClientPart, toolIndex: number, messageInfo?: MessageInfo): string {
const messageId = message.id
@@ -202,18 +190,27 @@ export default function MessageStream(props: MessageStreamProps) {
const sessionInfo = createMemo(() =>
getSessionInfo(props.instanceId, props.sessionId) ?? {
tokens: 0,
cost: 0,
contextWindow: 0,
isSubscriptionModel: false,
contextUsageTokens: 0,
contextUsagePercent: null,
inputTokens: 0,
outputTokens: 0,
reasoningTokens: 0,
actualUsageTokens: 0,
modelOutputLimit: 0,
contextAvailableTokens: null,
},
)
const formattedSessionInfo = createMemo(() => {
const tokenStats = createMemo(() => {
const info = sessionInfo()
return formatSessionInfo(info.contextUsageTokens, info.contextWindow, info.contextUsagePercent)
return {
input: info.inputTokens ?? 0,
output: info.outputTokens ?? 0,
cost: info.cost ?? 0,
used: info.actualUsageTokens ?? 0,
avail: info.contextAvailableTokens,
}
})
function isNearBottom(element: HTMLDivElement, offset = SCROLL_OFFSET) {
@@ -549,14 +546,42 @@ export default function MessageStream(props: MessageStreamProps) {
return (
<div class="message-stream-container">
<div class="connection-status">
<div class="connection-status-text connection-status-info flex items-center gap-2 text-sm font-medium">
<span>{formattedSessionInfo()}</span>
<div class="connection-status-text connection-status-info flex flex-wrap items-center gap-2 text-sm font-medium">
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-primary/70">Used</span>
<span class="font-semibold text-primary">{formatTokens(sessionInfo().actualUsageTokens ?? 0)}</span>
</div>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-primary/70">Avail</span>
<span class="font-semibold text-primary">
{sessionInfo().contextAvailableTokens !== null ? formatTokens(sessionInfo().contextAvailableTokens ?? 0) : "--"}
</span>
</div>
</div>
<div class="connection-status-text connection-status-shortcut flex items-center gap-2 text-sm font-medium">
<span>Command Palette</span>
<Kbd shortcut="cmd+shift+p" />
<div class="connection-status-text connection-status-shortcut">
<div class="connection-status-shortcut-action">
<button
type="button"
class="connection-status-button"
onClick={handleCommandPaletteClick}
aria-label="Open command palette"
>
Command Palette
</button>
<span class="connection-status-shortcut-hint">
<Kbd shortcut="cmd+shift+p" />
</span>
</div>
</div>
<div class="connection-status-meta flex items-center justify-end gap-3">
<Show when={connectionStatus() === "connected"}>
<span class="status-indicator connected">
<span class="status-dot" />

View File

@@ -3,7 +3,6 @@ import { createEffect, createMemo, createSignal } from "solid-js"
import { providers, fetchProviders } from "../stores/sessions"
import { ChevronDown } from "lucide-solid"
import type { Model } from "../types/session"
import Kbd from "./kbd"
interface ModelSelectorProps {
instanceId: string
@@ -132,9 +131,6 @@ export default function ModelSelector(props: ModelSelectorProps) {
</Combobox.Content>
</Combobox.Portal>
</Combobox>
<span class="hint sidebar-selector-hint">
<Kbd shortcut="cmd+shift+m" />
</span>
</div>
)
}

View File

@@ -7,7 +7,6 @@ import { createFileAttachment, createTextAttachment, createAgentAttachment } fro
import type { Attachment } from "../types/attachment"
import type { Agent } from "../types/session"
import Kbd from "./kbd"
import HintRow from "./hint-row"
import { getActiveInstance } from "../stores/instances"
import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt } from "../stores/sessions"
import { showAlertDialog } from "../stores/alerts"
@@ -777,6 +776,7 @@ export default function PromptInput(props: PromptInputProps) {
}
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "for shell mode" })
const shouldShowOverlay = () => prompt().length === 0
const instance = () => getActiveInstance()
@@ -884,28 +884,62 @@ export default function PromptInput(props: PromptInputProps) {
</For>
</div>
</Show>
<textarea
ref={textareaRef}
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
placeholder={
mode() === "shell"
? "Run a shell command (Esc to exit)..."
: "Type your message, @file, @agent, or paste images and text..."
}
value={prompt()}
onInput={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
disabled={props.disabled}
rows={4}
style={attachments().length > 0 ? { "padding-top": "8px" } : {}}
spellcheck={false}
autocorrect="off"
autoCapitalize="off"
autocomplete="off"
/>
<div class="prompt-input-field">
<textarea
ref={textareaRef}
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
placeholder={
mode() === "shell"
? "Run a shell command (Esc to exit)..."
: "Type your message, @file, @agent, or paste images and text..."
}
value={prompt()}
onInput={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
disabled={props.disabled}
rows={4}
style={attachments().length > 0 ? { "padding-top": "8px" } : {}}
spellcheck={false}
autocorrect="off"
autoCapitalize="off"
autocomplete="off"
/>
<Show when={shouldShowOverlay()}>
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
<Show
when={props.escapeInDebounce}
fallback={
<>
<span class="prompt-overlay-text">
<Kbd>Enter</Kbd> for new line <Kbd shortcut="cmd+enter" /> to send <Kbd>@</Kbd> for files/agents <Kbd></Kbd> for history
</span>
<Show when={attachments().length > 0}>
<span class="prompt-overlay-text prompt-overlay-muted"> {attachments().length} file(s) attached</span>
</Show>
<span class="prompt-overlay-text">
<Kbd>{shellHint().key}</Kbd> {shellHint().text}
</span>
<Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">Shell mode active</span>
</Show>
</>
}
>
<>
<span class="prompt-overlay-text prompt-overlay-warning">
Press <Kbd>Esc</Kbd> again to abort session
</span>
<Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">Shell mode active</span>
</Show>
</>
</Show>
</div>
</Show>
</div>
</div>
<button
@@ -925,33 +959,6 @@ export default function PromptInput(props: PromptInputProps) {
</Show>
</button>
</div>
<div class="prompt-input-hints">
<div class="flex justify-between w-full gap-4">
<HintRow>
<Show
when={props.escapeInDebounce}
fallback={
<>
<Kbd>Enter</Kbd> for new line <Kbd shortcut="cmd+enter" /> to send <Kbd>@</Kbd> for files/agents <Kbd></Kbd> for history
<Show when={attachments().length > 0}>
<span class="ml-2 text-xs" style="color: var(--text-muted);"> {attachments().length} file(s) attached</span>
</Show>
<span class="ml-2">
<Kbd>{shellHint().key}</Kbd> {shellHint().text}
</span>
</>
}
>
<span class="font-medium" style="color: var(--status-warning);">
Press <Kbd>Esc</Kbd> again to abort session
</span>
</Show>
</HintRow>
<Show when={mode() === "shell"}>
<HintRow>Shell mode active</HintRow>
</Show>
</div>
</div>
</div>
)
}

View File

@@ -24,9 +24,9 @@ interface SessionListProps {
}
const MIN_WIDTH = 200
const MAX_WIDTH = 500
const DEFAULT_WIDTH = 280
const STORAGE_KEY = "opencode-session-sidebar-width"
const MAX_WIDTH = 520
const DEFAULT_WIDTH = 350
const STORAGE_KEY = "opencode-session-sidebar-width-v7"
function formatSessionStatus(status: SessionStatus): string {
switch (status) {

View File

@@ -7,54 +7,72 @@ interface ContextUsagePanelProps {
sessionId: string
}
const chipClass = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
const chipLabelClass = "uppercase text-[10px] tracking-wide text-primary/70"
const headingClass = "text-xs font-semibold text-primary/70 uppercase tracking-wide"
const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
const info = createMemo(
() =>
getSessionInfo(props.instanceId, props.sessionId) ?? {
tokens: 0,
cost: 0,
contextWindow: 0,
isSubscriptionModel: false,
contextUsageTokens: 0,
contextUsagePercent: null,
inputTokens: 0,
outputTokens: 0,
reasoningTokens: 0,
actualUsageTokens: 0,
modelOutputLimit: 0,
contextAvailableTokens: null,
},
)
const tokens = createMemo(() => info().tokens)
const contextUsageTokens = createMemo(() => info().contextUsageTokens ?? 0)
const contextWindow = createMemo(() => info().contextWindow)
const contextUsagePercent = createMemo(() => info().contextUsagePercent)
const costLabel = createMemo(() => {
if (info().isSubscriptionModel || info().cost <= 0) return "Included in plan"
return `$${info().cost.toFixed(2)} spent`
const inputTokens = createMemo(() => info().inputTokens ?? 0)
const outputTokens = createMemo(() => info().outputTokens ?? 0)
const actualUsageTokens = createMemo(() => info().actualUsageTokens ?? 0)
const availableTokens = createMemo(() => info().contextAvailableTokens)
const outputLimit = createMemo(() => info().modelOutputLimit ?? 0)
const costValue = createMemo(() => {
const value = info().isSubscriptionModel ? 0 : info().cost
return value > 0 ? value : 0
})
const formatTokenValue = (value: number | null | undefined) => {
if (value === null || value === undefined) return "--"
return formatTokenTotal(value)
}
const costDisplay = createMemo(() => `$${costValue().toFixed(2)}`)
return (
<div class="session-context-panel border-r border-base border-b px-3 py-3">
<div class="flex items-center justify-between gap-4">
<div>
<div class="text-xs font-semibold text-primary/70 uppercase tracking-wide">Tokens (last call)</div>
<div class="text-lg font-semibold text-primary">{formatTokenTotal(tokens())}</div>
<div class="session-context-panel border-r border-base border-b px-3 py-3 space-y-3">
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
<div class={headingClass}>Tokens</div>
<div class={chipClass}>
<span class={chipLabelClass}>Input</span>
<span class="font-semibold text-primary">{formatTokenTotal(inputTokens())}</span>
</div>
<div class="text-xs text-primary/70 text-right leading-tight">{costLabel()}</div>
</div>
<div class="mt-4">
<div class="flex items-center justify-between mb-1">
<div class="text-xs font-semibold text-primary/70 uppercase tracking-wide">Context window usage</div>
<div class="text-sm font-medium text-primary">{contextUsagePercent() !== null ? `${contextUsagePercent()}%` : "--"}</div>
<div class={chipClass}>
<span class={chipLabelClass}>Output</span>
<span class="font-semibold text-primary">{formatTokenTotal(outputTokens())}</span>
</div>
<div class="text-sm text-primary/90">
{contextWindow()
? `${formatTokenTotal(contextUsageTokens())} of ${formatTokenTotal(contextWindow())}`
: "Window size unavailable"}
<div class={chipClass}>
<span class={chipLabelClass}>Cost</span>
<span class="font-semibold text-primary">{costDisplay()}</span>
</div>
</div>
<div class="mt-3 h-1.5 rounded-full bg-base relative overflow-hidden">
<div
class="absolute inset-y-0 left-0 rounded-full bg-accent-primary transition-[width]"
style={{ width: contextUsagePercent() === null ? "0%" : `${contextUsagePercent()}%` }}
/>
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
<div class={headingClass}>Context</div>
<div class={chipClass}>
<span class={chipLabelClass}>Used</span>
<span class="font-semibold text-primary">{formatTokenTotal(actualUsageTokens())}</span>
</div>
<div class={chipClass}>
<span class={chipLabelClass}>Avail</span>
<span class="font-semibold text-primary">{formatTokenValue(availableTokens())}</span>
</div>
</div>
</div>
)

View File

@@ -1,4 +1,7 @@
export function formatTokenTotal(value: number): string {
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(1)}B`
}
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(1)}M`
}

View File

@@ -17,6 +17,7 @@ import type { Instance } from "../../types/instance"
export interface UseCommandsOptions {
preferences: Accessor<Preferences>
toggleShowThinkingBlocks: () => void
toggleUsageMetrics: () => void
setDiffViewMode: (mode: "split" | "unified") => void
setToolOutputExpansion: (mode: ExpansionPreference) => void
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
@@ -421,9 +422,22 @@ export function useCommands(options: UseCommandsOptions) {
},
})
commandRegistry.register({
id: "token-usage-visibility",
label: () => {
const visible = options.preferences().showUsageMetrics ?? true
return `Token Usage Display · ${visible ? "Visible" : "Hidden"}`
},
description: "Show or hide token and cost stats for assistant messages",
category: "System",
keywords: ["token", "usage", "cost", "stats"],
action: options.toggleUsageMetrics,
})
commandRegistry.register({
id: "help",
label: "Show Help",
description: "Display keyboard shortcuts and help",
category: "System",
keywords: ["/help", "shortcuts", "help"],

View File

@@ -58,8 +58,11 @@ class SSEManager {
serverEvents.on("instance.eventStatus", (event) => {
const payload = event as InstanceStatusPayload
this.updateConnectionStatus(payload.instanceId, payload.status)
if (payload.status === "error") {
const reason = payload.reason ?? "Instance stream error"
if (payload.status === "disconnected") {
if (payload.reason === "workspace stopped") {
return
}
const reason = payload.reason ?? "Instance disconnected"
void this.onConnectionLost?.(payload.instanceId, reason)
}
})

View File

@@ -35,6 +35,7 @@ export interface Preferences {
diffViewMode: DiffViewMode
toolOutputExpansion: ExpansionPreference
diagnosticsExpansion: ExpansionPreference
showUsageMetrics: boolean
}
export interface OpenCodeBinary {
@@ -60,6 +61,7 @@ const defaultPreferences: Preferences = {
diffViewMode: "split",
toolOutputExpansion: "expanded",
diagnosticsExpansion: "expanded",
showUsageMetrics: true,
}
function deepEqual(a: unknown, b: unknown): boolean {
@@ -92,6 +94,7 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
diffViewMode: sanitized.diffViewMode ?? defaultPreferences.diffViewMode,
toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion,
diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion,
showUsageMetrics: sanitized.showUsageMetrics ?? defaultPreferences.showUsageMetrics,
}
}
@@ -270,6 +273,10 @@ function toggleShowThinkingBlocks(): void {
updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks })
}
function toggleUsageMetrics(): void {
updatePreferences({ showUsageMetrics: !preferences().showUsageMetrics })
}
function addRecentFolder(path: string): void {
updateConfig((draft) => {
draft.recentFolders = buildRecentFolderList(path, draft.recentFolders)
@@ -370,6 +377,7 @@ interface ConfigContextValue {
setThemePreference: typeof setThemePreference
updateConfig: typeof updateConfig
toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks
toggleUsageMetrics: typeof toggleUsageMetrics
setDiffViewMode: typeof setDiffViewMode
setToolOutputExpansion: typeof setToolOutputExpansion
setDiagnosticsExpansion: typeof setDiagnosticsExpansion
@@ -400,6 +408,7 @@ const configContextValue: ConfigContextValue = {
setThemePreference,
updateConfig,
toggleShowThinkingBlocks,
toggleUsageMetrics,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
@@ -454,6 +463,7 @@ export {
updateConfig,
updatePreferences,
toggleShowThinkingBlocks,
toggleUsageMetrics,
recentFolders,
addRecentFolder,
removeRecentFolder,

View File

@@ -21,7 +21,7 @@ import {
loading,
setLoading,
} from "./session-state"
import { getDefaultModel, isModelValid } from "./session-models"
import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
import {
computeDisplayParts,
clearSessionIndex,
@@ -29,6 +29,7 @@ import {
initializePartVersion,
normalizeMessagePart,
rebuildSessionIndex,
rebuildSessionUsage,
updateSessionInfo,
} from "./session-messages"
@@ -212,18 +213,25 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId)
const initialContextWindow = initialModel?.limit?.context ?? 0
const initialSubscriptionModel = initialModel?.cost?.input === 0 && initialModel?.cost?.output === 0
const initialContextPercent = initialContextWindow > 0 ? 0 : null
const initialOutputLimit =
initialModel?.limit?.output && initialModel.limit.output > 0
? initialModel.limit.output
: DEFAULT_MODEL_OUTPUT_LIMIT
const initialContextAvailable = initialContextWindow > 0 ? initialContextWindow : null
setSessionInfoByInstance((prev) => {
const next = new Map(prev)
const instanceInfo = new Map(prev.get(instanceId))
instanceInfo.set(session.id, {
tokens: 0,
cost: 0,
contextWindow: initialContextWindow,
isSubscriptionModel: Boolean(initialSubscriptionModel),
contextUsageTokens: 0,
contextUsagePercent: initialContextPercent,
inputTokens: 0,
outputTokens: 0,
reasoningTokens: 0,
actualUsageTokens: 0,
modelOutputLimit: initialOutputLimit,
contextAvailableTokens: initialContextAvailable,
})
next.set(instanceId, instanceInfo)
return next
@@ -310,18 +318,23 @@ async function forkSession(
const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId)
const forkContextWindow = forkModel?.limit?.context ?? 0
const forkSubscriptionModel = forkModel?.cost?.input === 0 && forkModel?.cost?.output === 0
const forkContextPercent = forkContextWindow > 0 ? 0 : null
const forkOutputLimit =
forkModel?.limit?.output && forkModel.limit.output > 0 ? forkModel.limit.output : DEFAULT_MODEL_OUTPUT_LIMIT
const forkContextAvailable = forkContextWindow > 0 ? forkContextWindow : null
setSessionInfoByInstance((prev) => {
const next = new Map(prev)
const instanceInfo = new Map(prev.get(instanceId))
instanceInfo.set(forkedSession.id, {
tokens: 0,
cost: 0,
contextWindow: forkContextWindow,
isSubscriptionModel: Boolean(forkSubscriptionModel),
contextUsageTokens: 0,
contextUsagePercent: forkContextPercent,
inputTokens: 0,
outputTokens: 0,
reasoningTokens: 0,
actualUsageTokens: 0,
modelOutputLimit: forkOutputLimit,
contextAvailableTokens: forkContextAvailable,
})
next.set(instanceId, instanceInfo)
return next
@@ -587,6 +600,7 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
})
rebuildSessionIndex(instanceId, sessionId, messages)
rebuildSessionUsage(instanceId, sessionId, messagesInfo)
setMessagesLoaded((prev) => {
const next = new Map(prev)
@@ -595,6 +609,7 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
next.set(instanceId, loadedSet)
return next
})
} catch (error) {
console.error("Failed to load messages:", error)
throw error
@@ -608,17 +623,17 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
return next
})
}
updateSessionInfo(instanceId, sessionId)
refreshPermissionsForSession(instanceId, sessionId)
}
updateSessionInfo(instanceId, sessionId)
refreshPermissionsForSession(instanceId, sessionId)
}
export {
createSession,
deleteSession,
fetchAgents,
fetchProviders,
fetchSessions,
forkSession,
loadMessages,

View File

@@ -30,6 +30,7 @@ import {
normalizeMessagePart,
rebuildSessionIndex,
updateSessionInfo,
updateUsageFromMessageInfo,
} from "./session-messages"
import { loadMessages } from "./session-api"
import { setSessionCompactionState } from "./session-compaction"
@@ -305,6 +306,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
}
session.messagesInfo.set(info.id, info)
updateUsageFromMessageInfo(instanceId, info.sessionID, info)
withSession(instanceId, info.sessionID, () => {
/* ensure reactivity */
})
@@ -314,6 +316,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
}
}
function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void {
const info = event.properties?.info
if (!info) return

View File

@@ -1,9 +1,9 @@
import type { Message, MessageDisplayParts } from "../types/message"
import { partHasRenderableText } from "../types/message"
import { partHasRenderableText, type MessageInfo } from "../types/message"
import type { Provider } from "../types/session"
import { decodeHtmlEntities } from "../lib/markdown"
import { providers, sessions, setSessionInfoByInstance } from "./session-state"
import { providers, sessions, sessionInfoByInstance, setSessionInfoByInstance } from "./session-state"
import { DEFAULT_MODEL_OUTPUT_LIMIT } from "./session-models"
interface SessionIndexCache {
@@ -11,7 +11,153 @@ interface SessionIndexCache {
partIndex: Map<string, Map<string, number>>
}
interface AssistantUsageEntry {
info: MessageInfo
inputTokens: number
outputTokens: number
reasoningTokens: number
combinedTokens: number
cost: number
hasContextUsage: boolean
timestamp: number
}
interface SessionUsageState {
entries: Map<string, AssistantUsageEntry>
totalInputTokens: number
totalOutputTokens: number
totalReasoningTokens: number
totalCost: number
latestEntry: AssistantUsageEntry | null
}
const sessionIndexes = new Map<string, Map<string, SessionIndexCache>>()
const sessionUsageStates = new Map<string, Map<string, SessionUsageState>>()
function createEmptyUsageState(): SessionUsageState {
return {
entries: new Map(),
totalInputTokens: 0,
totalOutputTokens: 0,
totalReasoningTokens: 0,
totalCost: 0,
latestEntry: null,
}
}
function getUsageInstance(instanceId: string): Map<string, SessionUsageState> {
let usageMap = sessionUsageStates.get(instanceId)
if (!usageMap) {
usageMap = new Map()
sessionUsageStates.set(instanceId, usageMap)
}
return usageMap
}
function getSessionUsageState(instanceId: string, sessionId: string): SessionUsageState {
const usageMap = getUsageInstance(instanceId)
let state = usageMap.get(sessionId)
if (!state) {
state = createEmptyUsageState()
usageMap.set(sessionId, state)
}
return state
}
function recomputeLatestEntry(state: SessionUsageState) {
state.latestEntry = null
for (const entry of state.entries.values()) {
if (!state.latestEntry || entry.timestamp >= state.latestEntry.timestamp) {
state.latestEntry = entry
}
}
}
function extractAssistantUsage(info: MessageInfo): AssistantUsageEntry | null {
if (!info || info.role !== "assistant") return null
if (!info.tokens) return null
const tokens = info.tokens
const inputTokens = tokens.input ?? 0
const outputTokens = tokens.output ?? 0
const reasoningTokens = tokens.reasoning ?? 0
if (inputTokens === 0 && outputTokens === 0 && reasoningTokens === 0) {
return null
}
const cacheReadTokens = tokens.cache?.read ?? 0
const cacheWriteTokens = tokens.cache?.write ?? 0
const combinedTokens = info.summary
? outputTokens
: inputTokens + cacheReadTokens + cacheWriteTokens + outputTokens + reasoningTokens
const cost = info.cost ?? 0
const hasContextUsage = inputTokens + cacheReadTokens + cacheWriteTokens > 0
return {
info,
inputTokens,
outputTokens,
reasoningTokens,
combinedTokens,
cost,
hasContextUsage,
timestamp: info.time?.created ?? 0,
}
}
function removeUsageEntry(state: SessionUsageState, messageId: string | undefined) {
if (!messageId) return
const existing = state.entries.get(messageId)
if (!existing) return
state.entries.delete(messageId)
state.totalInputTokens -= existing.inputTokens
state.totalOutputTokens -= existing.outputTokens
state.totalReasoningTokens -= existing.reasoningTokens
state.totalCost -= existing.cost
if (state.latestEntry?.info.id === messageId) {
recomputeLatestEntry(state)
}
}
function addUsageEntry(state: SessionUsageState, entry: AssistantUsageEntry) {
state.entries.set(entry.info.id, entry)
state.totalInputTokens += entry.inputTokens
state.totalOutputTokens += entry.outputTokens
state.totalReasoningTokens += entry.reasoningTokens
state.totalCost += entry.cost
if (!state.latestEntry || entry.timestamp >= state.latestEntry.timestamp) {
state.latestEntry = entry
}
}
function updateUsageFromMessageInfo(instanceId: string, sessionId: string, info: MessageInfo) {
const messageId = typeof info.id === "string" ? info.id : undefined
if (!messageId) return
const state = getSessionUsageState(instanceId, sessionId)
removeUsageEntry(state, messageId)
const entry = extractAssistantUsage(info)
if (entry) {
addUsageEntry(state, entry)
}
}
function rebuildSessionUsage(instanceId: string, sessionId: string, messagesInfo: Map<string, MessageInfo>) {
const usageMap = getUsageInstance(instanceId)
const nextState = createEmptyUsageState()
for (const info of messagesInfo.values()) {
const entry = extractAssistantUsage(info)
if (entry) {
addUsageEntry(nextState, entry)
}
}
usageMap.set(sessionId, nextState)
}
function clearSessionUsage(instanceId: string, sessionId: string) {
const usageMap = sessionUsageStates.get(instanceId)
if (!usageMap) return
usageMap.delete(sessionId)
if (usageMap.size === 0) {
sessionUsageStates.delete(instanceId)
}
}
function decodeTextSegment(segment: any): any {
if (typeof segment === "string") {
@@ -163,10 +309,12 @@ function clearSessionIndex(instanceId: string, sessionId: string) {
sessionIndexes.delete(instanceId)
}
}
clearSessionUsage(instanceId, sessionId)
}
function removeSessionIndexes(instanceId: string) {
sessionIndexes.delete(instanceId)
sessionUsageStates.delete(instanceId)
}
function updateSessionInfo(instanceId: string, sessionId: string) {
@@ -176,52 +324,67 @@ function updateSessionInfo(instanceId: string, sessionId: string) {
const session = instanceSessions.get(sessionId)
if (!session) return
let tokens = 0
let cost = 0
let contextWindow = 0
let isSubscriptionModel = false
let modelID = ""
let providerID = ""
let actualUsageTokens = 0
let contextUsagePercent: number | null = null
let hasContextUsage = false
if (session.messagesInfo.size > 0) {
const messageArray = Array.from(session.messagesInfo.values()).reverse()
const usageState = getSessionUsageState(instanceId, sessionId)
const hasUsageEntries = usageState.entries.size > 0
for (const info of messageArray) {
if (info.role === "assistant" && info.tokens) {
const usage = info.tokens
let totalInputTokens = hasUsageEntries ? usageState.totalInputTokens : 0
let totalOutputTokens = hasUsageEntries ? usageState.totalOutputTokens : 0
let totalReasoningTokens = hasUsageEntries ? usageState.totalReasoningTokens : 0
let totalCost = hasUsageEntries ? usageState.totalCost : 0
if (usage.output > 0) {
const inputTokens = usage.input || 0
const reasoningTokens = usage.reasoning || 0
const cacheReadTokens = usage.cache?.read || 0
const cacheWriteTokens = usage.cache?.write || 0
const outputTokens = usage.output || 0
let latestAssistantInfo: MessageInfo | null = usageState.latestEntry?.info ?? null
let latestHasContextUsage = usageState.latestEntry?.hasContextUsage ?? false
const previousInfo = sessionInfoByInstance().get(instanceId)?.get(sessionId)
let contextAvailableTokens: number | null = null
let contextAvailableFromPrevious = false
if (info.summary) {
tokens = outputTokens
} else {
tokens = inputTokens + cacheReadTokens + cacheWriteTokens + outputTokens + reasoningTokens
}
if (latestAssistantInfo) {
const infoAny = latestAssistantInfo as any
actualUsageTokens = usageState.latestEntry?.combinedTokens ?? 0
modelID = infoAny.modelID || ""
providerID = infoAny.providerID || ""
} else if (previousInfo) {
totalInputTokens = previousInfo.inputTokens
totalOutputTokens = previousInfo.outputTokens
totalReasoningTokens = previousInfo.reasoningTokens
totalCost = previousInfo.cost
actualUsageTokens = previousInfo.actualUsageTokens
cost = info.cost || 0
actualUsageTokens = tokens
hasContextUsage = inputTokens + cacheReadTokens + cacheWriteTokens > 0
const previousContextWindow = previousInfo.contextWindow
const previousContextAvailable = previousInfo.contextAvailableTokens ?? null
const previousHasContextUsage =
previousContextAvailable !== null && previousContextWindow > 0
? previousContextAvailable < previousContextWindow
: false
modelID = info.modelID || ""
providerID = info.providerID || ""
isSubscriptionModel = cost === 0
break
}
}
if (contextWindow === 0) {
contextWindow = previousContextWindow
}
if (contextWindow !== previousContextWindow) {
contextAvailableTokens = null
contextAvailableFromPrevious = false
latestHasContextUsage = previousHasContextUsage
} else {
contextAvailableTokens = previousContextAvailable
contextAvailableFromPrevious = true
latestHasContextUsage = previousHasContextUsage
}
isSubscriptionModel = previousInfo.isSubscriptionModel
}
const instanceProviders = providers().get(instanceId) || []
const sessionModel = session.model
let selectedModel: Provider["models"][number] | undefined
@@ -252,30 +415,32 @@ function updateSessionInfo(instanceId: string, sessionId: string) {
}
const outputBudget = Math.min(modelOutputLimit, DEFAULT_MODEL_OUTPUT_LIMIT)
let contextUsageTokens = 0
if (hasContextUsage && actualUsageTokens > 0) {
contextUsageTokens = actualUsageTokens + outputBudget
if (!contextAvailableFromPrevious) {
if (contextWindow > 0) {
const percent = Math.round((contextUsageTokens / contextWindow) * 100)
contextUsagePercent = Math.min(100, Math.max(0, percent))
if (latestHasContextUsage && actualUsageTokens > 0) {
contextAvailableTokens = Math.max(contextWindow - (actualUsageTokens + outputBudget), 0)
} else {
contextAvailableTokens = contextWindow
}
} else {
contextUsagePercent = null
contextAvailableTokens = null
}
} else {
contextUsagePercent = contextWindow > 0 ? 0 : null
}
setSessionInfoByInstance((prev) => {
const next = new Map(prev)
const instanceInfo = new Map(prev.get(instanceId))
instanceInfo.set(sessionId, {
tokens,
cost,
cost: totalCost,
contextWindow,
isSubscriptionModel,
contextUsageTokens,
contextUsagePercent,
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
reasoningTokens: totalReasoningTokens,
actualUsageTokens,
modelOutputLimit,
contextAvailableTokens,
})
next.set(instanceId, instanceInfo)
return next
@@ -290,6 +455,8 @@ export {
initializePartVersion,
normalizeMessagePart,
rebuildSessionIndex,
rebuildSessionUsage,
removeSessionIndexes,
updateSessionInfo,
updateUsageFromMessageInfo,
}

View File

@@ -3,12 +3,15 @@ import { createSignal } from "solid-js"
import type { Session, Agent, Provider } from "../types/session"
export interface SessionInfo {
tokens: number
cost: number
contextWindow: number
isSubscriptionModel: boolean
contextUsageTokens: number
contextUsagePercent: number | null
inputTokens: number
outputTokens: number
reasoningTokens: number
actualUsageTokens: number
modelOutputLimit: number
contextAvailableTokens: number | null
}
const [sessions, setSessions] = createSignal<Map<string, Map<string, Session>>>(new Map())

View File

@@ -27,6 +27,43 @@
color: var(--text-muted);
}
.connection-status-shortcut-action {
@apply flex items-center justify-center gap-2;
}
.connection-status-button {
@apply inline-flex items-center gap-2 px-3 py-1 text-sm font-medium border rounded-md transition-colors;
border-color: var(--border-base);
background-color: var(--surface-base);
color: var(--text-primary);
}
.connection-status-button:hover {
background-color: var(--surface-hover);
}
.connection-status-button:focus-visible {
@apply ring-2 ring-offset-1;
ring-color: var(--accent-primary);
ring-offset-color: var(--surface-secondary);
}
.connection-status-shortcut-hint {
@apply inline-flex items-center;
color: var(--text-secondary);
}
@media (pointer: coarse) {
.connection-status-shortcut-hint {
display: none;
}
.connection-status-button {
width: 100%;
justify-content: center;
}
}
.message-stream {
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-1;
background-color: var(--surface-base);

View File

@@ -9,9 +9,13 @@
@apply flex items-end gap-2 p-3;
}
.prompt-input-field {
position: relative;
width: 100%;
}
.prompt-input {
@apply flex-1 min-h-[96px] max-h-[200px] p-2.5 border rounded-md text-sm resize-none outline-none transition-colors;
@apply flex-1 w-full min-h-[56px] max-h-[96px] px-3 pt-2.5 pb-12 border rounded-md text-sm resize-none outline-none transition-colors;
font-family: inherit;
background-color: var(--surface-base);
color: inherit;
@@ -19,6 +23,45 @@
line-height: var(--line-height-normal);
}
.prompt-input-overlay {
position: absolute;
bottom: 1rem;
left: 0.75rem;
right: 0.75rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.75rem;
line-height: 1.3;
color: var(--text-muted);
pointer-events: none;
z-index: 1;
}
.prompt-input-overlay.shell-mode {
color: var(--text-primary);
}
.prompt-overlay-text {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.prompt-overlay-warning {
color: var(--status-warning);
font-weight: 500;
}
.prompt-overlay-shell-active {
color: var(--status-success);
font-weight: 600;
}
.prompt-overlay-muted {
color: var(--text-muted);
}
.prompt-input.shell-mode {
border-color: var(--status-success);
box-shadow: inset 0 0 0 1px rgba(76, 175, 80, 0.4);
@@ -80,9 +123,6 @@
height: 1rem;
}
.prompt-input-hints {
@apply px-4 pb-2 flex justify-between items-center;
}
.hint {
@apply text-xs;
@@ -141,3 +181,26 @@
.attachment-download:hover {
background-color: var(--attachment-chip-ring);
}
@media (pointer: coarse) {
.prompt-input-overlay {
display: none;
}
.prompt-input {
padding-bottom: 1.5rem;
}
}
@media (max-width: 640px) {
.prompt-input {
min-height: 64px;
padding: 0.5rem 0.75rem;
padding-bottom: 2.25rem;
}
.prompt-input-wrapper {
gap: 0.75rem;
padding: 0.75rem;
}
}

View File

@@ -58,6 +58,24 @@ session-sidebar-controls .selector-trigger-primary {
color: var(--text-muted);
}
.sidebar-selector-hints {
@apply flex items-center gap-2 w-full;
justify-content: space-between;
}
.sidebar-selector-hint--left,
.sidebar-selector-hint--right {
@apply flex-1;
}
.sidebar-selector-hint--left {
justify-content: flex-start;
}
.sidebar-selector-hint--right {
justify-content: flex-end;
}
.session-header-hints {
@apply flex-shrink-0;
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite"
import solid from "vite-plugin-solid"
import { dirname, resolve } from "path"
import { fileURLToPath } from "url"
const __dirname = dirname(fileURLToPath(import.meta.url))
export default defineConfig({
root: "./src/renderer",
plugins: [solid()],
css: {
postcss: "./postcss.config.js",
},
resolve: {
alias: {
"@": resolve(__dirname, "./src"),
},
},
optimizeDeps: {
exclude: ["lucide-solid"],
},
ssr: {
noExternal: ["lucide-solid"],
},
server: {
port: 3000,
},
build: {
outDir: "dist",
rollupOptions: {
input: {
main: resolve(__dirname, "./src/renderer/index.html"),
loading: resolve(__dirname, "./src/renderer/loading.html"),
},
},
},
})