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

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