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>>(new Map()) const [activeSessionId, setActiveSessionId] = createSignal>(new Map()) const [activeParentSessionId, setActiveParentSessionId] = createSignal>(new Map()) @@ -424,6 +429,8 @@ function updateSessionInfo(instanceId: string, sessionId: string) { let isSubscriptionModel = false let modelID = "" let providerID = "" + let inputTokensForUsage = 0 + let cacheReadTokensForUsage = 0 // Calculate from last assistant message in this session only if (session.messagesInfo.size > 0) { @@ -450,7 +457,10 @@ function updateSessionInfo(instanceId: string, sessionId: string) { cost = info.cost || 0 } - // Get model info for context window and subscription check + inputTokensForUsage = usage.input || 0 + cacheReadTokensForUsage = usage.cache?.read || 0 + + // Get model info identifiers for context lookups modelID = info.modelID || "" providerID = info.providerID || "" isSubscriptionModel = cost === 0 @@ -461,22 +471,39 @@ function updateSessionInfo(instanceId: string, sessionId: string) { } } - // Get context window from providers - if (modelID && providerID) { - const instanceProviders = providers().get(instanceId) || [] + const instanceProviders = providers().get(instanceId) || [] + + const sessionModel = session.model + let selectedModel: Provider["models"][number] | undefined + + if (sessionModel?.providerId && sessionModel?.modelId) { + const provider = instanceProviders.find((p) => p.id === sessionModel.providerId) + selectedModel = provider?.models.find((m) => m.id === sessionModel.modelId) + } + + if (!selectedModel && modelID && providerID) { const provider = instanceProviders.find((p) => p.id === providerID) - if (provider) { - const model = provider.models.find((m) => m.id === modelID) - if (model?.limit?.context) { - contextWindow = model.limit.context - } - // 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 - } + selectedModel = provider?.models.find((m) => m.id === modelID) + } + + let modelOutputLimit = DEFAULT_MODEL_OUTPUT_LIMIT + + if (selectedModel) { + if (selectedModel.limit?.context) { + contextWindow = selectedModel.limit.context + } + + if (selectedModel.limit?.output && selectedModel.limit.output > 0) { + modelOutputLimit = selectedModel.limit.output + } + + if (selectedModel.cost?.input === 0 && selectedModel.cost?.output === 0) { + isSubscriptionModel = true } } + const contextUsageTokens = inputTokensForUsage + cacheReadTokensForUsage + modelOutputLimit + setSessionInfoByInstance((prev) => { const next = new Map(prev) const instanceInfo = new Map(prev.get(instanceId)) @@ -485,6 +512,7 @@ function updateSessionInfo(instanceId: string, sessionId: string) { cost, contextWindow, isSubscriptionModel, + contextUsageTokens, }) next.set(instanceId, instanceInfo) return next @@ -552,14 +580,25 @@ async function createSession(instanceId: string, agent?: string): Promise p.id === session.model.providerId) + const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId) + const initialContextWindow = initialModel?.limit?.context ?? 0 + const initialOutputLimit = + initialModel?.limit?.output && initialModel.limit.output > 0 + ? initialModel.limit.output + : DEFAULT_MODEL_OUTPUT_LIMIT + const initialSubscriptionModel = initialModel?.cost?.input === 0 && initialModel?.cost?.output === 0 + setSessionInfoByInstance((prev) => { const next = new Map(prev) const instanceInfo = new Map(prev.get(instanceId)) instanceInfo.set(session.id, { tokens: 0, cost: 0, - contextWindow: 0, - isSubscriptionModel: false, + contextWindow: initialContextWindow, + isSubscriptionModel: Boolean(initialSubscriptionModel), + contextUsageTokens: initialOutputLimit, }) next.set(instanceId, instanceInfo) return next @@ -644,14 +683,23 @@ async function forkSession( return next }) + const instanceProviders = providers().get(instanceId) || [] + const forkProvider = instanceProviders.find((p) => p.id === forkedSession.model.providerId) + const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId) + const forkContextWindow = forkModel?.limit?.context ?? 0 + const forkOutputLimit = + forkModel?.limit?.output && forkModel.limit.output > 0 ? forkModel.limit.output : DEFAULT_MODEL_OUTPUT_LIMIT + const forkSubscriptionModel = forkModel?.cost?.input === 0 && forkModel?.cost?.output === 0 + setSessionInfoByInstance((prev) => { const next = new Map(prev) const instanceInfo = new Map(prev.get(instanceId)) instanceInfo.set(forkedSession.id, { tokens: 0, cost: 0, - contextWindow: 0, - isSubscriptionModel: false, + contextWindow: forkContextWindow, + isSubscriptionModel: Boolean(forkSubscriptionModel), + contextUsageTokens: forkOutputLimit, }) next.set(instanceId, instanceInfo) return next @@ -1597,6 +1645,10 @@ async function updateSessionAgent(instanceId: string, sessionId: string, agent: if (agent && shouldApplyModel) { setAgentModelPreference(instanceId, agent, nextModel) } + + if (shouldApplyModel) { + updateSessionInfo(instanceId, sessionId) + } } async function updateSessionModel( @@ -1632,6 +1684,8 @@ async function updateSessionModel( setAgentModelPreference(instanceId, currentAgent, model) } addRecentModelPreference(model) + + updateSessionInfo(instanceId, sessionId) } function handleSessionCompacted(instanceId: string, event: any): void { @@ -1677,12 +1731,30 @@ function handleMessagePartRemoved(instanceId: string, event: any): void { loadMessages(instanceId, sessionID, true).catch(console.error) } +function handleTuiToast(_instanceId: string, event: any): void { + const payload = event?.properties + if (!payload || typeof payload.message !== "string" || typeof payload.variant !== "string") return + if (!payload.message.trim()) return + + const variant: ToastVariant = ALLOWED_TOAST_VARIANTS.has(payload.variant as ToastVariant) + ? (payload.variant as ToastVariant) + : "info" + + showToastNotification({ + title: typeof payload.title === "string" ? payload.title : undefined, + message: payload.message, + variant, + duration: typeof payload.duration === "number" ? payload.duration : undefined, + }) +} + sseManager.onMessageUpdate = handleMessageUpdate sseManager.onMessageRemoved = handleMessageRemoved sseManager.onMessagePartRemoved = handleMessagePartRemoved sseManager.onSessionUpdate = handleSessionUpdate sseManager.onSessionCompacted = handleSessionCompacted sseManager.onSessionError = handleSessionError +sseManager.onTuiToast = handleTuiToast export { sessions, diff --git a/src/types/session.ts b/src/types/session.ts index 76fd83f5..e599c07c 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -47,6 +47,7 @@ export interface Model { providerId: string limit?: { context?: number + output?: number } cost?: { input?: number