From 81ab3a40ed5403a3ff798dcc3ac5a09a8dcef9ee Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sat, 8 Nov 2025 12:53:51 +0000 Subject: [PATCH] add diff viewer prefs and task session shortcut --- package-lock.json | 120 ++++++++++++++++++++++++ package.json | 1 + src/App.tsx | 20 +++- src/components/diff-viewer.tsx | 62 ++++++++++++ src/components/message-stream.tsx | 71 +++++++++++++- src/components/tool-call.tsx | 117 ++++++++++++++++------- src/lib/diff-utils.ts | 50 ++++++++++ src/lib/markdown.ts | 37 ++++++++ src/lib/storage.ts | 1 + src/main.tsx | 1 + src/stores/preferences.ts | 10 ++ src/styles/components.css | 151 ++++++++++++++++++++++++++++-- 12 files changed, 595 insertions(+), 46 deletions(-) create mode 100644 src/components/diff-viewer.tsx create mode 100644 src/lib/diff-utils.ts diff --git a/package-lock.json b/package-lock.json index e398803b..4464b210 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "@opencode-ai/client", "version": "0.1.0", "dependencies": { + "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", "@opencode-ai/sdk": "0.15.13", "@solidjs/router": "^0.13.0", @@ -1096,6 +1097,46 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, + "node_modules/@git-diff-view/core": { + "version": "0.0.35", + "resolved": "https://registry.npmjs.org/@git-diff-view/core/-/core-0.0.35.tgz", + "integrity": "sha512-cdH3BopR6AWUW+6hP78zGyryKxR9JkPgryd1JN78i5k+F9Eo4x/4S23ZF1VZnrpPlGLrSuYfiAZ0ho5m+pTuKg==", + "license": "MIT", + "dependencies": { + "@git-diff-view/lowlight": "^0.0.35", + "fast-diff": "^1.3.0", + "highlight.js": "^11.11.0", + "lowlight": "^3.3.0" + } + }, + "node_modules/@git-diff-view/lowlight": { + "version": "0.0.35", + "resolved": "https://registry.npmjs.org/@git-diff-view/lowlight/-/lowlight-0.0.35.tgz", + "integrity": "sha512-MVpOxrNn1oHVOTOWUjxLbbf1W4OtVHjj6CHxwJbBRg9ZWZdShBINjuEgHVMSGB6vZuHKfwruRfXw8XxV3aF8zw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "highlight.js": "^11.11.0", + "lowlight": "^3.3.0" + } + }, + "node_modules/@git-diff-view/solid": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@git-diff-view/solid/-/solid-0.0.8.tgz", + "integrity": "sha512-MvZpyV5Gz0Axv2vvAlPpOmHtaJRUGBMoqXmvjIdZlUls0091QsglpE8bMbdRdEHuXodzxPDYyZrx3HCniMlGKw==", + "license": "MIT", + "dependencies": { + "@git-diff-view/core": "^0.0.35", + "@types/hast": "^3.0.0", + "fast-diff": "^1.3.0", + "highlight.js": "^11.11.0", + "lowlight": "^3.3.0", + "reactivity-store": "^0.3.12" + }, + "peerDependencies": { + "solid-js": "^1.9.0" + } + }, "node_modules/@internationalized/date": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.0.tgz", @@ -2194,6 +2235,21 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, + "node_modules/@vue/reactivity": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz", + "integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.24" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.24.tgz", + "integrity": "sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A==", + "license": "MIT" + }, "node_modules/@xmldom/xmldom": { "version": "0.8.11", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", @@ -3578,6 +3634,7 @@ "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", @@ -4145,6 +4202,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "license": "Apache-2.0" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -4693,6 +4756,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -5256,6 +5328,21 @@ "node": ">=8" } }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6144,6 +6231,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reactivity-store": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/reactivity-store/-/reactivity-store-0.3.12.tgz", + "integrity": "sha512-Idz9EL4dFUtQbHySZQzckWOTUfqjdYpUtNW0iOysC32mG7IjiUGB77QrsyR5eAWBkRiS9JscF6A3fuQAIy+LrQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "~3.5.22", + "@vue/shared": "~3.5.22", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -7282,6 +7393,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", diff --git a/package.json b/package.json index 051ef2cf..84136572 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "package:linux": "electron-builder --linux" }, "dependencies": { + "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", "@opencode-ai/sdk": "0.15.13", "@solidjs/router": "^0.13.0", diff --git a/src/App.tsx b/src/App.tsx index 0cc2893c..7ffef5e5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,7 +24,7 @@ import { showFolderSelection, setShowFolderSelection, } from "./stores/ui" -import { toggleShowThinkingBlocks, preferences, addRecentFolder } from "./stores/preferences" +import { toggleShowThinkingBlocks, preferences, addRecentFolder, setDiffViewMode } from "./stores/preferences" import { createInstance, instances, @@ -752,6 +752,24 @@ const App: Component = () => { action: toggleShowThinkingBlocks, }) + commandRegistry.register({ + id: "diff-view-split", + label: () => `${(preferences().diffViewMode || "split") === "split" ? "✓ " : ""}Use Split Diff View`, + description: "Display tool-call diffs side-by-side", + category: "System", + keywords: ["diff", "split", "view"], + action: () => setDiffViewMode("split"), + }) + + commandRegistry.register({ + id: "diff-view-unified", + label: () => `${(preferences().diffViewMode || "split") === "unified" ? "✓ " : ""}Use Unified Diff View`, + description: "Display tool-call diffs inline", + category: "System", + keywords: ["diff", "unified", "view"], + action: () => setDiffViewMode("unified"), + }) + commandRegistry.register({ id: "help", label: "Show Help", diff --git a/src/components/diff-viewer.tsx b/src/components/diff-viewer.tsx new file mode 100644 index 00000000..350572ad --- /dev/null +++ b/src/components/diff-viewer.tsx @@ -0,0 +1,62 @@ +import { createMemo, Show } from "solid-js" +import { DiffView, DiffModeEnum } from "@git-diff-view/solid" +import { getLanguageFromPath } from "../lib/markdown" +import { normalizeDiffText } from "../lib/diff-utils" +import type { DiffViewMode } from "../stores/preferences" + +interface ToolCallDiffViewerProps { + diffText: string + filePath?: string + theme: "light" | "dark" + mode: DiffViewMode +} + +type DiffData = { + oldFile?: { fileName?: string | null; fileLang?: string | null; content?: string | null } + newFile?: { fileName?: string | null; fileLang?: string | null; content?: string | null } + hunks: string[] +} + +export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) { + const diffData = createMemo(() => { + const normalized = normalizeDiffText(props.diffText) + if (!normalized) { + return null + } + + const language = getLanguageFromPath(props.filePath) || "text" + const fileName = props.filePath || "diff" + + return { + oldFile: { + fileName, + fileLang: language, + }, + newFile: { + fileName, + fileLang: language, + }, + hunks: [normalized], + } + }) + + return ( +
+ {props.diffText}} + > + {(data) => ( + + )} + +
+ ) +} diff --git a/src/components/message-stream.tsx b/src/components/message-stream.tsx index fd977664..0f5a4626 100644 --- a/src/components/message-stream.tsx +++ b/src/components/message-stream.tsx @@ -5,12 +5,51 @@ import ToolCall from "./tool-call" import { sseManager } from "../lib/sse-manager" import Kbd from "./kbd" import { preferences } from "../stores/preferences" -import { providers, getSessionInfo, computeDisplayParts } from "../stores/sessions" +import { + providers, + getSessionInfo, + computeDisplayParts, + sessions, + setActiveSession, + setActiveParentSession, +} from "../stores/sessions" +import { setActiveInstanceId } from "../stores/instances" const SCROLL_OFFSET = 64 +interface TaskSessionLocation { + sessionId: string + instanceId: string + parentId: string | null +} + const messageScrollState = new Map() +function findTaskSessionLocation(sessionId: string): TaskSessionLocation | null { + if (!sessionId) return null + const allSessions = sessions() + for (const [instanceId, sessionMap] of allSessions) { + const session = sessionMap?.get(sessionId) + if (session) { + return { + sessionId: session.id, + instanceId, + parentId: session.parentId ?? null, + } + } + } + return null +} + +function navigateToTaskSession(location: TaskSessionLocation) { + setActiveInstanceId(location.instanceId) + const parentToActivate = location.parentId ?? location.sessionId + setActiveParentSession(location.instanceId, parentToActivate) + if (location.parentId) { + setActiveSession(location.instanceId, location.sessionId) + } +} + // Calculate session tokens and cost from messagesInfo (matches TUI logic) function calculateSessionInfo(messagesInfo?: Map, instanceId?: string) { if (!messagesInfo || messagesInfo.size === 0) @@ -611,12 +650,36 @@ export default function MessageStream(props: MessageStreamProps) { const toolPart = item.toolPart + const taskSessionId = + typeof toolPart?.state?.metadata?.sessionId === "string" ? toolPart.state.metadata.sessionId : "" + const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null + + const handleGoToTaskSession = (event: Event) => { + event.preventDefault() + event.stopPropagation() + if (!taskLocation) return + navigateToTaskSession(taskLocation) + } + return (
- 🔧 - Tool Call - {toolPart?.tool || "unknown"} +
+ 🔧 + Tool Call + {toolPart?.tool || "unknown"} +
+ + +
diff --git a/src/components/tool-call.tsx b/src/components/tool-call.tsx index 501ea845..54f1241f 100644 --- a/src/components/tool-call.tsx +++ b/src/components/tool-call.tsx @@ -1,9 +1,14 @@ import { createSignal, Show, For, createEffect, onCleanup } from "solid-js" import { isToolCallExpanded, toggleToolCallExpanded, setToolCallExpanded } from "../stores/tool-call-state" import { Markdown } from "./markdown" +import { ToolCallDiffViewer } from "./diff-viewer" import { useTheme } from "../lib/theme" +import { getLanguageFromPath } from "../lib/markdown" +import { isRenderableDiffText } from "../lib/diff-utils" +import { preferences, setDiffViewMode, type DiffViewMode } from "../stores/preferences" import type { TextPart } from "../types/message" + const toolScrollState = new Map() function updateScrollState(id: string, element: HTMLElement) { @@ -94,40 +99,36 @@ function getRelativePath(path: string): string { return parts.slice(-1)[0] || path } -function getLanguageFromPath(path: string): string | undefined { - if (!path) return undefined - const ext = path.split(".").pop()?.toLowerCase() - const langMap: Record = { - ts: "typescript", - tsx: "typescript", - js: "javascript", - jsx: "javascript", - py: "python", - sh: "bash", - bash: "bash", - json: "json", - html: "html", - css: "css", - md: "markdown", - yaml: "yaml", - yml: "yaml", - sql: "sql", - rs: "rust", - go: "go", - cpp: "cpp", - cc: "cpp", - cxx: "cpp", - hpp: "cpp", - h: "cpp", - c: "c", - java: "java", - cs: "csharp", - php: "php", - rb: "ruby", - swift: "swift", - kt: "kotlin", +const diffCapableTools = new Set(["edit", "patch"]) + +interface DiffPayload { + diffText: string + filePath?: string +} + +function extractDiffPayload(toolName: string, state: any): DiffPayload | null { + + if (!diffCapableTools.has(toolName)) return null + if (!state) return null + const metadata = state.metadata || {} + const candidates = [metadata.diff, state.output, metadata.output] + let diffText: string | null = null + + for (const candidate of candidates) { + if (typeof candidate === "string" && isRenderableDiffText(candidate)) { + diffText = candidate + break + } } - return ext ? langMap[ext] : undefined + + if (!diffText) { + return null + } + + const input = state.input || {} + const filePath = input.filePath || metadata.filePath || input.path + + return { diffText, filePath } } export default function ToolCall(props: ToolCallProps) { @@ -136,6 +137,7 @@ export default function ToolCall(props: ToolCallProps) { const expanded = () => isToolCallExpanded(toolCallId()) const [initializedId, setInitializedId] = createSignal(null) + let scrollContainerRef: HTMLDivElement | undefined const handleScrollRendered = () => { @@ -356,9 +358,58 @@ export default function ToolCall(props: ToolCallProps) { return renderTaskTool() } + const diffPayload = extractDiffPayload(toolName, state) + if (diffPayload) { + return renderDiffTool(diffPayload) + } + return renderMarkdownTool(toolName, state) } + function renderDiffTool(payload: DiffPayload) { + const diffMode = () => (preferences().diffViewMode || "split") as DiffViewMode + + const handleModeChange = (mode: DiffViewMode) => { + setDiffViewMode(mode) + } + + return ( +
initializeScrollContainer(element)} + onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)} + > +
+ Diff view +
+ + +
+
+ +
+ ) + } + function renderMarkdownTool(toolName: string, state: any) { const content = getMarkdownContent(toolName, state) if (!content) { diff --git a/src/lib/diff-utils.ts b/src/lib/diff-utils.ts new file mode 100644 index 00000000..cca3dc9d --- /dev/null +++ b/src/lib/diff-utils.ts @@ -0,0 +1,50 @@ +const HUNK_PATTERN = /(^|\n)@@/m +const FILE_MARKER_PATTERN = /(^|\n)(diff --git |--- |\+\+\+)/ +const BEGIN_PATCH_PATTERN = /^\*\*\* (Begin|End) Patch/ +const UPDATE_FILE_PATTERN = /^\*\*\* Update File: (.+)$/ + +function stripCodeFence(value: string): string { + const trimmed = value.trim() + if (!trimmed.startsWith("```")) return trimmed + const lines = trimmed.split("\n") + if (lines.length < 2) return "" + const lastLine = lines[lines.length - 1] + if (!lastLine.startsWith("```")) return trimmed + return lines.slice(1, -1).join("\n") +} + +export function normalizeDiffText(raw: string): string { + if (!raw) return "" + const withoutFence = stripCodeFence(raw.replace(/\r\n/g, "\n")) + const lines = withoutFence.split("\n").map((line) => line.replace(/\s+$/u, "")) + + let pendingFilePath: string | null = null + const cleanedLines: string[] = [] + + for (const line of lines) { + if (!line) continue + if (BEGIN_PATCH_PATTERN.test(line)) { + continue + } + const updateMatch = line.match(UPDATE_FILE_PATTERN) + if (updateMatch) { + pendingFilePath = updateMatch[1]?.trim() || null + continue + } + cleanedLines.push(line) + } + + if (pendingFilePath && !FILE_MARKER_PATTERN.test(cleanedLines.join("\n"))) { + cleanedLines.unshift(`+++ b/${pendingFilePath}`) + cleanedLines.unshift(`--- a/${pendingFilePath}`) + } + + return cleanedLines.join("\n").trim() +} + +export function isRenderableDiffText(raw?: string | null): raw is string { + if (!raw) return false + const normalized = normalizeDiffText(raw) + if (!normalized) return false + return HUNK_PATTERN.test(normalized) +} diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index ac31d5fc..df6e28cf 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -7,6 +7,43 @@ let currentTheme: "light" | "dark" = "light" let isInitialized = false let highlightSuppressed = false +const extensionToLanguage: Record = { + ts: "typescript", + tsx: "typescript", + js: "javascript", + jsx: "javascript", + py: "python", + sh: "bash", + bash: "bash", + json: "json", + html: "html", + css: "css", + md: "markdown", + yaml: "yaml", + yml: "yaml", + sql: "sql", + rs: "rust", + go: "go", + cpp: "cpp", + cc: "cpp", + cxx: "cpp", + hpp: "cpp", + h: "cpp", + c: "c", + java: "java", + cs: "csharp", + php: "php", + rb: "ruby", + swift: "swift", + kt: "kotlin", +} + +export function getLanguageFromPath(path?: string | null): string | undefined { + if (!path) return undefined + const ext = path.split(".").pop()?.toLowerCase() + return ext ? extensionToLanguage[ext] : undefined +} + // Track loaded languages and queue for on-demand loading const loadedLanguages = new Set() const queuedLanguages = new Set() diff --git a/src/lib/storage.ts b/src/lib/storage.ts index d0a3254e..9a9b3d1d 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -54,6 +54,7 @@ export class FileStorage { environmentVariables: {}, modelRecents: [], agentModelSelections: {}, + diffViewMode: "split", }, recentFolders: [], opencodeBinaries: [], diff --git a/src/main.tsx b/src/main.tsx index ff044f22..ede61d2e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,7 @@ import { render } from "solid-js/web" import App from "./App" import { ThemeProvider } from "./lib/theme" import "./index.css" +import "@git-diff-view/solid/styles/diff-view-pure.css" const root = document.getElementById("root") diff --git a/src/stores/preferences.ts b/src/stores/preferences.ts index c069c4cc..55a2dc1f 100644 --- a/src/stores/preferences.ts +++ b/src/stores/preferences.ts @@ -10,12 +10,15 @@ export interface AgentModelSelections { [instanceId: string]: Record } +export type DiffViewMode = "split" | "unified" + export interface Preferences { showThinkingBlocks: boolean lastUsedBinary?: string environmentVariables?: Record modelRecents?: ModelPreference[] agentModelSelections?: AgentModelSelections + diffViewMode?: DiffViewMode } export interface OpenCodeBinary { @@ -36,6 +39,7 @@ const defaultPreferences: Preferences = { showThinkingBlocks: false, modelRecents: [], agentModelSelections: {}, + diffViewMode: "split", } const [preferences, setPreferences] = createSignal(defaultPreferences) @@ -72,6 +76,11 @@ function updatePreferences(updates: Partial): void { saveConfig().catch(console.error) } +function setDiffViewMode(mode: DiffViewMode): void { + if (preferences().diffViewMode === mode) return + updatePreferences({ diffViewMode: mode }) +} + function toggleShowThinkingBlocks(): void { updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks }) } @@ -202,4 +211,5 @@ export { addRecentModelPreference, setAgentModelPreference, getAgentModelPreference, + setDiffViewMode, } diff --git a/src/styles/components.css b/src/styles/components.css index 5cac40f6..7bcf8f18 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -583,11 +583,46 @@ button.button-primary { } .tool-call-header-label { - @apply flex items-center gap-2 font-semibold text-sm; + @apply flex items-center justify-between gap-2 font-semibold text-sm; color: var(--text-muted); margin-bottom: 1px; } +.tool-call-header-meta { + @apply flex items-center gap-2; +} + +.tool-call-header-button { + background-color: transparent; + border: 1px solid var(--border-base); + color: var(--text-muted); + padding: 0.15rem 0.75rem; + border-radius: 0.375rem; + font-size: 0.75rem; + font-weight: var(--font-weight-semibold); + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + height: 1.5rem; + transition: all 0.2s ease; +} + +.tool-call-header-button:hover:not(:disabled) { + background-color: var(--surface-hover); + border-color: var(--accent-primary); + color: var(--accent-primary); +} + +.tool-call-header-button:active:not(:disabled) { + transform: scale(0.95); +} + +.tool-call-header-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + .tool-call-header-label .tool-call-icon { @apply text-base; } @@ -667,8 +702,14 @@ button.button-primary { @apply border rounded-md overflow-hidden; border-color: var(--border-base); color: inherit; + --tool-call-line-unit: 1.4em; + --tool-call-lines-compact: 24; + --tool-call-lines-large: 48; + --tool-call-max-height-compact: calc(var(--tool-call-lines-compact) * var(--tool-call-line-unit)); + --tool-call-max-height-large: calc(var(--tool-call-lines-large) * var(--tool-call-line-unit)); } + .tool-call-message .tool-call { border: none; border-radius: 0; @@ -746,11 +787,12 @@ button.button-primary { white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; - max-height: calc(10 * 1.4em); - overflow-y: auto; + max-height: var(--tool-call-max-height-compact, calc(25 * 1.4em)); + overflow-y: scroll; } .tool-call-details { + @apply flex flex-col; background-color: var(--surface-code); font-size: var(--font-size-xs); @@ -763,8 +805,8 @@ button.button-primary { padding: 0; font-size: var(--font-size-xs); line-height: var(--line-height-tight); - max-height: calc(15 * 1.4em); - overflow-y: auto; + max-height: var(--tool-call-max-height-compact, calc(25 * 1.4em)); + overflow-y: scroll; scrollbar-width: thin; scrollbar-color: var(--border-base) transparent; scrollbar-gutter: stable both-edges; @@ -772,10 +814,84 @@ button.button-primary { } .tool-call-markdown-large { - max-height: calc(50 * 1.4em); + max-height: var(--tool-call-max-height-large, calc(48 * 1.4em)); +} + +.tool-call-diff-shell { + padding: 0; +} + +.tool-call-diff-viewer { + max-height: var(--tool-call-max-height-large, calc(48 * 1.4em)); + overflow: auto; + background-color: var(--surface-code); +} + +.tool-call-diff-toolbar { + @apply flex items-center justify-between gap-3 px-3 py-2; + background-color: var(--surface-secondary); + border-bottom: 1px solid var(--border-base); +} + +.tool-call-diff-toolbar-label { + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.tool-call-diff-toggle { + @apply inline-flex items-center gap-1; +} + +.tool-call-diff-mode-button { + @apply border text-xs font-semibold px-3 py-1 rounded transition-all duration-150; + border-color: var(--border-base); + background-color: transparent; + color: var(--text-muted); +} + +.tool-call-diff-mode-button:hover { + background-color: var(--surface-hover); + color: var(--text-primary); +} + +.tool-call-diff-mode-button.active { + background-color: var(--accent-primary); + border-color: var(--accent-primary); + color: var(--text-inverted); +} + +.tool-call-diff-viewer .diff-tailwindcss-wrapper { + background-color: transparent; + color: inherit; +} + +.tool-call-diff-viewer .diff-view-wrapper { + font-family: var(--font-family-mono); +} + +.tool-call-diff-fallback { + margin: 0; + padding: 0.75rem; + background-color: var(--surface-code); + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + line-height: var(--line-height-tight); +} + +.tool-call-diff-viewer .diff-line-old-num, +.tool-call-diff-viewer .diff-line-new-num, +.tool-call-diff-viewer .diff-line-num { + width: auto !important; + min-width: 4ch; + padding-left: 0.5rem; + padding-right: 0.5rem; + white-space: nowrap; } .tool-call-markdown .markdown-code-block { + margin: 0; border: none; background-color: transparent; @@ -823,11 +939,12 @@ button.button-primary { background-color: var(--surface-base); border-radius: 4px; overflow-x: auto; - max-height: calc(25 * 1.4em); - overflow-y: auto; + max-height: var(--tool-call-max-height-compact, calc(25 * 1.4em)); + overflow-y: scroll; } .tool-call-section code { + font-family: var(--font-family-mono); font-size: var(--font-size-xs); line-height: var(--line-height-tight); @@ -861,6 +978,24 @@ button.button-primary { @apply text-base mr-1; } + +.tool-call-action-button { + @apply border text-xs font-semibold px-3 py-1 rounded transition-colors h-8 flex items-center; + border-color: var(--border-base); + color: var(--text-muted); + background-color: transparent; +} + +.tool-call-action-button:hover:not(:disabled) { + background-color: var(--surface-hover); + color: var(--text-primary); +} + +.tool-call-action-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + .tool-call-bash, .tool-call-diff { @apply my-2;