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(
|
let _ = app.emit(
|
||||||
"claude:output",
|
"claude:output",
|
||||||
OutputEvent {
|
OutputEvent {
|
||||||
line_type: "system".to_string(),
|
line_type: "thinking".to_string(),
|
||||||
content: format!("[Thinking] {}", thinking),
|
content: thinking.clone(),
|
||||||
tool_name: None,
|
tool_name: None,
|
||||||
conversation_id: conversation_id.clone(),
|
conversation_id: conversation_id.clone(),
|
||||||
cost: None,
|
cost: None,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
budget_action: "warn",
|
budget_action: "warn",
|
||||||
budget_warning_threshold: 0.8,
|
budget_warning_threshold: 0.8,
|
||||||
discord_rpc_enabled: true,
|
discord_rpc_enabled: true,
|
||||||
|
show_thinking_blocks: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
let showCustomThemeEditor = $state(false);
|
let showCustomThemeEditor = $state(false);
|
||||||
@@ -703,6 +704,22 @@
|
|||||||
Use Ctrl++ / Ctrl+- to quickly adjust, Ctrl+0 to reset
|
Use Ctrl++ / Ctrl+- to quickly adjust, Ctrl+0 to reset
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
<!-- Window Section -->
|
<!-- Window Section -->
|
||||||
|
|||||||
@@ -92,6 +92,7 @@
|
|||||||
budget_action: "warn",
|
budget_action: "warn",
|
||||||
budget_warning_threshold: 0.8,
|
budget_warning_threshold: 0.8,
|
||||||
discord_rpc_enabled: true,
|
discord_rpc_enabled: true,
|
||||||
|
show_thinking_blocks: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
let streamerModeActive = $state(false);
|
let streamerModeActive = $state(false);
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
import ConversationTabs from "./ConversationTabs.svelte";
|
import ConversationTabs from "./ConversationTabs.svelte";
|
||||||
import Markdown from "./Markdown.svelte";
|
import Markdown from "./Markdown.svelte";
|
||||||
import HighlightedText from "./HighlightedText.svelte";
|
import HighlightedText from "./HighlightedText.svelte";
|
||||||
|
import ThinkingBlock from "./ThinkingBlock.svelte";
|
||||||
import { searchState, searchQuery } from "$lib/stores/search";
|
import { searchState, searchQuery } from "$lib/stores/search";
|
||||||
import { clipboardStore } from "$lib/stores/clipboard";
|
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 terminalElement: HTMLDivElement;
|
||||||
let shouldAutoScroll = true;
|
let shouldAutoScroll = true;
|
||||||
@@ -24,6 +25,11 @@
|
|||||||
hidePaths = value;
|
hidePaths = value;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let showThinking = true;
|
||||||
|
showThinkingBlocks.subscribe((value) => {
|
||||||
|
showThinking = value;
|
||||||
|
});
|
||||||
|
|
||||||
claudeStore.terminalLines.subscribe((value) => {
|
claudeStore.terminalLines.subscribe((value) => {
|
||||||
lines = value;
|
lines = value;
|
||||||
});
|
});
|
||||||
@@ -84,6 +90,8 @@
|
|||||||
return "terminal-tool";
|
return "terminal-tool";
|
||||||
case "error":
|
case "error":
|
||||||
return "terminal-error";
|
return "terminal-error";
|
||||||
|
case "thinking":
|
||||||
|
return "terminal-thinking";
|
||||||
default:
|
default:
|
||||||
return "terminal-default";
|
return "terminal-default";
|
||||||
}
|
}
|
||||||
@@ -209,80 +217,86 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each lines as line (line.id)}
|
{#each lines as line (line.id)}
|
||||||
<div
|
{#if line.type === "thinking"}
|
||||||
class="terminal-line mb-2 {getLineClass(line.type)} relative group"
|
{#if showThinking}
|
||||||
style={line.parentToolUseId
|
<ThinkingBlock content={line.content} timestamp={line.timestamp} />
|
||||||
? "margin-left: 16px; padding-left: 8px; border-left: 2px solid var(--accent-primary);"
|
{/if}
|
||||||
: ""}
|
{:else}
|
||||||
>
|
<div
|
||||||
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
|
class="terminal-line mb-2 {getLineClass(line.type)} relative group"
|
||||||
{#if line.parentToolUseId}
|
style={line.parentToolUseId
|
||||||
<span class="text-xs mr-2 opacity-60" title="Message from subagent">
|
? "margin-left: 16px; padding-left: 8px; border-left: 2px solid var(--accent-primary);"
|
||||||
<svg
|
: ""}
|
||||||
class="inline-block w-3 h-3 -mt-0.5"
|
>
|
||||||
fill="none"
|
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
|
||||||
stroke="currentColor"
|
{#if line.parentToolUseId}
|
||||||
viewBox="0 0 24 24"
|
<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
|
${line.cost.costUsd < 0.01
|
||||||
stroke-linecap="round"
|
? line.cost.costUsd.toFixed(4)
|
||||||
stroke-linejoin="round"
|
: line.cost.costUsd.toFixed(3)}
|
||||||
stroke-width="2"
|
</span>
|
||||||
d="M9 5l7 7-7 7"
|
{/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>
|
<button
|
||||||
</span>
|
class="copy-message-btn opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
{/if}
|
onclick={() => handleCopyMessage(line.id, line.content)}
|
||||||
{#if line.cost && line.cost.costUsd > 0}
|
title="Copy message"
|
||||||
<span
|
>
|
||||||
class="terminal-cost text-xs mr-2"
|
<svg
|
||||||
title="Input: {line.cost.inputTokens} | Output: {line.cost.outputTokens}"
|
width="14"
|
||||||
>
|
height="14"
|
||||||
${line.cost.costUsd < 0.01
|
viewBox="0 0 24 24"
|
||||||
? line.cost.costUsd.toFixed(4)
|
fill="none"
|
||||||
: line.cost.costUsd.toFixed(3)}
|
stroke="currentColor"
|
||||||
</span>
|
stroke-width="2"
|
||||||
{/if}
|
stroke-linecap="round"
|
||||||
{#if getLinePrefix(line.type)}
|
stroke-linejoin="round"
|
||||||
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
|
>
|
||||||
{/if}
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||||
{#if line.toolName}
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||||
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
|
</svg>
|
||||||
{/if}
|
<span class="copy-text">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span>
|
||||||
{#if line.type === "assistant" || line.type === "user"}
|
</button>
|
||||||
<div class="message-content-wrapper">
|
</div>
|
||||||
<Markdown
|
{:else}
|
||||||
|
<HighlightedText
|
||||||
content={maskPaths(line.content, hidePaths)}
|
content={maskPaths(line.content, hidePaths)}
|
||||||
searchQuery={currentSearchQuery}
|
searchQuery={currentSearchQuery}
|
||||||
/>
|
/>
|
||||||
<button
|
{/if}
|
||||||
class="copy-message-btn opacity-0 group-hover:opacity-100 transition-opacity"
|
</div>
|
||||||
onclick={() => handleCopyMessage(line.id, line.content)}
|
{/if}
|
||||||
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>
|
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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_action: "warn",
|
||||||
budget_warning_threshold: 0.8,
|
budget_warning_threshold: 0.8,
|
||||||
discord_rpc_enabled: true,
|
discord_rpc_enabled: true,
|
||||||
|
show_thinking_blocks: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(config.model).toBe("claude-sonnet-4");
|
expect(config.model).toBe("claude-sonnet-4");
|
||||||
@@ -238,6 +239,7 @@ describe("config store", () => {
|
|||||||
budget_action: "warn",
|
budget_action: "warn",
|
||||||
budget_warning_threshold: 0.8,
|
budget_warning_threshold: 0.8,
|
||||||
discord_rpc_enabled: true,
|
discord_rpc_enabled: true,
|
||||||
|
show_thinking_blocks: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(config.model).toBeNull();
|
expect(config.model).toBeNull();
|
||||||
@@ -773,6 +775,7 @@ describe("config store", () => {
|
|||||||
budget_action: "block",
|
budget_action: "block",
|
||||||
budget_warning_threshold: 0.9,
|
budget_warning_threshold: 0.9,
|
||||||
discord_rpc_enabled: false,
|
discord_rpc_enabled: false,
|
||||||
|
show_thinking_blocks: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockInvokeImpl = vi.mocked(invoke);
|
const mockInvokeImpl = vi.mocked(invoke);
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export interface HikariConfig {
|
|||||||
budget_warning_threshold: number;
|
budget_warning_threshold: number;
|
||||||
// Discord RPC settings
|
// Discord RPC settings
|
||||||
discord_rpc_enabled: boolean;
|
discord_rpc_enabled: boolean;
|
||||||
|
// Thinking blocks settings
|
||||||
|
show_thinking_blocks: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultConfig: HikariConfig = {
|
const defaultConfig: HikariConfig = {
|
||||||
@@ -84,6 +86,7 @@ const defaultConfig: HikariConfig = {
|
|||||||
budget_action: "warn",
|
budget_action: "warn",
|
||||||
budget_warning_threshold: 0.8,
|
budget_warning_threshold: 0.8,
|
||||||
discord_rpc_enabled: true,
|
discord_rpc_enabled: true,
|
||||||
|
show_thinking_blocks: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
function createConfigStore() {
|
function createConfigStore() {
|
||||||
@@ -297,6 +300,10 @@ export const shouldHidePaths = derived(
|
|||||||
configStore.config,
|
configStore.config,
|
||||||
($config) => $config.streamer_mode && $config.streamer_hide_paths
|
($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.
|
* 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) {
|
if (conversation_id) {
|
||||||
claudeStore.addLineToConversation(
|
claudeStore.addLineToConversation(
|
||||||
conversation_id,
|
conversation_id,
|
||||||
line_type as "user" | "assistant" | "system" | "tool" | "error",
|
line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking",
|
||||||
content,
|
content,
|
||||||
tool_name || undefined,
|
tool_name || undefined,
|
||||||
costData,
|
costData,
|
||||||
@@ -281,7 +281,7 @@ export async function initializeTauriListeners() {
|
|||||||
} else {
|
} else {
|
||||||
// Fallback to active conversation if no conversation_id provided
|
// Fallback to active conversation if no conversation_id provided
|
||||||
claudeStore.addLine(
|
claudeStore.addLine(
|
||||||
line_type as "user" | "assistant" | "system" | "tool" | "error",
|
line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking",
|
||||||
content,
|
content,
|
||||||
tool_name || undefined,
|
tool_name || undefined,
|
||||||
costData,
|
costData,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export interface TerminalLine {
|
export interface TerminalLine {
|
||||||
id: string;
|
id: string;
|
||||||
type: "user" | "assistant" | "system" | "tool" | "error";
|
type: "user" | "assistant" | "system" | "tool" | "error" | "thinking";
|
||||||
content: string;
|
content: string;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user