generated from nhcarrigan/template
feat: add extended thinking blocks display
Implements support for displaying Claude's extended thinking blocks in the conversation UI with a collapsible, visually distinct component. Changes: - Backend: Update wsl_bridge.rs to emit thinking blocks with dedicated line_type instead of system messages - Types: Add "thinking" to TerminalLine type union - Config: Add show_thinking_blocks toggle (default: true) - UI: Create ThinkingBlock.svelte component with collapsible interface - Terminal: Update to conditionally render thinking blocks - Settings: Add toggle in Appearance section of ConfigSidebar The ThinkingBlock component features: - Lightbulb icon to indicate extended thinking - Collapsible/expandable with animated chevron - Distinct styling: dimmed, italic, monospace - Timestamp display - Respects global show_thinking_blocks config setting Note: Extended thinking support in Claude Code CLI appears to be in development. This implementation is ready and will automatically display thinking blocks once the CLI begins emitting them. Issue: #120 ✨ Implemented by Hikari~ 🌸
This commit is contained in:
@@ -974,8 +974,8 @@ fn process_json_line(
|
||||
let _ = app.emit(
|
||||
"claude:output",
|
||||
OutputEvent {
|
||||
line_type: "system".to_string(),
|
||||
content: format!("[Thinking] {}", thinking),
|
||||
line_type: "thinking".to_string(),
|
||||
content: thinking.clone(),
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
cost: None,
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
show_thinking_blocks: true,
|
||||
});
|
||||
|
||||
let showCustomThemeEditor = $state(false);
|
||||
@@ -703,6 +704,22 @@
|
||||
Use Ctrl++ / Ctrl+- to quickly adjust, Ctrl+0 to reset
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Show Thinking Blocks Toggle -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={config.show_thinking_blocks}
|
||||
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
|
||||
/>
|
||||
<span class="text-sm text-[var(--text-primary)]">Show Extended Thinking Blocks</span>
|
||||
</label>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||
Display Claude's extended thinking process in the conversation. Thinking blocks can be
|
||||
expanded/collapsed to see reasoning details.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Window Section -->
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
show_thinking_blocks: true,
|
||||
});
|
||||
|
||||
let streamerModeActive = $state(false);
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
import ConversationTabs from "./ConversationTabs.svelte";
|
||||
import Markdown from "./Markdown.svelte";
|
||||
import HighlightedText from "./HighlightedText.svelte";
|
||||
import ThinkingBlock from "./ThinkingBlock.svelte";
|
||||
import { searchState, searchQuery } from "$lib/stores/search";
|
||||
import { clipboardStore } from "$lib/stores/clipboard";
|
||||
import { shouldHidePaths, maskPaths } from "$lib/stores/config";
|
||||
import { shouldHidePaths, maskPaths, showThinkingBlocks } from "$lib/stores/config";
|
||||
|
||||
let terminalElement: HTMLDivElement;
|
||||
let shouldAutoScroll = true;
|
||||
@@ -24,6 +25,11 @@
|
||||
hidePaths = value;
|
||||
});
|
||||
|
||||
let showThinking = true;
|
||||
showThinkingBlocks.subscribe((value) => {
|
||||
showThinking = value;
|
||||
});
|
||||
|
||||
claudeStore.terminalLines.subscribe((value) => {
|
||||
lines = value;
|
||||
});
|
||||
@@ -84,6 +90,8 @@
|
||||
return "terminal-tool";
|
||||
case "error":
|
||||
return "terminal-error";
|
||||
case "thinking":
|
||||
return "terminal-thinking";
|
||||
default:
|
||||
return "terminal-default";
|
||||
}
|
||||
@@ -209,80 +217,86 @@
|
||||
</div>
|
||||
{:else}
|
||||
{#each lines as line (line.id)}
|
||||
<div
|
||||
class="terminal-line mb-2 {getLineClass(line.type)} relative group"
|
||||
style={line.parentToolUseId
|
||||
? "margin-left: 16px; padding-left: 8px; border-left: 2px solid var(--accent-primary);"
|
||||
: ""}
|
||||
>
|
||||
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
|
||||
{#if line.parentToolUseId}
|
||||
<span class="text-xs mr-2 opacity-60" title="Message from subagent">
|
||||
<svg
|
||||
class="inline-block w-3 h-3 -mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
{#if line.type === "thinking"}
|
||||
{#if showThinking}
|
||||
<ThinkingBlock content={line.content} timestamp={line.timestamp} />
|
||||
{/if}
|
||||
{:else}
|
||||
<div
|
||||
class="terminal-line mb-2 {getLineClass(line.type)} relative group"
|
||||
style={line.parentToolUseId
|
||||
? "margin-left: 16px; padding-left: 8px; border-left: 2px solid var(--accent-primary);"
|
||||
: ""}
|
||||
>
|
||||
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
|
||||
{#if line.parentToolUseId}
|
||||
<span class="text-xs mr-2 opacity-60" title="Message from subagent">
|
||||
<svg
|
||||
class="inline-block w-3 h-3 -mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{/if}
|
||||
{#if line.cost && line.cost.costUsd > 0}
|
||||
<span
|
||||
class="terminal-cost text-xs mr-2"
|
||||
title="Input: {line.cost.inputTokens} | Output: {line.cost.outputTokens}"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
${line.cost.costUsd < 0.01
|
||||
? line.cost.costUsd.toFixed(4)
|
||||
: line.cost.costUsd.toFixed(3)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if getLinePrefix(line.type)}
|
||||
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
|
||||
{/if}
|
||||
{#if line.toolName}
|
||||
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
|
||||
{/if}
|
||||
{#if line.type === "assistant" || line.type === "user"}
|
||||
<div class="message-content-wrapper">
|
||||
<Markdown
|
||||
content={maskPaths(line.content, hidePaths)}
|
||||
searchQuery={currentSearchQuery}
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{/if}
|
||||
{#if line.cost && line.cost.costUsd > 0}
|
||||
<span
|
||||
class="terminal-cost text-xs mr-2"
|
||||
title="Input: {line.cost.inputTokens} | Output: {line.cost.outputTokens}"
|
||||
>
|
||||
${line.cost.costUsd < 0.01
|
||||
? line.cost.costUsd.toFixed(4)
|
||||
: line.cost.costUsd.toFixed(3)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if getLinePrefix(line.type)}
|
||||
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
|
||||
{/if}
|
||||
{#if line.toolName}
|
||||
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
|
||||
{/if}
|
||||
{#if line.type === "assistant" || line.type === "user"}
|
||||
<div class="message-content-wrapper">
|
||||
<Markdown
|
||||
<button
|
||||
class="copy-message-btn opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onclick={() => handleCopyMessage(line.id, line.content)}
|
||||
title="Copy message"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
<span class="copy-text">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<HighlightedText
|
||||
content={maskPaths(line.content, hidePaths)}
|
||||
searchQuery={currentSearchQuery}
|
||||
/>
|
||||
<button
|
||||
class="copy-message-btn opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onclick={() => handleCopyMessage(line.id, line.content)}
|
||||
title="Copy message"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
<span class="copy-text">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<HighlightedText
|
||||
content={maskPaths(line.content, hidePaths)}
|
||||
searchQuery={currentSearchQuery}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
let { content, timestamp }: Props = $props();
|
||||
let isExpanded = $state(false);
|
||||
|
||||
function toggleExpanded() {
|
||||
isExpanded = !isExpanded;
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="thinking-block">
|
||||
<button class="thinking-header" onclick={toggleExpanded} type="button">
|
||||
<span class="thinking-timestamp">{formatTime(timestamp)}</span>
|
||||
<svg
|
||||
class="thinking-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="thinking-label">Extended Thinking</span>
|
||||
<svg
|
||||
class="chevron"
|
||||
class:expanded={isExpanded}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
width="14"
|
||||
height="14"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if isExpanded}
|
||||
<div class="thinking-content">
|
||||
{content}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.thinking-block {
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--bg-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.thinking-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.thinking-header:hover {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.thinking-timestamp {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.thinking-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.thinking-label {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.chevron.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.thinking-content {
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -193,6 +193,7 @@ describe("config store", () => {
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
show_thinking_blocks: true,
|
||||
};
|
||||
|
||||
expect(config.model).toBe("claude-sonnet-4");
|
||||
@@ -238,6 +239,7 @@ describe("config store", () => {
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
show_thinking_blocks: true,
|
||||
};
|
||||
|
||||
expect(config.model).toBeNull();
|
||||
@@ -773,6 +775,7 @@ describe("config store", () => {
|
||||
budget_action: "block",
|
||||
budget_warning_threshold: 0.9,
|
||||
discord_rpc_enabled: false,
|
||||
show_thinking_blocks: true,
|
||||
};
|
||||
|
||||
const mockInvokeImpl = vi.mocked(invoke);
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface HikariConfig {
|
||||
budget_warning_threshold: number;
|
||||
// Discord RPC settings
|
||||
discord_rpc_enabled: boolean;
|
||||
// Thinking blocks settings
|
||||
show_thinking_blocks: boolean;
|
||||
}
|
||||
|
||||
const defaultConfig: HikariConfig = {
|
||||
@@ -84,6 +86,7 @@ const defaultConfig: HikariConfig = {
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
show_thinking_blocks: true,
|
||||
};
|
||||
|
||||
function createConfigStore() {
|
||||
@@ -297,6 +300,10 @@ export const shouldHidePaths = derived(
|
||||
configStore.config,
|
||||
($config) => $config.streamer_mode && $config.streamer_hide_paths
|
||||
);
|
||||
export const showThinkingBlocks = derived(
|
||||
configStore.config,
|
||||
($config) => $config.show_thinking_blocks
|
||||
);
|
||||
|
||||
/**
|
||||
* Masks file paths in text when streamer mode with hide paths is enabled.
|
||||
|
||||
+2
-2
@@ -272,7 +272,7 @@ export async function initializeTauriListeners() {
|
||||
if (conversation_id) {
|
||||
claudeStore.addLineToConversation(
|
||||
conversation_id,
|
||||
line_type as "user" | "assistant" | "system" | "tool" | "error",
|
||||
line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking",
|
||||
content,
|
||||
tool_name || undefined,
|
||||
costData,
|
||||
@@ -281,7 +281,7 @@ export async function initializeTauriListeners() {
|
||||
} else {
|
||||
// Fallback to active conversation if no conversation_id provided
|
||||
claudeStore.addLine(
|
||||
line_type as "user" | "assistant" | "system" | "tool" | "error",
|
||||
line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking",
|
||||
content,
|
||||
tool_name || undefined,
|
||||
costData,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export interface TerminalLine {
|
||||
id: string;
|
||||
type: "user" | "assistant" | "system" | "tool" | "error";
|
||||
type: "user" | "assistant" | "system" | "tool" | "error" | "thinking";
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
toolName?: string;
|
||||
|
||||
Reference in New Issue
Block a user