From 4674a4779d16ceefcefbb794fb9ce372508b3d64 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Mon, 9 Mar 2026 02:31:04 +0100 Subject: [PATCH] style(agent): redesign AgentPane and MarkdownPane for polished readable UI Tribunal-elected design (S-3-R4, 88% confidence). AgentPane: sans-serif root font, tool call/result pairing via $derived.by, hook message collapsing, context window meter, minimal cost bar, session summary styling, two-phase scroll anchoring, tool-aware truncation, color-mix() softening, container query responsive margins. MarkdownPane: container query wrapper. Shared --bterminal-pane-padding-inline CSS variable in catppuccin.css. --- v2/src/lib/components/Agent/AgentPane.svelte | 635 ++++++++++++------ .../components/Markdown/MarkdownPane.svelte | 16 +- v2/src/lib/styles/catppuccin.css | 3 + 3 files changed, 463 insertions(+), 191 deletions(-) diff --git a/v2/src/lib/components/Agent/AgentPane.svelte b/v2/src/lib/components/Agent/AgentPane.svelte index df04357..62e5174 100644 --- a/v2/src/lib/components/Agent/AgentPane.svelte +++ b/v2/src/lib/components/Agent/AgentPane.svelte @@ -20,8 +20,15 @@ ToolResultContent, CostContent, ErrorContent, + StatusContent, } from '../../adapters/sdk-messages'; + // Tool-aware truncation limits + const MAX_BASH_LINES = 500; + const MAX_READ_LINES = 50; + const MAX_GLOB_LINES = 20; + const MAX_DEFAULT_LINES = 30; + interface Props { sessionId: string; prompt?: string; @@ -42,6 +49,27 @@ 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 = {}; + let _cachedUserMsgCount = -1; + let toolResultMap = $derived.by((): Record => { + if (!session) return {}; + const userMsgCount = session.messages.filter(m => m.type === 'tool_result').length; + if (userMsgCount === _cachedUserMsgCount) return _cachedResultMap; + const map: Record = {}; + 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([]); @@ -56,6 +84,9 @@ ); let skillMenuIndex = $state(0); + // Track expanded state for tool truncation + let expandedTools = $state>(new Set()); + const mdRenderer = new Renderer(); mdRenderer.code = function({ text, lang }: { text: string; lang?: string }) { if (lang) { @@ -75,7 +106,6 @@ onMount(async () => { await getHighlighter(); - // Load profiles and skills in parallel const [profileList, skillList] = await Promise.all([ listProfiles().catch(() => []), listSkills().catch(() => []), @@ -91,7 +121,6 @@ // not just explicit close. Stop-on-close is handled by workspace teardown. let promptRef = $state(); - let isRunning = $derived(session?.status === 'running' || session?.status === 'starting'); async function startQuery(text: string, resume = false) { if (!text.trim()) return; @@ -147,7 +176,6 @@ if (!inputPrompt.trim() || isRunning) return; const expanded = await expandSkillPrompt(inputPrompt); showSkillMenu = false; - // If session exists with sdkSessionId, this is a follow-up (resume) const isResume = !!(session?.sdkSessionId && session.messages.length > 0); startQuery(expanded, isResume); } @@ -182,22 +210,14 @@ } } - function scrollToBottom() { - if (autoScroll && scrollContainer) { - scrollContainer.scrollTop = scrollContainer.scrollHeight; - } - } - function handleScroll() { if (!scrollContainer) return; const { scrollTop, scrollHeight, clientHeight } = scrollContainer; - // Lock auto-scroll if user scrolled up more than 50px from bottom autoScroll = scrollHeight - scrollTop - clientHeight < 50; } function handleTreeNodeClick(nodeId: string) { if (!scrollContainer || !session) return; - // Find the message whose tool_call has this toolUseId const msg = session.messages.find( m => m.type === 'tool_call' && (m.content as ToolCallContent).toolUseId === nodeId ); @@ -206,10 +226,18 @@ scrollContainer.querySelector('#msg-' + CSS.escape(msg.id))?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } - // Auto-scroll when new messages arrive + // 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) { - scrollToBottom(); + if (session?.messages.length !== undefined && wasNearBottom && autoScroll) { + scrollContainer?.querySelector('#message-end')?.scrollIntoView({ behavior: 'instant' as ScrollBehavior }); } }); @@ -222,10 +250,42 @@ } } - function truncate(text: string, maxLen: number): string { - if (text.length <= maxLen) return text; - return text.slice(0, maxLen) + '...'; + /** 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)); + });
@@ -258,7 +318,7 @@ {/if} {/if} -
+
{#if !session || session.messages.length === 0}
@@ -281,64 +341,116 @@
{@html renderMarkdown((msg.content as TextContent).text)}
{:else if msg.type === 'thinking'}
- Thinking... + Thinking...
{(msg.content as ThinkingContent).text}
{:else if msg.type === 'tool_call'} {@const tc = msg.content as ToolCallContent} -
+ {@const pairedResult = toolResultMap[tc.toolUseId]} +
+ {tc.name} - {truncate(tc.toolUseId, 12)} + {#if pairedResult} + + {:else if isRunning} + + {/if} -
{formatToolInput(tc.input)}
+
+
+ +
{formatToolInput(tc.input)}
+
+ {#if pairedResult} + {@const outputStr = formatToolInput(pairedResult.output)} + {@const limit = getTruncationLimit(tc.name)} + {@const truncated = truncateByLines(outputStr, limit)} +
+ + {#if truncated.truncated && !expandedTools.has(tc.toolUseId)} +
{truncated.text}
+ + {:else} +
{outputStr}
+ {/if} +
+ {:else if isRunning} +
+ + Awaiting tool result +
+ {/if} +
{:else if msg.type === 'tool_result'} - {@const tr = msg.content as ToolResultContent} -
- Tool result -
{formatToolInput(tr.output)}
-
+ {:else if msg.type === 'cost'} {@const cost = msg.content as CostContent}
- ${cost.totalCostUsd.toFixed(4)} - {cost.inputTokens + cost.outputTokens} tokens - {cost.numTurns} turns - {(cost.durationMs / 1000).toFixed(1)}s + ${cost.totalCostUsd.toFixed(4)} + {cost.inputTokens + cost.outputTokens} tokens + {cost.numTurns} turns + {(cost.durationMs / 1000).toFixed(1)}s
+ {#if cost.result} +
{cost.result}
+ {/if} {:else if msg.type === 'error'}
{(msg.content as ErrorContent).message}
{:else if msg.type === 'status'} -
{JSON.stringify(msg.content)}
+ {@const statusContent = msg.content as StatusContent} + {#if isHookMessage(statusContent)} +
+ {hookDisplayName(statusContent.subtype)} +
{statusContent.message || JSON.stringify(msg.content, null, 2)}
+
+ {:else} +
{statusContent.message || statusContent.subtype}
+ {/if} {/if}
{/each} +
{/if}
- + {#if session}
{#if session.status === 'running' || session.status === 'starting'}
Running... + {#if contextPercent > 0} + + + {contextPercent}% + + {/if} {#if !autoScroll} - + {/if}
{:else if session.status === 'done'}
- ${session.costUsd.toFixed(4)} + ${session.costUsd.toFixed(4)} {#if totalCost && totalCost.costUsd > session.costUsd} (total: ${totalCost.costUsd.toFixed(4)}) {/if} - {session.inputTokens + session.outputTokens} tok - {(session.durationMs / 1000).toFixed(1)}s + {session.inputTokens + session.outputTokens} tok + {(session.durationMs / 1000).toFixed(1)}s + {#if contextPercent > 0} + + + {contextPercent}% + + {/if} {#if !autoScroll} - + {/if}
{:else if session.status === 'error'} @@ -373,7 +485,7 @@
{/if} - +
{#if showSkillMenu && filteredSkills.length > 0} @@ -445,15 +557,44 @@