Version column in tasks table with WHERE id=? AND version=? guard. Conflict detection in TaskBoardTab. error-classifier.ts: 6 error types with actionable messages and retry logic. UsageMeter.svelte.
1556 lines
48 KiB
Svelte
1556 lines
48 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { marked, Renderer } from 'marked';
|
|
import { queryAgent, stopAgent, isAgentReady, restartAgent } from '../../adapters/agent-bridge';
|
|
import {
|
|
getAgentSession,
|
|
createAgentSession,
|
|
getChildSessions,
|
|
getTotalCost,
|
|
} from '../../stores/agents.svelte';
|
|
import { focusPane } from '../../stores/layout.svelte';
|
|
import { isSidecarAlive, setSidecarAlive } from '../../agent-dispatcher';
|
|
import { listProfiles, listSkills, readSkill, type ClaudeProfile, type ClaudeSkill } from '../../adapters/claude-bridge';
|
|
import { getInjectableAnchors, getProjectAnchors, addAnchors, removeAnchor } from '../../stores/anchors.svelte';
|
|
import { estimateTokens } from '../../utils/anchor-serializer';
|
|
import type { SessionAnchor } from '../../types/anchors';
|
|
|
|
import AgentTree from './AgentTree.svelte';
|
|
import UsageMeter from './UsageMeter.svelte';
|
|
import { notify } from '../../stores/notifications.svelte';
|
|
import { getHighlighter, highlightCode, escapeHtml } from '../../utils/highlight';
|
|
import type {
|
|
TextContent,
|
|
ThinkingContent,
|
|
ToolCallContent,
|
|
ToolResultContent,
|
|
CostContent,
|
|
ErrorContent,
|
|
StatusContent,
|
|
} from '../../adapters/claude-messages';
|
|
import type { ProviderId, ProviderCapabilities } from '../../providers/types';
|
|
|
|
// Tool-aware truncation limits
|
|
const MAX_BASH_LINES = 500;
|
|
const MAX_READ_LINES = 50;
|
|
const MAX_GLOB_LINES = 20;
|
|
const MAX_DEFAULT_LINES = 30;
|
|
|
|
// Default capabilities (Claude — all enabled)
|
|
const DEFAULT_CAPABILITIES: ProviderCapabilities = {
|
|
hasProfiles: true,
|
|
hasSkills: true,
|
|
hasModelSelection: true,
|
|
hasSandbox: false,
|
|
supportsSubagents: true,
|
|
supportsCost: true,
|
|
supportsResume: true,
|
|
};
|
|
|
|
interface Props {
|
|
sessionId: string;
|
|
projectId?: string;
|
|
prompt?: string;
|
|
cwd?: string;
|
|
profile?: string;
|
|
provider?: ProviderId;
|
|
capabilities?: ProviderCapabilities;
|
|
useWorktrees?: boolean;
|
|
/** Prepended to system_prompt for agent role instructions */
|
|
agentSystemPrompt?: string;
|
|
/** Extra env vars injected into agent process (e.g. BTMSG_AGENT_ID) */
|
|
extraEnv?: Record<string, string>;
|
|
/** Auto-triggered prompt (e.g. periodic context refresh). Picked up when agent is idle. */
|
|
autoPrompt?: string;
|
|
/** Called when autoPrompt has been consumed */
|
|
onautopromptconsumed?: () => void;
|
|
onExit?: () => void;
|
|
}
|
|
|
|
let { sessionId, projectId, prompt: initialPrompt = '', cwd: initialCwd, profile: profileName, provider: providerId = 'claude', capabilities = DEFAULT_CAPABILITIES, useWorktrees = false, agentSystemPrompt, extraEnv, autoPrompt, onautopromptconsumed, onExit }: Props = $props();
|
|
|
|
let session = $derived(getAgentSession(sessionId));
|
|
let inputPrompt = $state(initialPrompt);
|
|
let scrollContainer: HTMLDivElement | undefined = $state();
|
|
let autoScroll = $state(true);
|
|
let restarting = $state(false);
|
|
let showTree = $state(false);
|
|
let hasToolCalls = $derived(session?.messages.some(m => m.type === 'tool_call') ?? false);
|
|
let parentSession = $derived(session?.parentSessionId ? getAgentSession(session.parentSessionId) : undefined);
|
|
let childSessions = $derived(session ? getChildSessions(session.id) : []);
|
|
let totalCost = $derived(session && childSessions.length > 0 ? getTotalCost(session.id) : null);
|
|
let isRunning = $derived(session?.status === 'running' || session?.status === 'starting');
|
|
|
|
// Tool result map — pairs tool_call with tool_result by toolUseId
|
|
// Cache guard: only rescan when user-role message count changes (tool_results come in user messages)
|
|
let _cachedResultMap: Record<string, ToolResultContent> = {};
|
|
let _cachedUserMsgCount = -1;
|
|
let toolResultMap = $derived.by((): Record<string, ToolResultContent> => {
|
|
if (!session) return {};
|
|
const userMsgCount = session.messages.filter(m => m.type === 'tool_result').length;
|
|
if (userMsgCount === _cachedUserMsgCount) return _cachedResultMap;
|
|
const map: Record<string, ToolResultContent> = {};
|
|
for (const msg of session.messages) {
|
|
if (msg.type === 'tool_result') {
|
|
const tr = msg.content as ToolResultContent;
|
|
map[tr.toolUseId] = tr;
|
|
}
|
|
}
|
|
_cachedUserMsgCount = userMsgCount;
|
|
_cachedResultMap = map;
|
|
return map;
|
|
});
|
|
|
|
// Profile list (for resolving profileName to config_dir)
|
|
let profiles = $state<ClaudeProfile[]>([]);
|
|
|
|
// Skill autocomplete
|
|
let skills = $state<ClaudeSkill[]>([]);
|
|
let showSkillMenu = $state(false);
|
|
let filteredSkills = $derived(
|
|
inputPrompt.startsWith('/')
|
|
? skills.filter(s => s.name.toLowerCase().startsWith(inputPrompt.slice(1).toLowerCase()))
|
|
: []
|
|
);
|
|
let skillMenuIndex = $state(0);
|
|
|
|
// Track expanded state for tool truncation
|
|
let expandedTools = $state<Set<string>>(new Set());
|
|
|
|
const mdRenderer = new Renderer();
|
|
mdRenderer.code = function({ text, lang }: { text: string; lang?: string }) {
|
|
if (lang) {
|
|
const highlighted = highlightCode(text, lang);
|
|
if (highlighted !== escapeHtml(text)) return highlighted;
|
|
}
|
|
return `<pre><code>${escapeHtml(text)}</code></pre>`;
|
|
};
|
|
|
|
function renderMarkdown(source: string): string {
|
|
try {
|
|
return marked.parse(source, { renderer: mdRenderer, async: false }) as string;
|
|
} catch {
|
|
return escapeHtml(source);
|
|
}
|
|
}
|
|
|
|
onMount(async () => {
|
|
await getHighlighter();
|
|
// Only load profiles/skills for providers that support them
|
|
const [profileList, skillList] = await Promise.all([
|
|
capabilities.hasProfiles ? listProfiles().catch(() => []) : Promise.resolve([]),
|
|
capabilities.hasSkills ? listSkills().catch(() => []) : Promise.resolve([]),
|
|
]);
|
|
profiles = profileList;
|
|
skills = skillList;
|
|
if (initialPrompt) {
|
|
await startQuery(initialPrompt);
|
|
}
|
|
});
|
|
|
|
// NOTE: Do NOT stop agents in onDestroy — it fires on layout changes/remounts,
|
|
// not just explicit close. Stop-on-close is handled by workspace teardown.
|
|
|
|
// Auto-prompt: pick up externally triggered prompts (e.g. periodic context refresh)
|
|
$effect(() => {
|
|
if (!autoPrompt || isRunning) return;
|
|
// Only trigger if session exists and is idle (done/error)
|
|
if (!session || (session.status !== 'done' && session.status !== 'error')) return;
|
|
const prompt = autoPrompt;
|
|
onautopromptconsumed?.();
|
|
startQuery(prompt, true); // resume session with context refresh
|
|
});
|
|
|
|
let promptRef = $state<HTMLTextAreaElement | undefined>();
|
|
|
|
async function startQuery(text: string, resume = false) {
|
|
if (!text.trim()) return;
|
|
|
|
const ready = await isAgentReady();
|
|
if (!ready) {
|
|
if (!resume) createAgentSession(sessionId, text);
|
|
const { updateAgentStatus } = await import('../../stores/agents.svelte');
|
|
updateAgentStatus(sessionId, 'error', 'Sidecar not ready — agent features unavailable');
|
|
return;
|
|
}
|
|
|
|
const resumeId = resume ? session?.sdkSessionId : undefined;
|
|
|
|
if (!resume) {
|
|
createAgentSession(sessionId, text);
|
|
} else {
|
|
const { updateAgentStatus } = await import('../../stores/agents.svelte');
|
|
updateAgentStatus(sessionId, 'starting');
|
|
}
|
|
|
|
const profile = profileName ? profiles.find(p => p.name === profileName) : undefined;
|
|
|
|
// Build system prompt: agent role instructions + anchor re-injection
|
|
const promptParts: string[] = [];
|
|
if (agentSystemPrompt) {
|
|
promptParts.push(agentSystemPrompt);
|
|
}
|
|
if (projectId) {
|
|
const anchors = getInjectableAnchors(projectId);
|
|
if (anchors.length > 0) {
|
|
promptParts.push(anchors.map(a => a.content).join('\n'));
|
|
}
|
|
}
|
|
const systemPrompt = promptParts.length > 0 ? promptParts.join('\n\n') : undefined;
|
|
|
|
await queryAgent({
|
|
provider: providerId,
|
|
session_id: sessionId,
|
|
prompt: text,
|
|
cwd: initialCwd || undefined,
|
|
max_turns: 50,
|
|
resume_session_id: resumeId,
|
|
setting_sources: ['user', 'project'],
|
|
claude_config_dir: profile?.config_dir,
|
|
system_prompt: systemPrompt,
|
|
worktree_name: useWorktrees ? sessionId : undefined,
|
|
extra_env: extraEnv,
|
|
});
|
|
inputPrompt = '';
|
|
if (promptRef) {
|
|
promptRef.style.height = 'auto';
|
|
}
|
|
}
|
|
|
|
async function expandSkillPrompt(text: string): Promise<string> {
|
|
if (!text.startsWith('/')) return text;
|
|
const skillName = text.slice(1).split(/\s+/)[0];
|
|
const skill = skills.find(s => s.name === skillName);
|
|
if (!skill) return text;
|
|
try {
|
|
const content = await readSkill(skill.source_path);
|
|
const args = text.slice(1 + skillName.length).trim();
|
|
return args ? `${content}\n\nUser input: ${args}` : content;
|
|
} catch {
|
|
return text;
|
|
}
|
|
}
|
|
|
|
async function handleUnifiedSubmit() {
|
|
if (!inputPrompt.trim() || isRunning) return;
|
|
const expanded = await expandSkillPrompt(inputPrompt);
|
|
showSkillMenu = false;
|
|
const isResume = !!(session?.sdkSessionId && session.messages.length > 0);
|
|
startQuery(expanded, isResume);
|
|
}
|
|
|
|
function handleNewSession() {
|
|
onExit?.();
|
|
}
|
|
|
|
function autoResizeTextarea(el: HTMLTextAreaElement) {
|
|
el.style.height = 'auto';
|
|
el.style.height = Math.min(el.scrollHeight, 150) + 'px';
|
|
}
|
|
|
|
function handleSkillSelect(skill: ClaudeSkill) {
|
|
inputPrompt = `/${skill.name} `;
|
|
showSkillMenu = false;
|
|
}
|
|
|
|
function handleStop() {
|
|
stopAgent(sessionId).catch(() => {});
|
|
}
|
|
|
|
async function handleRestart() {
|
|
restarting = true;
|
|
try {
|
|
await restartAgent();
|
|
setSidecarAlive(true);
|
|
} catch {
|
|
// Still dead
|
|
} finally {
|
|
restarting = false;
|
|
}
|
|
}
|
|
|
|
function handleScroll() {
|
|
if (!scrollContainer) return;
|
|
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
|
|
autoScroll = scrollHeight - scrollTop - clientHeight < 50;
|
|
}
|
|
|
|
function handleTreeNodeClick(nodeId: string) {
|
|
if (!scrollContainer || !session) return;
|
|
const msg = session.messages.find(
|
|
m => m.type === 'tool_call' && (m.content as ToolCallContent).toolUseId === nodeId
|
|
);
|
|
if (!msg) return;
|
|
autoScroll = false;
|
|
scrollContainer.querySelector('#msg-' + CSS.escape(msg.id))?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
|
|
// Scroll anchoring: two-phase pattern
|
|
let wasNearBottom = true;
|
|
$effect.pre(() => {
|
|
if (session?.messages.length !== undefined) {
|
|
wasNearBottom = scrollContainer
|
|
? scrollContainer.scrollTop + scrollContainer.clientHeight >= scrollContainer.scrollHeight - 80
|
|
: true;
|
|
}
|
|
});
|
|
$effect(() => {
|
|
if (session?.messages.length !== undefined && wasNearBottom && autoScroll) {
|
|
scrollContainer?.querySelector('#message-end')?.scrollIntoView({ behavior: 'instant' as ScrollBehavior });
|
|
}
|
|
});
|
|
|
|
// --- Anchor pinning ---
|
|
let projectAnchorIds = $derived(
|
|
projectId ? new Set(getProjectAnchors(projectId).map(a => a.messageId)) : new Set<string>()
|
|
);
|
|
|
|
function isMessagePinned(msgId: string): boolean {
|
|
return projectAnchorIds.has(msgId);
|
|
}
|
|
|
|
async function togglePin(msgId: string, content: string) {
|
|
if (!projectId) return;
|
|
if (isMessagePinned(msgId)) {
|
|
const anchors = getProjectAnchors(projectId);
|
|
const anchor = anchors.find(a => a.messageId === msgId);
|
|
if (anchor) await removeAnchor(projectId, anchor.id);
|
|
} else {
|
|
const anchor: SessionAnchor = {
|
|
id: crypto.randomUUID(),
|
|
projectId,
|
|
messageId: msgId,
|
|
anchorType: 'pinned',
|
|
content,
|
|
estimatedTokens: estimateTokens(content),
|
|
turnIndex: -1, // Manual pins don't track turn index
|
|
createdAt: Math.floor(Date.now() / 1000),
|
|
};
|
|
await addAnchors(projectId, [anchor]);
|
|
}
|
|
}
|
|
|
|
function formatToolInput(input: unknown): string {
|
|
if (typeof input === 'string') return input;
|
|
try {
|
|
return JSON.stringify(input, null, 2);
|
|
} catch {
|
|
return String(input);
|
|
}
|
|
}
|
|
|
|
/** Get truncation limit for a tool name */
|
|
function getTruncationLimit(toolName: string): number {
|
|
const name = toolName.toLowerCase();
|
|
if (name === 'bash' || name.includes('bash')) return MAX_BASH_LINES;
|
|
if (name === 'read' || name === 'write' || name === 'edit') return MAX_READ_LINES;
|
|
if (name === 'glob' || name === 'grep' || name === 'ls') return MAX_GLOB_LINES;
|
|
return MAX_DEFAULT_LINES;
|
|
}
|
|
|
|
/** Truncate text by lines, return { text, truncated, totalLines } */
|
|
function truncateByLines(text: string, maxLines: number): { text: string; truncated: boolean; totalLines: number } {
|
|
const lines = text.split('\n');
|
|
if (lines.length <= maxLines) return { text, truncated: false, totalLines: lines.length };
|
|
return { text: lines.slice(0, maxLines).join('\n'), truncated: true, totalLines: lines.length };
|
|
}
|
|
|
|
/** Check if a status message is a hook event */
|
|
function isHookMessage(content: StatusContent): boolean {
|
|
return content.subtype === 'hook_started' || content.subtype === 'hook_response';
|
|
}
|
|
|
|
/** Get display name for hook subtype */
|
|
function hookDisplayName(subtype: string): string {
|
|
if (subtype === 'hook_started') return 'Hook started';
|
|
if (subtype === 'hook_response') return 'Hook response';
|
|
return subtype;
|
|
}
|
|
|
|
// Context meter: estimate percentage of context window used
|
|
const DEFAULT_CONTEXT_LIMIT = 200_000;
|
|
let contextPercent = $derived.by(() => {
|
|
if (!session) return 0;
|
|
const totalTokens = session.inputTokens + session.outputTokens;
|
|
if (totalTokens === 0) return 0;
|
|
return Math.min(100, Math.round((totalTokens / DEFAULT_CONTEXT_LIMIT) * 100));
|
|
});
|
|
|
|
// Context meter color class based on thresholds
|
|
let contextColorClass = $derived.by(() => {
|
|
if (contextPercent >= 90) return 'context-critical';
|
|
if (contextPercent >= 75) return 'context-high';
|
|
if (contextPercent >= 50) return 'context-medium';
|
|
return '';
|
|
});
|
|
|
|
// Session burn rate ($/hr)
|
|
let burnRatePerHr = $derived.by(() => {
|
|
if (!session || session.durationMs <= 0 || session.costUsd <= 0) return 0;
|
|
return (session.costUsd / session.durationMs) * 3_600_000;
|
|
});
|
|
|
|
// 90% context warning (fire once per session)
|
|
let contextWarningFired = $state(false);
|
|
$effect(() => {
|
|
if (contextPercent >= 90 && !contextWarningFired && session?.status === 'running') {
|
|
contextWarningFired = true;
|
|
notify('warning', `Context usage at ${contextPercent}% — approaching model limit`);
|
|
}
|
|
});
|
|
// Reset warning tracker when session changes
|
|
$effect(() => {
|
|
if (session?.id) {
|
|
contextWarningFired = false;
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<div class="agent-pane" data-testid="agent-pane" data-agent-status={session?.status ?? 'idle'}>
|
|
{#if parentSession}
|
|
<div class="parent-link">
|
|
<span class="parent-badge">SUB</span>
|
|
<button class="parent-btn" onclick={() => focusPane(parentSession!.id)}>
|
|
← {parentSession.prompt ? parentSession.prompt.slice(0, 40) : 'Parent agent'}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{#if childSessions.length > 0}
|
|
<div class="children-bar">
|
|
<span class="children-label">{childSessions.length} subagent{childSessions.length > 1 ? 's' : ''}</span>
|
|
{#each childSessions as child (child.id)}
|
|
<button class="child-chip" class:running={child.status === 'running'} class:done={child.status === 'done'} class:error={child.status === 'error'} onclick={() => focusPane(child.id)}>
|
|
{child.prompt.slice(0, 20)}{child.prompt.length > 20 ? '...' : ''}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{#if hasToolCalls}
|
|
<div class="tree-toggle">
|
|
<button class="tree-btn" onclick={() => showTree = !showTree}>
|
|
{showTree ? '▼' : '▶'} Agent Tree
|
|
</button>
|
|
</div>
|
|
{#if showTree && session}
|
|
<AgentTree {session} onNodeClick={handleTreeNodeClick} />
|
|
{/if}
|
|
{/if}
|
|
|
|
<div class="agent-pane-scroll" data-testid="agent-messages" bind:this={scrollContainer} onscroll={handleScroll}>
|
|
{#if !session || session.messages.length === 0}
|
|
<div class="welcome-state">
|
|
<div class="welcome-icon">
|
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
|
</svg>
|
|
</div>
|
|
<span class="welcome-text">Ask Claude anything</span>
|
|
<span class="welcome-hint">Type / for skills • Shift+Enter for newline</span>
|
|
</div>
|
|
{:else}
|
|
{#each session.messages as msg (msg.id)}
|
|
<div class="message msg-{msg.type}" id="msg-{msg.id}">
|
|
{#if msg.type === 'init'}
|
|
<div class="msg-init">
|
|
<span class="label">Session started</span>
|
|
<span class="model">{(msg.content as import('../../adapters/claude-messages').InitContent).model}</span>
|
|
</div>
|
|
{:else if msg.type === 'text'}
|
|
{@const textContent = (msg.content as TextContent).text}
|
|
{@const firstLine = textContent.split('\n')[0].slice(0, 120)}
|
|
<details class="msg-text-collapsible" open>
|
|
<summary>
|
|
<span class="chevron" aria-hidden="true">▶</span>
|
|
<span class="text-preview">{firstLine}{firstLine.length >= 120 ? '...' : ''}</span>
|
|
{#if projectId}
|
|
<button
|
|
class="pin-btn"
|
|
class:pinned={isMessagePinned(msg.id)}
|
|
title={isMessagePinned(msg.id) ? 'Unpin message' : 'Pin as anchor'}
|
|
onclick={(e: MouseEvent) => { e.stopPropagation(); togglePin(msg.id, textContent); }}
|
|
aria-label={isMessagePinned(msg.id) ? 'Unpin message' : 'Pin as anchor'}
|
|
>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill={isMessagePinned(msg.id) ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2">
|
|
<path d="M12 2L12 12M12 12L8 8M12 12L16 8M5 21L12 15L19 21" />
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
</summary>
|
|
<div class="msg-text markdown-body">{@html renderMarkdown(textContent)}</div>
|
|
</details>
|
|
{:else if msg.type === 'thinking'}
|
|
<details class="msg-thinking">
|
|
<summary><span class="chevron" aria-hidden="true">▶</span> Thinking...</summary>
|
|
<pre>{(msg.content as ThinkingContent).text}</pre>
|
|
</details>
|
|
{:else if msg.type === 'tool_call'}
|
|
{@const tc = msg.content as ToolCallContent}
|
|
{@const pairedResult = toolResultMap[tc.toolUseId]}
|
|
<details class="msg-tool-group">
|
|
<summary>
|
|
<span class="chevron" aria-hidden="true">▶</span>
|
|
<span class="tool-name">{tc.name}</span>
|
|
{#if pairedResult}
|
|
<span class="tool-status tool-status--done">✓</span>
|
|
{:else if isRunning}
|
|
<span class="tool-status tool-status--pending">⋯</span>
|
|
{/if}
|
|
</summary>
|
|
<div class="tool-group-body">
|
|
<div class="tool-section">
|
|
<div class="tool-section-label">Input</div>
|
|
<pre class="tool-input">{formatToolInput(tc.input)}</pre>
|
|
</div>
|
|
{#if pairedResult}
|
|
{@const outputStr = formatToolInput(pairedResult.output)}
|
|
{@const limit = getTruncationLimit(tc.name)}
|
|
{@const truncated = truncateByLines(outputStr, limit)}
|
|
<div class="tool-section">
|
|
<div class="tool-section-label">Output</div>
|
|
{#if truncated.truncated && !expandedTools.has(tc.toolUseId)}
|
|
<pre class="tool-output">{truncated.text}</pre>
|
|
<button class="truncation-btn" onclick={() => { expandedTools = new Set([...expandedTools, tc.toolUseId]); }}>
|
|
Show all ({truncated.totalLines} lines)
|
|
</button>
|
|
{:else}
|
|
<pre class="tool-output">{outputStr}</pre>
|
|
{/if}
|
|
</div>
|
|
{:else if isRunning}
|
|
<div class="tool-pending" role="status">
|
|
<span aria-hidden="true">⋯</span>
|
|
<span class="sr-only">Awaiting tool result</span>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</details>
|
|
{:else if msg.type === 'tool_result'}
|
|
<!-- Tool results rendered inline with their tool_call above; skip standalone rendering -->
|
|
{:else if msg.type === 'cost'}
|
|
{@const cost = msg.content as CostContent}
|
|
<div class="msg-cost">
|
|
<span class="cost-value">${cost.totalCostUsd.toFixed(4)}</span>
|
|
<span class="cost-detail">{cost.inputTokens + cost.outputTokens} tokens</span>
|
|
<span class="cost-detail">{cost.numTurns} turns</span>
|
|
<span class="cost-detail">{(cost.durationMs / 1000).toFixed(1)}s</span>
|
|
</div>
|
|
{#if cost.result}
|
|
<details class="msg-summary-collapsible">
|
|
<summary>
|
|
<span class="chevron" aria-hidden="true">▶</span>
|
|
<span class="summary-preview">{cost.result.split('\n')[0].slice(0, 80)}{cost.result.length > 80 ? '...' : ''}</span>
|
|
</summary>
|
|
<div class="msg-summary">{cost.result}</div>
|
|
</details>
|
|
{/if}
|
|
{:else if msg.type === 'error'}
|
|
<div class="msg-error">{(msg.content as ErrorContent).message}</div>
|
|
{:else if msg.type === 'status'}
|
|
{@const statusContent = msg.content as StatusContent}
|
|
{#if isHookMessage(statusContent)}
|
|
<details class="msg-hook">
|
|
<summary><span class="chevron" aria-hidden="true">▶</span> <span class="hook-icon">⚙</span> {hookDisplayName(statusContent.subtype)}</summary>
|
|
<pre>{statusContent.message || JSON.stringify(msg.content, null, 2)}</pre>
|
|
</details>
|
|
{:else}
|
|
<div class="msg-status">{statusContent.message || statusContent.subtype}</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
<div id="message-end"></div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Context meter + status strip -->
|
|
{#if session}
|
|
<div class="status-strip">
|
|
{#if session.status === 'running' || session.status === 'starting'}
|
|
<div class="running-indicator">
|
|
<span class="pulse"></span>
|
|
<span>Running...</span>
|
|
{#if capabilities.supportsCost && (session.inputTokens > 0 || session.outputTokens > 0)}
|
|
<UsageMeter inputTokens={session.inputTokens} outputTokens={session.outputTokens} contextLimit={DEFAULT_CONTEXT_LIMIT} />
|
|
{:else if contextPercent > 0}
|
|
<span class="context-meter {contextColorClass}" title="Context window usage">
|
|
<span class="context-fill context-streaming" style="width: {contextPercent}%"></span>
|
|
<span class="context-label">{contextPercent}%</span>
|
|
</span>
|
|
{/if}
|
|
{#if burnRatePerHr > 0}
|
|
<span class="burn-rate" title="Current session burn rate">${burnRatePerHr.toFixed(2)}/hr</span>
|
|
{/if}
|
|
{#if !autoScroll}
|
|
<button class="scroll-btn" onclick={() => { autoScroll = true; scrollContainer?.querySelector('#message-end')?.scrollIntoView({ behavior: 'instant' as ScrollBehavior }); }}>↓ Bottom</button>
|
|
{/if}
|
|
<button class="stop-btn" data-testid="agent-stop" onclick={handleStop}>Stop</button>
|
|
</div>
|
|
{:else if session.status === 'done'}
|
|
<div class="done-bar">
|
|
<span class="cost-value">${session.costUsd.toFixed(4)}</span>
|
|
{#if totalCost && totalCost.costUsd > session.costUsd}
|
|
<span class="total-cost">(total: ${totalCost.costUsd.toFixed(4)})</span>
|
|
{/if}
|
|
{#if capabilities.supportsCost}
|
|
<span class="cost-detail token-in" title="Input tokens">{session.inputTokens.toLocaleString()} in</span>
|
|
<span class="cost-detail token-out" title="Output tokens">{session.outputTokens.toLocaleString()} out</span>
|
|
{:else}
|
|
<span class="cost-detail">{session.inputTokens + session.outputTokens} tok</span>
|
|
{/if}
|
|
<span class="cost-detail">{(session.durationMs / 1000).toFixed(1)}s</span>
|
|
{#if burnRatePerHr > 0}
|
|
<span class="burn-rate" title="Session average burn rate">${burnRatePerHr.toFixed(2)}/hr</span>
|
|
{/if}
|
|
<UsageMeter inputTokens={session.inputTokens} outputTokens={session.outputTokens} contextLimit={DEFAULT_CONTEXT_LIMIT} />
|
|
{#if !autoScroll}
|
|
<button class="scroll-btn" onclick={() => { autoScroll = true; scrollContainer?.querySelector('#message-end')?.scrollIntoView({ behavior: 'instant' as ScrollBehavior }); }}>↓ Bottom</button>
|
|
{/if}
|
|
</div>
|
|
{:else if session.status === 'error'}
|
|
<div class="error-bar">
|
|
<span>Error: {session.error ?? 'Unknown'}</span>
|
|
{#if session.error?.includes('Sidecar') || session.error?.includes('crashed')}
|
|
<button class="restart-btn" onclick={handleRestart} disabled={restarting}>
|
|
{restarting ? 'Restarting...' : 'Restart Sidecar'}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Session controls (new / continue) -->
|
|
{#if session && (session.status === 'done' || session.status === 'error') && session.sdkSessionId}
|
|
<div class="session-controls">
|
|
<button class="session-btn session-btn-new" onclick={handleNewSession}>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<line x1="12" y1="5" x2="12" y2="19"></line>
|
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
|
</svg>
|
|
New Session
|
|
</button>
|
|
{#if capabilities.supportsResume}
|
|
<button class="session-btn session-btn-continue" onclick={() => promptRef?.focus()}>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<polyline points="9 18 15 12 9 6"></polyline>
|
|
</svg>
|
|
Continue
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Unified prompt input -->
|
|
<div class="prompt-container" class:disabled={isRunning}>
|
|
<div class="prompt-wrapper">
|
|
{#if capabilities.hasSkills && showSkillMenu && filteredSkills.length > 0}
|
|
<div class="skill-menu">
|
|
{#each filteredSkills as skill, i (skill.name)}
|
|
<button
|
|
class="skill-item"
|
|
class:active={i === skillMenuIndex}
|
|
onmousedown={(e) => { e.preventDefault(); handleSkillSelect(skill); }}
|
|
>
|
|
<span class="skill-name">/{skill.name}</span>
|
|
<span class="skill-desc">{skill.description}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
<textarea
|
|
bind:this={promptRef}
|
|
bind:value={inputPrompt}
|
|
placeholder={isRunning ? 'Agent is running...' : 'Ask Claude something... (/ for skills)'}
|
|
class="prompt-input"
|
|
data-testid="agent-prompt"
|
|
rows="1"
|
|
disabled={isRunning}
|
|
oninput={(e) => {
|
|
showSkillMenu = inputPrompt.startsWith('/') && filteredSkills.length > 0;
|
|
skillMenuIndex = 0;
|
|
autoResizeTextarea(e.currentTarget as HTMLTextAreaElement);
|
|
}}
|
|
onkeydown={async (e) => {
|
|
if (showSkillMenu && filteredSkills.length > 0) {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
skillMenuIndex = Math.min(skillMenuIndex + 1, filteredSkills.length - 1);
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
skillMenuIndex = Math.max(skillMenuIndex - 1, 0);
|
|
return;
|
|
}
|
|
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {
|
|
e.preventDefault();
|
|
handleSkillSelect(filteredSkills[skillMenuIndex]);
|
|
return;
|
|
}
|
|
if (e.key === 'Escape') {
|
|
showSkillMenu = false;
|
|
return;
|
|
}
|
|
}
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleUnifiedSubmit();
|
|
}
|
|
}}
|
|
></textarea>
|
|
<button
|
|
class="submit-icon-btn"
|
|
data-testid="agent-submit"
|
|
onclick={handleUnifiedSubmit}
|
|
disabled={!inputPrompt.trim() || isRunning}
|
|
aria-label="Send message"
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M3.478 2.405a.75.75 0 0 0-.926.94l2.432 7.905H13.5a.75.75 0 0 1 0 1.5H4.984l-2.432 7.905a.75.75 0 0 0 .926.94 60.519 60.519 0 0 0 18.445-8.986.75.75 0 0 0 0-1.218A60.517 60.517 0 0 0 3.478 2.405Z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
/* === Root === */
|
|
.agent-pane {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
background: var(--ctp-base);
|
|
color: var(--ctp-text);
|
|
font-family: system-ui, -apple-system, sans-serif;
|
|
font-size: 0.875rem;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
/* === Scroll wrapper with container queries === */
|
|
.agent-pane-scroll {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
container-type: inline-size;
|
|
padding: 0.5rem var(--bterminal-pane-padding-inline, 0.75rem);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.125rem;
|
|
min-height: 0;
|
|
}
|
|
|
|
/* === Screen reader only === */
|
|
.sr-only {
|
|
position: absolute;
|
|
width: 1px;
|
|
height: 1px;
|
|
padding: 0;
|
|
margin: -1px;
|
|
overflow: hidden;
|
|
clip: rect(0, 0, 0, 0);
|
|
white-space: nowrap;
|
|
border-width: 0;
|
|
}
|
|
|
|
/* === Subagent bars === */
|
|
.parent-link {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
padding: 0.25rem 0.75rem;
|
|
border-bottom: 1px solid var(--ctp-surface0);
|
|
flex-shrink: 0;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.parent-badge {
|
|
background: var(--ctp-mauve);
|
|
color: var(--ctp-crust);
|
|
padding: 0.0625rem 0.3125rem;
|
|
border-radius: 0.1875rem;
|
|
font-size: 0.625rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.03em;
|
|
}
|
|
|
|
.parent-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--ctp-mauve);
|
|
cursor: pointer;
|
|
font-size: 0.75rem;
|
|
padding: 0;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.parent-btn:hover { color: var(--ctp-text); text-decoration: underline; }
|
|
|
|
.children-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
padding: 0.25rem 0.75rem;
|
|
border-bottom: 1px solid var(--ctp-surface0);
|
|
flex-shrink: 0;
|
|
flex-wrap: wrap;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.children-label {
|
|
color: var(--ctp-overlay0);
|
|
font-size: 0.6875rem;
|
|
margin-right: 0.25rem;
|
|
}
|
|
|
|
.child-chip {
|
|
background: var(--ctp-surface0);
|
|
border: 1px solid var(--ctp-surface1);
|
|
color: var(--ctp-subtext0);
|
|
padding: 0.0625rem 0.375rem;
|
|
border-radius: 0.1875rem;
|
|
font-size: 0.6875rem;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.child-chip:hover { color: var(--ctp-text); border-color: var(--accent); }
|
|
.child-chip.running { border-color: var(--ctp-blue); color: var(--ctp-blue); }
|
|
.child-chip.done { border-color: var(--ctp-green); color: var(--ctp-green); }
|
|
.child-chip.error { border-color: var(--ctp-red); color: var(--ctp-red); }
|
|
|
|
.tree-toggle {
|
|
padding: 0.25rem 0.75rem;
|
|
border-bottom: 1px solid var(--ctp-surface0);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.tree-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--ctp-mauve);
|
|
font-size: 0.75rem;
|
|
cursor: pointer;
|
|
font-family: var(--term-font-family, monospace);
|
|
padding: 0.125rem 0.25rem;
|
|
}
|
|
|
|
.tree-btn:hover { color: var(--ctp-text); }
|
|
|
|
/* === Welcome state === */
|
|
.welcome-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
gap: 0.5rem;
|
|
color: var(--ctp-overlay1);
|
|
}
|
|
|
|
.welcome-icon { color: var(--ctp-overlay0); opacity: 0.6; }
|
|
.welcome-text { font-size: 0.9375rem; font-weight: 500; color: var(--ctp-subtext1); }
|
|
.welcome-hint { font-size: 0.75rem; color: var(--ctp-overlay0); }
|
|
|
|
/* === Messages === */
|
|
.message { padding: 0.1875rem 0; }
|
|
|
|
.msg-init {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
color: var(--ctp-overlay0);
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.msg-init .model {
|
|
background: var(--ctp-surface0);
|
|
padding: 0.0625rem 0.375rem;
|
|
border-radius: 0.1875rem;
|
|
font-family: var(--term-font-family, monospace);
|
|
font-size: 0.6875rem;
|
|
}
|
|
|
|
/* === Text collapsible wrapper === */
|
|
.msg-text-collapsible summary {
|
|
color: var(--ctp-subtext0);
|
|
font-size: 0.8125rem;
|
|
}
|
|
|
|
.msg-text-collapsible summary .text-preview {
|
|
color: var(--ctp-subtext1);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.msg-text-collapsible[open] > summary .text-preview {
|
|
display: none;
|
|
}
|
|
|
|
/* === Pin button === */
|
|
.pin-btn {
|
|
opacity: 0;
|
|
background: none;
|
|
border: none;
|
|
padding: 0.125em 0.25em;
|
|
cursor: pointer;
|
|
color: var(--ctp-overlay0);
|
|
transition: opacity 0.15s, color 0.15s;
|
|
flex-shrink: 0;
|
|
line-height: 1;
|
|
}
|
|
|
|
.pin-btn.pinned {
|
|
opacity: 1;
|
|
color: var(--ctp-yellow);
|
|
}
|
|
|
|
.msg-text-collapsible summary:hover .pin-btn,
|
|
.pin-btn:focus-visible {
|
|
opacity: 1;
|
|
}
|
|
|
|
.pin-btn:hover {
|
|
color: var(--ctp-yellow);
|
|
}
|
|
|
|
/* === Text messages (markdown) === */
|
|
.msg-text {
|
|
word-break: break-word;
|
|
line-height: 1.65;
|
|
}
|
|
|
|
.msg-text.markdown-body :global(h1) {
|
|
font-size: 1.4em;
|
|
font-weight: 700;
|
|
margin: 0.75em 0 0.35em;
|
|
color: var(--ctp-lavender);
|
|
line-height: 1.25;
|
|
}
|
|
|
|
.msg-text.markdown-body :global(h2) {
|
|
font-size: 1.2em;
|
|
font-weight: 600;
|
|
margin: 0.6em 0 0.3em;
|
|
color: var(--ctp-blue);
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.msg-text.markdown-body :global(h3) {
|
|
font-size: 1.05em;
|
|
font-weight: 600;
|
|
margin: 0.5em 0 0.25em;
|
|
color: var(--ctp-sapphire);
|
|
}
|
|
|
|
.msg-text.markdown-body :global(p) { margin: 0.5em 0; }
|
|
|
|
.msg-text.markdown-body :global(code) {
|
|
background: var(--ctp-surface0);
|
|
padding: 0.1em 0.3em;
|
|
border-radius: 0.2em;
|
|
font-family: var(--term-font-family, monospace);
|
|
font-size: 0.85em;
|
|
color: var(--ctp-green);
|
|
}
|
|
|
|
.msg-text.markdown-body :global(pre) {
|
|
background: var(--ctp-mantle);
|
|
padding: 0.75rem 0.875rem;
|
|
border-radius: 0.25rem;
|
|
border: 1px solid var(--ctp-surface0);
|
|
overflow-x: auto;
|
|
font-size: 0.8rem;
|
|
line-height: 1.5;
|
|
margin: 0.625em 0;
|
|
direction: ltr;
|
|
unicode-bidi: embed;
|
|
}
|
|
|
|
.msg-text.markdown-body :global(pre code) {
|
|
background: none;
|
|
padding: 0;
|
|
color: var(--ctp-text);
|
|
font-size: inherit;
|
|
}
|
|
|
|
.msg-text.markdown-body :global(.shiki) {
|
|
background: var(--ctp-mantle) !important;
|
|
padding: 0.75rem 0.875rem;
|
|
border-radius: 0.25rem;
|
|
border: 1px solid var(--ctp-surface0);
|
|
overflow-x: auto;
|
|
font-size: 0.8rem;
|
|
line-height: 1.5;
|
|
margin: 0.625em 0;
|
|
}
|
|
|
|
.msg-text.markdown-body :global(.shiki code) {
|
|
background: none !important;
|
|
padding: 0;
|
|
}
|
|
|
|
.msg-text.markdown-body :global(blockquote) {
|
|
border-left: 3px solid var(--ctp-mauve);
|
|
margin: 0.5em 0;
|
|
padding: 0.125rem 0.75rem;
|
|
color: var(--ctp-subtext0);
|
|
background: color-mix(in srgb, var(--ctp-surface0) 20%, transparent);
|
|
border-radius: 0 0.25rem 0.25rem 0;
|
|
}
|
|
|
|
.msg-text.markdown-body :global(ul), .msg-text.markdown-body :global(ol) {
|
|
padding-left: 1.5rem;
|
|
margin: 0.4em 0;
|
|
}
|
|
|
|
.msg-text.markdown-body :global(li) { margin: 0.2em 0; }
|
|
|
|
.msg-text.markdown-body :global(a) { color: var(--ctp-blue); text-decoration: none; }
|
|
.msg-text.markdown-body :global(a:hover) { text-decoration: underline; }
|
|
|
|
.msg-text.markdown-body :global(table) {
|
|
border-collapse: collapse;
|
|
width: 100%;
|
|
margin: 0.5em 0;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.msg-text.markdown-body :global(th), .msg-text.markdown-body :global(td) {
|
|
border: 1px solid var(--ctp-surface0);
|
|
padding: 0.25rem 0.5rem;
|
|
text-align: left;
|
|
}
|
|
|
|
.msg-text.markdown-body :global(th) {
|
|
background: var(--ctp-surface0);
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* === Shared collapsible styles === */
|
|
.chevron {
|
|
display: inline-block;
|
|
font-size: 0.625rem;
|
|
transition: transform 0.15s ease;
|
|
margin-right: 0.25rem;
|
|
}
|
|
|
|
details[open] > summary > .chevron {
|
|
transform: rotate(90deg);
|
|
}
|
|
|
|
details summary {
|
|
cursor: pointer;
|
|
list-style: none;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
details summary::-webkit-details-marker { display: none; }
|
|
|
|
details summary:focus-visible {
|
|
outline: 0.125rem solid var(--ctp-blue);
|
|
outline-offset: 0.125rem;
|
|
border-radius: 0.25rem;
|
|
}
|
|
|
|
/* === Thinking === */
|
|
.msg-thinking {
|
|
color: var(--ctp-overlay1);
|
|
font-size: 0.8125rem;
|
|
}
|
|
|
|
.msg-thinking summary {
|
|
color: color-mix(in srgb, var(--ctp-mauve) 65%, var(--ctp-surface1) 35%);
|
|
font-size: 0.8125rem;
|
|
}
|
|
|
|
.msg-thinking pre {
|
|
margin: 0.25rem 0 0 1rem;
|
|
white-space: pre-wrap;
|
|
font-size: 0.75rem;
|
|
font-family: var(--term-font-family, monospace);
|
|
max-height: 12.5rem;
|
|
overflow-y: auto;
|
|
direction: ltr;
|
|
unicode-bidi: embed;
|
|
}
|
|
|
|
/* === Tool groups (paired call + result) === */
|
|
.msg-tool-group {
|
|
border-left: 2px solid color-mix(in srgb, var(--ctp-blue) 50%, var(--ctp-surface1) 50%);
|
|
padding-left: 0.625rem;
|
|
font-size: 0.8125rem;
|
|
}
|
|
|
|
.msg-tool-group summary {
|
|
color: var(--ctp-subtext0);
|
|
}
|
|
|
|
.tool-name {
|
|
font-weight: 600;
|
|
font-family: var(--term-font-family, monospace);
|
|
font-size: 0.75rem;
|
|
color: color-mix(in srgb, var(--ctp-green) 65%, var(--ctp-surface1) 35%);
|
|
}
|
|
|
|
.tool-status {
|
|
font-size: 0.6875rem;
|
|
margin-left: 0.25rem;
|
|
}
|
|
|
|
.tool-status--done { color: color-mix(in srgb, var(--ctp-green) 65%, var(--ctp-surface1) 35%); }
|
|
.tool-status--pending { color: var(--ctp-overlay0); animation: pulse 1.5s ease-in-out infinite; }
|
|
|
|
.tool-group-body {
|
|
margin-top: 0.25rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.tool-section-label {
|
|
font-size: 0.6875rem;
|
|
font-weight: 500;
|
|
color: var(--ctp-overlay0);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
margin-bottom: 0.125rem;
|
|
}
|
|
|
|
.tool-input, .tool-output {
|
|
margin: 0;
|
|
white-space: pre-wrap;
|
|
font-size: 0.75rem;
|
|
font-family: var(--term-font-family, monospace);
|
|
max-height: 18.75rem;
|
|
overflow-y: auto;
|
|
background: var(--ctp-mantle);
|
|
padding: 0.375rem 0.5rem;
|
|
border-radius: 0.1875rem;
|
|
color: var(--ctp-subtext0);
|
|
border: 1px solid var(--ctp-surface0);
|
|
direction: ltr;
|
|
unicode-bidi: embed;
|
|
}
|
|
|
|
.tool-pending {
|
|
color: var(--ctp-overlay0);
|
|
font-size: 0.75rem;
|
|
padding: 0.25rem 0;
|
|
animation: pulse 1.5s ease-in-out infinite;
|
|
}
|
|
|
|
.truncation-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--ctp-blue);
|
|
font-size: 0.6875rem;
|
|
cursor: pointer;
|
|
padding: 0.125rem 0;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.truncation-btn:hover { text-decoration: underline; }
|
|
|
|
/* === Hook messages === */
|
|
.msg-hook {
|
|
font-size: 0.75rem;
|
|
color: var(--ctp-overlay0);
|
|
}
|
|
|
|
.msg-hook summary {
|
|
color: var(--ctp-overlay1);
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.hook-icon { opacity: 0.7; }
|
|
|
|
.msg-hook pre {
|
|
margin: 0.25rem 0 0 1rem;
|
|
white-space: pre-wrap;
|
|
font-size: 0.6875rem;
|
|
font-family: var(--term-font-family, monospace);
|
|
color: var(--ctp-overlay0);
|
|
max-height: 6.25rem;
|
|
overflow-y: auto;
|
|
direction: ltr;
|
|
unicode-bidi: embed;
|
|
}
|
|
|
|
/* === Cost message (inline in chat) === */
|
|
.msg-cost {
|
|
display: flex;
|
|
gap: 0.625rem;
|
|
padding: 0.25rem 0;
|
|
font-size: 0.8125rem;
|
|
color: var(--ctp-subtext0);
|
|
border-top: 1px solid var(--ctp-surface1);
|
|
align-items: baseline;
|
|
}
|
|
|
|
.cost-value {
|
|
font-family: var(--term-font-family, monospace);
|
|
font-size: 0.75rem;
|
|
color: var(--ctp-subtext1);
|
|
}
|
|
|
|
.cost-detail {
|
|
font-size: 0.6875rem;
|
|
color: var(--ctp-overlay0);
|
|
}
|
|
|
|
/* === Session summary (collapsible) === */
|
|
.msg-summary-collapsible {
|
|
font-size: 0.8125rem;
|
|
}
|
|
|
|
.msg-summary-collapsible summary {
|
|
color: var(--ctp-overlay1);
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.msg-summary-collapsible summary .summary-preview {
|
|
color: var(--ctp-overlay0);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.msg-summary-collapsible[open] > summary .summary-preview {
|
|
display: none;
|
|
}
|
|
|
|
.msg-summary {
|
|
background: color-mix(in srgb, var(--ctp-surface0) 60%, var(--ctp-base) 40%);
|
|
border-top: 0.125rem solid var(--ctp-surface2);
|
|
padding: 0.5rem 0.625rem;
|
|
border-radius: 0 0 0.25rem 0.25rem;
|
|
font-size: 0.8125rem;
|
|
line-height: 1.5;
|
|
color: var(--ctp-subtext1);
|
|
}
|
|
|
|
/* === Error === */
|
|
.msg-error {
|
|
color: var(--ctp-red);
|
|
background: color-mix(in srgb, var(--ctp-red) 10%, transparent);
|
|
padding: 0.375rem 0.5rem;
|
|
border-radius: 0.1875rem;
|
|
font-size: 0.8125rem;
|
|
}
|
|
|
|
/* === Status (non-hook) === */
|
|
.msg-status {
|
|
color: var(--ctp-overlay0);
|
|
font-size: 0.75rem;
|
|
font-style: italic;
|
|
}
|
|
|
|
/* === Status strip === */
|
|
.status-strip {
|
|
padding: 0.25rem var(--bterminal-pane-padding-inline, 0.75rem);
|
|
border-top: 1px solid var(--ctp-surface1);
|
|
flex-shrink: 0;
|
|
font-size: 0.8125rem;
|
|
}
|
|
|
|
.running-indicator {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-size: 0.8125rem;
|
|
color: var(--ctp-subtext0);
|
|
}
|
|
|
|
.pulse {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: var(--ctp-blue);
|
|
animation: pulse 1.5s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.3; }
|
|
}
|
|
|
|
/* === Context meter === */
|
|
.context-meter {
|
|
position: relative;
|
|
width: 3.5rem;
|
|
height: 0.375rem;
|
|
background: var(--ctp-surface0);
|
|
border-radius: 0.1875rem;
|
|
overflow: hidden;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.context-fill {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
height: 100%;
|
|
background: var(--ctp-blue);
|
|
border-radius: 0.1875rem;
|
|
transition: width 0.3s ease;
|
|
}
|
|
|
|
.context-fill.context-streaming {
|
|
animation: ctx-pulse 1.2s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes ctx-pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.6; }
|
|
}
|
|
|
|
.context-label {
|
|
position: relative;
|
|
z-index: 1;
|
|
font-size: 0.5rem;
|
|
font-family: var(--term-font-family, monospace);
|
|
color: var(--ctp-text);
|
|
width: 100%;
|
|
text-align: center;
|
|
line-height: 0.375rem;
|
|
}
|
|
|
|
.stop-btn {
|
|
margin-left: auto;
|
|
background: var(--ctp-red);
|
|
color: var(--ctp-crust);
|
|
border: none;
|
|
border-radius: 0.1875rem;
|
|
padding: 0.125rem 0.625rem;
|
|
font-size: 0.6875rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.stop-btn:hover { opacity: 0.9; }
|
|
|
|
.scroll-btn {
|
|
background: var(--ctp-surface0);
|
|
border: 1px solid var(--ctp-surface1);
|
|
color: var(--ctp-subtext0);
|
|
border-radius: 0.1875rem;
|
|
padding: 0.125rem 0.5rem;
|
|
font-size: 0.625rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.scroll-btn:hover { color: var(--ctp-text); }
|
|
|
|
.restart-btn {
|
|
margin-left: auto;
|
|
background: var(--ctp-peach);
|
|
color: var(--ctp-crust);
|
|
border: none;
|
|
border-radius: 0.1875rem;
|
|
padding: 0.125rem 0.625rem;
|
|
font-size: 0.6875rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.restart-btn:hover { opacity: 0.9; }
|
|
.restart-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
|
|
.done-bar, .error-bar {
|
|
display: flex;
|
|
gap: 0.625rem;
|
|
font-size: 0.8125rem;
|
|
align-items: center;
|
|
}
|
|
|
|
.done-bar { color: var(--ctp-subtext0); }
|
|
.total-cost { color: var(--ctp-overlay1); font-size: 0.6875rem; }
|
|
.error-bar { color: var(--ctp-red); }
|
|
|
|
.burn-rate {
|
|
font-size: 0.625rem;
|
|
font-family: var(--term-font-family, monospace);
|
|
color: var(--ctp-peach);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.token-in { color: var(--ctp-blue); }
|
|
.token-out { color: var(--ctp-green); }
|
|
|
|
/* Context meter threshold colors */
|
|
.context-medium .context-fill { background: var(--ctp-yellow); }
|
|
.context-high .context-fill { background: var(--ctp-peach); }
|
|
.context-critical .context-fill { background: var(--ctp-red); }
|
|
|
|
/* === Session controls === */
|
|
.session-controls {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
padding: 0.375rem var(--bterminal-pane-padding-inline, 0.75rem);
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.session-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 0.25rem;
|
|
font-size: 0.75rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
transition: background 0.12s ease, color 0.12s ease;
|
|
}
|
|
|
|
.session-btn-new {
|
|
background: transparent;
|
|
border: 1px solid var(--ctp-surface1);
|
|
color: var(--ctp-subtext0);
|
|
}
|
|
|
|
.session-btn-new:hover {
|
|
background: var(--ctp-surface0);
|
|
color: var(--ctp-text);
|
|
border-color: var(--ctp-surface2);
|
|
}
|
|
|
|
.session-btn-continue {
|
|
background: var(--ctp-surface0);
|
|
border: 1px solid var(--ctp-surface1);
|
|
color: var(--ctp-blue);
|
|
}
|
|
|
|
.session-btn-continue:hover {
|
|
background: var(--ctp-surface1);
|
|
color: var(--ctp-sapphire);
|
|
}
|
|
|
|
/* === Prompt container === */
|
|
.prompt-container {
|
|
padding: 0.5rem var(--bterminal-pane-padding-inline, 0.75rem);
|
|
flex-shrink: 0;
|
|
border-top: 1px solid var(--ctp-surface0);
|
|
}
|
|
|
|
.prompt-container.disabled { opacity: 0.6; }
|
|
|
|
.prompt-wrapper {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: flex-end;
|
|
background: var(--ctp-mantle);
|
|
border: 1px solid var(--ctp-surface1);
|
|
border-radius: 0.5rem;
|
|
transition: border-color 0.15s ease;
|
|
}
|
|
|
|
.prompt-wrapper:focus-within { border-color: var(--ctp-blue); }
|
|
|
|
.prompt-input {
|
|
flex: 1;
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--ctp-text);
|
|
font-family: inherit;
|
|
font-size: 0.875rem;
|
|
padding: 0.5rem 0.625rem;
|
|
resize: none;
|
|
min-height: 1.25rem;
|
|
max-height: 9.375rem;
|
|
line-height: 1.4;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.prompt-input:focus { outline: none; }
|
|
.prompt-input::placeholder { color: var(--ctp-overlay0); }
|
|
.prompt-input:disabled { cursor: not-allowed; }
|
|
|
|
.submit-icon-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 1.75rem;
|
|
height: 1.75rem;
|
|
margin: 0.25rem;
|
|
border: none;
|
|
border-radius: 0.375rem;
|
|
background: var(--ctp-blue);
|
|
color: var(--ctp-crust);
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
transition: background 0.12s ease, opacity 0.12s ease;
|
|
}
|
|
|
|
.submit-icon-btn:hover:not(:disabled) { background: var(--ctp-sapphire); }
|
|
|
|
.submit-icon-btn:disabled {
|
|
background: var(--ctp-surface1);
|
|
color: var(--ctp-overlay0);
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* === Skill autocomplete === */
|
|
.skill-menu {
|
|
position: absolute;
|
|
bottom: 100%;
|
|
left: 0;
|
|
right: 0;
|
|
background: var(--ctp-surface0);
|
|
border: 1px solid var(--ctp-surface1);
|
|
border-radius: 0.375rem;
|
|
max-height: 12.5rem;
|
|
overflow-y: auto;
|
|
z-index: 10;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.skill-item {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: baseline;
|
|
padding: 0.375rem 0.625rem;
|
|
width: 100%;
|
|
text-align: left;
|
|
background: none;
|
|
border: none;
|
|
color: var(--ctp-text);
|
|
font-size: 0.8125rem;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.skill-item:hover, .skill-item.active {
|
|
background: var(--ctp-blue);
|
|
color: var(--ctp-crust);
|
|
}
|
|
|
|
.skill-name {
|
|
font-weight: 600;
|
|
font-family: var(--term-font-family, monospace);
|
|
color: var(--ctp-green);
|
|
flex-shrink: 0;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.skill-item:hover .skill-name, .skill-item.active .skill-name { color: var(--ctp-crust); }
|
|
|
|
.skill-desc {
|
|
color: var(--ctp-overlay1);
|
|
font-size: 0.75rem;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.skill-item:hover .skill-desc, .skill-item.active .skill-desc {
|
|
color: var(--ctp-crust);
|
|
opacity: 0.8;
|
|
}
|
|
</style>
|