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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -80,9 +80,11 @@ function parseCliOptions(argv: string[]): CliOptions {
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd() const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
const normalizedHost = resolveHost(parsed.host)
return { return {
port: parsed.port, port: parsed.port,
host: parsed.host, host: normalizedHost,
rootDir: resolvedRoot, rootDir: resolvedRoot,
configPath: parsed.config, configPath: parsed.config,
unrestrictedRoot: Boolean(parsed.unrestrictedRoot), unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
@@ -102,6 +104,13 @@ function parsePort(input: string): number {
return value 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() { async function main() {
const options = parseCliOptions(process.argv.slice(2)) const options = parseCliOptions(process.argv.slice(2))
const logger = createLogger({ level: options.logLevel, destination: options.logDestination, component: "app" }) 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 { EventBus } from "../events/bus"
import { Logger } from "../logger" import { Logger } from "../logger"
import { WorkspaceManager } from "./manager" import { WorkspaceManager } from "./manager"
import { InstanceStreamEvent, InstanceStreamStatus } from "../api-types" import { InstanceStreamEvent, InstanceStreamStatus } from "../api-types"
const INSTANCE_HOST = "127.0.0.1" const INSTANCE_HOST = "127.0.0.1"
const STREAM_AGENT = new UndiciAgent({ bodyTimeout: 0, headersTimeout: 0 })
const RECONNECT_DELAY_MS = 1000 const RECONNECT_DELAY_MS = 1000
interface InstanceEventBridgeOptions { interface InstanceEventBridgeOptions {
@@ -24,8 +26,8 @@ export class InstanceEventBridge {
constructor(private readonly options: InstanceEventBridgeOptions) { constructor(private readonly options: InstanceEventBridgeOptions) {
const bus = this.options.eventBus const bus = this.options.eventBus
bus.on("workspace.started", (event) => this.startStream(event.workspace.id)) bus.on("workspace.started", (event) => this.startStream(event.workspace.id))
bus.on("workspace.stopped", (event) => this.stopStream(event.workspaceId)) bus.on("workspace.stopped", (event) => this.stopStream(event.workspaceId, "workspace stopped"))
bus.on("workspace.error", (event) => this.stopStream(event.workspace.id)) bus.on("workspace.error", (event) => this.stopStream(event.workspace.id, "workspace error"))
} }
shutdown() { shutdown() {
@@ -59,14 +61,14 @@ export class InstanceEventBridge {
this.streams.set(workspaceId, { controller, task }) this.streams.set(workspaceId, { controller, task })
} }
private stopStream(workspaceId: string) { private stopStream(workspaceId: string, reason?: string) {
const active = this.streams.get(workspaceId) const active = this.streams.get(workspaceId)
if (!active) { if (!active) {
return return
} }
active.controller.abort() active.controller.abort()
this.streams.delete(workspaceId) this.streams.delete(workspaceId)
this.publishStatus(workspaceId, "disconnected") this.publishStatus(workspaceId, "disconnected", reason)
} }
private async runStream(workspaceId: string, signal: AbortSignal) { private async runStream(workspaceId: string, signal: AbortSignal) {
@@ -97,6 +99,7 @@ export class InstanceEventBridge {
const response = await fetch(url, { const response = await fetch(url, {
headers: { Accept: "text/event-stream" }, headers: { Accept: "text/event-stream" },
signal, signal,
dispatcher: STREAM_AGENT,
}) })
if (!response.ok || !response.body) { if (!response.ok || !response.body) {

View File

@@ -1,15 +1,15 @@
{ {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.2.2", "version": "0.2.4",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "tauri dev", "dev": "npx --yes @tauri-apps/cli@^2.9.4 dev",
"dev:ui": "npm run dev --workspace @codenomad/ui", "dev:ui": "npm run dev --workspace @codenomad/ui",
"dev:prep": "node ./scripts/dev-prep.js", "dev:prep": "node ./scripts/dev-prep.js",
"dev:bootstrap": "npm run dev:prep && npm run dev:ui", "dev:bootstrap": "npm run dev:prep && npm run dev:ui",
"prebuild": "node ./scripts/prebuild.js", "prebuild": "node ./scripts/prebuild.js",
"bundle:server": "npm --workspace @neuralnomads/codenomad run build && npm run prebuild", "bundle:server": "npm run prebuild",
"build": "tauri build" "build": "npx --yes @tauri-apps/cli@^2.9.4 build"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.9.4" "@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 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() { function ensureServerBuild() {
const distPath = path.join(serverRoot, "dist") const distPath = path.join(serverRoot, "dist")
const publicPath = path.join(serverRoot, "public") const publicPath = path.join(serverRoot, "public")
@@ -24,6 +44,10 @@ function ensureServerBuild() {
execSync("npm --workspace @neuralnomads/codenomad run build", { execSync("npm --workspace @neuralnomads/codenomad run build", {
cwd: workspaceRoot, cwd: workspaceRoot,
stdio: "inherit", stdio: "inherit",
env: {
...process.env,
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
},
}) })
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) { 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() { function copyServerArtifacts() {
fs.rmSync(serverDest, { recursive: true, force: true }) fs.rmSync(serverDest, { recursive: true, force: true })
fs.mkdirSync(serverDest, { recursive: true }) fs.mkdirSync(serverDest, { recursive: true })
@@ -59,7 +108,7 @@ function copyServerArtifacts() {
console.warn(`[prebuild] skipped missing ${from}`) console.warn(`[prebuild] skipped missing ${from}`)
continue continue
} }
fs.cpSync(from, to, { recursive: true }) fs.cpSync(from, to, { recursive: true, dereference: true })
console.log(`[prebuild] copied ${from} -> ${to}`) console.log(`[prebuild] copied ${from} -> ${to}`)
} }
} }
@@ -83,7 +132,9 @@ function copyUiLoadingAssets() {
console.log(`[prebuild] prepared UI loading assets from ${uiDist}`) console.log(`[prebuild] prepared UI loading assets from ${uiDist}`)
} }
ensureServerDevDependencies()
ensureServerBuild() ensureServerBuild()
ensureUiBuild() ensureUiBuild()
ensureServerDependencies()
copyServerArtifacts() copyServerArtifacts()
copyUiLoadingAssets() copyUiLoadingAssets()

View File

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

View File

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

View File

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

View File

@@ -231,7 +231,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
> >
<div class="mb-6 text-center shrink-0"> <div class="mb-6 text-center shrink-0">
<div class="mb-3 flex justify-center"> <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> </div>
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1> <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> <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> </Show>
<div class="panel shrink-0"> <div class="panel shrink-0">
<div class="panel-header"> <div class="panel-header hidden sm:block">
<h2 class="panel-title">Browse for Folder</h2> <h2 class="panel-title">Browse for Folder</h2>
<p class="panel-subtitle">Select any folder on your computer</p> <p class="panel-subtitle">Select any folder on your computer</p>
</div> </div>
@@ -354,7 +354,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div> </div>
</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"> <div class="panel-footer-hints">
<Show when={folders().length > 0}> <Show when={folders().length > 0}>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">

View File

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

View File

@@ -14,6 +14,7 @@ import InfoView from "../info-view"
import AgentSelector from "../agent-selector" import AgentSelector from "../agent-selector"
import ModelSelector from "../model-selector" import ModelSelector from "../model-selector"
import CommandPalette from "../command-palette" import CommandPalette from "../command-palette"
import Kbd from "../kbd"
import ContextUsagePanel from "../session/context-usage-panel" import ContextUsagePanel from "../session/context-usage-panel"
import SessionView from "../session/session-view" import SessionView from "../session/session-view"
@@ -28,7 +29,7 @@ interface InstanceShellProps {
onExecuteCommand: (command: Command) => void onExecuteCommand: (command: Command) => void
} }
const DEFAULT_SESSION_SIDEBAR_WIDTH = 280 const DEFAULT_SESSION_SIDEBAR_WIDTH = 350
const InstanceShell: Component<InstanceShellProps> = (props) => { const InstanceShell: Component<InstanceShellProps> = (props) => {
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH) const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
@@ -114,12 +115,22 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)} 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 <ModelSelector
instanceId={props.instance.id} instanceId={props.instance.id}
sessionId={activeSession().id} sessionId={activeSession().id}
currentModel={activeSession().model} currentModel={activeSession().model}
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)} onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
/> />
</div> </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 type { Message, SDKPart, MessageInfo, ClientPart } from "../types/message"
import { partHasRenderableText } from "../types/message" import { partHasRenderableText } from "../types/message"
import { formatTokenTotal } from "../lib/formatters"
import { preferences } from "../stores/preferences"
import MessagePart from "./message-part" import MessagePart from "./message-part"
interface MessageItemProps { interface MessageItemProps {
@@ -16,6 +18,7 @@ interface MessageItemProps {
export default function MessageItem(props: MessageItemProps) { export default function MessageItem(props: MessageItemProps) {
const isUser = () => props.message.type === "user" const isUser = () => props.message.type === "user"
const showUsageMetrics = () => preferences().showUsageMetrics ?? true
const timestamp = () => { const timestamp = () => {
const date = new Date(props.message.timestamp) const date = new Date(props.message.timestamp)
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
@@ -138,6 +141,44 @@ export default function MessageItem(props: MessageItemProps) {
isUser() isUser()
? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]" ? "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)]" : "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 = () => { const agentIdentifier = () => {
if (isUser()) return "" if (isUser()) return ""
@@ -225,6 +266,7 @@ export default function MessageItem(props: MessageItemProps) {
</For> </For>
</div> </div>
<Show when={fileAttachments().length > 0}> <Show when={fileAttachments().length > 0}>
<div class="message-attachments"> <div class="message-attachments">
<For each={fileAttachments()}> <For each={fileAttachments()}>
@@ -269,8 +311,39 @@ export default function MessageItem(props: MessageItemProps) {
</div> </div>
</Show> </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"}> <Show when={props.message.status === "sending"}>
<div class="message-sending"> <div class="message-sending">
<span class="generating-spinner"></span> Sending... <span class="generating-spinner"></span> Sending...
</div> </div>

View File

@@ -32,7 +32,9 @@ import { sseManager } from "../lib/sse-manager"
import Kbd from "./kbd" import Kbd from "./kbd"
import { useConfig } from "../stores/preferences" import { useConfig } from "../stores/preferences"
import { getSessionInfo, computeDisplayParts, sessions, setActiveSession, setActiveParentSession } from "../stores/sessions" import { getSessionInfo, computeDisplayParts, sessions, setActiveSession, setActiveParentSession } from "../stores/sessions"
import { formatTokenTotal } from "../lib/formatters"
import { setActiveInstanceId } from "../stores/instances" import { setActiveInstanceId } from "../stores/instances"
import { showCommandPalette } from "../stores/command-palette"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
const SCROLL_OFFSET = 64 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 { function formatTokens(tokens: number): string {
if (tokens >= 1000000) { return formatTokenTotal(tokens)
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)
} }
interface MessageStreamProps { interface MessageStreamProps {
@@ -186,6 +171,9 @@ export default function MessageStream(props: MessageStreamProps) {
const scrollStateKey = () => makeScrollKey(props.instanceId, props.sessionId) const scrollStateKey = () => makeScrollKey(props.instanceId, props.sessionId)
const connectionStatus = () => sseManager.getStatus(props.instanceId) const connectionStatus = () => sseManager.getStatus(props.instanceId)
const handleCommandPaletteClick = () => {
showCommandPalette(props.instanceId)
}
function createToolSignature(message: Message, toolPart: ClientPart, toolIndex: number, messageInfo?: MessageInfo): string { function createToolSignature(message: Message, toolPart: ClientPart, toolIndex: number, messageInfo?: MessageInfo): string {
const messageId = message.id const messageId = message.id
@@ -202,18 +190,27 @@ export default function MessageStream(props: MessageStreamProps) {
const sessionInfo = createMemo(() => const sessionInfo = createMemo(() =>
getSessionInfo(props.instanceId, props.sessionId) ?? { getSessionInfo(props.instanceId, props.sessionId) ?? {
tokens: 0,
cost: 0, cost: 0,
contextWindow: 0, contextWindow: 0,
isSubscriptionModel: false, isSubscriptionModel: false,
contextUsageTokens: 0, inputTokens: 0,
contextUsagePercent: null, outputTokens: 0,
reasoningTokens: 0,
actualUsageTokens: 0,
modelOutputLimit: 0,
contextAvailableTokens: null,
}, },
) )
const formattedSessionInfo = createMemo(() => { const tokenStats = createMemo(() => {
const info = sessionInfo() 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) { function isNearBottom(element: HTMLDivElement, offset = SCROLL_OFFSET) {
@@ -549,14 +546,42 @@ export default function MessageStream(props: MessageStreamProps) {
return ( return (
<div class="message-stream-container"> <div class="message-stream-container">
<div class="connection-status"> <div class="connection-status">
<div class="connection-status-text connection-status-info flex items-center gap-2 text-sm font-medium"> <div class="connection-status-text connection-status-info flex flex-wrap items-center gap-2 text-sm font-medium">
<span>{formattedSessionInfo()}</span> <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>
<div class="connection-status-text connection-status-shortcut flex items-center gap-2 text-sm font-medium">
<span>Command Palette</span> <div class="connection-status-text connection-status-shortcut">
<Kbd shortcut="cmd+shift+p" />
<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>
<div class="connection-status-meta flex items-center justify-end gap-3"> <div class="connection-status-meta flex items-center justify-end gap-3">
<Show when={connectionStatus() === "connected"}> <Show when={connectionStatus() === "connected"}>
<span class="status-indicator connected"> <span class="status-indicator connected">
<span class="status-dot" /> <span class="status-dot" />

View File

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

View File

@@ -7,7 +7,6 @@ import { createFileAttachment, createTextAttachment, createAgentAttachment } fro
import type { Attachment } from "../types/attachment" import type { Attachment } from "../types/attachment"
import type { Agent } from "../types/session" import type { Agent } from "../types/session"
import Kbd from "./kbd" import Kbd from "./kbd"
import HintRow from "./hint-row"
import { getActiveInstance } from "../stores/instances" import { getActiveInstance } from "../stores/instances"
import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt } from "../stores/sessions" import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt } from "../stores/sessions"
import { showAlertDialog } from "../stores/alerts" 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 shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "for shell mode" })
const shouldShowOverlay = () => prompt().length === 0
const instance = () => getActiveInstance() const instance = () => getActiveInstance()
@@ -884,28 +884,62 @@ export default function PromptInput(props: PromptInputProps) {
</For> </For>
</div> </div>
</Show> </Show>
<textarea <div class="prompt-input-field">
ref={textareaRef} <textarea
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`} ref={textareaRef}
placeholder={ class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
mode() === "shell" placeholder={
? "Run a shell command (Esc to exit)..." mode() === "shell"
: "Type your message, @file, @agent, or paste images and text..." ? "Run a shell command (Esc to exit)..."
} : "Type your message, @file, @agent, or paste images and text..."
value={prompt()} }
onInput={handleInput} value={prompt()}
onKeyDown={handleKeyDown} onInput={handleInput}
onPaste={handlePaste} onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)} onPaste={handlePaste}
onBlur={() => setIsFocused(false)} onFocus={() => setIsFocused(true)}
disabled={props.disabled} onBlur={() => setIsFocused(false)}
rows={4} disabled={props.disabled}
style={attachments().length > 0 ? { "padding-top": "8px" } : {}} rows={4}
spellcheck={false} style={attachments().length > 0 ? { "padding-top": "8px" } : {}}
autocorrect="off" spellcheck={false}
autoCapitalize="off" autocorrect="off"
autocomplete="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> </div>
<button <button
@@ -925,33 +959,6 @@ export default function PromptInput(props: PromptInputProps) {
</Show> </Show>
</button> </button>
</div> </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> </div>
) )
} }

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ import type { Instance } from "../../types/instance"
export interface UseCommandsOptions { export interface UseCommandsOptions {
preferences: Accessor<Preferences> preferences: Accessor<Preferences>
toggleShowThinkingBlocks: () => void toggleShowThinkingBlocks: () => void
toggleUsageMetrics: () => void
setDiffViewMode: (mode: "split" | "unified") => void setDiffViewMode: (mode: "split" | "unified") => void
setToolOutputExpansion: (mode: ExpansionPreference) => void setToolOutputExpansion: (mode: ExpansionPreference) => void
setDiagnosticsExpansion: (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({ commandRegistry.register({
id: "help", id: "help",
label: "Show Help", label: "Show Help",
description: "Display keyboard shortcuts and help", description: "Display keyboard shortcuts and help",
category: "System", category: "System",
keywords: ["/help", "shortcuts", "help"], keywords: ["/help", "shortcuts", "help"],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,43 @@
color: var(--text-muted); 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 { .message-stream {
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-1; @apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-1;
background-color: var(--surface-base); background-color: var(--surface-base);

View File

@@ -9,9 +9,13 @@
@apply flex items-end gap-2 p-3; @apply flex items-end gap-2 p-3;
} }
.prompt-input-field {
position: relative;
width: 100%;
}
.prompt-input { .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; font-family: inherit;
background-color: var(--surface-base); background-color: var(--surface-base);
color: inherit; color: inherit;
@@ -19,6 +23,45 @@
line-height: var(--line-height-normal); 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 { .prompt-input.shell-mode {
border-color: var(--status-success); border-color: var(--status-success);
box-shadow: inset 0 0 0 1px rgba(76, 175, 80, 0.4); box-shadow: inset 0 0 0 1px rgba(76, 175, 80, 0.4);
@@ -80,9 +123,6 @@
height: 1rem; height: 1rem;
} }
.prompt-input-hints {
@apply px-4 pb-2 flex justify-between items-center;
}
.hint { .hint {
@apply text-xs; @apply text-xs;
@@ -141,3 +181,26 @@
.attachment-download:hover { .attachment-download:hover {
background-color: var(--attachment-chip-ring); 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); 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 { .session-header-hints {
@apply flex-shrink-0; @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"),
},
},
},
})