add diff viewer prefs and task session shortcut

This commit is contained in:
Shantur Rathore
2025-11-08 12:53:51 +00:00
parent f59c36f6f8
commit 81ab3a40ed
12 changed files with 595 additions and 46 deletions

View File

@@ -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",

View 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>
)
}

View File

@@ -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>

View File

@@ -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
View 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)
}

View File

@@ -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>()

View File

@@ -54,6 +54,7 @@ export class FileStorage {
environmentVariables: {},
modelRecents: [],
agentModelSelections: {},
diffViewMode: "split",
},
recentFolders: [],
opencodeBinaries: [],

View File

@@ -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")

View File

@@ -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,
}

View File

@@ -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;