diff --git a/package-lock.json b/package-lock.json
index 4464b210..d5c3e543 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,7 +16,8 @@
"lucide-solid": "^0.300.0",
"marked": "^12.0.0",
"shiki": "^3.13.0",
- "solid-js": "^1.8.0"
+ "solid-js": "^1.8.0",
+ "solid-toast": "^0.5.0"
},
"devDependencies": {
"@tsconfig/bun": "^1.0.9",
@@ -6761,6 +6762,15 @@
"solid-js": "^1.3"
}
},
+ "node_modules/solid-toast": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/solid-toast/-/solid-toast-0.5.0.tgz",
+ "integrity": "sha512-t770JakjyS2P9b8Qa1zMLOD51KYKWXbTAyJePVUoYex5c5FH5S/HtUBUbZAWFcqRCKmAE8KhyIiCvDZA8bOnxQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "solid-js": "^1.5.4"
+ }
+ },
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
diff --git a/package.json b/package.json
index 84136572..654b3d32 100644
--- a/package.json
+++ b/package.json
@@ -33,7 +33,8 @@
"lucide-solid": "^0.300.0",
"marked": "^12.0.0",
"shiki": "^3.13.0",
- "solid-js": "^1.8.0"
+ "solid-js": "^1.8.0",
+ "solid-toast": "^0.5.0"
},
"devDependencies": {
"@tsconfig/bun": "^1.0.9",
diff --git a/src/App.tsx b/src/App.tsx
index 7ffef5e5..8a57d7ec 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,4 +1,5 @@
import { Component, onMount, onCleanup, Show, createMemo, createEffect, createSignal } from "solid-js"
+import { Toaster } from "solid-toast"
import type { Session } from "./types/session"
import type { Attachment } from "./types/attachment"
import FolderSelectionView from "./components/folder-selection-view"
@@ -219,15 +220,18 @@ const ContextUsagePanel: Component<{ instanceId: string; sessionId: string }> =
cost: 0,
contextWindow: 0,
isSubscriptionModel: false,
+ contextUsageTokens: 0,
},
)
const tokens = createMemo(() => info().tokens)
+ const contextUsageTokens = createMemo(() => info().contextUsageTokens ?? 0)
const contextWindow = createMemo(() => info().contextWindow)
const percentage = createMemo(() => {
const windowSize = contextWindow()
if (!windowSize || windowSize <= 0) return null
- const percent = Math.round((tokens() / windowSize) * 100)
+ const usage = contextUsageTokens()
+ const percent = Math.round((usage / windowSize) * 100)
return Math.min(100, Math.max(0, percent))
})
@@ -252,7 +256,7 @@ const ContextUsagePanel: Component<{ instanceId: string; sessionId: string }> =
{contextWindow()
- ? `${formatTokenTotal(tokens())} of ${formatTokenTotal(contextWindow())}`
+ ? `${formatTokenTotal(contextUsageTokens())} of ${formatTokenTotal(contextWindow())}`
: "Window size unavailable"}
@@ -1081,6 +1085,17 @@ const App: Component = () => {
+
+
)
}
diff --git a/src/components/message-stream.tsx b/src/components/message-stream.tsx
index 0f5a4626..536d5d6b 100644
--- a/src/components/message-stream.tsx
+++ b/src/components/message-stream.tsx
@@ -15,6 +15,7 @@ import {
} from "../stores/sessions"
import { setActiveInstanceId } from "../stores/instances"
+const FALLBACK_MODEL_OUTPUT_LIMIT = 32_000
const SCROLL_OFFSET = 64
interface TaskSessionLocation {
@@ -53,7 +54,7 @@ function navigateToTaskSession(location: TaskSessionLocation) {
// Calculate session tokens and cost from messagesInfo (matches TUI logic)
function calculateSessionInfo(messagesInfo?: Map, instanceId?: string) {
if (!messagesInfo || messagesInfo.size === 0)
- return { tokens: 0, cost: 0, contextWindow: 0, isSubscriptionModel: false }
+ return { tokens: 0, cost: 0, contextWindow: 0, isSubscriptionModel: false, contextUsageTokens: 0 }
let tokens = 0
let cost = 0
@@ -61,6 +62,9 @@ function calculateSessionInfo(messagesInfo?: Map, instanceId?: stri
let isSubscriptionModel = false
let modelID = ""
let providerID = ""
+ let inputTokensForUsage = 0
+ let cacheReadTokensForUsage = 0
+ let modelOutputLimit = FALLBACK_MODEL_OUTPUT_LIMIT
// Go backwards through messages to find the last relevant assistant message (like TUI)
const messageArray = Array.from(messagesInfo.values()).reverse()
@@ -85,6 +89,9 @@ function calculateSessionInfo(messagesInfo?: Map, instanceId?: stri
cost = info.cost || 0
}
+ inputTokensForUsage = usage.input || 0
+ cacheReadTokensForUsage = usage.cache?.read || 0
+
// Get model info for context window and subscription check
modelID = info.modelID || ""
providerID = info.providerID || ""
@@ -108,6 +115,9 @@ function calculateSessionInfo(messagesInfo?: Map, instanceId?: stri
if (model?.limit?.context) {
contextWindow = model.limit.context
}
+ if (model?.limit?.output && model.limit.output > 0) {
+ modelOutputLimit = model.limit.output
+ }
// Check if it's a subscription model (cost is 0 for both input and output)
if (model?.cost?.input === 0 && model?.cost?.output === 0) {
isSubscriptionModel = true
@@ -115,9 +125,12 @@ function calculateSessionInfo(messagesInfo?: Map, instanceId?: stri
}
}
- return { tokens, cost, contextWindow, isSubscriptionModel }
+ const contextUsageTokens = inputTokensForUsage + cacheReadTokensForUsage + modelOutputLimit
+
+ return { tokens, cost, contextWindow, isSubscriptionModel, contextUsageTokens }
}
+
// Format tokens like TUI (e.g., "110K", "1.2M")
function formatTokens(tokens: number): string {
if (tokens >= 1000000) {
@@ -129,16 +142,23 @@ function formatTokens(tokens: number): string {
}
// Format session info for the session view header
-function formatSessionInfo(tokens: number, _cost: number, contextWindow: number, _isSubscriptionModel: boolean): string {
- const tokensStr = formatTokens(tokens)
+function formatSessionInfo(
+ tokens: number,
+ _cost: number,
+ contextWindow: number,
+ _isSubscriptionModel: boolean,
+ contextUsageTokens?: number,
+): string {
+ const usageTokens = typeof contextUsageTokens === "number" && contextUsageTokens > 0 ? contextUsageTokens : tokens
+ const usageLabel = formatTokens(usageTokens)
if (contextWindow > 0) {
const windowStr = formatTokens(contextWindow)
- const percentage = Math.min(100, Math.max(0, Math.round((tokens / contextWindow) * 100)))
- return `${tokensStr} of ${windowStr} (${percentage}%)`
+ const percentage = Math.min(100, Math.max(0, Math.round((usageTokens / contextWindow) * 100)))
+ return `${usageLabel} of ${windowStr} (${percentage}%)`
}
- return tokensStr
+ return usageLabel
}
interface MessageStreamProps {
@@ -245,6 +265,7 @@ export default function MessageStream(props: MessageStreamProps) {
cost: 0,
contextWindow: 0,
isSubscriptionModel: false,
+ contextUsageTokens: 0,
}
)
})
@@ -255,12 +276,14 @@ export default function MessageStream(props: MessageStreamProps) {
cost: 0,
contextWindow: 0,
isSubscriptionModel: false,
+ contextUsageTokens: 0,
}
return formatSessionInfo(
sessionInfo.tokens,
sessionInfo.cost,
sessionInfo.contextWindow,
sessionInfo.isSubscriptionModel,
+ sessionInfo.contextUsageTokens,
)
})
diff --git a/src/components/session-list.tsx b/src/components/session-list.tsx
index e60c28cb..0c9babe6 100644
--- a/src/components/session-list.tsx
+++ b/src/components/session-list.tsx
@@ -1,10 +1,11 @@
import { Component, For, Show, createSignal, createEffect, onCleanup, onMount, createMemo, JSX } from "solid-js"
import type { Session } from "../types/session"
-import { MessageSquare, Info, X } from "lucide-solid"
+import { MessageSquare, Info, X, Copy } from "lucide-solid"
import KeyboardHint from "./keyboard-hint"
import Kbd from "./kbd"
import { keyboardRegistry } from "../lib/keyboard-registry"
import { formatShortcut } from "../lib/keyboard-utils"
+import { showToastNotification } from "../lib/notifications"
interface SessionListProps {
instanceId: string
@@ -76,9 +77,26 @@ const SessionList: Component = (props) => {
createEffect(() => {
props.onWidthChange?.(sidebarWidth())
})
-
+
+ const copySessionId = async (event: MouseEvent, sessionId: string) => {
+ event.stopPropagation()
+
+ try {
+ if (typeof navigator === "undefined" || !navigator.clipboard) {
+ throw new Error("Clipboard API unavailable")
+ }
+
+ await navigator.clipboard.writeText(sessionId)
+ showToastNotification({ message: "Session ID copied", variant: "success" })
+ } catch (error) {
+ console.error(`Failed to copy session ID ${sessionId}:`, error)
+ showToastNotification({ message: "Unable to copy session ID", variant: "error" })
+ }
+ }
+
const clampWidth = (width: number) => Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, width))
+
const removeMouseListeners = () => {
if (mouseMoveHandler) {
document.removeEventListener("mousemove", mouseMoveHandler)
@@ -269,18 +287,30 @@ const SessionList: Component = (props) => {
>
{title()}
- {
- event.stopPropagation()
- props.onClose(id)
- }}
- role="button"
- tabIndex={0}
- aria-label="Close session"
- >
-
-
+
+ copySessionId(event, id)}
+ role="button"
+ tabIndex={0}
+ aria-label="Copy session ID"
+ title="Copy session ID"
+ >
+
+
+ {
+ event.stopPropagation()
+ props.onClose(id)
+ }}
+ role="button"
+ tabIndex={0}
+ aria-label="Close session"
+ >
+
+
+
)
@@ -315,6 +345,18 @@ const SessionList: Component = (props) => {
>
{title()}
+
+ copySessionId(event, id)}
+ role="button"
+ tabIndex={0}
+ aria-label="Copy session ID"
+ title="Copy session ID"
+ >
+
+
+
)
diff --git a/src/lib/notifications.tsx b/src/lib/notifications.tsx
new file mode 100644
index 00000000..d4910e9e
--- /dev/null
+++ b/src/lib/notifications.tsx
@@ -0,0 +1,59 @@
+import toast from "solid-toast"
+
+export type ToastVariant = "info" | "success" | "warning" | "error"
+
+export type ToastPayload = {
+ title?: string
+ message: string
+ variant: ToastVariant
+ duration?: number
+}
+
+const variantAccent: Record = {
+ info: {
+ badge: "bg-blue-500",
+ border: "border-blue-500/40",
+ text: "text-blue-100",
+ },
+ success: {
+ badge: "bg-emerald-500",
+ border: "border-emerald-500/40",
+ text: "text-emerald-100",
+ },
+ warning: {
+ badge: "bg-amber-500",
+ border: "border-amber-500/40",
+ text: "text-amber-100",
+ },
+ error: {
+ badge: "bg-rose-500",
+ border: "border-rose-500/40",
+ text: "text-rose-100",
+ },
+}
+
+export function showToastNotification(payload: ToastPayload) {
+ const accent = variantAccent[payload.variant]
+ const duration = payload.duration ?? 5000
+
+ toast.custom(
+ () => (
+
+
+
+
+ {payload.title &&
{payload.title}
}
+
{payload.message}
+
+
+
+ ),
+ {
+ duration,
+ ariaProps: {
+ role: "status",
+ "aria-live": "polite",
+ },
+ },
+ )
+}
diff --git a/src/lib/sse-manager.ts b/src/lib/sse-manager.ts
index f7dc407a..05bfb8ac 100644
--- a/src/lib/sse-manager.ts
+++ b/src/lib/sse-manager.ts
@@ -20,6 +20,16 @@ interface SessionUpdateEvent {
session: any
}
+interface TuiToastEvent {
+ type: "tui.toast.show"
+ properties: {
+ title?: string
+ message: string
+ variant: "info" | "success" | "warning" | "error"
+ duration?: number
+ }
+}
+
const [connectionStatus, setConnectionStatus] = createSignal<
Map
>(new Map())
@@ -104,6 +114,9 @@ class SSEManager {
case "session.error":
this.onSessionError?.(instanceId, event)
break
+ case "tui.toast.show":
+ this.onTuiToast?.(instanceId, event as TuiToastEvent)
+ break
case "session.idle":
console.log("[SSE] Session idle")
break
@@ -147,6 +160,7 @@ class SSEManager {
onSessionUpdate?: (instanceId: string, event: SessionUpdateEvent) => void
onSessionCompacted?: (instanceId: string, event: any) => void
onSessionError?: (instanceId: string, event: any) => void
+ onTuiToast?: (instanceId: string, event: TuiToastEvent) => void
getStatus(instanceId: string): "connecting" | "connected" | "disconnected" | "error" | null {
return connectionStatus().get(instanceId) ?? null
diff --git a/src/lib/storage.ts b/src/lib/storage.ts
index 9a9b3d1d..eb4c99bb 100644
--- a/src/lib/storage.ts
+++ b/src/lib/storage.ts
@@ -40,12 +40,39 @@ export class FileStorage {
}
}
+ private parseConfig(content: string): ConfigData {
+ const trimmed = content.trim()
+
+ try {
+ return JSON.parse(trimmed)
+ } catch (error) {
+ const firstBrace = trimmed.indexOf("{")
+ const lastBrace = trimmed.lastIndexOf("}")
+
+ if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
+ const sanitized = trimmed.slice(firstBrace, lastBrace + 1)
+
+ if (sanitized.length !== trimmed.length) {
+ console.warn("Config file contained trailing data; attempting recovery")
+ }
+
+ try {
+ return JSON.parse(sanitized)
+ } catch {
+ // Fall through to rethrow original error below
+ }
+ }
+
+ throw error
+ }
+ }
+
// Config operations
async loadConfig(): Promise {
await this.ensureInitialized()
try {
const content = await window.electronAPI.readConfigFile()
- return JSON.parse(content)
+ return this.parseConfig(content)
} catch (error) {
console.warn("Failed to load config, using defaults:", error)
return {
diff --git a/src/stores/sessions.ts b/src/stores/sessions.ts
index cf84cd91..a78ec34b 100644
--- a/src/stores/sessions.ts
+++ b/src/stores/sessions.ts
@@ -6,6 +6,7 @@ import { instances } from "./instances"
import { sseManager } from "../lib/sse-manager"
import { decodeHtmlEntities } from "../lib/markdown"
+import { showToastNotification, ToastVariant } from "../lib/notifications"
import { preferences, addRecentModelPreference, getAgentModelPreference, setAgentModelPreference } from "./preferences"
interface SessionInfo {
@@ -13,8 +14,12 @@ interface SessionInfo {
cost: number
contextWindow: number
isSubscriptionModel: boolean
+ contextUsageTokens: number
}
+const DEFAULT_MODEL_OUTPUT_LIMIT = 32_000
+const ALLOWED_TOAST_VARIANTS = new Set(["info", "success", "warning", "error"])
+
const [sessions, setSessions] = createSignal