]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
ui: Context usage gauge and panel (#25340)
authorAleksander Grygier <redacted>
Wed, 8 Jul 2026 07:22:35 +0000 (09:22 +0200)
committerGitHub <redacted>
Wed, 8 Jul 2026 07:22:35 +0000 (09:22 +0200)
* feat: WIP

* feat: Retire ChatScreenProcessingInfo component, context, and keepStatsVisible settings

* feat: Always-on gauge with active-model /props, conversation stats and live-reactive reading/output/avg

* feat: Add /tokenize endpoint, TokenizeService, FNV-1a and JSON Schema utilities

* feat: Surface enabled-tools token count in context hover card

* refactor(tools): make toolsStore the sole owner of the OpenAI wire format

Previously mcpStore.getToolDefinitionsForLLM() owned the MCP->OpenAI
shape conversion (plus normalizeSchemaProperties). That created two
sources of truth for what gets sent to the LLM, with the
duplication-prone risk of the deduplicated enabled list (which feeds
the token-count cache) drifting from the bytes actually shipped on
chat.

Now:
- mcpStore: pure protocol state + routing. Drop getToolDefinitionsForLLM
  and the inline OpenAIToolDefinition conversion + normalizeSchemaProperties.
  Doc comment adjusted to declare wire-format ownership as belonging
  to toolsStore. Connection lifecycle, health checks, executeTool,
  and the connections/toolsIndex remain.
- toolsStore: owns the wire shape (added earlier this series). mcpEntries()
  inlines the MCP tool conversion; uses normalizeJsonSchema (the JSON
  Schema util extracted in the prior commit) so missing 'type' fields
  are inferred from defaults. mcpTools getter iterates mcpEntries() so
  the Settings UI and the deduplicated enabled list see the same
  definitions. getEnabledToolsForLLM iterates mcpEntries() instead of
  calling mcpStore, so the JSON sent to the LLM is identical to what
  toolsStore.refreshEnabledToolsTokenCount tokenizes.
- agentic: the chat-completion tools field's type was annotated as
  ReturnType<typeof mcpStore.getToolDefinitionsForLLM>, claiming the
  shape was owned by mcpStore. Switch to ReturnType<typeof
  toolsStore.getEnabledToolsForLLM>, the actual source.

Assisted-by: Claude
* feat: UI WIP

* feat: UI WIP

* feat: UI WIP

* feat: Adjust reasoning submenu layout and spacing

* feat: Adjust context usage gauge thresholds and styling

* feat: Split context usage gauge stats into current and cumulative breakdowns

* chore: Format

* refactor: Cleanup

* refactor: Cleanup

* feat: improve token gauge accuracy and display

* refactor: remove MCP recommendation gating and simplify server visibility

* feat: add token audit logging to ChatStore for debugging

* refactor: Simplify context token reading to use server promptTokens directly

* feat: Replace last-known token tracking with live server-derived stats for accurate streaming gauges

* feat: UI Improvements

* feat: Move prompt processing stats to the preceding user message

* feat: Fix context token double-counting and refine gauge layout

* refactor: remove always-show-agentic-turns setting and simplify agentic turn display

* feat: track and display cache tokens in context gauge

* feat: add diagnostic logging for chat completion requests

* refactor: improve token audit console output with fresh/cached breakdown

* fix: invalidate enabled tools token count cache on tool changes

* test: add unit tests for tools store token count invalidation

* refactor: Remove tools token counting infrastructure

* refactor: Update ChatFormContextGauge to use simplified token tracking

* refactor: Update ChatStore to remove tools token counting

* chore: Formatting

* feat: Improve UI text

* feat: simplify context usage derivation and refine gauge labels

* refactor: cleanup logs

* cleaning

* fix: UI

* refactor: Enums

* refactor: Extract context gauge logic into hook and split UI into sub-components

* refactor: Cleanup comments

---------

Co-authored-by: Pascal <redacted>
54 files changed:
tools/ui/package-lock.json
tools/ui/package.json
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddReasoningSubmenu.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormReasoningEffortSubmenu.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormReasoningToggle.svelte [deleted file]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ChatFormContextGauge.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDetailRow.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDetails.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDial.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeLoadModel.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/context-gauge.ts [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserPending.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatistics.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte
tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte [deleted file]
tools/ui/src/lib/components/app/chat/index.ts
tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte
tools/ui/src/lib/components/app/content/SyntaxHighlightedCode.svelte
tools/ui/src/lib/components/app/dialogs/DialogMcpServerRecommendations.svelte
tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatToolsTab.svelte
tools/ui/src/lib/components/ui/hover-card/hover-card-content.svelte [new file with mode: 0644]
tools/ui/src/lib/components/ui/hover-card/hover-card-portal.svelte [new file with mode: 0644]
tools/ui/src/lib/components/ui/hover-card/hover-card-trigger.svelte [new file with mode: 0644]
tools/ui/src/lib/components/ui/hover-card/hover-card.svelte [new file with mode: 0644]
tools/ui/src/lib/components/ui/hover-card/index.ts [new file with mode: 0644]
tools/ui/src/lib/constants/context-keys.ts
tools/ui/src/lib/constants/css-classes.ts
tools/ui/src/lib/constants/reasoning-effort.ts
tools/ui/src/lib/constants/settings-keys.ts
tools/ui/src/lib/constants/settings-registry.ts
tools/ui/src/lib/constants/storage.ts
tools/ui/src/lib/contexts/index.ts
tools/ui/src/lib/contexts/processing-info.context.ts [deleted file]
tools/ui/src/lib/enums/chat.enums.ts
tools/ui/src/lib/enums/index.ts
tools/ui/src/lib/enums/reasoning-effort.enums.ts
tools/ui/src/lib/hooks/use-context-gauge.svelte.ts [new file with mode: 0644]
tools/ui/src/lib/hooks/use-mcp-recommendations.svelte.ts
tools/ui/src/lib/hooks/use-processing-state.svelte.ts
tools/ui/src/lib/services/chat.service.ts
tools/ui/src/lib/services/migration.service.ts
tools/ui/src/lib/stores/agentic.svelte.ts
tools/ui/src/lib/stores/chat.svelte.ts
tools/ui/src/lib/stores/conversations.svelte.ts
tools/ui/src/lib/stores/mcp.svelte.ts
tools/ui/src/lib/stores/tools.svelte.ts
tools/ui/src/lib/utils/branching.ts

index 9dce3a0c9d4b4f9f344a68b9e422734d6afc24a0..7216de682340876a48c0bc06647159cb1f25ba21 100644 (file)
@@ -11,7 +11,7 @@
                                "@chromatic-com/storybook": "5.0.0",
                                "@eslint/compat": "1.4.1",
                                "@eslint/js": "9.39.2",
-                               "@internationalized/date": "3.10.1",
+                               "@internationalized/date": "3.12.2",
                                "@lucide/svelte": "0.515.0",
                                "@modelcontextprotocol/sdk": "1.26.0",
                                "@playwright/test": "1.56.1",
                        }
                },
                "node_modules/@internationalized/date": {
-                       "version": "3.10.1",
-                       "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.1.tgz",
-                       "integrity": "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA==",
+                       "version": "3.12.2",
+                       "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz",
+                       "integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==",
                        "dev": true,
                        "license": "Apache-2.0",
                        "dependencies": {
index bcb4165d10151b8eccd935dbb079c2c1fc3a2b1c..8b3516a02cf262e7d9a48b031c1ebb8d761a4344 100644 (file)
@@ -30,7 +30,7 @@
                "@chromatic-com/storybook": "5.0.0",
                "@eslint/compat": "1.4.1",
                "@eslint/js": "9.39.2",
-               "@internationalized/date": "3.10.1",
+               "@internationalized/date": "3.12.2",
                "@lucide/svelte": "0.515.0",
                "@modelcontextprotocol/sdk": "1.26.0",
                "@playwright/test": "1.56.1",
index 47954032117a3cb87b7c093d91c9f5f98b378937..433a3662df2ea60f5b0cde1a289bcb5e958d2648 100644 (file)
@@ -11,7 +11,8 @@
        } from '$lib/constants';
        import {
                ChatFormActionAddToolsSubmenu,
-               ChatFormActionAddMcpServersSubmenu
+               ChatFormActionAddMcpServersSubmenu,
+               ChatFormActionAddReasoningSubmenu
        } from '$lib/components/app';
        import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
 
                        </Tooltip.Content>
                </Tooltip.Root>
 
-               <DropdownMenu.Content align="start" class="w-48">
+               <DropdownMenu.Content align="start" class="w-52">
+                       <ChatFormActionAddReasoningSubmenu />
+
+                       <DropdownMenu.Separator />
+
                        <DropdownMenu.Sub>
                                <DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
                                        <File class="h-4 w-4" />
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddReasoningSubmenu.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddReasoningSubmenu.svelte
new file mode 100644 (file)
index 0000000..070fd3a
--- /dev/null
@@ -0,0 +1,128 @@
+<script lang="ts">
+       import { Lightbulb, LightbulbOff, Check, Info } from '@lucide/svelte';
+       import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
+       import * as Tooltip from '$lib/components/ui/tooltip';
+       import { ReasoningEffort } from '$lib/enums';
+       import { REASONING_EFFORT_TOKENS } from '$lib/constants/reasoning-effort-tokens';
+       import { REASONING_EFFORT_LEVELS } from '$lib/constants/reasoning-effort';
+       import type { ReasoningEffortLevel } from '$lib/types';
+       import {
+               modelsStore,
+               checkModelSupportsThinking,
+               supportsThinking,
+               propsCacheVersion,
+               loadedModelIds
+       } from '$lib/stores/models.svelte';
+       import { chatStore } from '$lib/stores/chat.svelte';
+       import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
+       import { isRouterMode } from '$lib/stores/server.svelte';
+       import type { DatabaseMessage } from '$lib/types/database';
+
+       let subOpen = $state(false);
+
+       let conversationModel = $derived(
+               chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
+       );
+
+       let modelSupportsThinkingFromMessages = $derived.by(() => {
+               const modelId = isRouterMode() ? modelsStore.selectedModelName || conversationModel : null;
+               if (!modelId) return false;
+
+               const messages = conversationsStore.activeMessages;
+
+               return messages.some(
+                       (m) => m.role === 'assistant' && m.model === modelId && !!m.reasoningContent
+               );
+       });
+
+       let modelSupportsThinking = $derived.by(() => {
+               loadedModelIds();
+               propsCacheVersion();
+
+               if (isRouterMode()) {
+                       const modelId = modelsStore.selectedModelName || conversationModel;
+                       return checkModelSupportsThinking(modelId ?? '') || modelSupportsThinkingFromMessages;
+               }
+
+               return supportsThinking() || modelSupportsThinkingFromMessages;
+       });
+
+       let thinkingEnabled = $derived(conversationsStore.getThinkingEnabled());
+       let currentEffort = $derived(conversationsStore.getReasoningEffort());
+       let isOff = $derived(!thinkingEnabled);
+
+       function isSelected(item: ReasoningEffortLevel): boolean {
+               if (item.isOff) return isOff;
+               return thinkingEnabled && currentEffort === item.value;
+       }
+
+       function handleSelection(item: ReasoningEffortLevel) {
+               if (item.isOff) {
+                       conversationsStore.setThinkingEnabled(false);
+               } else {
+                       conversationsStore.setThinkingEnabled(true);
+                       conversationsStore.setReasoningEffort(item.value as ReasoningEffort);
+               }
+               subOpen = false;
+       }
+</script>
+
+{#if modelSupportsThinking}
+       <DropdownMenu.Sub bind:open={subOpen}>
+               <DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
+                       {#if thinkingEnabled}
+                               <Lightbulb class="h-4 w-4 shrink-0 text-amber-400" />
+                       {:else}
+                               <LightbulbOff class="h-4 w-4 shrink-0 text-muted-foreground" />
+                       {/if}
+
+                       <span class="text-sm inline-flex gap-2 {!thinkingEnabled ? 'text-muted-foreground' : ''}">
+                               Reasoning
+
+                               <span class="capitalize text-muted-foreground">
+                                       {thinkingEnabled ? currentEffort : 'off'}
+                               </span>
+                       </span>
+               </DropdownMenu.SubTrigger>
+
+               <DropdownMenu.SubContent
+                       class="w-60 bg-popover p-1.5 text-popover-foreground shadow-md outline-none"
+               >
+                       {#each REASONING_EFFORT_LEVELS as level (level.value)}
+                               <button
+                                       type="button"
+                                       class="flex w-full cursor-pointer items-center gap-3 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent"
+                                       class:bg-accent={isSelected(level)}
+                                       onclick={() => handleSelection(level)}
+                               >
+                                       {#if isSelected(level)}
+                                               <Check class="h-4 w-4 shrink-0 text-foreground" />
+                                       {:else}
+                                               <div class="h-4 w-4 shrink-0"></div>
+                                       {/if}
+
+                                       <span class="flex-1">{level.label}</span>
+
+                                       {#if !level.isOff}
+                                               <span class="text-[11px] text-muted-foreground opacity-60">
+                                                       {REASONING_EFFORT_TOKENS[level.value] === -1
+                                                               ? 'Unlimited'
+                                                               : `Max ${REASONING_EFFORT_TOKENS[level.value].toLocaleString()} tokens`}
+                                               </span>
+                                       {/if}
+
+                                       {#if level.hasInfo}
+                                               <Tooltip.Root>
+                                                       <Tooltip.Trigger>
+                                                               <Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
+                                                       </Tooltip.Trigger>
+                                                       <Tooltip.Content side="left">
+                                                               <p>Maximum reasoning effort with extended context usage</p>
+                                                       </Tooltip.Content>
+                                               </Tooltip.Root>
+                                       {/if}
+                               </button>
+                       {/each}
+               </DropdownMenu.SubContent>
+       </DropdownMenu.Sub>
+{/if}
index a80f00bc6787207e6607d6e213ce168d51d4727c..7be356f2a8daefc23b0227e68fe44062bda3e34c 100644 (file)
@@ -7,14 +7,20 @@
                ChatFormActionModels,
                ChatFormActionRecord,
                ChatFormActionSubmit,
-               ChatFormReasoningToggle
+               ChatFormContextGauge
        } from '$lib/components/app';
-       import { FileTypeCategory } from '$lib/enums';
+       import { FileTypeCategory, MessageRole } from '$lib/enums';
        import { mcpStore } from '$lib/stores/mcp.svelte';
        import { config } from '$lib/stores/settings.svelte';
-       import { conversationsStore } from '$lib/stores/conversations.svelte';
+       import { activeMessages, conversationsStore } from '$lib/stores/conversations.svelte';
+       import {
+               activeProcessingState,
+               isChatStreaming,
+               isLoading as chatIsLoading
+       } from '$lib/stores/chat.svelte';
        import { getFileTypeCategory } from '$lib/utils';
        import { goto } from '$app/navigation';
+       import { page } from '$app/state';
        import { ROUTES } from '$lib/constants/routes';
 
        interface Props {
        let activeMessage = $derived(
                conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1]
        );
+
+       let hasProcessedTokens = $derived.by(() => {
+               if (!page.params.id) return false;
+
+               const messages = activeMessages() as DatabaseMessage[];
+               let totalHistoricalTokens = 0;
+               for (const m of messages) {
+                       if (m.role !== MessageRole.ASSISTANT) continue;
+                       const timings = m.timings;
+                       if (!timings) continue;
+                       const agenticLlm = timings.agentic?.llm;
+                       if (agenticLlm?.prompt_n != null || agenticLlm?.predicted_n != null) {
+                               totalHistoricalTokens += (agenticLlm?.prompt_n ?? 0) + (agenticLlm?.predicted_n ?? 0);
+                       } else {
+                               totalHistoricalTokens += (timings.prompt_n ?? 0) + (timings.predicted_n ?? 0);
+                       }
+               }
+               if (totalHistoricalTokens > 0) return true;
+
+               if (!chatIsLoading() && !isChatStreaming()) return false;
+
+               const processingState = activeProcessingState();
+               if (!processingState) return false;
+               const livePromptTokens = Math.max(
+                       processingState.promptTokens ?? 0,
+                       processingState.promptProgress?.processed ?? 0
+               );
+               const liveOutputTokens = processingState.outputTokensUsed ?? 0;
+               return livePromptTokens > 0 || liveOutputTokens > 0;
+       });
 </script>
 
 <div
        style="container-type: inline-size"
 >
        {#if showAddButton}
-               <div class="mr-auto flex items-center gap-3">
+               <div class="mr-auto flex items-center gap-2">
                        <ChatFormActionsAdd
                                {disabled}
                                {hasAudioModality}
                </div>
        {/if}
 
-       <div class="flex items-center gap-2">
-               <ChatFormReasoningToggle />
+       <div class="flex items-center gap-1.5">
+               {#if hasProcessedTokens}
+                       <ChatFormContextGauge />
+               {/if}
 
                {#if showModelSelector}
                        <ChatFormActionModels
index 936e037736f3055467c7b5fe9f86f227f05d72d3..cfb01537c9401d6adf0d13e5e5819c3eed0ed9ff 100644 (file)
@@ -6,6 +6,7 @@
        import { REASONING_EFFORT_TOKENS } from '$lib/constants/reasoning-effort-tokens';
        import { REASONING_EFFORT_LEVELS } from '$lib/constants/reasoning-effort';
        import type { ReasoningEffortLevel } from '$lib/types';
+       import { DIALOG_SUBMENU_CONTENT } from '$lib/constants/css-classes';
        import {
                modelsStore,
                checkModelSupportsThinking,
@@ -71,9 +72,7 @@
 
 {#if modelSupportsThinking}
        <DropdownMenu.Sub bind:open={subOpen}>
-               <DropdownMenu.SubTrigger
-                       class="flex cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors outline-none hover:bg-accent focus:bg-accent"
-               >
+               <DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
                        {#if thinkingEnabled}
                                <Lightbulb class="h-4 w-4 shrink-0 text-amber-400" />
                        {:else}
                        {/if}
                </DropdownMenu.SubTrigger>
 
-               <DropdownMenu.SubContent
-                       class="w-60 rounded-xl bg-popover p-3 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
-               >
+               <DropdownMenu.SubContent class={DIALOG_SUBMENU_CONTENT}>
                        {#each REASONING_EFFORT_LEVELS as level (level.value)}
                                <button
                                        type="button"
-                                       class="flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm transition-colors hover:bg-accent"
+                                       class="flex w-full cursor-pointer items-center gap-2"
                                        class:bg-accent={isSelected(level)}
                                        onclick={() => handleSelection(level)}
                                >
-                                       {#if isSelected(level)}
-                                               <Check class="h-4 w-4 shrink-0 text-foreground" />
-                                       {:else}
-                                               <div class="h-4 w-4 shrink-0"></div>
-                                       {/if}
-
-                                       <span class="flex-1">{level.label}</span>
+                                       <span class="flex-1 text-left">{level.label}</span>
 
                                        {#if !level.isOff}
                                                <span class="text-[11px] text-muted-foreground opacity-60">
                                                        </Tooltip.Content>
                                                </Tooltip.Root>
                                        {/if}
+
+                                       {#if isSelected(level)}
+                                               <Check class="h-4 w-4 shrink-0 text-foreground" />
+                                       {/if}
                                </button>
                        {/each}
                </DropdownMenu.SubContent>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormReasoningToggle.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormReasoningToggle.svelte
deleted file mode 100644 (file)
index f6bcbcb..0000000
+++ /dev/null
@@ -1,145 +0,0 @@
-<script lang="ts">
-       import { Lightbulb, LightbulbOff, Check, Info } from '@lucide/svelte';
-       import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
-       import * as Tooltip from '$lib/components/ui/tooltip';
-       import { ReasoningEffort, MessageRole } from '$lib/enums';
-       import { REASONING_EFFORT_TOKENS } from '$lib/constants/reasoning-effort-tokens';
-       import { REASONING_EFFORT_LEVELS } from '$lib/constants/reasoning-effort';
-       import type { ReasoningEffortLevel } from '$lib/types';
-       import {
-               modelsStore,
-               checkModelSupportsThinking,
-               supportsThinking,
-               propsCacheVersion,
-               loadedModelIds
-       } from '$lib/stores/models.svelte';
-       import { chatStore } from '$lib/stores/chat.svelte';
-       import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
-       import { isRouterMode } from '$lib/stores/server.svelte';
-       import type { DatabaseMessage } from '$lib/types/database';
-
-       let thinkingEnabled = $derived(conversationsStore.getThinkingEnabled());
-       let currentEffort = $derived(conversationsStore.getReasoningEffort());
-       let isOff = $derived(!thinkingEnabled);
-       let tooltipText = $derived(thinkingEnabled ? `${currentEffort} Reasoning` : 'Disabled Reasoning');
-       let subOpen = $state(false);
-
-       // Get conversation model from message history
-       let conversationModel = $derived(
-               chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
-       );
-
-       // Fallback: if model props aren't available, check if any assistant messages
-       // for this model in the active conversation have reasoning content.
-       let modelSupportsThinkingFromMessages = $derived.by(() => {
-               const modelId = isRouterMode() ? modelsStore.selectedModelName || conversationModel : null;
-               if (!modelId) return false;
-               const messages = conversationsStore.activeMessages;
-               return messages.some(
-                       (m: DatabaseMessage) =>
-                               m.role === MessageRole.ASSISTANT && m.model === modelId && !!m.reasoningContent
-               );
-       });
-
-       // Check if model supports thinking. Primary: chat template from /props.
-       // Fallback: message history (reasoning content in assistant messages).
-       let modelSupportsThinking = $derived.by(() => {
-               loadedModelIds();
-               propsCacheVersion();
-
-               if (isRouterMode()) {
-                       const modelId = modelsStore.selectedModelName || conversationModel;
-                       return checkModelSupportsThinking(modelId ?? '') || modelSupportsThinkingFromMessages;
-               }
-
-               // In non-router mode, use the built-in supportsThinking
-               return supportsThinking() || modelSupportsThinkingFromMessages;
-       });
-
-       // Check if current item is selected
-       function isSelected(item: ReasoningEffortLevel): boolean {
-               if (item.isOff) {
-                       return isOff;
-               }
-               return thinkingEnabled && currentEffort === item.value;
-       }
-
-       function handleSelection(item: ReasoningEffortLevel) {
-               if (item.isOff) {
-                       conversationsStore.setThinkingEnabled(false);
-               } else {
-                       conversationsStore.setThinkingEnabled(true);
-                       conversationsStore.setReasoningEffort(item.value as ReasoningEffort);
-               }
-               subOpen = false;
-       }
-</script>
-
-{#if modelSupportsThinking}
-       <DropdownMenu.Root bind:open={subOpen}>
-               <Tooltip.Root>
-                       <Tooltip.Trigger>
-                               <DropdownMenu.Trigger
-                                       class={[
-                                               'flex h-6 w-6 cursor-pointer items-center justify-center rounded-full p-0 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
-                                               thinkingEnabled ? 'bg-amber-400/10 hover:bg-amber-400/20' : 'bg-muted'
-                                       ]}
-                                       aria-label={`${tooltipText}. Click to configure.`}
-                               >
-                                       {#if thinkingEnabled}
-                                               <Lightbulb class="h-3 w-3 text-amber-400" />
-                                       {:else}
-                                               <LightbulbOff class="h-3 w-3 text-muted-foreground" />
-                                       {/if}
-                               </DropdownMenu.Trigger>
-                       </Tooltip.Trigger>
-
-                       <Tooltip.Content>
-                               <p class="capitalize">{tooltipText}</p>
-                       </Tooltip.Content>
-               </Tooltip.Root>
-
-               <DropdownMenu.Content
-                       align="start"
-                       class="w-60 rounded-xl bg-popover p-3 text-popover-foreground shadow-md outline-none"
-               >
-                       <div class="mb-2 px-2.5 text-sm font-medium">Reasoning effort</div>
-
-                       {#each REASONING_EFFORT_LEVELS as level (level.value)}
-                               <button
-                                       type="button"
-                                       class="flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm transition-colors hover:bg-accent"
-                                       class:bg-accent={isSelected(level)}
-                                       onclick={() => handleSelection(level)}
-                               >
-                                       {#if isSelected(level)}
-                                               <Check class="h-4 w-4 shrink-0 text-foreground" />
-                                       {:else}
-                                               <div class="h-4 w-4 shrink-0"></div>
-                                       {/if}
-
-                                       <span class="flex-1">{level.label}</span>
-
-                                       {#if !level.isOff}
-                                               <span class="text-[11px] text-muted-foreground opacity-60">
-                                                       {REASONING_EFFORT_TOKENS[level.value] === -1
-                                                               ? 'Unlimited'
-                                                               : `Max ${REASONING_EFFORT_TOKENS[level.value].toLocaleString()} tokens`}
-                                               </span>
-                                       {/if}
-
-                                       {#if level.hasInfo}
-                                               <Tooltip.Root>
-                                                       <Tooltip.Trigger>
-                                                               <Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
-                                                       </Tooltip.Trigger>
-                                                       <Tooltip.Content side="left">
-                                                               <p>Maximum reasoning effort with extended context usage</p>
-                                                       </Tooltip.Content>
-                                               </Tooltip.Root>
-                                       {/if}
-                               </button>
-                       {/each}
-               </DropdownMenu.Content>
-       </DropdownMenu.Root>
-{/if}
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ChatFormContextGauge.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ChatFormContextGauge.svelte
new file mode 100644 (file)
index 0000000..855cf6c
--- /dev/null
@@ -0,0 +1,108 @@
+<script lang="ts">
+       import { untrack } from 'svelte';
+       import * as HoverCard from '$lib/components/ui/hover-card';
+       import { activeConversation, activeMessages } from '$lib/stores/conversations.svelte';
+       import { chatStore, isChatStreaming, isLoading } from '$lib/stores/chat.svelte';
+       import { formatParameters } from '$lib/utils/formatters';
+       import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
+       import ContextGaugeDial from './ContextGaugeDial.svelte';
+       import ContextGaugeDetails from './ContextGaugeDetails.svelte';
+       import ContextGaugeLoadModel from './ContextGaugeLoadModel.svelte';
+       import { colorLevelBgClass, colorLevelTextClass } from './context-gauge';
+
+       const gauge = useContextGauge();
+
+       $effect(() => {
+               const conv = activeConversation();
+               untrack(() => chatStore.setActiveProcessingConversation(conv?.id ?? null));
+       });
+
+       $effect(() => {
+               const conv = activeConversation();
+               const messages = activeMessages() as DatabaseMessage[];
+               if (!conv) return;
+               if (isLoading() || isChatStreaming()) return;
+
+               if (messages.length === 0) {
+                       untrack(() => chatStore.clearProcessingState(conv.id));
+                       return;
+               }
+
+               untrack(() => chatStore.restoreProcessingStateFromMessages(messages, conv.id));
+       });
+
+       $effect(() => {
+               gauge.startMonitoring();
+       });
+
+       const showProgressBar = $derived(
+               gauge.contextTotal !== null &&
+                       gauge.contextTotal > 0 &&
+                       (gauge.activeModelId !== null || gauge.isActiveModelLoaded)
+       );
+</script>
+
+<HoverCard.Root>
+       <HoverCard.Trigger class="flex h-5 w-5 cursor-default items-center justify-center">
+               <ContextGaugeDial percent={gauge.contextPercent} level={gauge.colorLevel} />
+       </HoverCard.Trigger>
+
+       <HoverCard.Content
+               side="bottom"
+               class="z-50 w-64 rounded-lg border border-border/50 bg-popover p-3 text-popover-foreground shadow-lg"
+       >
+               <div class="flex flex-col gap-2">
+                       <div class="flex items-center gap-2">
+                               <span class="font-medium">Context</span>
+                               <span class="text-muted-foreground">·</span>
+                               <span class="font-mono text-muted-foreground">
+                                       {formatParameters(gauge.contextUsed)}
+                                       / {gauge.contextTotal !== null ? formatParameters(gauge.contextTotal) : '-'}
+                               </span>
+                       </div>
+
+                       {#if gauge.activeModelId !== null && !gauge.isActiveModelLoaded}
+                               <ContextGaugeLoadModel
+                                       modelId={gauge.activeModelId}
+                                       isLoading={gauge.isActiveModelLoading}
+                                       onLoad={gauge.loadModel}
+                               />
+                       {:else if showProgressBar}
+                               <div class="h-1.5 w-full overflow-hidden rounded-full bg-muted">
+                                       <div
+                                               class="h-full rounded-full transition-all duration-300 {colorLevelBgClass(
+                                                       gauge.colorLevel
+                                               )}"
+                                               style="width: {gauge.contextPercent}%"
+                                       ></div>
+                               </div>
+
+                               <div class="flex justify-between text-xs text-muted-foreground">
+                                       <span>
+                                               <span class={colorLevelTextClass(gauge.colorLevel)}>{gauge.contextPercent}%</span> used
+                                       </span>
+                                       <span>
+                                               {formatParameters((gauge.contextTotal ?? 0) - gauge.contextUsed)} remaining
+                                       </span>
+                               </div>
+                       {:else}
+                               <div class="text-xs text-muted-foreground">No context info available</div>
+                       {/if}
+
+                       {#if gauge.hasAnyUsage}
+                               <ContextGaugeDetails
+                                       currentRead={gauge.currentRead}
+                                       currentFresh={gauge.currentFresh}
+                                       currentCache={gauge.currentCache}
+                                       currentOutput={gauge.currentOutput}
+                                       kvTotal={gauge.kvTotal}
+                                       cumulativeRead={gauge.cumulativeRead}
+                                       cumulativeOutput={gauge.cumulativeOutput}
+                                       cumulativeCacheTotal={gauge.cumulativeCacheTotal}
+                                       averageTokensPerSecond={gauge.averageTokensPerSecond}
+                                       transientDetails={gauge.transientDetails}
+                               />
+                       {/if}
+               </div>
+       </HoverCard.Content>
+</HoverCard.Root>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDetailRow.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDetailRow.svelte
new file mode 100644 (file)
index 0000000..71c6a33
--- /dev/null
@@ -0,0 +1,20 @@
+<script lang="ts">
+       interface Props {
+               label: string;
+               value: string;
+               subtitle?: string;
+       }
+
+       let { label, value, subtitle }: Props = $props();
+</script>
+
+<div class="grid gap-1.5">
+       <div class="flex items-baseline justify-between">
+               <span class="text-muted-foreground">{label}</span>
+               <span class="font-mono text-muted-foreground">{value}</span>
+       </div>
+
+       {#if subtitle}
+               <div class="text-[10px] leading-tight text-muted-foreground/70">{subtitle}</div>
+       {/if}
+</div>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDetails.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDetails.svelte
new file mode 100644 (file)
index 0000000..fdec5ac
--- /dev/null
@@ -0,0 +1,122 @@
+<script lang="ts">
+       import { ChevronDown } from '@lucide/svelte';
+       import * as Collapsible from '$lib/components/ui/collapsible';
+       import { STATS_UNITS } from '$lib/constants';
+       import ContextGaugeDetailRow from './ContextGaugeDetailRow.svelte';
+
+       interface Props {
+               currentRead: number;
+               currentFresh: number;
+               currentCache: number;
+               currentOutput: number;
+               kvTotal: number;
+               cumulativeRead: number;
+               cumulativeOutput: number;
+               cumulativeCacheTotal: number;
+               averageTokensPerSecond: number | null;
+               transientDetails: string[];
+       }
+
+       let {
+               currentRead,
+               currentFresh,
+               currentCache,
+               currentOutput,
+               kvTotal,
+               cumulativeRead,
+               cumulativeOutput,
+               cumulativeCacheTotal,
+               averageTokensPerSecond,
+               transientDetails
+       }: Props = $props();
+
+       let open = $state(false);
+
+       const hasCumulative = $derived(cumulativeRead > 0 || cumulativeOutput > 0);
+       const hasCurrent = $derived(currentRead > 0 || currentOutput > 0);
+</script>
+
+<Collapsible.Root bind:open class="mt-3 border-t border-border/50 pt-4">
+       <Collapsible.Trigger
+               class="flex w-full cursor-pointer items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
+       >
+               <span>Token usage details</span>
+
+               <ChevronDown class={'ml-auto h-3 w-3 transition-transform' + (open ? ' rotate-180' : '')} />
+       </Collapsible.Trigger>
+
+       <Collapsible.Content class="flex flex-col gap-4 text-xs pt-4">
+               {#if hasCumulative}
+                       <div>
+                               <h3 class="text-[11px] font-medium uppercase tracking-wide text-muted-foreground/70 mb-2">
+                                       Across all turns
+                               </h3>
+
+                               <div class="flex flex-col gap-2">
+                                       {#if cumulativeRead > 0}
+                                               <ContextGaugeDetailRow
+                                                       label="Prompt tokens evaluated"
+                                                       value={`${cumulativeRead.toLocaleString()} tok`}
+                                                       subtitle={cumulativeCacheTotal > 0
+                                                               ? `${cumulativeCacheTotal.toLocaleString()} reused from KV cache`
+                                                               : undefined}
+                                               />
+                                       {/if}
+                                       {#if cumulativeOutput > 0}
+                                               <ContextGaugeDetailRow
+                                                       label="Tokens generated"
+                                                       value={`${cumulativeOutput.toLocaleString()} tok`}
+                                               />
+                                       {/if}
+                               </div>
+                       </div>
+               {/if}
+
+               {#if hasCurrent}
+                       <div>
+                               <h3 class="text-[11px] font-medium uppercase tracking-wide text-muted-foreground/70 mb-2">
+                                       This turn Â· KV cache
+                               </h3>
+
+                               <div class="flex flex-col gap-2">
+                                       {#if currentRead > 0}
+                                               <ContextGaugeDetailRow
+                                                       label="Prompt"
+                                                       value={`${currentRead.toLocaleString()} tok`}
+                                                       subtitle={currentCache > 0
+                                                               ? `${currentFresh.toLocaleString()} fresh + ${currentCache.toLocaleString()} cached`
+                                                               : undefined}
+                                               />
+                                       {/if}
+
+                                       {#if currentOutput > 0}
+                                               <ContextGaugeDetailRow
+                                                       label="Generated"
+                                                       value={`${currentOutput.toLocaleString()} tok`}
+                                               />
+                                       {/if}
+
+                                       <div class="pt-1 mt-0.5 border-t border-border/30">
+                                               <div class="flex justify-between">
+                                                       <span class="text-muted-foreground">KV cache total</span>
+                                                       <span class="font-mono font-medium">{kvTotal.toLocaleString()} tok</span>
+                                               </div>
+                                       </div>
+                               </div>
+                       </div>
+               {/if}
+
+               {#if averageTokensPerSecond !== null}
+                       <div class="pt-1.5 mt-1 border-t border-border/30">
+                               <ContextGaugeDetailRow
+                                       label="Avg speed"
+                                       value={`${averageTokensPerSecond.toFixed(1)}${STATS_UNITS.TOKENS_PER_SECOND}`}
+                               />
+                       </div>
+               {/if}
+
+               {#each transientDetails as detail (detail)}
+                       <div class="font-mono text-muted-foreground">{detail}</div>
+               {/each}
+       </Collapsible.Content>
+</Collapsible.Root>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDial.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDial.svelte
new file mode 100644 (file)
index 0000000..6e2616d
--- /dev/null
@@ -0,0 +1,43 @@
+<script lang="ts">
+       import type { ColorLevel } from './context-gauge';
+       import { colorLevelTextClass } from './context-gauge';
+
+       interface Props {
+               percent: number | null;
+               level: ColorLevel;
+               size?: 'sm' | 'md';
+       }
+
+       let { percent, level, size = 'sm' }: Props = $props();
+
+       const RADIUS = 11;
+       const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
+
+       const strokeLevelClass = $derived(colorLevelTextClass(level));
+       const dimensions = $derived(size === 'md' ? 'h-6 w-6' : 'h-5 w-5');
+       const strokeWidth = $derived(size === 'md' ? 4 : 3);
+</script>
+
+<svg viewBox="0 0 32 32" fill="none" class={dimensions}>
+       <circle
+               cx="16"
+               cy="16"
+               r={RADIUS}
+               stroke="currentColor"
+               stroke-opacity="0.1"
+               stroke-width={strokeWidth}
+       />
+
+       <circle
+               cx="16"
+               cy="16"
+               r={RADIUS}
+               class="transition-colors duration-300 {strokeLevelClass}"
+               stroke="currentColor"
+               stroke-width={strokeWidth}
+               stroke-linecap="round"
+               stroke-dasharray={CIRCUMFERENCE}
+               stroke-dashoffset={percent !== null ? CIRCUMFERENCE * (1 - percent / 100) : CIRCUMFERENCE}
+               transform="rotate(-90 16 16)"
+       />
+</svg>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeLoadModel.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeLoadModel.svelte
new file mode 100644 (file)
index 0000000..24a67cf
--- /dev/null
@@ -0,0 +1,24 @@
+<script lang="ts">
+       import { Loader2 } from '@lucide/svelte';
+       import { Button } from '$lib/components/ui/button';
+
+       interface Props {
+               modelId: string | null;
+               isLoading: boolean;
+               onLoad: () => void;
+       }
+
+       let { modelId, isLoading, onLoad }: Props = $props();
+</script>
+
+{#if modelId !== null && !isLoading}
+       <div class="flex flex-col gap-2 border-t border-border/50 pt-2 text-xs text-muted-foreground">
+               <span>Available context size is only visible once the model is loaded.</span>
+               <Button size="sm" variant="secondary" class="self-start" onclick={onLoad}>Load model</Button>
+       </div>
+{:else if isLoading}
+       <div class="flex items-center gap-2 border-t border-border/50 pt-2 text-xs text-muted-foreground">
+               <Loader2 class="h-3.5 w-3.5 animate-spin" />
+               <span>Loading model...</span>
+       </div>
+{/if}
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/context-gauge.ts b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/context-gauge.ts
new file mode 100644 (file)
index 0000000..5b00100
--- /dev/null
@@ -0,0 +1,37 @@
+export type ColorLevel = 'ok' | 'warning' | 'critical' | 'neutral';
+
+const WARNING_THRESHOLD = 80;
+const CRITICAL_THRESHOLD = 95;
+
+export function colorLevelFromPercent(percent: number | null): ColorLevel {
+       if (percent === null) return 'neutral';
+       if (percent >= CRITICAL_THRESHOLD) return 'critical';
+       if (percent >= WARNING_THRESHOLD) return 'warning';
+       return 'ok';
+}
+
+export function colorLevelTextClass(level: ColorLevel): string {
+       switch (level) {
+               case 'critical':
+                       return 'text-red-400';
+               case 'warning':
+                       return 'text-amber-400';
+               case 'ok':
+                       return 'text-muted-foreground';
+               default:
+                       return 'text-muted-foreground';
+       }
+}
+
+export function colorLevelBgClass(level: ColorLevel): string {
+       switch (level) {
+               case 'critical':
+                       return 'bg-red-500';
+               case 'warning':
+                       return 'bg-amber-500';
+               case 'ok':
+                       return 'bg-green-500';
+               default:
+                       return 'bg-muted';
+       }
+}
index dadcae0c497d89de8a020228e6051f72d68cba2a..2b4a3f9d393c13ed9cf414f44a02a9aaa4fce5af 100644 (file)
@@ -24,6 +24,8 @@
                message: DatabaseMessage;
                toolMessages?: DatabaseMessage[];
                isLastAssistantMessage?: boolean;
+               isLastUserMessage?: boolean;
+               nextAssistantMessage?: DatabaseMessage | null;
                siblingInfo?: ChatMessageSiblingInfo | null;
        }
 
@@ -32,6 +34,8 @@
                message,
                toolMessages = [],
                isLastAssistantMessage = false,
+               isLastUserMessage = false,
+               nextAssistantMessage = null,
                siblingInfo = null
        }: Props = $props();
 
                <ChatMessageUser
                        class={className}
                        {deletionInfo}
+                       {isLastUserMessage}
                        {message}
+                       {nextAssistantMessage}
                        onConfirmDelete={handleConfirmDelete}
                        onCopy={handleCopy}
                        onDelete={handleDelete}
index 14f6c5ca027b24f79d217bcb1f76cfd11199ffb6..6670d9302dc9553e3206027cd6e7920402078d45 100644 (file)
        import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
        import { isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
        import { copyToClipboard, deriveAgenticSections, modelLoadProgressText } from '$lib/utils';
-       import { AgenticSectionType } from '$lib/enums';
+       import { AgenticSectionType, ChatMessageStatisticsMode } from '$lib/enums';
        import { REASONING_TAGS } from '$lib/constants/agentic';
-       import { tick } from 'svelte';
        import { fade } from 'svelte/transition';
-       import { MessageRole, ChatMessageStatsView } from '$lib/enums';
+       import { MessageRole } from '$lib/enums';
        import { config } from '$lib/stores/settings.svelte';
        import { isRouterMode } from '$lib/stores/server.svelte';
        import { modelsStore } from '$lib/stores/models.svelte';
                return parts.join('\n\n\n');
        });
 
-       let activeStatsView = $state<ChatMessageStatsView>(ChatMessageStatsView.GENERATION);
-       let statsContainerEl: HTMLDivElement | undefined = $state();
-
-       function getScrollParent(el: HTMLElement): HTMLElement | null {
-               let parent = el.parentElement;
-               while (parent) {
-                       const style = getComputedStyle(parent);
-                       if (/(auto|scroll)/.test(style.overflowY)) {
-                               return parent;
-                       }
-                       parent = parent.parentElement;
-               }
-               return null;
-       }
-
-       async function handleStatsViewChange(view: ChatMessageStatsView) {
-               const el = statsContainerEl;
-               if (!el) {
-                       activeStatsView = view;
-
-                       return;
-               }
-
-               const scrollParent = getScrollParent(el);
-               if (!scrollParent) {
-                       activeStatsView = view;
-
-                       return;
-               }
-
-               const yBefore = el.getBoundingClientRect().top;
-
-               activeStatsView = view;
-
-               await tick();
-
-               const delta = el.getBoundingClientRect().top - yBefore;
-               if (delta !== 0) {
-                       scrollParent.scrollTop += delta;
-               }
-
-               // Correct any drift after browser paint
-               requestAnimationFrame(() => {
-                       const drift = el.getBoundingClientRect().top - yBefore;
-
-                       if (Math.abs(drift) > 1) {
-                               scrollParent.scrollTop += drift;
-                       }
-               });
-       }
-
-       let highlightAgenticTurns = $derived(
-               isAgentic &&
-                       (currentConfig.alwaysShowAgenticTurns || activeStatsView === ChatMessageStatsView.SUMMARY)
-       );
-
        let displayedModel = $derived(message.model ?? null);
 
        // model being switched to while it loads, so the selector bar tracks it
                                {toolMessages}
                                isStreaming={isChatStreaming()}
                                {isLastAssistantMessage}
-                               highlightTurns={highlightAgenticTurns}
                        />
                {/if}
        {:else}
 
        <div class="info my-6 grid gap-4 tabular-nums">
                {#if displayedModel}
-                       <div
-                               bind:this={statsContainerEl}
-                               class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground"
-                       >
+                       <div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
                                {#if isRouter}
                                        <ModelsSelectorDropdown
                                                currentModel={pendingModel ?? displayedModel}
                                {#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
                                        {@const agentic = message.timings.agentic}
                                        <ChatMessageStatistics
+                                               mode={ChatMessageStatisticsMode.GENERATION}
                                                promptTokens={agentic ? agentic.llm.prompt_n : message.timings.prompt_n}
                                                promptMs={agentic ? agentic.llm.prompt_ms : message.timings.prompt_ms}
                                                predictedTokens={agentic ? agentic.llm.predicted_n : message.timings.predicted_n}
                                                predictedMs={agentic ? agentic.llm.predicted_ms : message.timings.predicted_ms}
                                                agenticTimings={agentic}
-                                               onActiveViewChange={handleStatsViewChange}
                                        />
                                {:else if isLoading() && currentConfig.showMessageStats}
                                        {@const liveStats = processingState.getLiveProcessingStats()}
                                        {@const genStats = processingState.getLiveGenerationStats()}
-                                       {@const promptProgress = processingState.processingState?.promptProgress}
-                                       {@const isStillProcessingPrompt =
-                                               promptProgress && promptProgress.processed < promptProgress.total}
 
-                                       {#if liveStats || genStats}
+                                       {#if genStats}
                                                <ChatMessageStatistics
+                                                       mode={ChatMessageStatisticsMode.GENERATION}
                                                        isLive
-                                                       isProcessingPrompt={!!isStillProcessingPrompt}
                                                        promptTokens={liveStats?.tokensProcessed}
                                                        promptMs={liveStats?.timeMs}
-                                                       predictedTokens={genStats?.tokensGenerated}
-                                                       predictedMs={genStats?.timeMs}
+                                                       predictedTokens={genStats.tokensGenerated}
+                                                       predictedMs={genStats.timeMs}
                                                />
                                        {/if}
                                {/if}
index 80a0183e6bef56e9e823a339cc56a1649c07a140..f7590c3a3158ee397063938a933907f83fc7a4be 100644 (file)
@@ -2,10 +2,14 @@
        import {
                ChatMessageActionIcons,
                ChatMessageEditForm,
+               ChatMessageStatistics,
                ChatMessageUserBubble
        } from '$lib/components/app/chat';
        import { getMessageEditContext } from '$lib/contexts';
-       import { MessageRole } from '$lib/enums';
+       import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
+       import { isLoading } from '$lib/stores/chat.svelte';
+       import { MessageRole, ChatMessageStatisticsMode } from '$lib/enums';
+       import { config } from '$lib/stores/settings.svelte';
 
        interface Props {
                class?: string;
@@ -17,6 +21,8 @@
                        assistantMessages: number;
                        messageTypes: string[];
                } | null;
+               isLastUserMessage?: boolean;
+               nextAssistantMessage?: DatabaseMessage | null;
                showDeleteDialog: boolean;
                onEdit: () => void;
                onDelete: () => void;
@@ -32,6 +38,8 @@
                message,
                siblingInfo = null,
                deletionInfo,
+               isLastUserMessage = false,
+               nextAssistantMessage = null,
                showDeleteDialog,
                onEdit,
                onDelete,
 
        // Get contexts
        const editCtx = getMessageEditContext();
+       const processingState = useProcessingState();
+
+       const currentConfig = $derived(config());
+       const isActivelyProcessing = $derived(isLastUserMessage && isLoading());
+
+       // For agentic turns, prefer the cumulative agentic.llm totals over per-call timings.
+       let storedReadingStats = $derived.by(() => {
+               const timings = nextAssistantMessage?.timings;
+               if (!timings?.prompt_n || !timings?.prompt_ms) return null;
+
+               const agentic = timings.agentic;
+
+               return {
+                       promptTokens: agentic ? agentic.llm.prompt_n : timings.prompt_n,
+                       promptMs: agentic ? agentic.llm.prompt_ms : timings.prompt_ms
+               };
+       });
+
+       let showStoredReadingStats = $derived(
+               Boolean(currentConfig.showMessageStats) && storedReadingStats !== null
+       );
+
+       let showLiveReadingStats = $derived(
+               Boolean(currentConfig.showMessageStats) && isActivelyProcessing && storedReadingStats === null
+       );
+
+       $effect(() => {
+               if (showLiveReadingStats) {
+                       processingState.startMonitoring();
+               }
+       });
 </script>
 
 <div
                        renderMarkdown={true}
                />
 
+               {#if showStoredReadingStats}
+                       <!-- Reading stats sourced from the assistant message that followed this turn -->
+                       <div class="info my-2 grid w-full justify-items-end gap-4 tabular-nums">
+                               <div
+                                       class="inline-flex flex-wrap items-start justify-end gap-2 text-xs text-muted-foreground"
+                               >
+                                       <ChatMessageStatistics
+                                               mode={ChatMessageStatisticsMode.READING}
+                                               promptTokens={storedReadingStats!.promptTokens}
+                                               promptMs={storedReadingStats!.promptMs}
+                                       />
+                               </div>
+                       </div>
+               {:else if showLiveReadingStats}
+                       {@const liveStats = processingState.getLiveProcessingStats()}
+                       {#if liveStats}
+                               <div class="info my-2 grid w-full justify-items-end gap-4 tabular-nums">
+                                       <div
+                                               class="inline-flex flex-wrap items-start justify-end gap-2 text-xs text-muted-foreground"
+                                       >
+                                               <ChatMessageStatistics
+                                                       mode={ChatMessageStatisticsMode.READING}
+                                                       isLive
+                                                       promptTokens={liveStats.tokensProcessed}
+                                                       promptMs={liveStats.timeMs}
+                                               />
+                                       </div>
+                               </div>
+                       {/if}
+               {/if}
+
                {#if message.timestamp}
                        <div class="max-w-[80%]">
                                <ChatMessageActionIcons
index 4be582b39aecef7cbcf1213227b636430375f2c8..58b3a42e07ac072de0a4a49fed5321d4e7afb481 100644 (file)
@@ -2,7 +2,6 @@
        import { ActionIcon, ChatMessageEditForm, ChatMessageUserBubble } from '$lib/components/app';
        import { fadeInView } from '$lib/actions/fade-in-view.svelte';
        import { ArrowUp, Edit, Trash2 } from '@lucide/svelte';
-       import { getProcessingInfoContext } from '$lib/contexts';
        import { useMessageEditContext } from '$lib/hooks/use-message-edit-context.svelte';
 
        interface Props {
@@ -23,9 +22,6 @@
                onDelete
        }: Props = $props();
 
-       const processingInfoCtx = getProcessingInfoContext();
-       let showProcessingInfo = $derived(processingInfoCtx.showProcessingInfo);
-
        const editCtx = useMessageEditContext({
                getContent: () => content,
                getExtras: () => extras,
@@ -36,9 +32,7 @@
 <div
        use:fadeInView
        aria-label="Pending user message"
-       class="group flex flex-col items-end gap-3 transition-opacity hover:opacity-80 md:gap-2 {className} sticky {showProcessingInfo
-               ? 'bottom-44'
-               : 'bottom-32'}"
+       class="group flex flex-col items-end gap-3 transition-opacity hover:opacity-80 md:gap-2 {className} sticky bottom-32"
        role="group"
 >
        {#if editCtx.isEditing}
index 07b0489cc3546729a3a247f8814c247e92854479..f0d03f547862d5586573f1aae732a42f33b270c3 100644 (file)
                toolMessages?: DatabaseMessage[];
                isStreaming?: boolean;
                isLastAssistantMessage?: boolean;
-               highlightTurns?: boolean;
        }
 
        let {
                message,
                toolMessages = [],
                isStreaming = false,
-               isLastAssistantMessage = false,
-               highlightTurns = false
+               isLastAssistantMessage = false
        }: Props = $props();
 
        let expandedStates: Record<number, boolean> = $state({});
@@ -57,6 +55,7 @@
        const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean);
        const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean);
        const renderThinkingAsMarkdown = $derived(config().renderThinkingAsMarkdown as boolean);
+       const showMessageStats = $derived(config().showMessageStats as boolean);
 
        const hasReasoningError = $derived(
                isLastAssistantMessage ? !!agenticLastError(message.convId) : false
 {/snippet}
 
 <div class="agentic-content">
-       {#if highlightTurns && turnGroups.length > 1}
+       {#if turnGroups.length > 1}
                {#each turnGroups as turn, turnIndex (turnIndex)}
                        {@const turnStats = message?.timings?.agentic?.perTurn?.[turnIndex]}
-                       <div class="agentic-turn my-2 hover:bg-muted/80 dark:hover:bg-muted/30">
-                               <span class="agentic-turn-label">Turn {turnIndex + 1}</span>
+
+                       <div class="agentic-turn group/turn grid gap-3 mb-4">
                                {#each turn.sections as section, sIdx (turn.flatIndices[sIdx])}
                                        {@render renderSection(section, turn.flatIndices[sIdx])}
                                {/each}
-                               {#if turnStats}
-                                       <div class="turn-stats">
+
+                               {#if turnStats && showMessageStats}
+                                       <div class="turn-stats transition-opacity duration-150">
                                                <ChatMessageStatistics
                                                        promptTokens={turnStats.llm.prompt_n}
                                                        promptMs={turnStats.llm.prompt_ms}
        .agentic-content {
                display: flex;
                flex-direction: column;
-               gap: 0.5rem;
                width: 100%;
                max-width: 48rem;
+               gap: 1rem;
        }
 
-       .agentic-text {
-               width: 100%;
-       }
-
-       .agentic-turn {
-               position: relative;
-               border: 1.5px dashed var(--muted-foreground);
-               border-radius: 0.75rem;
-               padding: 1rem;
-               transition: background 0.1s;
+       .agentic-content > :global(*),
+       .agentic-turn > :global(*) {
+               min-width: 0;
        }
 
-       .agentic-turn-label {
-               position: absolute;
-               top: -1rem;
-               left: 0.75rem;
-               padding: 0 0.375rem;
-               background: var(--background);
-               font-size: 0.7rem;
-               font-weight: 500;
-               color: var(--muted-foreground);
-               text-transform: uppercase;
-               letter-spacing: 0.05em;
+       .agentic-text {
+               width: 100%;
        }
 
        .turn-stats {
-               margin-top: 0.75rem;
-               padding-top: 0.5rem;
                border-top: 1px solid hsl(var(--muted) / 0.5);
        }
 </style>
index 6906adbb1734e0a8c057e9fbb2a66094cec679ff..7ef73a49945bd1c64611ed8ae0c65b19933b1a29 100644 (file)
@@ -2,7 +2,7 @@
        import { Clock, Gauge, WholeWord, BookOpenText, Sparkles, Wrench, Layers } from '@lucide/svelte';
        import { ChatMessageStatisticsBadge } from '$lib/components/app';
        import * as Tooltip from '$lib/components/ui/tooltip';
-       import { ChatMessageStatsView } from '$lib/enums';
+       import { ChatMessageStatsView, ChatMessageStatisticsMode } from '$lib/enums';
        import type { ChatMessageAgenticTimings } from '$lib/types/chat';
        import { formatPerformanceTime } from '$lib/utils';
        import { MS_PER_SECOND, DEFAULT_PERFORMANCE_TIME } from '$lib/constants';
@@ -19,6 +19,7 @@
                agenticTimings?: ChatMessageAgenticTimings;
                onActiveViewChange?: (view: ChatMessageStatsView) => void;
                hideSummary?: boolean;
+               mode?: ChatMessageStatisticsMode;
        }
 
        let {
                initialView = ChatMessageStatsView.GENERATION,
                agenticTimings,
                onActiveViewChange,
-               hideSummary = false
+               hideSummary = false,
+               mode = ChatMessageStatisticsMode.SWITCHABLE
        }: Props = $props();
 
-       let activeView: ChatMessageStatsView = $derived(initialView);
+       let isSwitchable = $derived(mode === ChatMessageStatisticsMode.SWITCHABLE);
+
+       let activeView: ChatMessageStatsView = $derived(
+               mode === ChatMessageStatisticsMode.READING
+                       ? ChatMessageStatsView.READING
+                       : mode === ChatMessageStatisticsMode.GENERATION
+                               ? ChatMessageStatsView.GENERATION
+                               : initialView
+       );
        let hasAutoSwitchedToGeneration = $state(false);
 
        $effect(() => {
-               onActiveViewChange?.(activeView);
+               if (isSwitchable) {
+                       onActiveViewChange?.(activeView);
+               }
        });
 
        // In live mode: auto-switch to GENERATION tab when prompt processing completes
        $effect(() => {
-               if (isLive) {
+               if (isLive && isSwitchable) {
                        // Auto-switch to generation tab only when prompt processing is done (once)
                        if (
                                !hasAutoSwitchedToGeneration &&
                        formattedPromptTime !== undefined
        );
 
-       // In live mode, generation tab is disabled until we have generation stats
-       let isGenerationDisabled = $derived(isLive && !hasGenerationStats);
+       let isGenerationDisabled = $derived(isLive && isSwitchable && !hasGenerationStats);
 
        let hasAgenticStats = $derived(agenticTimings !== undefined && agenticTimings.toolCallsCount > 0);
 
 {/snippet}
 
 <div class="inline-flex items-center text-xs text-muted-foreground">
-       <div class="inline-flex items-center rounded-sm bg-muted-foreground/15 p-0.5">
-               {#if hasPromptStats || isLive}
-                       {@render viewButton({
-                               view: ChatMessageStatsView.READING,
-                               icon: BookOpenText,
-                               label: 'Reading',
-                               tooltipText: 'Reading (prompt processing)'
-                       })}
-               {/if}
+       {#if isSwitchable}
+               <div class="inline-flex items-center rounded-sm bg-muted-foreground/15 p-0.5">
+                       {#if hasPromptStats || isLive}
+                               {@render viewButton({
+                                       view: ChatMessageStatsView.READING,
+                                       icon: BookOpenText,
+                                       label: 'Reading',
+                                       tooltipText: 'Processing'
+                               })}
+                       {/if}
 
-               {@render viewButton({
-                       view: ChatMessageStatsView.GENERATION,
-                       icon: Sparkles,
-                       label: 'Generation',
-                       tooltipText: isGenerationDisabled
-                               ? 'Generation (waiting for tokens...)'
-                               : 'Generation (token output)',
-                       disabled: isGenerationDisabled
-               })}
-
-               {#if hasAgenticStats}
                        {@render viewButton({
-                               view: ChatMessageStatsView.TOOLS,
-                               icon: Wrench,
-                               label: 'Tools',
-                               tooltipText: 'Tool calls'
+                               view: ChatMessageStatsView.GENERATION,
+                               icon: Sparkles,
+                               label: 'Generation',
+                               tooltipText: isGenerationDisabled ? 'Waiting for tokens...' : 'Generation',
+                               disabled: isGenerationDisabled
                        })}
 
-                       {#if !hideSummary}
+                       {#if hasAgenticStats}
                                {@render viewButton({
-                                       view: ChatMessageStatsView.SUMMARY,
-                                       icon: Layers,
-                                       label: 'Summary',
-                                       tooltipText: 'Agentic summary'
+                                       view: ChatMessageStatsView.TOOLS,
+                                       icon: Wrench,
+                                       label: 'Tools',
+                                       tooltipText: 'Tool calls'
                                })}
+
+                               {#if !hideSummary}
+                                       {@render viewButton({
+                                               view: ChatMessageStatsView.SUMMARY,
+                                               icon: Layers,
+                                               label: 'Summary',
+                                               tooltipText: 'Agentic summary'
+                                       })}
+                               {/if}
                        {/if}
-               {/if}
-       </div>
+               </div>
+       {/if}
 
        <div class="flex items-center gap-1 px-2">
                {#if activeView === ChatMessageStatsView.GENERATION && hasGenerationStats}
                                value={formattedAgenticTotalTime}
                                tooltipLabel="Total time (LLM + tools)"
                        />
-               {:else if hasPromptStats}
+               {:else if hasPromptStats && (mode === ChatMessageStatisticsMode.READING || isSwitchable)}
                        <ChatMessageStatisticsBadge
                                class="bg-transparent"
                                icon={WholeWord}
index 88efcc4b07634c1dcbf5f80f7005465ea86e49ed..dce0edd03138a81fe803c7ab597900328012897a 100644 (file)
                        message: DatabaseMessage;
                        toolMessages: DatabaseMessage[];
                        isLastAssistantMessage: boolean;
+                       isLastUserMessage: boolean;
+                       nextAssistantMessage: DatabaseMessage | null;
                        siblingInfo: ChatMessageSiblingInfo;
                }> = [];
 
                                message: msg,
                                toolMessages,
                                isLastAssistantMessage: false,
+                               isLastUserMessage: false,
+                               nextAssistantMessage: null,
                                siblingInfo
                        });
                }
 
-               // Mark the last assistant message
+               let lastAssistantIdx = -1;
                for (let i = result.length - 1; i >= 0; i--) {
                        if (result[i].message.role === MessageRole.ASSISTANT) {
                                result[i].isLastAssistantMessage = true;
+                               lastAssistantIdx = i;
                                break;
                        }
                }
 
+               if (lastAssistantIdx > 0 && result[lastAssistantIdx - 1].message.role === MessageRole.USER) {
+                       result[lastAssistantIdx - 1].isLastUserMessage = true;
+               }
+
+               for (let i = 0; i < result.length; i++) {
+                       if (result[i].message.role !== MessageRole.USER) continue;
+
+                       for (let j = i + 1; j < result.length; j++) {
+                               if (result[j].message.role === MessageRole.ASSISTANT) {
+                                       result[i].nextAssistantMessage = result[j].message;
+                                       break;
+                               }
+                       }
+               }
+
                return result;
        });
 </script>
                {isVisible ? 'opacity-100' : 'opacity-0'}
                {previousRouteId === '/(chat)/chat/[id]' ? '' : 'delay-300'}"
 >
-       {#each displayMessages as { message, toolMessages, isLastAssistantMessage, siblingInfo } (message.id)}
+       {#each displayMessages as { message, toolMessages, isLastAssistantMessage, isLastUserMessage, nextAssistantMessage, siblingInfo } (message.id)}
                <ChatMessage
                        class="mx-auto mt-12 w-full max-w-3xl"
                        {message}
                        {toolMessages}
                        {isLastAssistantMessage}
+                       {isLastUserMessage}
+                       {nextAssistantMessage}
                        {siblingInfo}
                />
        {/each}
index d2fba5a932e00160584dbd093009e4ec172d1933..a0d66c6421780ecb8d1d0dd083cb81240216ead9 100644 (file)
@@ -4,12 +4,10 @@
                ChatScreenForm,
                ChatMessages,
                ChatScreenDragOverlay,
-               ChatScreenProcessingInfo,
                ChatScreenStreamResumeStatus,
                ServerLoadingSplash,
                ChatScreenServerError
        } from '$lib/components/app';
-       import { setProcessingInfoContext } from '$lib/contexts';
        import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
        import { useChatScreenActiveModel } from '$lib/hooks/use-chat-screen-active-model.svelte';
        import { useChatScreenDragAndDrop } from '$lib/hooks/use-chat-screen-drag-and-drop.svelte';
@@ -23,8 +21,7 @@
                errorDialog,
                isLoading,
                isChatStreaming,
-               isEditing,
-               activeProcessingState
+               isEditing
        } from '$lib/stores/chat.svelte';
        import {
                conversationsStore,
 
        let { showCenteredEmpty = false } = $props();
 
-       setProcessingInfoContext({
-               get showProcessingInfo() {
-                       return showProcessingInfo;
-               }
-       });
-
        let disableAutoScroll = $derived(Boolean(config().disableAutoScroll) || isMobile.current);
        let isMobileUserScrolledUp = $state(false);
        let mobileScrollDownHint = $state(false);
        let isServerLoading = $derived(serverLoading());
        let hasPropsError = $derived(!!serverError());
        let isCurrentConversationLoading = $derived(isLoading() || isChatStreaming());
-       let showProcessingInfo = $derived(
-               isCurrentConversationLoading ||
-                       (config().keepStatsVisible && !!page.params.id) ||
-                       activeProcessingState() !== null
-       );
        let chatFormBottomPosition = $derived.by(() => {
                if (!isMobile.current) return '1rem';
                if (device.isStandalone) return '1.5rem';
                                                }}
                                        />
                                {/if}
-
-                               {#if showProcessingInfo}
-                                       <ChatScreenProcessingInfo />
-                               {/if}
                        </div>
 
                        <ChatScreenForm
diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte
deleted file mode 100644 (file)
index 0615d9f..0000000
+++ /dev/null
@@ -1,127 +0,0 @@
-<script lang="ts">
-       import { untrack } from 'svelte';
-       import { PROCESSING_INFO_TIMEOUT } from '$lib/constants';
-       import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
-       import { chatStore, isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
-       import { activeMessages, activeConversation } from '$lib/stores/conversations.svelte';
-       import { config } from '$lib/stores/settings.svelte';
-
-       const processingState = useProcessingState();
-
-       let isCurrentConversationLoading = $derived(isLoading());
-       let isStreaming = $derived(isChatStreaming());
-       let processingDetails = $derived(processingState.getTechnicalDetails());
-
-       let processingVisible = $derived(processingDetails.length > 0);
-
-       let { onVisibilityChange }: { onVisibilityChange?: (visible: boolean) => void } = $props();
-
-       $effect(() => {
-               onVisibilityChange?.(processingVisible);
-       });
-
-       $effect(() => {
-               const conversation = activeConversation();
-
-               untrack(() => chatStore.setActiveProcessingConversation(conversation?.id ?? null));
-       });
-
-       $effect(() => {
-               const keepStatsVisible = config().keepStatsVisible;
-               const shouldMonitor = keepStatsVisible || isCurrentConversationLoading || isStreaming;
-
-               if (shouldMonitor) {
-                       processingState.startMonitoring();
-               }
-
-               if (!isCurrentConversationLoading && !isStreaming && !keepStatsVisible) {
-                       const timeout = setTimeout(() => {
-                               if (!config().keepStatsVisible && !isChatStreaming()) {
-                                       processingState.stopMonitoring();
-                               }
-                       }, PROCESSING_INFO_TIMEOUT);
-
-                       return () => clearTimeout(timeout);
-               }
-       });
-
-       $effect(() => {
-               const conversation = activeConversation();
-               const messages = activeMessages() as DatabaseMessage[];
-               const keepStatsVisible = config().keepStatsVisible;
-
-               if (keepStatsVisible && conversation) {
-                       if (messages.length === 0) {
-                               untrack(() => chatStore.clearProcessingState(conversation.id));
-                               return;
-                       }
-
-                       if (!isCurrentConversationLoading && !isStreaming) {
-                               untrack(() => chatStore.restoreProcessingStateFromMessages(messages, conversation.id));
-                       }
-               }
-       });
-</script>
-
-<div
-       class={[
-               'chat-processing-info-container pointer-events-none relative w-full hidden md:block',
-               processingVisible && 'visible'
-       ]}
->
-       <div class="chat-processing-info-content absolute bottom-4 left-1/2 -translate-x-1/2">
-               {#each processingDetails as detail (detail)}
-                       <span class="chat-processing-info-detail pointer-events-auto backdrop-blur-sm">{detail}</span>
-               {/each}
-       </div>
-</div>
-
-<style>
-       .chat-processing-info-container {
-               position: sticky;
-               top: 0;
-               z-index: 10;
-               padding: 0 1rem 0.75rem;
-               opacity: 0;
-               transform: translateY(50%);
-               transition:
-                       opacity 300ms ease-out,
-                       transform 300ms ease-out;
-       }
-
-       .chat-processing-info-container.visible {
-               opacity: 1;
-               transform: translateY(0);
-       }
-
-       .chat-processing-info-content {
-               display: flex;
-               flex-wrap: wrap;
-               align-items: center;
-               gap: 1rem;
-               justify-content: center;
-               max-width: 48rem;
-               margin: 0 auto;
-       }
-
-       .chat-processing-info-detail {
-               color: var(--muted-foreground);
-               font-size: 0.75rem;
-               padding: 0.25rem 0.75rem;
-               border-radius: 0.375rem;
-               font-family:
-                       ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
-               white-space: nowrap;
-       }
-
-       @media (max-width: 768px) {
-               .chat-processing-info-content {
-                       gap: 0.5rem;
-               }
-
-               .chat-processing-info-detail {
-                       font-size: 0.7rem;
-                       padding: 0.2rem 0.5rem;
-               }
-       }
-</style>
index d7004d3ae3771bba36470406e0c3590c98ef016e..4f826841e4b79aadb78dcc091d8515fbef2448cb 100644 (file)
@@ -241,13 +241,18 @@ export { default as ChatFormActionAddToolsSubmenu } from './ChatForm/ChatFormAct
 export { default as ChatFormActionAddMcpServersSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpServersSubmenu.svelte';
 
 /**
- * **ChatFormReasoningToggle** - Thinking toggle button with effort dropdown
+ * Dropdown submenu for selecting reasoning effort level.
  *
- * A toggle button with lightbulb icon that indicates thinking status.
- * Shows the reasoning effort dropdown when clicked.
+ * Shows a "Reasoning" sub-menu item with a lightbulb icon indicating
+ * thinking status, and a nested list of effort levels.
  * Only visible when the current model supports thinking.
  */
-export { default as ChatFormReasoningToggle } from './ChatForm/ChatFormActions/ChatFormReasoningToggle.svelte';
+export { default as ChatFormActionAddReasoningSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddReasoningSubmenu.svelte';
+
+/**
+ * Compact context-usage gauge with per-turn and cumulative breakdown in the tooltip.
+ */
+export { default as ChatFormContextGauge } from './ChatForm/ChatFormContextGauge/ChatFormContextGauge.svelte';
 
 /**
  * Hidden file input element for programmatic file selection.
@@ -669,14 +674,6 @@ export { default as ChatScreenDragOverlay } from './ChatScreen/ChatScreenDragOve
  */
 export { default as ChatScreenForm } from './ChatScreen/ChatScreenForm.svelte';
 
-/**
- * Processing info display during generation. Shows real-time statistics:
- * tokens per second, prompt/completion token counts, and elapsed time.
- * Data sourced from slotsService polling during active generation.
- * Only visible when `isCurrentConversationLoading` is true.
- */
-export { default as ChatScreenProcessingInfo } from './ChatScreen/ChatScreenProcessingInfo.svelte';
-
 /**
  * Server error alert displayed when the server is unreachable.
  * Shows the error message with a retry button.
index 8bab55d19fb98df8ee912a2ccf8ec1a73660a923..3875b449a186147731a8c200b678ba75cc2302d8 100644 (file)
@@ -76,7 +76,7 @@
                open = value;
                onToggle?.();
        }}
-       class={className}
+       class="{className} my-0!"
 >
        <Card class="gap-0 border-muted bg-muted/30 py-0">
                <Collapsible.Trigger class="flex w-full cursor-pointer items-start justify-between gap-2 p-3">
index c4d1706bfe1b406afe8df97627573890cdfde32e..d8dfe0ad9c3627c540c229d6175d6c670b68fe10 100644 (file)
@@ -72,8 +72,8 @@
 </script>
 
 <div
-       class="code-preview-wrapper rounded-lg border border-border bg-muted {className}"
-       style="max-height: {maxHeight}; max-width: {maxWidth};"
+       class="code-preview-wrapper min-w-0 max-w-full overflow-x-auto rounded-lg border border-border bg-muted {className}"
+       style="max-height: {maxHeight}; {maxWidth ? `max-width: ${maxWidth};` : ''}"
 >
        <!-- Needs to be formatted as single line for proper rendering -->
        <pre class="m-0"><code class="hljs text-sm leading-relaxed">{@html highlightedHtml}</code></pre>
index 9b4489b8287d93b40e6708a30ec2dad1ab16d8e0..cdbc055eef09fbcd715925be0173bff8efd15b1b 100644 (file)
@@ -4,9 +4,10 @@
        import * as Dialog from '$lib/components/ui/dialog';
        import { fly } from 'svelte/transition';
        import { McpServerCardCompact, McpServerForm } from '$lib/components/app/mcp';
-       import { RECOMMENDED_MCP_SERVERS } from '$lib/constants';
+       import { RECOMMENDED_MCP_SERVERS, SETTINGS_KEYS } from '$lib/constants';
        import { conversationsStore } from '$lib/stores/conversations.svelte';
        import { mcpStore } from '$lib/stores/mcp.svelte';
+       import { settingsStore } from '$lib/stores/settings.svelte';
        import { uuid } from '$lib/utils';
        import { MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY, MCP_SERVER_ID_PREFIX } from '$lib/constants';
        import type { MCPServerSettingsEntry } from '$lib/types';
        );
 
        let addedServers = $state<MCPServerSettingsEntry[]>([]);
+       let didAddAny = $state(false);
+
+       let selectedRecommendedCount = $derived.by(
+               () => RECOMMENDED_MCP_SERVERS.filter((server) => selected[server.id]).length
+       );
+
+       let footerLabel = $derived.by(() => {
+               const recommended = selectedRecommendedCount;
+               const custom = addedServers.length;
+               const total = recommended + custom;
+
+               if (total === 0) return 'Continue';
+               if (recommended === 0) return custom === 1 ? 'Add server' : `Add ${custom} servers`;
+               if (custom === 0) return recommended === 1 ? 'Add server' : `Add ${recommended} servers`;
+               return `Add ${recommended} servers and ${custom} custom`;
+       });
 
        let showAddForm = $state(false);
        let newServerUrl = $state('');
                        showAddForm = false;
                        newServerUrl = '';
                        newServerHeaders = '';
-                       addedServers = [];
+
+                       if (!didAddAny) {
+                               settingsStore.updateConfig(SETTINGS_KEYS.MCP_SERVERS, []);
+                       }
 
                        localStorage.setItem(MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY, 'true');
+                       addedServers = [];
+                       didAddAny = false;
                }
                open = value;
                onOpenChange?.(value);
@@ -59,6 +81,7 @@
        }
 
        function enableSelected() {
+               didAddAny = true;
                localStorage.setItem(MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY, 'true');
 
                for (const server of RECOMMENDED_MCP_SERVERS) {
        function saveNewServer() {
                if (newServerUrlError) return;
 
+               didAddAny = true;
+
                const newServerId = uuid() ?? `${MCP_SERVER_ID_PREFIX}-${Date.now()}`;
 
                localStorage.setItem(MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY, 'true');
                <Dialog.Footer>
                        <Button variant="secondary" size="sm" onclick={() => handleOpenChange(false)}>Not now</Button>
 
-                       <Button variant="default" size="sm" onclick={enableSelected}>Add selected</Button>
+                       <Button
+                               variant="default"
+                               size="sm"
+                               onclick={enableSelected}
+                               disabled={footerLabel === 'Continue'}>{footerLabel}</Button
+                       >
                </Dialog.Footer>
        </Dialog.Content>
 </Dialog.Root>
index b568324965802a5de3923c9a84728096d4027b69..8bb49a0958cd4ec52b66a7355be55880412d52b5 100644 (file)
                                        {@const faviconUrl = group.serverId ? mcpStore.getServerFavicon(group.serverId) : null}
 
                                        <span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
-                                               <McpServerIdentity
-                                                       iconClass="h-4 w-4"
-                                                       iconRounded="rounded-sm"
-                                                       showVersion={false}
-                                                       displayName={group.label}
-                                                       {faviconUrl}
-                                               />
+                                               {#if group.source === 'mcp'}
+                                                       <McpServerIdentity
+                                                               iconClass="h-4 w-4"
+                                                               iconRounded="rounded-sm"
+                                                               showVersion={false}
+                                                               displayName={group.label}
+                                                               {faviconUrl}
+                                                       />
+                                               {:else}
+                                                       <TruncatedText text={group.label} class="font-medium" />
+                                               {/if}
                                        </span>
 
                                        <span class="ml-auto shrink-0 text-xs text-muted-foreground">
diff --git a/tools/ui/src/lib/components/ui/hover-card/hover-card-content.svelte b/tools/ui/src/lib/components/ui/hover-card/hover-card-content.svelte
new file mode 100644 (file)
index 0000000..db1406a
--- /dev/null
@@ -0,0 +1,31 @@
+<script lang="ts">
+       import { LinkPreview as HoverCardPrimitive } from 'bits-ui';
+       import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
+       import HoverCardPortal from './hover-card-portal.svelte';
+       import type { ComponentProps } from 'svelte';
+
+       let {
+               ref = $bindable(null),
+               class: className,
+               align = 'center',
+               sideOffset = 4,
+               portalProps,
+               ...restProps
+       }: HoverCardPrimitive.ContentProps & {
+               portalProps?: WithoutChildrenOrChild<ComponentProps<typeof HoverCardPortal>>;
+       } = $props();
+</script>
+
+<HoverCardPortal {...portalProps}>
+       <HoverCardPrimitive.Content
+               bind:ref
+               data-slot="hover-card-content"
+               {align}
+               {sideOffset}
+               class={cn(
+                       'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground w-64 rounded-lg p-2.5 text-sm shadow-md ring-1 duration-100 z-50 origin-(--transform-origin) outline-hidden',
+                       className
+               )}
+               {...restProps}
+       />
+</HoverCardPortal>
diff --git a/tools/ui/src/lib/components/ui/hover-card/hover-card-portal.svelte b/tools/ui/src/lib/components/ui/hover-card/hover-card-portal.svelte
new file mode 100644 (file)
index 0000000..9dc2582
--- /dev/null
@@ -0,0 +1,7 @@
+<script lang="ts">
+       import { LinkPreview as HoverCardPrimitive } from 'bits-ui';
+
+       let { ...restProps }: HoverCardPrimitive.PortalProps = $props();
+</script>
+
+<HoverCardPrimitive.Portal {...restProps} />
diff --git a/tools/ui/src/lib/components/ui/hover-card/hover-card-trigger.svelte b/tools/ui/src/lib/components/ui/hover-card/hover-card-trigger.svelte
new file mode 100644 (file)
index 0000000..2d42f89
--- /dev/null
@@ -0,0 +1,7 @@
+<script lang="ts">
+       import { LinkPreview as HoverCardPrimitive } from 'bits-ui';
+
+       let { ref = $bindable(null), ...restProps }: HoverCardPrimitive.TriggerProps = $props();
+</script>
+
+<HoverCardPrimitive.Trigger bind:ref data-slot="hover-card-trigger" {...restProps} />
diff --git a/tools/ui/src/lib/components/ui/hover-card/hover-card.svelte b/tools/ui/src/lib/components/ui/hover-card/hover-card.svelte
new file mode 100644 (file)
index 0000000..ffc075d
--- /dev/null
@@ -0,0 +1,7 @@
+<script lang="ts">
+       import { LinkPreview as HoverCardPrimitive } from 'bits-ui';
+
+       let { open = $bindable(false), ...restProps }: HoverCardPrimitive.RootProps = $props();
+</script>
+
+<HoverCardPrimitive.Root bind:open {...restProps} />
diff --git a/tools/ui/src/lib/components/ui/hover-card/index.ts b/tools/ui/src/lib/components/ui/hover-card/index.ts
new file mode 100644 (file)
index 0000000..098f691
--- /dev/null
@@ -0,0 +1,15 @@
+import Root from './hover-card.svelte';
+import Content from './hover-card-content.svelte';
+import Trigger from './hover-card-trigger.svelte';
+import Portal from './hover-card-portal.svelte';
+
+export {
+       Root,
+       Content,
+       Trigger,
+       Portal,
+       Root as HoverCard,
+       Content as HoverCardContent,
+       Trigger as HoverCardTrigger,
+       Portal as HoverCardPortal
+};
index 12de0d0bcb9aab5a3d720702ccbe458824f90185..0bd733b37066bf9dd8ab18b4b67f083483baddd2 100644 (file)
@@ -1,4 +1,3 @@
 export const CONTEXT_KEY_MESSAGE_EDIT = 'chat-message-edit';
 export const CONTEXT_KEY_CHAT_ACTIONS = 'chat-actions';
 export const CONTEXT_KEY_CHAT_SETTINGS_CONFIG = 'chat-settings-config';
-export const CONTEXT_KEY_PROCESSING_INFO = 'processing-info';
index ca5386fcdf04251bba6918f7d28a837890f0f712..009b5c52eeb9fab6d33d117028d133f36d83d34d 100644 (file)
@@ -17,3 +17,4 @@ export const PANEL_CLASSES = `
 `;
 
 export const CHAT_FORM_POPOVER_MAX_HEIGHT = 'max-h-80';
+export const DIALOG_SUBMENU_CONTENT = 'w-60';
index d854e912a8510cd4e9debe64321a76daa4c2c1e7..28a24420e614f9942de230398697cd6df64e18d6 100644 (file)
@@ -6,6 +6,7 @@ import type { ReasoningEffortLevel } from '$lib/types';
  * Keys match the ReasoningEffort enum values for type-safe lookups.
  */
 export const REASONING_EFFORT_LABELS: Record<string, string> = {
+       [ReasoningEffort.OFF]: 'Off',
        [ReasoningEffort.LOW]: 'Low',
        [ReasoningEffort.MEDIUM]: 'Medium',
        [ReasoningEffort.HIGH]: 'High',
@@ -13,7 +14,7 @@ export const REASONING_EFFORT_LABELS: Record<string, string> = {
 };
 
 export const REASONING_EFFORT_LEVELS: ReasoningEffortLevel[] = [
-       { value: 'off', label: 'Off', isOff: true },
+       { value: ReasoningEffort.OFF, label: 'Off', isOff: true },
        { value: ReasoningEffort.LOW, label: 'Low' },
        { value: ReasoningEffort.MEDIUM, label: 'Medium' },
        { value: ReasoningEffort.HIGH, label: 'High' },
index d4d782705140bd7a5e51fa7e361c99eb2150d3c9..ea8963044df29100d9a5fb4269a67df68d23e03f 100644 (file)
@@ -22,7 +22,6 @@ export const SETTINGS_KEYS = {
        // Display
        SHOW_MESSAGE_STATS: 'showMessageStats',
        SHOW_THOUGHT_IN_PROGRESS: 'showThoughtInProgress',
-       KEEP_STATS_VISIBLE: 'keepStatsVisible',
        AUTO_MIC_ON_EMPTY: 'autoMicOnEmpty',
        RENDER_USER_CONTENT_AS_MARKDOWN: 'renderUserContentAsMarkdown',
        DISABLE_AUTO_SCROLL: 'disableAutoScroll',
@@ -61,7 +60,6 @@ export const SETTINGS_KEYS = {
        MCP_REQUEST_TIMEOUT_SECONDS: 'mcpRequestTimeoutSeconds',
        MCP_DEFAULT_SERVER_OVERRIDES: 'mcpDefaultServerOverrides',
        AGENTIC_MAX_TURNS: 'agenticMaxTurns',
-       ALWAYS_SHOW_AGENTIC_TURNS: 'alwaysShowAgenticTurns',
        AGENTIC_MAX_TOOL_PREVIEW_LINES: 'agenticMaxToolPreviewLines',
        SHOW_TOOL_CALL_IN_PROGRESS: 'showToolCallInProgress',
        // Performance
index b314510ab02a29f7b3d9692283e8fd03e4dac8b0..f8657d673d508196a609ff6a911c7800994c98a4 100644 (file)
@@ -258,18 +258,6 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
                                        paramType: SyncableParameterType.BOOLEAN
                                }
                        },
-                       {
-                               key: SETTINGS_KEYS.KEEP_STATS_VISIBLE,
-                               label: 'Keep stats visible after generation',
-                               help: 'Keep processing statistics visible after generation finishes.',
-                               defaultValue: false,
-                               type: SettingsFieldType.CHECKBOX,
-                               section: SETTINGS_SECTION_SLUGS.DISPLAY,
-                               sync: {
-                                       serverKey: SETTINGS_KEYS.KEEP_STATS_VISIBLE,
-                                       paramType: SyncableParameterType.BOOLEAN
-                               }
-                       },
                        {
                                key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
                                label: 'Show microphone on empty input',
@@ -379,18 +367,6 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
                                        paramType: SyncableParameterType.BOOLEAN
                                }
                        },
-                       {
-                               key: SETTINGS_KEYS.ALWAYS_SHOW_AGENTIC_TURNS,
-                               label: 'Always show agentic turns in conversation',
-                               help: 'Always expand and display agentic loop turns in conversation messages.',
-                               defaultValue: false,
-                               type: SettingsFieldType.CHECKBOX,
-                               section: SETTINGS_SECTION_SLUGS.DISPLAY,
-                               sync: {
-                                       serverKey: SETTINGS_KEYS.ALWAYS_SHOW_AGENTIC_TURNS,
-                                       paramType: SyncableParameterType.BOOLEAN
-                               }
-                       },
                        {
                                key: SETTINGS_KEYS.SHOW_BUILD_VERSION,
                                label: 'Show build version information',
index d0c5f2effe5ed813a1673c0b4c5eff800b130249..eca9739bae9ec6aad1c5f773ab8aac8e43c84c78 100644 (file)
@@ -21,7 +21,6 @@ export const DISABLED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledTool
 /** Disabled tools keyed by stable selection identity, no migration from the name based key */
 export const DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledToolKeys`;
 export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`;
-export const THINKING_ENABLED_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.thinkingEnabledDefault`;
 export const REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.reasoningEffortDefault`;
 /** Set when user has interacted with the MCP server recommendations dialog (checked servers, added custom server, or dismissed) */
 export const MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.mcpServersSetupDone`;
index 01cd1d4b70abbc8d0ca15ef1e7ecbfdc6c34e80e..c6719fa9e47728f87b076764b9697f3b0e9267c3 100644 (file)
@@ -17,9 +17,3 @@ export {
        setChatSettingsConfigContext,
        type ChatSettingsConfigContext
 } from './chat-settings-config.context';
-
-export {
-       getProcessingInfoContext,
-       setProcessingInfoContext,
-       type ProcessingInfoContext
-} from './processing-info.context';
diff --git a/tools/ui/src/lib/contexts/processing-info.context.ts b/tools/ui/src/lib/contexts/processing-info.context.ts
deleted file mode 100644 (file)
index 0cf4333..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-import { getContext, setContext } from 'svelte';
-import { CONTEXT_KEY_PROCESSING_INFO } from '$lib/constants';
-
-export interface ProcessingInfoContext {
-       readonly showProcessingInfo: boolean;
-}
-
-const PROCESSING_INFO_KEY = Symbol.for(CONTEXT_KEY_PROCESSING_INFO);
-
-export function setProcessingInfoContext(ctx: ProcessingInfoContext): ProcessingInfoContext {
-       return setContext(PROCESSING_INFO_KEY, ctx);
-}
-
-export function getProcessingInfoContext(): ProcessingInfoContext {
-       return getContext(PROCESSING_INFO_KEY);
-}
index 278e4af84e184816bf443c2cd16b04eec052ef78..f4994bb8e94dc91edbcb98a6f3009901b62d75c4 100644 (file)
@@ -5,6 +5,12 @@ export enum ChatMessageStatsView {
        SUMMARY = 'summary'
 }
 
+export enum ChatMessageStatisticsMode {
+       SWITCHABLE = 'switchable',
+       READING = 'reading',
+       GENERATION = 'generation'
+}
+
 /**
  * Connection state of a streamed completion, drives the resume status indicator.
  */
index d8e47958bb04e254f13b3e78ce1b75b2f6419c45..847014e78c24338853a243e5e4dc80b2d28dfef8 100644 (file)
@@ -10,6 +10,7 @@ export { AgenticSectionType, ContinueIntentKind, ToolCallType } from './agentic.
 
 export {
        ChatMessageStatsView,
+       ChatMessageStatisticsMode,
        StreamConnectionState,
        ContentPartType,
        ConversationSelectionMode,
index dadb0c7264e8728b8b37843869330b94ed06683a..172f118e7f79d878fbf1c443604f2debde4b8238 100644 (file)
@@ -3,6 +3,7 @@
  * These values are sent to the server and mapped to token budgets.
  */
 export enum ReasoningEffort {
+       OFF = 'off',
        LOW = 'low',
        MEDIUM = 'medium',
        HIGH = 'high',
diff --git a/tools/ui/src/lib/hooks/use-context-gauge.svelte.ts b/tools/ui/src/lib/hooks/use-context-gauge.svelte.ts
new file mode 100644 (file)
index 0000000..e11e2f7
--- /dev/null
@@ -0,0 +1,295 @@
+/**
+ * Reactive state for the context usage gauge: resolves the active model,
+ * fetches its cached props, parses live server stats, and exposes per-turn
+ * read / fresh / cache / output and cumulative token counts.
+ */
+
+import {
+       modelsStore,
+       modelOptions,
+       selectedModelId,
+       singleModelName
+} from '$lib/stores/models.svelte';
+import { chatStore } from '$lib/stores/chat.svelte';
+import { activeMessages } from '$lib/stores/conversations.svelte';
+import { isRouterMode } from '$lib/stores/server.svelte';
+import { MessageRole } from '$lib/enums';
+import { STATS_UNITS } from '$lib/constants';
+import type { ChatMessageTimings, DatabaseMessage } from '$lib/types';
+import { useProcessingState } from './use-processing-state.svelte';
+import {
+       colorLevelFromPercent,
+       type ColorLevel
+} from '$lib/components/app/chat/ChatForm/ChatFormContextGauge/context-gauge';
+
+interface LiveStats {
+       freshTokens: number;
+       promptTokens: number;
+       cacheTokens: number;
+       outputTokens: number;
+}
+
+export interface UseContextGaugeReturn {
+       readonly activeModelId: string | null;
+       readonly isActiveModelLoaded: boolean;
+       readonly isActiveModelLoading: boolean;
+       readonly contextTotal: number | null;
+       readonly contextUsed: number;
+       readonly currentRead: number;
+       readonly currentFresh: number;
+       readonly currentCache: number;
+       readonly currentOutput: number;
+       readonly kvTotal: number;
+       readonly cumulativeRead: number;
+       readonly cumulativeOutput: number;
+       readonly cumulativeCacheTotal: number;
+       readonly averageTokensPerSecond: number | null;
+       readonly contextPercent: number | null;
+       readonly colorLevel: ColorLevel;
+       readonly transientDetails: string[];
+       readonly hasAnyUsage: boolean;
+       loadModel(): Promise<void>;
+       startMonitoring(): void;
+}
+
+function lastAssistantTimings(messages: DatabaseMessage[]): ChatMessageTimings | undefined {
+       for (let i = messages.length - 1; i >= 0; i--) {
+               const m = messages[i];
+               if (m.role === MessageRole.ASSISTANT && m.timings) return m.timings;
+       }
+       return undefined;
+}
+
+function deriveLiveStats(
+       state: ReturnType<typeof useProcessingState>['processingState']
+): LiveStats | null {
+       if (!state || (state.status !== 'preparing' && state.status !== 'generating')) {
+               return null;
+       }
+       const promptTokens = state.promptTokens ?? 0;
+       const cacheTokens = state.cacheTokens ?? 0;
+       return {
+               freshTokens: promptTokens,
+               promptTokens: promptTokens + cacheTokens,
+               cacheTokens,
+               outputTokens: state.outputTokensUsed ?? 0
+       };
+}
+
+const TRANSIENT_DETAILS_EXCLUDED_PREFIXES = ['Context:', 'Output:'];
+
+function filterTransientDetails(raw: string[]): string[] {
+       return raw.filter((detail) => {
+               if (TRANSIENT_DETAILS_EXCLUDED_PREFIXES.some((prefix) => detail.startsWith(prefix))) {
+                       return false;
+               }
+               return !detail.includes(STATS_UNITS.TOKENS_PER_SECOND);
+       });
+}
+
+export function useContextGauge(): UseContextGaugeReturn {
+       const processingState = useProcessingState();
+
+       // Resolve the model the gauge reports context for: explicit selection >
+       // last assistant model > single-model mode (mirrors useChatScreenActiveModel).
+       const activeModelId = $derived.by(() => {
+               if (!isRouterMode()) {
+                       return singleModelName();
+               }
+
+               const selectedId = selectedModelId();
+               if (selectedId) {
+                       const model = modelOptions().find((m) => m.id === selectedId);
+                       if (model) return model.model;
+               }
+
+               return chatStore.getConversationModel(activeMessages() as DatabaseMessage[]);
+       });
+
+       const isActiveModelLoaded = $derived(
+               activeModelId !== null && modelsStore.isModelLoaded(activeModelId)
+       );
+
+       const isActiveModelLoading = $derived(
+               activeModelId !== null && modelsStore.isModelOperationInProgress(activeModelId)
+       );
+
+       // Pull /props on demand so n_ctx surfaces before the first chat request.
+       $effect(() => {
+               if (activeModelId && isActiveModelLoaded) {
+                       const cached = modelsStore.getModelProps(activeModelId);
+                       if (!cached) {
+                               void modelsStore.fetchModelProps(activeModelId);
+                       }
+               }
+       });
+
+       const contextTotal = $derived.by(() => {
+               void modelsStore.propsCacheVersion;
+               return activeModelId ? modelsStore.getModelContextSize(activeModelId) : null;
+       });
+
+       const liveStats = $derived(deriveLiveStats(processingState.processingState));
+
+       const currentRead = $derived.by(() => {
+               const timings = lastAssistantTimings(activeMessages() as DatabaseMessage[]);
+               let read = 0;
+               if (timings) {
+                       read = (timings.prompt_n ?? 0) + (timings.cache_n ?? 0);
+               }
+               // live.promptTokens is already the combined reading (prompt + cache),
+               // so do not also add live.cacheTokens.
+               if (liveStats && liveStats.promptTokens > 0) {
+                       read = Math.max(read, liveStats.promptTokens);
+               }
+               return read;
+       });
+
+       const currentFresh = $derived.by(() => {
+               const timings = lastAssistantTimings(activeMessages() as DatabaseMessage[]);
+               const fresh = timings?.prompt_n ?? 0;
+               return Math.max(fresh, liveStats?.freshTokens ?? 0);
+       });
+
+       const currentCache = $derived.by(() => {
+               const timings = lastAssistantTimings(activeMessages() as DatabaseMessage[]);
+               const cached = timings?.cache_n ?? 0;
+               if (liveStats && liveStats.promptTokens > 0) {
+                       return Math.max(cached, liveStats.cacheTokens);
+               }
+               return cached;
+       });
+
+       const currentOutput = $derived.by(() => {
+               if (liveStats && liveStats.outputTokens > 0) return liveStats.outputTokens;
+               const timings = lastAssistantTimings(activeMessages() as DatabaseMessage[]);
+               return timings?.predicted_n ?? 0;
+       });
+
+       const kvTotal = $derived(currentRead + currentOutput);
+       const contextUsed = $derived(currentRead + currentOutput);
+
+       const cumulative = $derived.by(() => {
+               const messages = activeMessages() as DatabaseMessage[];
+
+               // Agentic sessions stamp the same agentic.llm totals onto every
+               // assistant message; cache_n is never per-turn so cache_total stays 0.
+               const agenticMessages = messages.filter(
+                       (m) => m.role === MessageRole.ASSISTANT && m.timings?.agentic?.llm?.predicted_n != null
+               );
+
+               if (agenticMessages.length > 0) {
+                       const llm = agenticMessages[agenticMessages.length - 1].timings!.agentic!.llm;
+                       const output = llm.predicted_n ?? 0;
+                       const outputMs = llm.predicted_ms ?? 0;
+                       const averageTokensPerSecond = outputMs > 0 && output > 0 ? (output / outputMs) * 1000 : null;
+                       return {
+                               read: llm.prompt_n ?? 0,
+                               output,
+                               cacheTotal: 0,
+                               averageTokensPerSecond
+                       };
+               }
+
+               let read = 0;
+               let output = 0;
+               let outputMs = 0;
+               let cacheTotal = 0;
+               for (const m of messages) {
+                       if (m.role !== MessageRole.ASSISTANT || !m.timings) continue;
+                       read += m.timings.prompt_n ?? 0;
+                       cacheTotal += m.timings.cache_n ?? 0;
+                       output += m.timings.predicted_n ?? 0;
+                       outputMs += m.timings.predicted_ms ?? 0;
+               }
+               const averageTokensPerSecond = outputMs > 0 && output > 0 ? (output / outputMs) * 1000 : null;
+               return { read, output, cacheTotal, averageTokensPerSecond };
+       });
+
+       const contextPercent = $derived.by(() => {
+               if (contextTotal === null || contextTotal <= 0) return null;
+               return Math.round((contextUsed / contextTotal) * 100);
+       });
+
+       const colorLevel = $derived(colorLevelFromPercent(contextPercent));
+
+       // Drop lines the surrounding Context / Output / speed rows already render.
+       const transientDetails = $derived(filterTransientDetails(processingState.getTechnicalDetails()));
+
+       const hasAnyUsage = $derived(
+               cumulative.read > 0 ||
+                       cumulative.output > 0 ||
+                       currentRead > 0 ||
+                       currentOutput > 0 ||
+                       cumulative.averageTokensPerSecond !== null ||
+                       transientDetails.length > 0
+       );
+
+       async function loadModel() {
+               if (!activeModelId || isActiveModelLoading) return;
+               try {
+                       await modelsStore.loadModel(activeModelId);
+               } catch {
+                       // toast already surfaced by modelsStore.loadModel
+               }
+       }
+
+       return {
+               get activeModelId() {
+                       return activeModelId;
+               },
+               get isActiveModelLoaded() {
+                       return isActiveModelLoaded;
+               },
+               get isActiveModelLoading() {
+                       return isActiveModelLoading;
+               },
+               get contextTotal() {
+                       return contextTotal;
+               },
+               get contextUsed() {
+                       return contextUsed;
+               },
+               get currentRead() {
+                       return currentRead;
+               },
+               get currentFresh() {
+                       return currentFresh;
+               },
+               get currentCache() {
+                       return currentCache;
+               },
+               get currentOutput() {
+                       return currentOutput;
+               },
+               get kvTotal() {
+                       return kvTotal;
+               },
+               get cumulativeRead() {
+                       return cumulative.read;
+               },
+               get cumulativeOutput() {
+                       return cumulative.output;
+               },
+               get cumulativeCacheTotal() {
+                       return cumulative.cacheTotal;
+               },
+               get averageTokensPerSecond() {
+                       return cumulative.averageTokensPerSecond;
+               },
+               get contextPercent() {
+                       return contextPercent;
+               },
+               get colorLevel() {
+                       return colorLevel;
+               },
+               get transientDetails() {
+                       return transientDetails;
+               },
+               get hasAnyUsage() {
+                       return hasAnyUsage;
+               },
+               loadModel,
+               startMonitoring: () => processingState.startMonitoring()
+       };
+}
index c8a85fa8f7d5c9cc3182a342d0c4d023560b97a5..4f4c2c7825b2bb04009d0bd5bbda762e114c40ea 100644 (file)
@@ -54,11 +54,6 @@ export function useMcpRecommendations() {
                // effect, and we must not wipe the timeout that was just scheduled.
                if (checked) return;
 
-               if (mcpStore.optedInRecommendationIds.size > 0) {
-                       checked = true;
-                       return;
-               }
-
                const hasRecommendations = mcpStore
                        .getServers()
                        .some((server) => RECOMMENDED_MCP_SERVER_IDS.has(server.id));
index f280319723f6faa7dfe1c9a8660f0845a0a25960..9fbda75d67240b5842cef797da56043cc7772d85 100644 (file)
@@ -1,5 +1,4 @@
 import { activeProcessingState } from '$lib/stores/chat.svelte';
-import { config } from '$lib/stores/settings.svelte';
 import { STATS_UNITS } from '$lib/constants';
 import type { ApiProcessingState, LiveProcessingStats, LiveGenerationStats } from '$lib/types';
 
@@ -46,7 +45,6 @@ export function useProcessingState(): UseProcessingStateReturn {
                return activeProcessingState();
        });
 
-       // Track last known state for keepStatsVisible functionality
        $effect(() => {
                if (processingState && isMonitoring) {
                        lastKnownState = processingState;
@@ -88,14 +86,8 @@ export function useProcessingState(): UseProcessingStateReturn {
 
        function stopMonitoring(): void {
                if (!isMonitoring) return;
-               isMonitoring = false;
 
-               // Only clear last known state if keepStatsVisible is disabled
-               const currentConfig = config();
-               if (!currentConfig.keepStatsVisible) {
-                       lastKnownState = null;
-                       lastKnownProcessingStats = null;
-               }
+               isMonitoring = false;
        }
 
        function getProcessingMessage(): string {
index dbd0a94f10ed7677d9f4aac64165233ed6e16911..15f4cc3b5f8bf3e218f8f2bf3cf1c835974f2cae 100644 (file)
@@ -340,6 +340,7 @@ export class ChatService {
                        if (stream && conversationId) {
                                headers['X-Conversation-Id'] = streamIdentity(conversationId, options.model);
                        }
+
                        const response = await fetch(API_CHAT.COMPLETIONS, {
                                method: 'POST',
                                headers,
@@ -1011,7 +1012,7 @@ export class ChatService {
         *
         * @param response - The fetch Response object containing the JSON data
         * @param onComplete - Optional callback invoked when response is successfully parsed
-        * @param onError - Optional callback invoked if an error occurs during parsing
+        * @param onError - Optional callback invoked if an error occurs while parsing
         * @returns {Promise<string>} Promise that resolves to the generated content string
         * @throws {Error} if the response cannot be parsed or is malformed
         */
index d7709bc6bd8c9b2f259b572002c802dcacda60e4..981283be936e47ba2604713a5b7acddd71f50996 100644 (file)
@@ -564,9 +564,9 @@ const configTypesMigration: Migration = {
                const config = JSON.parse(configRaw);
                let changed = false;
 
-               // Pre-schema configs persisted booleans as the strings "true"/"false", which the
-               // strict server schema now rejects. Coerce those back to real booleans. No config
-               // string field holds exactly "true"/"false", so the match is unambiguous.
+               // Pre-schema configs persisted booleans as "true"/"false" strings; the strict server
+               // schema rejects them. No config string field holds exactly "true"/"false", so the
+               // match is unambiguous.
                for (const key of Object.keys(config)) {
                        if (config[key] === 'true') {
                                config[key] = true;
index 27491257ac7295d0649d1edba68f54360e9415af..1a677602f9b5ea5dd0c3ff6d42d9fc9370d5f69c 100644 (file)
@@ -477,7 +477,7 @@ class AgenticStore {
                conversationId: string;
                messages: ApiChatMessageData[];
                options: AgenticFlowOptions;
-               tools: ReturnType<typeof mcpStore.getToolDefinitionsForLLM>;
+               tools: ReturnType<typeof toolsStore.getEnabledToolsForLLM>;
                agenticConfig: AgenticConfig;
                callbacks: AgenticFlowCallbacks;
                signal?: AbortSignal;
index 6949047b50b95d714f310081b77e5241e52684ad..fcd07c4fd6bcfc125cdcbc12042f10a56977d5fc 100644 (file)
@@ -60,6 +60,7 @@ import {
        ErrorDialogType,
        MessageRole,
        MessageType,
+       ReasoningEffort,
        StreamConnectionState
 } from '$lib/enums';
 
@@ -2334,7 +2335,8 @@ class ChatStore {
                if (currentConfig.excludeReasoningFromContext) apiOptions.excludeReasoningFromContext = true;
 
                apiOptions.enableThinking = conversationsStore.getThinkingEnabled();
-               apiOptions.reasoningEffort = conversationsStore.getReasoningEffort();
+               const effort = conversationsStore.getReasoningEffort();
+               if (effort !== ReasoningEffort.OFF) apiOptions.reasoningEffort = effort;
 
                if (hasValue(currentConfig.temperature))
                        apiOptions.temperature = Number(currentConfig.temperature);
index 48620220774d9a921111b78de910359f9fd886c2..b29a900fe329144df3dc457701365ba72a1f18e9 100644 (file)
@@ -47,7 +47,6 @@ import {
        NON_ALPHANUMERIC_REGEX,
        MULTIPLE_UNDERSCORE_REGEX,
        SETTINGS_KEYS,
-       THINKING_ENABLED_DEFAULT_LOCALSTORAGE_KEY,
        REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY
 } from '$lib/constants';
 
@@ -84,11 +83,16 @@ class ConversationsStore {
        /** Pending MCP server overrides for new conversations (before first message) */
        pendingMcpServerOverrides = $state<McpServerOverride[]>(ConversationsStore.loadMcpDefaults());
 
-       /** Global (non-conversation-specific) thinking toggle default */
-       pendingThinkingEnabled = $state(ConversationsStore.loadThinkingDefaults());
+       /** Global (non-conversation-specific) thinking toggle default, derived from reasoning effort */
+       pendingThinkingEnabled = $state(false);
 
        /** Global (non-conversation-specific) reasoning effort default */
-       pendingReasoningEffort = $state<ReasoningEffort>(ConversationsStore.loadReasoningEffortDefault());
+       pendingReasoningEffort = $state<ReasoningEffort | ReasoningEffort.OFF>(
+               ConversationsStore.loadReasoningEffortDefault()
+       );
+
+       /** Last non-off reasoning effort, restored when re-enabling thinking globally */
+       private lastNonOffEffort: ReasoningEffort | null = null;
 
        private static loadMcpDefaults(): McpServerOverride[] {
                const raw = config()[SETTINGS_KEYS.MCP_DEFAULT_SERVER_OVERRIDES];
@@ -112,35 +116,14 @@ class ConversationsStore {
                settingsStore.updateConfig(SETTINGS_KEYS.MCP_DEFAULT_SERVER_OVERRIDES, JSON.stringify(plain));
        }
 
-       /** Load thinking-enabled default from localStorage */
-       private static loadThinkingDefaults(): boolean {
-               if (typeof globalThis.localStorage === 'undefined') return true;
-               try {
-                       const raw = localStorage.getItem(THINKING_ENABLED_DEFAULT_LOCALSTORAGE_KEY);
-                       if (!raw) return true;
-                       return raw === 'true';
-               } catch {
-                       return true;
-               }
-       }
-
-       /** Persist thinking-enabled default to localStorage */
-       private saveThinkingDefaults(): void {
-               if (typeof globalThis.localStorage === 'undefined') return;
-               localStorage.setItem(
-                       THINKING_ENABLED_DEFAULT_LOCALSTORAGE_KEY,
-                       this.pendingThinkingEnabled ? 'true' : 'false'
-               );
-       }
-
        /** Load reasoning effort default from localStorage */
-       private static loadReasoningEffortDefault(): ReasoningEffort {
-               if (typeof globalThis.localStorage === 'undefined') return ReasoningEffort.MEDIUM;
+       private static loadReasoningEffortDefault(): ReasoningEffort | ReasoningEffort.OFF {
+               if (typeof globalThis.localStorage === 'undefined') return ReasoningEffort.OFF;
                try {
                        const raw = localStorage.getItem(REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY);
-                       return (raw as ReasoningEffort) || ReasoningEffort.MEDIUM;
+                       return (raw as ReasoningEffort | ReasoningEffort.OFF) || ReasoningEffort.OFF;
                } catch {
-                       return ReasoningEffort.MEDIUM;
+                       return ReasoningEffort.OFF;
                }
        }
 
@@ -303,10 +286,17 @@ class ConversationsStore {
                        this.pendingMcpServerOverrides = [];
                }
 
-               // Inherit global thinking default into the new conversation
-               conversation.thinkingEnabled = this.pendingThinkingEnabled;
+               // Inherit global thinking/reasoning defaults into the new conversation
+               const thinkingEnabled = this.getThinkingEnabled();
+               conversation.thinkingEnabled = thinkingEnabled;
+               conversation.reasoningEffort =
+                       this.pendingReasoningEffort === ReasoningEffort.OFF ? undefined : this.pendingReasoningEffort;
                await DatabaseService.updateConversation(conversation.id, {
-                       thinkingEnabled: this.pendingThinkingEnabled
+                       thinkingEnabled,
+                       reasoningEffort:
+                               this.pendingReasoningEffort === ReasoningEffort.OFF
+                                       ? undefined
+                                       : this.pendingReasoningEffort
                });
 
                this.conversations = [conversation, ...this.conversations];
@@ -332,7 +322,6 @@ class ConversationsStore {
                        }
 
                        this.pendingMcpServerOverrides = [];
-                       this.pendingThinkingEnabled = ConversationsStore.loadThinkingDefaults();
                        this.activeConversation = conversation;
 
                        if (conversation.currNode) {
@@ -363,7 +352,7 @@ class ConversationsStore {
                this.activeMessages = [];
                // reload defaults so new chats inherit persisted state
                this.pendingMcpServerOverrides = ConversationsStore.loadMcpDefaults();
-               this.pendingThinkingEnabled = ConversationsStore.loadThinkingDefaults();
+               this.pendingReasoningEffort = ConversationsStore.loadReasoningEffortDefault();
        }
 
        /**
@@ -794,9 +783,11 @@ class ConversationsStore {
         */
        getThinkingEnabled(): boolean {
                if (this.activeConversation) {
-                       return this.activeConversation.thinkingEnabled ?? this.pendingThinkingEnabled;
+                       if (this.activeConversation.thinkingEnabled !== undefined) {
+                               return this.activeConversation.thinkingEnabled;
+                       }
                }
-               return this.pendingThinkingEnabled;
+               return this.getReasoningEffort() !== ReasoningEffort.OFF;
        }
 
        /**
@@ -806,8 +797,17 @@ class ConversationsStore {
         */
        async setThinkingEnabled(enabled: boolean): Promise<void> {
                if (!this.activeConversation) {
-                       this.pendingThinkingEnabled = enabled;
-                       this.saveThinkingDefaults();
+                       if (enabled) {
+                               const effort = this.lastNonOffEffort ?? ReasoningEffort.LOW;
+                               this.pendingReasoningEffort = effort;
+                               this.saveReasoningEffortDefaults();
+                       } else {
+                               if (this.pendingReasoningEffort !== ReasoningEffort.OFF) {
+                                       this.lastNonOffEffort = this.pendingReasoningEffort;
+                               }
+                               this.pendingReasoningEffort = ReasoningEffort.OFF;
+                               this.saveReasoningEffortDefaults();
+                       }
                        return;
                }
 
@@ -831,7 +831,7 @@ class ConversationsStore {
         * Gets the effective reasoning effort for the active conversation.
         * Returns the conversation override if set, otherwise the global default.
         */
-       getReasoningEffort(): ReasoningEffort {
+       getReasoningEffort(): ReasoningEffort | ReasoningEffort.OFF {
                if (this.activeConversation) {
                        return this.activeConversation.reasoningEffort ?? this.pendingReasoningEffort;
                }
index 37eb563329e4cbca6f7ed25bc0d6c2f406a8e841..a53463ee9aa36978f156ef7f65401a383121c709 100644 (file)
  * - Lifecycle management (initialize, shutdown)
  * - Multi-server coordination
  * - Tool name conflict detection and resolution
- * - OpenAI-compatible tool definition generation
  * - Automatic tool-to-server routing
  * - Health checks
  *
+ * MCP connection state and raw `Tool[]` per server are owned here; the
+ * OpenAI-compatible wire format for those tools is built in `toolsStore`
+ * (see {@link toolsStore.mcpEntries} / {@link toolsStore.getEnabledToolsForLLM}).
+ *
  * @see MCPService in services/mcp.service.ts for protocol operations
  */
 
 import { browser } from '$app/environment';
-import { SvelteSet } from 'svelte/reactivity';
 import { SETTINGS_KEYS } from '$lib/constants';
 import { MCPService } from '$lib/services/mcp.service';
 import { config, settingsStore } from '$lib/stores/settings.svelte';
 import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
 import { serverStore } from '$lib/stores/server.svelte';
-import { conversationsStore } from '$lib/stores/conversations.svelte';
 import { mode } from 'mode-watcher';
 import {
        parseMcpServerSettings,
@@ -40,9 +41,7 @@ import {
        HealthCheckStatus,
        MCPRefType,
        ColorMode,
-       UrlProtocol,
-       JsonSchemaType,
-       ToolCallType
+       UrlProtocol
 } from '$lib/enums';
 import {
        DEFAULT_CACHE_TTL_MS,
@@ -53,12 +52,10 @@ import {
        MCP_RECONNECT_BACKOFF_MULTIPLIER,
        MCP_RECONNECT_INITIAL_DELAY,
        MCP_RECONNECT_MAX_DELAY,
-       MCP_RECONNECT_ATTEMPT_TIMEOUT_MS,
-       RECOMMENDED_MCP_SERVER_IDS
+       MCP_RECONNECT_ATTEMPT_TIMEOUT_MS
 } from '$lib/constants';
 import type {
        MCPToolCall,
-       OpenAIToolDefinition,
        ServerStatus,
        ToolExecutionResult,
        MCPClientConfig,
@@ -582,30 +579,10 @@ class MCPStore {
        }
 
        /**
-        * Recommended MCP server IDs the user opted in to via per-chat overrides.
-        * Single source of truth for "which recommendations has the user accepted",
-        * shared by the recommendations hook and the visible-servers getter.
-        */
-       get optedInRecommendationIds(): ReadonlySet<string> {
-               const ids = new SvelteSet<string>();
-               for (const override of conversationsStore.pendingMcpServerOverrides) {
-                       if (RECOMMENDED_MCP_SERVER_IDS.has(override.serverId) && override.enabled) {
-                               ids.add(override.serverId);
-                       }
-               }
-               return ids;
-       }
-
-       /**
-        * MCP servers selectable in chat-add UIs and the settings page:
-        * enabled in settings and either non-recommended or explicitly opted in.
+        * MCP servers selectable in chat-add UIs and the settings page.
         */
        get visibleMcpServers(): MCPServerSettingsEntry[] {
-               const optedIn = this.optedInRecommendationIds;
-               return this.getServersSorted().filter(
-                       (server) =>
-                               server.enabled && (!RECOMMENDED_MCP_SERVER_IDS.has(server.id) || optedIn.has(server.id))
-               );
+               return this.getServersSorted().filter((server) => server.enabled);
        }
 
        async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise<boolean> {
@@ -979,73 +956,6 @@ class MCPStore {
                }
        }
 
-       getToolDefinitionsForLLM(): OpenAIToolDefinition[] {
-               const tools: OpenAIToolDefinition[] = [];
-
-               for (const connection of this.connections.values()) {
-                       for (const tool of connection.tools) {
-                               const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
-                                       type: JsonSchemaType.OBJECT,
-                                       properties: {},
-                                       required: []
-                               };
-
-                               tools.push({
-                                       type: ToolCallType.FUNCTION as const,
-                                       function: {
-                                               name: tool.name,
-                                               description: tool.description,
-                                               parameters: this.normalizeSchemaProperties(rawSchema)
-                                       }
-                               });
-                       }
-               }
-
-               return tools;
-       }
-
-       private normalizeSchemaProperties(schema: Record<string, unknown>): Record<string, unknown> {
-               if (!schema || typeof schema !== 'object') {
-                       return schema;
-               }
-
-               const normalized = { ...schema };
-               if (normalized.properties && typeof normalized.properties === 'object') {
-                       const props = normalized.properties as Record<string, Record<string, unknown>>;
-                       const normalizedProps: Record<string, Record<string, unknown>> = {};
-                       for (const [key, prop] of Object.entries(props)) {
-                               if (!prop || typeof prop !== 'object') {
-                                       normalizedProps[key] = prop;
-                                       continue;
-                               }
-                               const normalizedProp = { ...prop };
-                               if (!normalizedProp.type && normalizedProp.default !== undefined) {
-                                       const defaultVal = normalizedProp.default;
-                                       if (typeof defaultVal === 'string') normalizedProp.type = 'string';
-                                       else if (typeof defaultVal === 'number')
-                                               normalizedProp.type = Number.isInteger(defaultVal) ? 'integer' : 'number';
-                                       else if (typeof defaultVal === 'boolean') normalizedProp.type = 'boolean';
-                                       else if (Array.isArray(defaultVal)) normalizedProp.type = 'array';
-                                       else if (typeof defaultVal === 'object' && defaultVal !== null)
-                                               normalizedProp.type = 'object';
-                               }
-                               if (normalizedProp.properties)
-                                       Object.assign(
-                                               normalizedProp,
-                                               this.normalizeSchemaProperties(normalizedProp as Record<string, unknown>)
-                                       );
-                               if (normalizedProp.items && typeof normalizedProp.items === 'object')
-                                       normalizedProp.items = this.normalizeSchemaProperties(
-                                               normalizedProp.items as Record<string, unknown>
-                                       );
-                               normalizedProps[key] = normalizedProp;
-                       }
-                       normalized.properties = normalizedProps;
-               }
-
-               return normalized;
-       }
-
        getToolNames(): string[] {
                return Array.from(this.toolsIndex.keys());
        }
index a637819885064caf179dff859240185792805159..dcaab5f42b16ea3bc213d413e559bde7c5d49f40 100644 (file)
@@ -13,33 +13,6 @@ import {
 import { SvelteMap, SvelteSet } from 'svelte/reactivity';
 
 /** Stable selection identity for a tool, shared by the disabled set and the permission store */
-function toolKey(source: ToolSource, name: string, serverId?: string): string {
-       switch (source) {
-               case ToolSource.MCP:
-                       return serverId ? `mcp-${serverId}:${name}` : `mcp:${name}`;
-               case ToolSource.CUSTOM:
-                       return `custom:${name}`;
-               case ToolSource.FRONTEND:
-                       return `frontend:${name}`;
-               default:
-                       return `builtin:${name}`;
-       }
-}
-
-function mcpDefinition(
-       name: string,
-       description: string | undefined,
-       schema?: Record<string, unknown>
-): OpenAIToolDefinition {
-       return {
-               type: ToolCallType.FUNCTION,
-               function: {
-                       name,
-                       description,
-                       parameters: schema ?? { type: JsonSchemaType.OBJECT, properties: {}, required: [] }
-               }
-       };
-}
 
 class ToolsStore {
        private _builtinTools = $state<OpenAIToolDefinition[]>([]);
@@ -77,12 +50,96 @@ class ToolsStore {
                }
        }
 
+       private toolKey(source: ToolSource, name: string, serverId?: string): string {
+               switch (source) {
+                       case ToolSource.MCP:
+                               return serverId ? `mcp-${serverId}:${name}` : `mcp:${name}`;
+                       case ToolSource.CUSTOM:
+                               return `custom:${name}`;
+                       case ToolSource.FRONTEND:
+                               return `frontend:${name}`;
+                       default:
+                               return `builtin:${name}`;
+               }
+       }
+
+       private inferTypeFromDefault(value: unknown): string | undefined {
+               if (typeof value === 'string') return 'string';
+               if (typeof value === 'boolean') return 'boolean';
+               if (typeof value === 'number') return Number.isInteger(value) ? 'integer' : 'number';
+               if (Array.isArray(value)) return 'array';
+               if (value !== null && typeof value === 'object') return 'object';
+               return undefined;
+       }
+
+       /**
+        * Recursively normalize a JSON Schema object: infers `type` from `default`
+        * for properties / items that omit it, and descends into nested `properties`
+        * and `items`. Returns a new object -- does not mutate the input.
+        */
+       private normalizeJsonSchema(schema: Record<string, unknown>): Record<string, unknown> {
+               if (!schema || typeof schema !== 'object') return schema;
+
+               const normalized: Record<string, unknown> = { ...schema };
+
+               if (normalized.properties && typeof normalized.properties === 'object') {
+                       const props = normalized.properties as Record<string, Record<string, unknown>>;
+                       const normalizedProps: Record<string, Record<string, unknown>> = {};
+                       for (const [key, prop] of Object.entries(props)) {
+                               if (!prop || typeof prop !== 'object') {
+                                       normalizedProps[key] = prop;
+                                       continue;
+                               }
+
+                               const normalizedProp: Record<string, unknown> = { ...prop };
+
+                               if (!normalizedProp.type && normalizedProp.default !== undefined) {
+                                       const inferred = this.inferTypeFromDefault(normalizedProp.default);
+                                       if (inferred) normalizedProp.type = inferred;
+                               }
+
+                               if (normalizedProp.properties) {
+                                       Object.assign(
+                                               normalizedProp,
+                                               this.normalizeJsonSchema(normalizedProp as Record<string, unknown>)
+                                       );
+                               }
+
+                               if (normalizedProp.items && typeof normalizedProp.items === 'object') {
+                                       normalizedProp.items = this.normalizeJsonSchema(
+                                               normalizedProp.items as Record<string, unknown>
+                                       );
+                               }
+
+                               normalizedProps[key] = normalizedProp;
+                       }
+                       normalized.properties = normalizedProps;
+               }
+
+               return normalized;
+       }
+
+       private mcpDefinition(
+               name: string,
+               description: string | undefined,
+               schema?: Record<string, unknown>
+       ): OpenAIToolDefinition {
+               return {
+                       type: ToolCallType.FUNCTION,
+                       function: {
+                               name,
+                               description,
+                               parameters: schema ?? { type: JsonSchemaType.OBJECT, properties: {}, required: [] }
+                       }
+               };
+       }
+
        get builtinTools(): OpenAIToolDefinition[] {
                return this._builtinTools;
        }
 
        get mcpTools(): OpenAIToolDefinition[] {
-               return mcpStore.getToolDefinitionsForLLM();
+               return this.mcpEntries().map((e) => e.definition);
        }
 
        get frontendTools(): OpenAIToolDefinition[] {
@@ -124,11 +181,22 @@ class ToolsStore {
                        for (const [serverId, connection] of connections) {
                                const serverName = mcpStore.getServerDisplayName(serverId);
                                for (const tool of connection.tools) {
-                                       const schema = (tool.inputSchema as Record<string, unknown>) ?? undefined;
+                                       const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
+                                               type: JsonSchemaType.OBJECT,
+                                               properties: {},
+                                               required: []
+                                       };
                                        out.push({
                                                serverId,
                                                serverName,
-                                               definition: mcpDefinition(tool.name, tool.description, schema)
+                                               definition: {
+                                                       type: ToolCallType.FUNCTION,
+                                                       function: {
+                                                               name: tool.name,
+                                                               description: tool.description,
+                                                               parameters: this.normalizeJsonSchema(rawSchema)
+                                                       }
+                                               }
                                        });
                                }
                        }
@@ -138,7 +206,7 @@ class ToolsStore {
                                        out.push({
                                                serverId,
                                                serverName,
-                                               definition: mcpDefinition(tool.name, tool.description)
+                                               definition: this.mcpDefinition(tool.name, tool.description)
                                        });
                                }
                        }
@@ -160,14 +228,18 @@ class ToolsStore {
 
                for (const def of this._builtinTools) {
                        const name = def.function.name;
-                       push({ source: ToolSource.BUILTIN, key: toolKey(ToolSource.BUILTIN, name), definition: def });
+                       push({
+                               source: ToolSource.BUILTIN,
+                               key: this.toolKey(ToolSource.BUILTIN, name),
+                               definition: def
+                       });
                }
 
                for (const def of this.frontendTools) {
                        const name = def.function.name;
                        push({
                                source: ToolSource.FRONTEND,
-                               key: toolKey(ToolSource.FRONTEND, name),
+                               key: this.toolKey(ToolSource.FRONTEND, name),
                                definition: def
                        });
                }
@@ -178,14 +250,18 @@ class ToolsStore {
                                source: ToolSource.MCP,
                                serverId,
                                serverName,
-                               key: toolKey(ToolSource.MCP, name, serverId),
+                               key: this.toolKey(ToolSource.MCP, name, serverId),
                                definition
                        });
                }
 
                for (const def of this.customTools) {
                        const name = def.function.name;
-                       push({ source: ToolSource.CUSTOM, key: toolKey(ToolSource.CUSTOM, name), definition: def });
+                       push({
+                               source: ToolSource.CUSTOM,
+                               key: this.toolKey(ToolSource.CUSTOM, name),
+                               definition: def
+                       });
                }
 
                return entries;
@@ -233,7 +309,8 @@ class ToolsStore {
 
        /**
         * Enabled tool definitions for sending to the LLM.
-        * MCP tools keep their normalized schemas from mcpStore.
+        * MCP tool schemas are normalized here so the wire payload is consistent
+        * across all four sources (built-in, frontend/sandbox, MCP, custom JSON).
         * The API identifies tools by name, so a name is sent at most once.
         */
        getEnabledToolsForLLM(): OpenAIToolDefinition[] {
@@ -256,7 +333,8 @@ class ToolsStore {
 
                for (const def of this._builtinTools) take(def);
                for (const def of this.frontendTools) take(def);
-               for (const def of mcpStore.getToolDefinitionsForLLM()) take(def);
+               // mcpEntries() over mcpStore directly so wire shape stays normalized and aligned with the tools UI.
+               for (const entry of this.mcpEntries()) take(entry.definition);
                for (const def of this.customTools) take(def);
 
                return result;
@@ -308,15 +386,17 @@ class ToolsStore {
                const connection = mcpStore.getConnections().get(serverId);
                if (!connection) return;
                for (const tool of connection.tools) {
-                       this._disabledTools.delete(toolKey(ToolSource.MCP, tool.name, serverId));
+                       this._disabledTools.delete(this.toolKey(ToolSource.MCP, tool.name, serverId));
                }
                this.persistDisabledTools();
        }
 
        toggleGroup(group: ToolGroup): void {
                const allEnabled = group.tools.every((t) => this.isToolEnabled(t.key));
+               const target = !allEnabled;
                for (const tool of group.tools) {
-                       this.setToolEnabled(tool.key, !allEnabled);
+                       if (target) this._disabledTools.delete(tool.key);
+                       else this._disabledTools.add(tool.key);
                }
                this.persistDisabledTools();
        }
@@ -332,7 +412,7 @@ class ToolsStore {
                tools: { name: string; description?: string }[];
        }[] {
                const result: ReturnType<ToolsStore['getMcpToolsFromHealthChecks']> = [];
-               for (const server of mcpStore.getServersSorted().filter((s) => s.enabled)) {
+               for (const server of mcpStore.visibleMcpServers) {
                        const health = mcpStore.getHealthCheckState(server.id);
                        if (health.status === HealthCheckStatus.SUCCESS && health.tools.length > 0) {
                                result.push({
index c40abbdd6bc1f52ee414e8c66fcd0446d01cec63..6ff701318a11f4b83401fc2b238cd899146f5dd7 100644 (file)
@@ -111,15 +111,7 @@ function findLeafNodeInMap(
 }
 
 /**
- * Convenience wrapper around {@link findLeafNodeInMap} for callers that only have
- * a flat message array.
- *
- * Finds the leaf node (message with no children) for a given message branch.
- * Traverses down the tree following the last child until reaching a leaf.
- *
- * @param messages - All messages in the conversation
- * @param messageId - Starting message ID to find leaf for
- * @returns The leaf node ID, or the original messageId if no children
+ * Convenience wrapper around {@link findLeafNodeInMap} for callers that have a flat message array.
  */
 export function findLeafNode(messages: readonly DatabaseMessage[], messageId: string): string {
        const nodeMap = new Map(messages.map((msg) => [msg.id, msg] as const));
@@ -225,7 +217,6 @@ export function getMessageSiblings(
 
 /**
  * Builds sibling information for every message in a conversation.
- * A single node map is shared across all lookups for O(1) access.
  *
  * @param messages - All messages in the conversation
  * @returns Map of message ID to its sibling information