Merge branch 'main' into cline
This commit is contained in:
@@ -91,7 +91,7 @@ export const MessageThreadScrollButton = ({
|
||||
!isAtBottom && (
|
||||
<Button
|
||||
className={cn(
|
||||
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
|
||||
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full bg-background/90 hover:bg-muted/90",
|
||||
className
|
||||
)}
|
||||
onClick={handleScrollToBottom}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useTranslations } from "next-intl"
|
||||
import { isValidElement } from "react"
|
||||
|
||||
import { CodeBlock } from "./code-block"
|
||||
import { UnifiedDiffPreview } from "@/components/diff/unified-diff-preview"
|
||||
import { MessageResponse } from "./message"
|
||||
|
||||
export type ToolProps = ComponentProps<typeof Collapsible>
|
||||
@@ -380,9 +381,10 @@ export const ToolOutput = ({
|
||||
<MessageResponse>{output}</MessageResponse>
|
||||
</div>
|
||||
)
|
||||
} else if (lang === "diff") {
|
||||
Output = <UnifiedDiffPreview diffText={output} />
|
||||
} else {
|
||||
const language = detectOutputLanguage(output)
|
||||
Output = <CodeBlock code={output} language={language} />
|
||||
Output = <CodeBlock code={output} language={lang} />
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useTranslations } from "next-intl"
|
||||
import {
|
||||
ShieldAlert,
|
||||
Terminal,
|
||||
FilePenLine,
|
||||
ListTodo,
|
||||
Compass,
|
||||
FileText,
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { CodeBlock } from "@/components/ai-elements/code-block"
|
||||
import { UnifiedDiffPreview } from "@/components/diff/unified-diff-preview"
|
||||
import { MessageResponse } from "@/components/ai-elements/message"
|
||||
import type { PendingPermission } from "@/contexts/acp-connections-context"
|
||||
import { parsePermissionToolCall } from "@/lib/permission-request"
|
||||
@@ -86,38 +86,8 @@ export function PermissionDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasFileChanges && (
|
||||
<div className="space-y-1.5 rounded-md border border-border/60 bg-muted/20 p-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<FilePenLine className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
{t("filesSummary", { count: parsed.fileChanges.length })}
|
||||
</span>
|
||||
{(parsed.additions > 0 || parsed.deletions > 0) && (
|
||||
<span>
|
||||
+{parsed.additions} / -{parsed.deletions}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1 rounded-md bg-muted/40 p-2">
|
||||
{parsed.fileChanges.slice(0, 8).map((change, index) => (
|
||||
<div
|
||||
key={`${change.path}-${index}`}
|
||||
className="break-all font-mono text-xs text-foreground/90"
|
||||
>
|
||||
{change.path}
|
||||
</div>
|
||||
))}
|
||||
{parsed.fileChanges.length > 8 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("moreFiles", { count: parsed.fileChanges.length - 8 })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{parsed.diffPreview && (
|
||||
<CodeBlock code={parsed.diffPreview} language="diff" />
|
||||
)}
|
||||
</div>
|
||||
{hasFileChanges && parsed.diffPreview && (
|
||||
<UnifiedDiffPreview diffText={parsed.diffPreview} />
|
||||
)}
|
||||
|
||||
{hasPlan && (
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react"
|
||||
import dynamic from "next/dynamic"
|
||||
import type { editor as MonacoEditorNs } from "monaco-editor"
|
||||
import { useMemo } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes"
|
||||
import { cn } from "@/lib/utils"
|
||||
import "@/lib/monaco-local"
|
||||
|
||||
type RowMarker = "none" | "added" | "deleted" | "modified"
|
||||
type DiffFileMode = "modified" | "added" | "deleted" | "renamed"
|
||||
@@ -67,14 +63,18 @@ interface WorkingFile {
|
||||
hunks: WorkingHunk[]
|
||||
}
|
||||
|
||||
interface HunkPreviewLine {
|
||||
text: string
|
||||
marker: RowMarker
|
||||
const ROW_CLASS: Record<RowMarker, string> = {
|
||||
none: "",
|
||||
added: "bg-green-500/10 text-green-900 dark:text-green-300",
|
||||
deleted: "bg-red-500/10 text-red-900 dark:text-red-300",
|
||||
modified: "bg-blue-500/10 text-blue-900 dark:text-blue-300",
|
||||
}
|
||||
|
||||
const MonacoEditor = dynamic(async () => import("@monaco-editor/react"), {
|
||||
ssr: false,
|
||||
})
|
||||
const SIGN_CLASS: Record<string, string> = {
|
||||
"+": "text-green-700 dark:text-green-400",
|
||||
"-": "text-red-700 dark:text-red-400",
|
||||
" ": "text-muted-foreground/50",
|
||||
}
|
||||
|
||||
function normalizePath(raw: string): string | null {
|
||||
const trimmed = raw.trim().replace(/^"|"$/g, "")
|
||||
@@ -190,13 +190,11 @@ function classifyRows(rows: RawDiffRow[]): ParsedDiffRow[] {
|
||||
addEnd += 1
|
||||
}
|
||||
|
||||
const delRows = rows.slice(index, delEnd)
|
||||
const addRows = rows.slice(delEnd, addEnd)
|
||||
const modifiedPairs = Math.min(delRows.length, addRows.length)
|
||||
|
||||
for (const [delta, row] of delRows.entries()) {
|
||||
for (let d = index; d < delEnd; d++) {
|
||||
const row = rows[d]
|
||||
if (!row) continue
|
||||
parsed.push({
|
||||
type: delta < modifiedPairs ? "modified" : "deleted",
|
||||
type: "deleted",
|
||||
text: row.text,
|
||||
sign: "-",
|
||||
oldLine: row.oldLine,
|
||||
@@ -204,9 +202,11 @@ function classifyRows(rows: RawDiffRow[]): ParsedDiffRow[] {
|
||||
})
|
||||
}
|
||||
|
||||
for (const [delta, row] of addRows.entries()) {
|
||||
for (let a = delEnd; a < addEnd; a++) {
|
||||
const row = rows[a]
|
||||
if (!row) continue
|
||||
parsed.push({
|
||||
type: delta < modifiedPairs ? "modified" : "added",
|
||||
type: "added",
|
||||
text: row.text,
|
||||
sign: "+",
|
||||
oldLine: row.oldLine,
|
||||
@@ -440,171 +440,65 @@ function toDisplayPath(filePath: string, folderPath: string | null): string {
|
||||
return normalizedPath
|
||||
}
|
||||
|
||||
function countHunkChanges(hunk: ParsedDiffHunk): {
|
||||
additions: number
|
||||
deletions: number
|
||||
} {
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
|
||||
for (const row of hunk.rows) {
|
||||
if (row.sign === "+") additions += 1
|
||||
if (row.sign === "-") deletions += 1
|
||||
}
|
||||
|
||||
return { additions, deletions }
|
||||
function rowMarker(row: ParsedDiffRow): RowMarker {
|
||||
if (row.type === "added") return "added"
|
||||
if (row.type === "deleted") return "deleted"
|
||||
return "none"
|
||||
}
|
||||
|
||||
function buildHunkPreviewLines(rows: ParsedDiffRow[]): {
|
||||
lines: HunkPreviewLine[]
|
||||
} {
|
||||
const lines: HunkPreviewLine[] = rows.map((row) => {
|
||||
let marker: RowMarker = "none"
|
||||
if (row.type === "added") marker = "added"
|
||||
else if (row.type === "deleted") marker = "deleted"
|
||||
else if (row.type === "modified") marker = "modified"
|
||||
|
||||
return {
|
||||
text: `${row.sign}${row.text}`,
|
||||
marker,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
lines,
|
||||
}
|
||||
}
|
||||
|
||||
function HunkMonacoPreview({
|
||||
hunk,
|
||||
modelId,
|
||||
theme,
|
||||
}: {
|
||||
hunk: ParsedDiffHunk
|
||||
modelId: string
|
||||
theme: string
|
||||
}) {
|
||||
const t = useTranslations("Folder.diffPreview")
|
||||
const editorRef = useRef<MonacoEditorNs.IStandaloneCodeEditor | null>(null)
|
||||
const decorationsRef = useRef<string[]>([])
|
||||
|
||||
const { lines } = useMemo(() => buildHunkPreviewLines(hunk.rows), [hunk.rows])
|
||||
|
||||
const renderedContent = useMemo(
|
||||
() => lines.map((line) => line.text).join("\n"),
|
||||
[lines]
|
||||
)
|
||||
|
||||
const applyDecorations = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
|
||||
const model = editor.getModel()
|
||||
if (!model) return
|
||||
|
||||
const maxLine = model.getLineCount()
|
||||
const decorations: MonacoEditorNs.IModelDeltaDecoration[] = []
|
||||
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const lineNumber = index + 1
|
||||
if (lineNumber > maxLine) continue
|
||||
|
||||
let cls: string | null = null
|
||||
if (line.marker === "added") {
|
||||
cls = "codeg-session-diff-line-added"
|
||||
} else if (line.marker === "modified") {
|
||||
cls = "codeg-session-diff-line-modified"
|
||||
} else if (line.marker === "deleted") {
|
||||
cls = "codeg-session-diff-line-deleted"
|
||||
}
|
||||
|
||||
if (!cls) continue
|
||||
|
||||
decorations.push({
|
||||
range: {
|
||||
startLineNumber: lineNumber,
|
||||
startColumn: 1,
|
||||
endLineNumber: lineNumber,
|
||||
endColumn: 1,
|
||||
},
|
||||
options: {
|
||||
isWholeLine: true,
|
||||
className: cls,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
decorationsRef.current = editor.deltaDecorations(
|
||||
decorationsRef.current,
|
||||
decorations
|
||||
)
|
||||
}, [lines])
|
||||
|
||||
useEffect(() => {
|
||||
applyDecorations()
|
||||
}, [applyDecorations])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
editor.deltaDecorations(decorationsRef.current, [])
|
||||
decorationsRef.current = []
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
function HunkSeparator({ hunk }: { hunk: ParsedDiffHunk }) {
|
||||
const label =
|
||||
hunk.oldStart != null && hunk.oldCount != null
|
||||
? `@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart ?? hunk.oldStart},${hunk.newCount ?? hunk.oldCount} @@`
|
||||
: "···"
|
||||
return (
|
||||
<MonacoEditor
|
||||
beforeMount={defineMonacoThemes}
|
||||
onMount={(editor) => {
|
||||
editorRef.current = editor
|
||||
applyDecorations()
|
||||
}}
|
||||
path={`inmemory://session-hunk/${encodeURIComponent(modelId)}`}
|
||||
value={renderedContent}
|
||||
language="plaintext"
|
||||
theme={theme}
|
||||
loading={
|
||||
<div className="h-28 flex items-center justify-center text-xs text-muted-foreground">
|
||||
{t("loadingHunk")}
|
||||
</div>
|
||||
}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
automaticLayout: true,
|
||||
fontSize: 12,
|
||||
lineNumbers: "off",
|
||||
lineDecorationsWidth: 10,
|
||||
glyphMargin: false,
|
||||
wordWrap: "off",
|
||||
scrollBeyondLastLine: false,
|
||||
renderLineHighlight: "none",
|
||||
contextmenu: false,
|
||||
folding: false,
|
||||
scrollbar: {
|
||||
alwaysConsumeMouseWheel: false,
|
||||
},
|
||||
padding: { top: 6, bottom: 6 },
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-2 border-y border-border/50 bg-muted/30 px-3 py-0.5 font-mono text-[11px] text-muted-foreground/60">
|
||||
<span className="select-none">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HunkLines({ rows }: { rows: ParsedDiffRow[] }) {
|
||||
return (
|
||||
<div className="font-mono text-[12px] leading-[20px]">
|
||||
{rows.map((row, i) => {
|
||||
const marker = rowMarker(row)
|
||||
return (
|
||||
<div key={i} className={cn("flex", ROW_CLASS[marker])}>
|
||||
<span className="w-[3.5rem] shrink-0 select-none pr-1 text-right text-muted-foreground/40">
|
||||
{row.oldLine ?? ""}
|
||||
</span>
|
||||
<span className="w-[3.5rem] shrink-0 select-none pr-1 text-right text-muted-foreground/40">
|
||||
{row.newLine ?? ""}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"w-4 shrink-0 select-none text-center",
|
||||
SIGN_CLASS[row.sign] ?? ""
|
||||
)}
|
||||
>
|
||||
{row.sign === " " ? "" : row.sign}
|
||||
</span>
|
||||
<span className="flex-1 whitespace-pre pr-3">{row.text}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function UnifiedDiffPreview({
|
||||
diffText,
|
||||
modelId,
|
||||
className,
|
||||
}: {
|
||||
diffText: string
|
||||
/** @deprecated No longer used — kept for API compat */
|
||||
modelId?: string
|
||||
className?: string
|
||||
}) {
|
||||
const t = useTranslations("Folder.diffPreview")
|
||||
const { folder } = useFolderContext()
|
||||
const files = useMemo(() => parseUnifiedDiff(diffText), [diffText])
|
||||
const theme = useMonacoThemeSync()
|
||||
|
||||
if (!diffText.trim()) {
|
||||
return (
|
||||
@@ -621,8 +515,8 @@ export function UnifiedDiffPreview({
|
||||
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<div className={cn("h-full overflow-auto p-3", className)}>
|
||||
<pre className="font-mono text-[11px] leading-5 whitespace-pre-wrap text-muted-foreground">
|
||||
<div className={cn("h-full overflow-auto", className)}>
|
||||
<pre className="font-mono text-[11px] leading-5 whitespace-pre-wrap text-muted-foreground p-3">
|
||||
{diffText}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -630,14 +524,14 @@ export function UnifiedDiffPreview({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("h-full overflow-auto p-3", className)}>
|
||||
<div className={cn("h-full overflow-auto", className)}>
|
||||
<div className="space-y-3">
|
||||
{files.map((file) => (
|
||||
<section
|
||||
key={file.key}
|
||||
className="overflow-hidden rounded-lg border border-border bg-background"
|
||||
className="flex max-h-[420px] flex-col rounded-lg border border-border bg-background"
|
||||
>
|
||||
<header className="flex items-center gap-2 border-b border-border bg-muted/40 px-3 py-2 text-[11px]">
|
||||
<header className="flex shrink-0 items-center gap-2 border-b border-border bg-muted/40 px-3 py-2 text-[11px]">
|
||||
<span className="shrink-0 rounded border border-border bg-background px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
{t(modeKey(file.mode))}
|
||||
</span>
|
||||
@@ -657,41 +551,15 @@ export function UnifiedDiffPreview({
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="space-y-2 p-2">
|
||||
{file.hunks.map((hunk, index) => {
|
||||
const hunkStats = countHunkChanges(hunk)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={hunk.key}
|
||||
className="rounded-md border border-border"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 text-[10px] font-mono text-muted-foreground">
|
||||
<span>{t("hunkLabel", { index: index + 1 })}</span>
|
||||
<span className="ml-auto inline-flex items-center gap-2">
|
||||
<span className="text-green-700 dark:text-green-400">
|
||||
+{hunkStats.additions}
|
||||
</span>
|
||||
<span className="text-red-700 dark:text-red-400">
|
||||
-{hunkStats.deletions}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="min-h-[7rem]"
|
||||
style={{
|
||||
height: `${Math.max(120, hunk.rows.length * 20 + 18)}px`,
|
||||
}}
|
||||
>
|
||||
<HunkMonacoPreview
|
||||
hunk={hunk}
|
||||
modelId={`${modelId ?? "session"}:${file.key}:${hunk.key}`}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-auto">
|
||||
<div className="inline-block min-w-full">
|
||||
{file.hunks.map((hunk, hunkIdx) => (
|
||||
<div key={hunk.key}>
|
||||
{hunkIdx > 0 && <HunkSeparator hunk={hunk} />}
|
||||
<HunkLines rows={hunk.rows} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
@@ -1270,8 +1270,7 @@ export function FileWorkspacePanel() {
|
||||
)}
|
||||
<UnifiedDiffPreview
|
||||
diffText={activeFileTab.content}
|
||||
modelId={activeFileTab.id}
|
||||
className="h-full"
|
||||
className="h-full p-3"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
Columns2,
|
||||
FileCode2,
|
||||
MessageSquare,
|
||||
PanelBottom,
|
||||
PanelLeft,
|
||||
PanelRight,
|
||||
Search,
|
||||
Settings,
|
||||
SquareTerminal,
|
||||
} from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { getGitBranch, openFolderWindow, openSettingsWindow } from "@/lib/api"
|
||||
@@ -340,21 +340,6 @@ export function FolderTitleBar() {
|
||||
>
|
||||
<PanelLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`h-6 w-6 hover:text-foreground/80 ${terminalOpen ? "bg-accent" : ""}`}
|
||||
onClick={() => toggleTerminal()}
|
||||
title={tTitleBar("withShortcut", {
|
||||
label: tTitleBar("toggleTerminal"),
|
||||
shortcut: formatShortcutLabel(
|
||||
shortcuts.toggle_terminal,
|
||||
isMac
|
||||
),
|
||||
})}
|
||||
>
|
||||
<PanelBottom className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -370,6 +355,21 @@ export function FolderTitleBar() {
|
||||
>
|
||||
<PanelRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`h-6 w-6 hover:text-foreground/80 ${terminalOpen ? "bg-accent" : ""}`}
|
||||
onClick={() => toggleTerminal()}
|
||||
title={tTitleBar("withShortcut", {
|
||||
label: tTitleBar("toggleTerminal"),
|
||||
shortcut: formatShortcutLabel(
|
||||
shortcuts.toggle_terminal,
|
||||
isMac
|
||||
),
|
||||
})}
|
||||
>
|
||||
<SquareTerminal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { memo, useMemo, useState, type ReactNode } from "react"
|
||||
import type { BundledLanguage } from "shiki"
|
||||
import type { AdaptedContentPart } from "@/lib/adapters/ai-elements-adapter"
|
||||
import type { MessageRole } from "@/lib/types"
|
||||
import { normalizeToolName } from "@/lib/tool-call-normalization"
|
||||
@@ -17,6 +16,8 @@ import {
|
||||
} from "@/components/ai-elements/tool"
|
||||
import { Terminal } from "@/components/ai-elements/terminal"
|
||||
import { CodeBlock } from "@/components/ai-elements/code-block"
|
||||
import { UnifiedDiffPreview } from "@/components/diff/unified-diff-preview"
|
||||
import { generateUnifiedDiff } from "@/lib/unified-diff-generator"
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningTrigger,
|
||||
@@ -374,62 +375,6 @@ function num(obj: Record<string, unknown>, key: string): number | undefined {
|
||||
return typeof v === "number" ? v : undefined
|
||||
}
|
||||
|
||||
/** Guess shiki language from file path extension. */
|
||||
const EXT_LANG_MAP: Record<string, BundledLanguage> = {
|
||||
ts: "typescript",
|
||||
tsx: "tsx",
|
||||
js: "javascript",
|
||||
jsx: "jsx",
|
||||
mjs: "javascript",
|
||||
cjs: "javascript",
|
||||
py: "python",
|
||||
rs: "rust",
|
||||
go: "go",
|
||||
java: "java",
|
||||
css: "css",
|
||||
scss: "scss",
|
||||
less: "less",
|
||||
html: "html",
|
||||
json: "json",
|
||||
jsonl: "json",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
md: "markdown",
|
||||
mdx: "mdx",
|
||||
sql: "sql",
|
||||
sh: "bash",
|
||||
bash: "bash",
|
||||
zsh: "bash",
|
||||
toml: "toml",
|
||||
xml: "xml",
|
||||
svg: "xml",
|
||||
vue: "vue",
|
||||
svelte: "svelte",
|
||||
rb: "ruby",
|
||||
php: "php",
|
||||
swift: "swift",
|
||||
kt: "kotlin",
|
||||
c: "c",
|
||||
cpp: "cpp",
|
||||
h: "c",
|
||||
hpp: "cpp",
|
||||
cs: "csharp",
|
||||
dart: "dart",
|
||||
lua: "lua",
|
||||
r: "r",
|
||||
dockerfile: "dockerfile",
|
||||
graphql: "graphql",
|
||||
prisma: "prisma",
|
||||
}
|
||||
|
||||
function guessLangFromPath(filePath: string): BundledLanguage {
|
||||
const ext = filePath.split(".").pop()?.toLowerCase() ?? ""
|
||||
// Handle dotfiles like "Dockerfile"
|
||||
const basename = filePath.split("/").pop()?.toLowerCase() ?? ""
|
||||
if (basename === "dockerfile") return "dockerfile"
|
||||
return EXT_LANG_MAP[ext] ?? ("log" as BundledLanguage)
|
||||
}
|
||||
|
||||
type ApplyPatchOp = "add" | "update" | "delete" | "move"
|
||||
|
||||
type ApplyPatchFile = {
|
||||
@@ -1227,115 +1172,55 @@ function localizeDerivedToolTitle(
|
||||
|
||||
/** Edit tool: file path + unified diff view */
|
||||
function EditToolInput({ input }: { input: Record<string, unknown> }) {
|
||||
const t = useTranslations("Folder.chat.contentParts")
|
||||
const filePath = str(input, "file_path")
|
||||
const oldString = str(input, "old_string") ?? ""
|
||||
const newString = str(input, "new_string") ?? ""
|
||||
const replaceAll = input.replace_all === true
|
||||
const startLine = num(input, "_start_line")
|
||||
|
||||
const diffCode = useMemo(() => {
|
||||
const parts: string[] = []
|
||||
if (oldString) {
|
||||
for (const line of oldString.split("\n")) {
|
||||
parts.push(`- ${line}`)
|
||||
}
|
||||
}
|
||||
if (newString) {
|
||||
for (const line of newString.split("\n")) {
|
||||
parts.push(`+ ${line}`)
|
||||
}
|
||||
}
|
||||
return parts.join("\n")
|
||||
}, [oldString, newString])
|
||||
const diff = generateUnifiedDiff(
|
||||
oldString,
|
||||
newString,
|
||||
filePath ?? undefined
|
||||
)
|
||||
if (!diff || !startLine || startLine <= 1) return diff ?? ""
|
||||
// Replace line numbers in hunk headers with real start line
|
||||
return diff.replace(
|
||||
/^@@ -(\d+),(\d+) \+(\d+),(\d+) @@/gm,
|
||||
(_, _o, oc, _n, nc) => `@@ -${startLine},${oc} +${startLine},${nc} @@`
|
||||
)
|
||||
}, [oldString, newString, filePath, startLine])
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<FilePenLineIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="break-all font-mono text-foreground">
|
||||
{filePath ?? t("unknown")}
|
||||
</span>
|
||||
{replaceAll && (
|
||||
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{t("replaceAll")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{diffCode && <CodeBlock code={diffCode} language="diff" />}
|
||||
</div>
|
||||
)
|
||||
return diffCode ? <UnifiedDiffPreview diffText={diffCode} /> : null
|
||||
}
|
||||
|
||||
/** Edit tool (changes payload): file list + summary + combined diff view */
|
||||
/** Edit tool (changes payload): combined diff view */
|
||||
function EditChangesToolInput({ changes }: { changes: EditChangePreview[] }) {
|
||||
const t = useTranslations("Folder.chat.contentParts")
|
||||
const { additions, deletions, diffCode } = useMemo(() => {
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
const diffCode = useMemo(() => {
|
||||
const diffParts: string[] = []
|
||||
|
||||
for (const change of changes) {
|
||||
if (change.unifiedDiff && change.unifiedDiff.trim().length > 0) {
|
||||
diffParts.push(change.unifiedDiff.trim())
|
||||
diffParts.push("")
|
||||
for (const line of change.unifiedDiff.split("\n")) {
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) additions += 1
|
||||
if (line.startsWith("-") && !line.startsWith("---")) deletions += 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const oldLines = change.oldText ? change.oldText.split("\n") : []
|
||||
const newLines = change.newText ? change.newText.split("\n") : []
|
||||
|
||||
deletions += oldLines.length
|
||||
additions += newLines.length
|
||||
|
||||
diffParts.push(`--- ${change.path}`)
|
||||
diffParts.push(`+++ ${change.path}`)
|
||||
for (const line of oldLines) {
|
||||
diffParts.push(`-${line}`)
|
||||
const generated = generateUnifiedDiff(
|
||||
change.oldText,
|
||||
change.newText,
|
||||
change.path
|
||||
)
|
||||
if (generated) {
|
||||
diffParts.push(generated)
|
||||
diffParts.push("")
|
||||
}
|
||||
for (const line of newLines) {
|
||||
diffParts.push(`+${line}`)
|
||||
}
|
||||
diffParts.push("")
|
||||
}
|
||||
|
||||
return {
|
||||
additions,
|
||||
deletions,
|
||||
diffCode: diffParts.join("\n").trim(),
|
||||
}
|
||||
return diffParts.join("\n").trim()
|
||||
}, [changes])
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
<span>{t("filesCount", { count: changes.length })}</span>
|
||||
{additions > 0 && <span>+{additions}</span>}
|
||||
{deletions > 0 && <span>-{deletions}</span>}
|
||||
</div>
|
||||
<div className="space-y-1 rounded-md bg-muted/40 p-2">
|
||||
{changes.slice(0, 8).map((change, index) => (
|
||||
<div key={`${change.path}-${index}`} className="flex gap-2 text-xs">
|
||||
<span className="shrink-0 rounded bg-blue-500/15 px-1.5 py-0.5 font-medium uppercase text-blue-600">
|
||||
{t("update")}
|
||||
</span>
|
||||
<span className="break-all font-mono text-foreground">
|
||||
{change.path}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{changes.length > 8 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("moreFiles", { count: changes.length - 8 })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{diffCode && <CodeBlock code={diffCode} language="diff" />}
|
||||
</div>
|
||||
)
|
||||
return diffCode ? <UnifiedDiffPreview diffText={diffCode} /> : null
|
||||
}
|
||||
|
||||
/** Bash / exec_command: terminal-style command display */
|
||||
@@ -1370,13 +1255,60 @@ function BashToolInput({ input }: { input: Record<string, unknown> }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse structured read output from backend: `{"start_line":N,"content":"..."}`.
|
||||
* Falls back to raw text with startLine=1 if not structured.
|
||||
*/
|
||||
function parseReadOutput(raw: string): { startLine: number; content: string } {
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (
|
||||
typeof parsed === "object" &&
|
||||
parsed !== null &&
|
||||
typeof parsed.start_line === "number" &&
|
||||
typeof parsed.content === "string"
|
||||
) {
|
||||
return { startLine: parsed.start_line, content: parsed.content }
|
||||
}
|
||||
} catch {
|
||||
// not JSON
|
||||
}
|
||||
return { startLine: 1, content: raw }
|
||||
}
|
||||
|
||||
/** Lightweight file content viewer with line numbers */
|
||||
function FileContentLines({
|
||||
content,
|
||||
startLine = 1,
|
||||
}: {
|
||||
content: string
|
||||
startLine?: number
|
||||
}) {
|
||||
const lines = useMemo(() => content.split("\n"), [content])
|
||||
|
||||
return (
|
||||
<div className="inline-block min-w-full font-mono text-[12px] leading-[20px]">
|
||||
{lines.map((line, i) => (
|
||||
<div key={i} className="flex">
|
||||
<span className="w-[3.5rem] shrink-0 select-none pr-1 text-right text-muted-foreground/40">
|
||||
{startLine + i}
|
||||
</span>
|
||||
<span className="flex-1 whitespace-pre pr-3">{line}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Read / Write / NotebookEdit: file-focused display */
|
||||
function FileToolInput({
|
||||
toolName,
|
||||
input,
|
||||
output,
|
||||
}: {
|
||||
toolName: string
|
||||
input: Record<string, unknown>
|
||||
output?: string | null
|
||||
}) {
|
||||
const t = useTranslations("Folder.chat.contentParts")
|
||||
const name = toolName.toLowerCase()
|
||||
@@ -1389,48 +1321,52 @@ function FileToolInput({
|
||||
const pages = str(input, "pages")
|
||||
const cellType = str(input, "cell_type")
|
||||
const editMode = str(input, "edit_mode")
|
||||
const isRead = name === "read" || name === "read file"
|
||||
|
||||
const lang = filePath
|
||||
? guessLangFromPath(filePath)
|
||||
: ("log" as BundledLanguage)
|
||||
const badges: string[] = []
|
||||
if (offset != null) badges.push(t("offset", { offset }))
|
||||
if (limit != null) badges.push(t("limit", { limit }))
|
||||
if (pages) badges.push(t("pages", { pages }))
|
||||
if (editMode) badges.push(t("mode", { mode: editMode }))
|
||||
if (cellType) badges.push(t("cell", { cell: cellType }))
|
||||
|
||||
const { displayContent, startLine } = useMemo(() => {
|
||||
if (isRead && output) {
|
||||
const parsed = parseReadOutput(output)
|
||||
return { displayContent: parsed.content, startLine: parsed.startLine }
|
||||
}
|
||||
return {
|
||||
displayContent: content ?? newSource ?? null,
|
||||
startLine: 1,
|
||||
}
|
||||
}, [isRead, output, content, newSource])
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{filePath && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{name === "read" || name === "read file" ? (
|
||||
<FileTextIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<FilePlusIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="break-all font-mono text-foreground">
|
||||
{filePath}
|
||||
<section className="flex max-h-[420px] flex-col rounded-lg border border-border bg-background">
|
||||
<header className="flex shrink-0 items-center gap-2 border-b border-border bg-muted/40 px-3 py-2 text-[11px]">
|
||||
<span className="shrink-0 rounded border border-border bg-background px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
{isRead ? "READ" : "WRITE"}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-mono text-foreground"
|
||||
title={filePath ?? undefined}
|
||||
>
|
||||
{filePath ?? t("unknown")}
|
||||
</span>
|
||||
{badges.length > 0 && (
|
||||
<span className="ml-auto inline-flex shrink-0 items-center gap-2 text-[10px] text-muted-foreground">
|
||||
{badges.map((b) => (
|
||||
<span key={b}>{b}</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
{displayContent && (
|
||||
<div className="overflow-auto">
|
||||
<FileContentLines content={displayContent} startLine={startLine} />
|
||||
</div>
|
||||
)}
|
||||
{(offset != null || limit != null || pages) && (
|
||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
{offset != null && <span>{t("offset", { offset })}</span>}
|
||||
{limit != null && <span>{t("limit", { limit })}</span>}
|
||||
{pages && <span>{t("pages", { pages })}</span>}
|
||||
</div>
|
||||
)}
|
||||
{(cellType || editMode) && (
|
||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
{editMode && <span>{t("mode", { mode: editMode })}</span>}
|
||||
{cellType && <span>{t("cell", { cell: cellType })}</span>}
|
||||
</div>
|
||||
)}
|
||||
{(name === "write" || name === "notebookedit") &&
|
||||
(content || newSource) &&
|
||||
(lang === "markdown" || lang === "mdx" ? (
|
||||
<div className="rounded-md border p-3 text-sm prose prose-sm dark:prose-invert max-w-none [&_ul]:list-inside [&_ol]:list-inside">
|
||||
<MessageResponse>{content ?? newSource ?? ""}</MessageResponse>
|
||||
</div>
|
||||
) : (
|
||||
<CodeBlock code={content ?? newSource ?? ""} language={lang} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1641,49 +1577,7 @@ function TodoWriteToolInput({ input }: { input: Record<string, unknown> }) {
|
||||
}
|
||||
|
||||
function ApplyPatchToolInput({ input }: { input: string }) {
|
||||
const t = useTranslations("Folder.chat.contentParts")
|
||||
const { files, additions, deletions } = useMemo(
|
||||
() => parseApplyPatchInput(input),
|
||||
[input]
|
||||
)
|
||||
const opClass: Record<ApplyPatchOp, string> = {
|
||||
add: "bg-green-500/15 text-green-600",
|
||||
update: "bg-blue-500/15 text-blue-600",
|
||||
delete: "bg-red-500/15 text-red-600",
|
||||
move: "bg-purple-500/15 text-purple-600",
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
<span>{t("filesCount", { count: files.length })}</span>
|
||||
{additions > 0 && <span>+{additions}</span>}
|
||||
{deletions > 0 && <span>-{deletions}</span>}
|
||||
</div>
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-1 rounded-md bg-muted/40 p-2">
|
||||
{files.slice(0, 8).map((file, index) => (
|
||||
<div key={`${file.path}-${index}`} className="flex gap-2 text-xs">
|
||||
<span
|
||||
className={`shrink-0 rounded px-1.5 py-0.5 font-medium uppercase ${opClass[file.op]}`}
|
||||
>
|
||||
{file.op}
|
||||
</span>
|
||||
<span className="break-all font-mono text-foreground">
|
||||
{file.path}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{files.length > 8 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("moreFiles", { count: files.length - 8 })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<CodeBlock code={input} language="diff" />
|
||||
</div>
|
||||
)
|
||||
return <UnifiedDiffPreview diffText={input} />
|
||||
}
|
||||
|
||||
// ── Switch mode (plan) input ──────────────────────────────────────────
|
||||
@@ -1806,20 +1700,41 @@ function GenericToolInput({ input }: { input: string }) {
|
||||
|
||||
// ── Dispatcher ───────────────────────────────────────────────────────
|
||||
|
||||
function isTruncatedInput(input: string): boolean {
|
||||
return input.endsWith('..."') || input.endsWith("...")
|
||||
}
|
||||
|
||||
function StructuredToolInput({
|
||||
toolName,
|
||||
input,
|
||||
output,
|
||||
}: {
|
||||
toolName: string
|
||||
input: string
|
||||
output?: string | null
|
||||
}) {
|
||||
const t = useTranslations("Folder.chat.contentParts")
|
||||
const name = toolName.toLowerCase()
|
||||
const parsed = tryParseJson(input)
|
||||
const truncated =
|
||||
(name === "edit" || name === "write" || name === "apply_patch") &&
|
||||
isTruncatedInput(input)
|
||||
|
||||
const truncationBanner = truncated ? (
|
||||
<div className="rounded-md bg-yellow-500/10 px-2.5 py-1.5 text-[11px] text-yellow-700 dark:text-yellow-400">
|
||||
{t("inputTruncated")}
|
||||
</div>
|
||||
) : null
|
||||
|
||||
if (name === "apply_patch") {
|
||||
const patchInput =
|
||||
extractApplyPatchTextFromUnknownInput(input, parsed) ?? input
|
||||
return <ApplyPatchToolInput input={patchInput} />
|
||||
return (
|
||||
<>
|
||||
{truncationBanner}
|
||||
<ApplyPatchToolInput input={patchInput} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === "bash" || name === "exec_command") {
|
||||
@@ -1843,16 +1758,41 @@ function StructuredToolInput({
|
||||
if (name === "edit") {
|
||||
const patchInput = extractApplyPatchTextFromUnknownInput(input, parsed)
|
||||
if (patchInput) {
|
||||
return <ApplyPatchToolInput input={patchInput} />
|
||||
return (
|
||||
<>
|
||||
{truncationBanner}
|
||||
<ApplyPatchToolInput input={patchInput} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (parsed) {
|
||||
const changesPayload = extractEditChangesPayload(parsed)
|
||||
if (changesPayload.length > 0) {
|
||||
return <EditChangesToolInput changes={changesPayload} />
|
||||
return (
|
||||
<>
|
||||
{truncationBanner}
|
||||
<EditChangesToolInput changes={changesPayload} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
// Prefer tool output if it contains a structured diff with real line numbers
|
||||
// (injected by backend from toolUseResult.structuredPatch)
|
||||
if (output && typeof output === "string" && /^@@ /m.test(output)) {
|
||||
return (
|
||||
<>
|
||||
{truncationBanner}
|
||||
<UnifiedDiffPreview diffText={output} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (isCanonicalEditPayload(parsed)) {
|
||||
return <EditToolInput input={parsed} />
|
||||
return (
|
||||
<>
|
||||
{truncationBanner}
|
||||
<EditToolInput input={parsed} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
return <GenericToolInput input={input} />
|
||||
}
|
||||
@@ -1864,7 +1804,7 @@ function StructuredToolInput({
|
||||
name === "write" ||
|
||||
name === "notebookedit"
|
||||
)
|
||||
return <FileToolInput toolName={toolName} input={parsed} />
|
||||
return <FileToolInput toolName={toolName} input={parsed} output={output} />
|
||||
if (name === "glob" || name === "grep")
|
||||
return <SearchToolInput toolName={toolName} input={parsed} />
|
||||
if (name === "webfetch" || name === "websearch")
|
||||
@@ -2287,12 +2227,18 @@ const ToolCallPart = memo(function ToolCallPart({
|
||||
displayCommand,
|
||||
isRunning,
|
||||
])
|
||||
const isFileTool =
|
||||
toolNameLower === "read" ||
|
||||
toolNameLower === "read file" ||
|
||||
toolNameLower === "write" ||
|
||||
toolNameLower === "notebookedit"
|
||||
const shouldHideDuplicateResult =
|
||||
(toolNameLower === "edit" ||
|
||||
toolNameLower === "apply_patch" ||
|
||||
toolNameLower === "switch_mode" ||
|
||||
toolNameLower === "enterplanmode" ||
|
||||
toolNameLower === "exitplanmode") &&
|
||||
toolNameLower === "exitplanmode" ||
|
||||
isFileTool) &&
|
||||
!part.errorText
|
||||
// Cline: attempt_completion — render as an expanded card with result + progress
|
||||
if (toolNameLower === "attempt_completion") {
|
||||
@@ -2348,6 +2294,7 @@ const ToolCallPart = memo(function ToolCallPart({
|
||||
<StructuredToolInput
|
||||
toolName={normalizedToolName}
|
||||
input={part.input}
|
||||
output={part.output}
|
||||
/>
|
||||
)}
|
||||
{(toolNameLower === "task" || toolNameLower === "agent") &&
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Settings } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { loadFolderHistory, openSettingsWindow } from "@/lib/api"
|
||||
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
|
||||
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
|
||||
import type { FolderHistoryEntry } from "@/lib/types"
|
||||
import { FolderList } from "@/components/welcome/folder-list"
|
||||
import { FolderActions } from "@/components/welcome/folder-actions"
|
||||
@@ -18,6 +20,28 @@ export function WelcomeScreen() {
|
||||
const t = useTranslations("WelcomePage")
|
||||
const [history, setHistory] = useState<FolderHistoryEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { shortcuts } = useShortcutSettings()
|
||||
|
||||
const handleOpenSettings = useCallback(() => {
|
||||
openSettingsWindow().catch((err) => {
|
||||
console.error("[WelcomeScreen] failed to open settings:", err)
|
||||
const resolvedError = resolveWelcomeError(err)
|
||||
toast.error(t("toasts.openSettingsFailed"), {
|
||||
description: resolvedError.detail ?? t(resolvedError.key),
|
||||
})
|
||||
})
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (matchShortcutEvent(e, shortcuts.open_settings)) {
|
||||
e.preventDefault()
|
||||
handleOpenSettings()
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
return () => document.removeEventListener("keydown", handleKeyDown)
|
||||
}, [shortcuts, handleOpenSettings])
|
||||
|
||||
const refreshHistory = useCallback(async () => {
|
||||
try {
|
||||
@@ -49,15 +73,7 @@ export function WelcomeScreen() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 hover:text-foreground/80"
|
||||
onClick={() => {
|
||||
openSettingsWindow().catch((err) => {
|
||||
console.error("[WelcomeScreen] failed to open settings:", err)
|
||||
const resolvedError = resolveWelcomeError(err)
|
||||
toast.error(t("toasts.openSettingsFailed"), {
|
||||
description: resolvedError.detail ?? t(resolvedError.key),
|
||||
})
|
||||
})
|
||||
}}
|
||||
onClick={handleOpenSettings}
|
||||
title={t("openSettings")}
|
||||
aria-label={t("openSettings")}
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user