add diff viewer prefs and task session shortcut
This commit is contained in:
120
package-lock.json
generated
120
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
20
src/App.tsx
20
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",
|
||||
|
||||
62
src/components/diff-viewer.tsx
Normal file
62
src/components/diff-viewer.tsx
Normal file
@@ -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<DiffData | null>(() => {
|
||||
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 (
|
||||
<div class="tool-call-diff-viewer">
|
||||
<Show
|
||||
when={diffData()}
|
||||
fallback={<pre class="tool-call-diff-fallback">{props.diffText}</pre>}
|
||||
>
|
||||
{(data) => (
|
||||
<DiffView
|
||||
data={data()}
|
||||
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
|
||||
diffViewTheme={props.theme}
|
||||
diffViewHighlight
|
||||
diffViewWrap={false}
|
||||
diffViewFontSize={13}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string, { scrollTop: number; autoScroll: boolean }>()
|
||||
|
||||
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<string, any>, 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 (
|
||||
<div class="tool-call-message" data-key={item.key}>
|
||||
<div class="tool-call-header-label">
|
||||
<span class="tool-call-icon">🔧</span>
|
||||
<span>Tool Call</span>
|
||||
<span class="tool-name">{toolPart?.tool || "unknown"}</span>
|
||||
<div class="tool-call-header-meta">
|
||||
<span class="tool-call-icon">🔧</span>
|
||||
<span>Tool Call</span>
|
||||
<span class="tool-name">{toolPart?.tool || "unknown"}</span>
|
||||
</div>
|
||||
<Show when={taskSessionId}>
|
||||
<button
|
||||
class="tool-call-header-button"
|
||||
type="button"
|
||||
disabled={!taskLocation}
|
||||
onClick={handleGoToTaskSession}
|
||||
title={!taskLocation ? "Session not available yet" : "Go to session"}
|
||||
>
|
||||
Go to Session
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<ToolCall toolCall={toolPart} toolCallId={item.key} />
|
||||
</div>
|
||||
|
||||
@@ -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<string, { scrollTop: number; atBottom: boolean }>()
|
||||
|
||||
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<string, string> = {
|
||||
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<string | null>(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 (
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
|
||||
ref={(element) => initializeScrollContainer(element)}
|
||||
onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)}
|
||||
>
|
||||
<div class="tool-call-diff-toolbar" role="group" aria-label="Diff view mode">
|
||||
<span class="tool-call-diff-toolbar-label">Diff view</span>
|
||||
<div class="tool-call-diff-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class={`tool-call-diff-mode-button${diffMode() === "split" ? " active" : ""}`}
|
||||
aria-pressed={diffMode() === "split"}
|
||||
onClick={() => handleModeChange("split")}
|
||||
>
|
||||
Split
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`tool-call-diff-mode-button${diffMode() === "unified" ? " active" : ""}`}
|
||||
aria-pressed={diffMode() === "unified"}
|
||||
onClick={() => handleModeChange("unified")}
|
||||
>
|
||||
Unified
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ToolCallDiffViewer
|
||||
diffText={payload.diffText}
|
||||
filePath={payload.filePath}
|
||||
theme={isDark() ? "dark" : "light"}
|
||||
mode={diffMode()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderMarkdownTool(toolName: string, state: any) {
|
||||
const content = getMarkdownContent(toolName, state)
|
||||
if (!content) {
|
||||
|
||||
50
src/lib/diff-utils.ts
Normal file
50
src/lib/diff-utils.ts
Normal file
@@ -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)
|
||||
}
|
||||
@@ -7,6 +7,43 @@ let currentTheme: "light" | "dark" = "light"
|
||||
let isInitialized = false
|
||||
let highlightSuppressed = false
|
||||
|
||||
const extensionToLanguage: Record<string, string> = {
|
||||
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<string>()
|
||||
const queuedLanguages = new Set<string>()
|
||||
|
||||
@@ -54,6 +54,7 @@ export class FileStorage {
|
||||
environmentVariables: {},
|
||||
modelRecents: [],
|
||||
agentModelSelections: {},
|
||||
diffViewMode: "split",
|
||||
},
|
||||
recentFolders: [],
|
||||
opencodeBinaries: [],
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -10,12 +10,15 @@ export interface AgentModelSelections {
|
||||
[instanceId: string]: Record<string, ModelPreference>
|
||||
}
|
||||
|
||||
export type DiffViewMode = "split" | "unified"
|
||||
|
||||
export interface Preferences {
|
||||
showThinkingBlocks: boolean
|
||||
lastUsedBinary?: string
|
||||
environmentVariables?: Record<string, string>
|
||||
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<Preferences>(defaultPreferences)
|
||||
@@ -72,6 +76,11 @@ function updatePreferences(updates: Partial<Preferences>): 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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user