Initial commit
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
import type { SessionModeInfo } from "@/lib/types"
|
||||
|
||||
// Legacy helper kept for compatibility while ACP mode discovery fully
|
||||
// drives mode selection in connected chat sessions.
|
||||
export function getAgentModes(): SessionModeInfo[] {
|
||||
return []
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import type {
|
||||
AdaptedContentPart,
|
||||
AdaptedMessage,
|
||||
} from "@/lib/adapters/ai-elements-adapter"
|
||||
import type { PlanEntryInfo } from "@/lib/types"
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function normalizeStatus(raw: string | null | undefined): string {
|
||||
const normalized = (raw ?? "").trim().toLowerCase()
|
||||
if (normalized === "completed" || normalized === "done") return "completed"
|
||||
if (
|
||||
normalized === "in_progress" ||
|
||||
normalized === "in-progress" ||
|
||||
normalized === "in progress" ||
|
||||
normalized === "running" ||
|
||||
normalized === "active"
|
||||
) {
|
||||
return "in_progress"
|
||||
}
|
||||
return "pending"
|
||||
}
|
||||
|
||||
function normalizePriority(raw: string | null | undefined): string {
|
||||
const normalized = (raw ?? "").trim().toLowerCase()
|
||||
if (normalized === "high" || normalized === "urgent") return "high"
|
||||
if (normalized === "low") return "low"
|
||||
return "medium"
|
||||
}
|
||||
|
||||
function parseTodosFromJson(input: string): PlanEntryInfo[] {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(input)
|
||||
const obj = asRecord(parsed)
|
||||
if (!obj) return []
|
||||
|
||||
const candidateLists: unknown[][] = []
|
||||
if (Array.isArray(obj.todos)) {
|
||||
candidateLists.push(obj.todos)
|
||||
}
|
||||
if (Array.isArray(obj.entries)) {
|
||||
candidateLists.push(obj.entries)
|
||||
}
|
||||
if (Array.isArray(obj.plan)) {
|
||||
candidateLists.push(obj.plan)
|
||||
}
|
||||
|
||||
for (const list of candidateLists) {
|
||||
const parsedEntries = parsePlanEntriesArray(list)
|
||||
if (parsedEntries.length > 0) {
|
||||
return parsedEntries
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function parsePlanEntriesArray(items: unknown[]): PlanEntryInfo[] {
|
||||
const entries: PlanEntryInfo[] = []
|
||||
|
||||
for (const item of items) {
|
||||
const record = asRecord(item)
|
||||
if (!record) continue
|
||||
|
||||
const contentCandidate =
|
||||
typeof record.content === "string"
|
||||
? record.content
|
||||
: typeof record.step === "string"
|
||||
? record.step
|
||||
: typeof record.title === "string"
|
||||
? record.title
|
||||
: typeof record.name === "string"
|
||||
? record.name
|
||||
: ""
|
||||
const content = contentCandidate.trim()
|
||||
if (!content) continue
|
||||
|
||||
entries.push({
|
||||
content,
|
||||
status: normalizeStatus(
|
||||
typeof record.status === "string" ? record.status : undefined
|
||||
),
|
||||
priority: normalizePriority(
|
||||
typeof record.priority === "string" ? record.priority : undefined
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
function parseEntriesFromReasoningText(text: string): PlanEntryInfo[] {
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
|
||||
if (lines.length === 0) return []
|
||||
|
||||
const entries: PlanEntryInfo[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
const bracketMatch = line.match(
|
||||
/^-\s*\[([^\]]+)\]\s*(.*?)(?:\s*\(([^)]+)\))?$/i
|
||||
)
|
||||
if (bracketMatch) {
|
||||
const [, rawStatus, rawContent, rawPriority] = bracketMatch
|
||||
const content = rawContent.trim()
|
||||
if (!content) continue
|
||||
entries.push({
|
||||
content,
|
||||
status: normalizeStatus(rawStatus),
|
||||
priority: normalizePriority(rawPriority),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const markdownMatch = line.match(/^[-*]\s*\[(x|\s)\]\s*(.+)$/i)
|
||||
if (markdownMatch) {
|
||||
const [, done, rawContent] = markdownMatch
|
||||
const content = rawContent.trim()
|
||||
if (!content) continue
|
||||
entries.push({
|
||||
content,
|
||||
status: done.toLowerCase() === "x" ? "completed" : "pending",
|
||||
priority: "medium",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
function normalizeToolName(toolName: string): string {
|
||||
return toolName.toLowerCase().replace(/[^a-z0-9]/g, "")
|
||||
}
|
||||
|
||||
function isPlanLikeToolName(toolName: string): boolean {
|
||||
const normalized = normalizeToolName(toolName)
|
||||
if (normalized === "todowrite") return true
|
||||
return normalized.includes("plan")
|
||||
}
|
||||
|
||||
function extractPlanEntriesFromPart(part: AdaptedContentPart): PlanEntryInfo[] {
|
||||
if (part.type === "tool-call") {
|
||||
if (!isPlanLikeToolName(part.toolName)) return []
|
||||
if (!part.input) return []
|
||||
return parseTodosFromJson(part.input)
|
||||
}
|
||||
|
||||
if (part.type === "reasoning") {
|
||||
return parseEntriesFromReasoningText(part.content)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export function extractLatestPlanEntriesFromMessages(
|
||||
messages: AdaptedMessage[]
|
||||
): PlanEntryInfo[] {
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const message = messages[i]
|
||||
for (let j = message.content.length - 1; j >= 0; j -= 1) {
|
||||
const entries = extractPlanEntriesFromPart(message.content[j])
|
||||
if (entries.length > 0) {
|
||||
return entries
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export function buildPlanKey(entries: PlanEntryInfo[]): string | null {
|
||||
if (entries.length === 0) return null
|
||||
return entries
|
||||
.map((entry) => `${entry.status}:${entry.priority}:${entry.content}`)
|
||||
.join("|")
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const CONNECTION_IDLE_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes
|
||||
export const IDLE_SWEEP_INTERVAL_MS = 60 * 1000 // 1 minute
|
||||
@@ -0,0 +1,331 @@
|
||||
export type ShortcutActionId =
|
||||
| "toggle_search"
|
||||
| "toggle_sidebar"
|
||||
| "toggle_terminal"
|
||||
| "new_terminal_tab"
|
||||
| "close_current_terminal_tab"
|
||||
| "toggle_aux_panel"
|
||||
| "new_conversation"
|
||||
| "open_folder"
|
||||
| "open_settings"
|
||||
| "close_current_tab"
|
||||
| "close_all_file_tabs"
|
||||
|
||||
export interface ShortcutDefinition {
|
||||
id: ShortcutActionId
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
|
||||
{
|
||||
id: "toggle_search",
|
||||
title: "打开搜索",
|
||||
description: "打开或关闭会话搜索面板",
|
||||
},
|
||||
{
|
||||
id: "toggle_sidebar",
|
||||
title: "切换左侧边栏",
|
||||
description: "显示或隐藏会话列表侧边栏",
|
||||
},
|
||||
{
|
||||
id: "toggle_terminal",
|
||||
title: "切换终端",
|
||||
description: "显示或隐藏底部终端面板",
|
||||
},
|
||||
{
|
||||
id: "new_terminal_tab",
|
||||
title: "新建终端",
|
||||
description: "当最近鼠标活动在终端时新建终端标签",
|
||||
},
|
||||
{
|
||||
id: "close_current_terminal_tab",
|
||||
title: "关闭当前终端",
|
||||
description: "当最近鼠标活动在终端时关闭当前终端标签",
|
||||
},
|
||||
{
|
||||
id: "toggle_aux_panel",
|
||||
title: "切换右侧面板",
|
||||
description: "显示或隐藏辅助信息面板",
|
||||
},
|
||||
{
|
||||
id: "new_conversation",
|
||||
title: "新建会话",
|
||||
description: "在当前文件夹中创建新的对话标签",
|
||||
},
|
||||
{
|
||||
id: "open_folder",
|
||||
title: "Open Folder",
|
||||
description: "打开文件夹选择器并在新窗口中打开",
|
||||
},
|
||||
{
|
||||
id: "open_settings",
|
||||
title: "打开设置",
|
||||
description: "打开设置窗口",
|
||||
},
|
||||
{
|
||||
id: "close_current_tab",
|
||||
title: "关闭当前标签",
|
||||
description: "关闭当前会话或文件标签",
|
||||
},
|
||||
{
|
||||
id: "close_all_file_tabs",
|
||||
title: "关闭全部文件标签",
|
||||
description: "仅在文件模式下关闭所有文件标签",
|
||||
},
|
||||
]
|
||||
|
||||
export type ShortcutSettings = Record<ShortcutActionId, string>
|
||||
|
||||
export const DEFAULT_SHORTCUTS: ShortcutSettings = {
|
||||
toggle_search: "mod+k",
|
||||
toggle_sidebar: "mod+b",
|
||||
toggle_terminal: "mod+j",
|
||||
new_terminal_tab: "mod+t",
|
||||
close_current_terminal_tab: "mod+w",
|
||||
toggle_aux_panel: "mod+e",
|
||||
new_conversation: "mod+t",
|
||||
open_folder: "mod+o",
|
||||
open_settings: "mod+,",
|
||||
close_current_tab: "mod+w",
|
||||
close_all_file_tabs: "mod+shift+w",
|
||||
}
|
||||
|
||||
export const SHORTCUTS_STORAGE_KEY = "settings:shortcuts:v1"
|
||||
export const SHORTCUTS_UPDATED_EVENT = "codeg:shortcuts-updated"
|
||||
|
||||
const FUNCTION_KEY_PATTERN = /^f\d{1,2}$/
|
||||
const MODIFIER_KEY_SET = new Set(["shift", "meta", "control", "alt"])
|
||||
|
||||
const SPECIAL_KEY_ALIASES: Record<string, string> = {
|
||||
" ": "space",
|
||||
spacebar: "space",
|
||||
esc: "escape",
|
||||
return: "enter",
|
||||
up: "arrowup",
|
||||
down: "arrowdown",
|
||||
left: "arrowleft",
|
||||
right: "arrowright",
|
||||
}
|
||||
|
||||
const KEY_LABELS: Record<string, string> = {
|
||||
space: "Space",
|
||||
escape: "Esc",
|
||||
enter: "Enter",
|
||||
tab: "Tab",
|
||||
arrowup: "Up",
|
||||
arrowdown: "Down",
|
||||
arrowleft: "Left",
|
||||
arrowright: "Right",
|
||||
backspace: "Backspace",
|
||||
delete: "Delete",
|
||||
}
|
||||
|
||||
function normalizeKeyToken(rawKey: string): string | null {
|
||||
const key = rawKey.toLowerCase()
|
||||
if (!key) return null
|
||||
|
||||
if (key.length === 1) return key
|
||||
if (FUNCTION_KEY_PATTERN.test(key)) return key
|
||||
|
||||
const aliased = SPECIAL_KEY_ALIASES[key] ?? key
|
||||
if (aliased.length === 1 || FUNCTION_KEY_PATTERN.test(aliased)) return aliased
|
||||
|
||||
if (
|
||||
aliased === "space" ||
|
||||
aliased === "escape" ||
|
||||
aliased === "enter" ||
|
||||
aliased === "tab" ||
|
||||
aliased === "backspace" ||
|
||||
aliased === "delete" ||
|
||||
aliased === "arrowup" ||
|
||||
aliased === "arrowdown" ||
|
||||
aliased === "arrowleft" ||
|
||||
aliased === "arrowright"
|
||||
) {
|
||||
return aliased
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeSettings(input: unknown): ShortcutSettings {
|
||||
const next: ShortcutSettings = { ...DEFAULT_SHORTCUTS }
|
||||
if (!input || typeof input !== "object") return next
|
||||
|
||||
const record = input as Record<string, unknown>
|
||||
for (const definition of SHORTCUT_DEFINITIONS) {
|
||||
const rawValue = record[definition.id]
|
||||
if (typeof rawValue !== "string") continue
|
||||
|
||||
const normalized = normalizeShortcut(rawValue)
|
||||
if (normalized) next[definition.id] = normalized
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export function normalizeShortcut(rawShortcut: string): string | null {
|
||||
const parts = rawShortcut
|
||||
.toLowerCase()
|
||||
.split("+")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
if (parts.length === 0) return null
|
||||
|
||||
let mod = false
|
||||
let alt = false
|
||||
let shift = false
|
||||
let keyToken: string | null = null
|
||||
|
||||
for (const part of parts) {
|
||||
if (
|
||||
part === "mod" ||
|
||||
part === "cmd" ||
|
||||
part === "command" ||
|
||||
part === "meta" ||
|
||||
part === "ctrl" ||
|
||||
part === "control"
|
||||
) {
|
||||
mod = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (part === "alt" || part === "option") {
|
||||
alt = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (part === "shift") {
|
||||
shift = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (keyToken) return null
|
||||
|
||||
const normalizedKey = normalizeKeyToken(part)
|
||||
if (!normalizedKey || MODIFIER_KEY_SET.has(normalizedKey)) return null
|
||||
|
||||
keyToken = normalizedKey
|
||||
}
|
||||
|
||||
if (!keyToken) return null
|
||||
|
||||
const normalizedParts: string[] = []
|
||||
if (mod) normalizedParts.push("mod")
|
||||
if (alt) normalizedParts.push("alt")
|
||||
if (shift) normalizedParts.push("shift")
|
||||
normalizedParts.push(keyToken)
|
||||
|
||||
return normalizedParts.join("+")
|
||||
}
|
||||
|
||||
export function readShortcutSettings(): ShortcutSettings {
|
||||
if (typeof window === "undefined") return { ...DEFAULT_SHORTCUTS }
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SHORTCUTS_STORAGE_KEY)
|
||||
if (!raw) return { ...DEFAULT_SHORTCUTS }
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
return normalizeSettings(parsed)
|
||||
} catch {
|
||||
return { ...DEFAULT_SHORTCUTS }
|
||||
}
|
||||
}
|
||||
|
||||
export function writeShortcutSettings(settings: ShortcutSettings): void {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
const normalized = normalizeSettings(settings)
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
SHORTCUTS_STORAGE_KEY,
|
||||
JSON.stringify(normalized)
|
||||
)
|
||||
window.dispatchEvent(new Event(SHORTCUTS_UPDATED_EVENT))
|
||||
} catch {
|
||||
// Ignore storage failures so UI shortcuts still work in memory.
|
||||
}
|
||||
}
|
||||
|
||||
export function shortcutFromKeyboardEvent(
|
||||
event: Pick<
|
||||
KeyboardEvent,
|
||||
"key" | "metaKey" | "ctrlKey" | "altKey" | "shiftKey"
|
||||
>
|
||||
): string | null {
|
||||
const keyToken = normalizeKeyToken(event.key)
|
||||
if (!keyToken || MODIFIER_KEY_SET.has(keyToken)) return null
|
||||
|
||||
if (!event.metaKey && !event.ctrlKey && !event.altKey) return null
|
||||
|
||||
const parts: string[] = []
|
||||
if (event.metaKey || event.ctrlKey) parts.push("mod")
|
||||
if (event.altKey) parts.push("alt")
|
||||
if (event.shiftKey) parts.push("shift")
|
||||
parts.push(keyToken)
|
||||
|
||||
return parts.join("+")
|
||||
}
|
||||
|
||||
export function matchShortcutEvent(
|
||||
event: Pick<
|
||||
KeyboardEvent,
|
||||
"key" | "metaKey" | "ctrlKey" | "altKey" | "shiftKey"
|
||||
>,
|
||||
shortcut: string
|
||||
): boolean {
|
||||
const normalized = normalizeShortcut(shortcut)
|
||||
if (!normalized) return false
|
||||
|
||||
const parts = normalized.split("+")
|
||||
const keyToken = parts[parts.length - 1]
|
||||
const needsMod = parts.includes("mod")
|
||||
const needsAlt = parts.includes("alt")
|
||||
const needsShift = parts.includes("shift")
|
||||
|
||||
const actualKey = normalizeKeyToken(event.key)
|
||||
if (!actualKey) return false
|
||||
if (actualKey !== keyToken) return false
|
||||
|
||||
const hasMod = event.metaKey || event.ctrlKey
|
||||
if (hasMod !== needsMod) return false
|
||||
if (event.altKey !== needsAlt) return false
|
||||
if (event.shiftKey !== needsShift) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function toKeyLabel(keyToken: string): string {
|
||||
const common = KEY_LABELS[keyToken]
|
||||
if (common) return common
|
||||
|
||||
if (keyToken.length === 1) return keyToken.toUpperCase()
|
||||
if (FUNCTION_KEY_PATTERN.test(keyToken)) return keyToken.toUpperCase()
|
||||
|
||||
return keyToken
|
||||
}
|
||||
|
||||
export function formatShortcutLabel(shortcut: string, isMac: boolean): string {
|
||||
const normalized = normalizeShortcut(shortcut)
|
||||
if (!normalized) return shortcut
|
||||
|
||||
const parts = normalized.split("+")
|
||||
const keyToken = parts[parts.length - 1]
|
||||
|
||||
const modifiers: string[] = []
|
||||
if (parts.includes("mod")) modifiers.push(isMac ? "⌘" : "Ctrl")
|
||||
if (parts.includes("alt")) modifiers.push(isMac ? "⌥" : "Alt")
|
||||
if (parts.includes("shift")) modifiers.push(isMac ? "⇧" : "Shift")
|
||||
|
||||
const keyLabel = toKeyLabel(keyToken)
|
||||
|
||||
if (isMac) {
|
||||
return `${modifiers.join("")}${keyLabel}`
|
||||
}
|
||||
|
||||
if (modifiers.length === 0) return keyLabel
|
||||
return `${modifiers.join("+")}+${keyLabel}`
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export function languageFromPath(path: string): string {
|
||||
const lower = path.toLowerCase()
|
||||
const ext = lower.split(".").pop() ?? ""
|
||||
|
||||
switch (ext) {
|
||||
case "ts":
|
||||
return "typescript"
|
||||
case "tsx":
|
||||
return "typescript"
|
||||
case "js":
|
||||
case "mjs":
|
||||
case "cjs":
|
||||
return "javascript"
|
||||
case "jsx":
|
||||
return "javascript"
|
||||
case "rs":
|
||||
return "rust"
|
||||
case "py":
|
||||
return "python"
|
||||
case "go":
|
||||
return "go"
|
||||
case "json":
|
||||
return "json"
|
||||
case "md":
|
||||
return "markdown"
|
||||
case "yml":
|
||||
case "yaml":
|
||||
return "yaml"
|
||||
case "toml":
|
||||
return "toml"
|
||||
case "css":
|
||||
return "css"
|
||||
case "html":
|
||||
return "html"
|
||||
case "sh":
|
||||
return "shell"
|
||||
case "sql":
|
||||
return "sql"
|
||||
default:
|
||||
return "plaintext"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
export interface LineChangeStats {
|
||||
additions: number
|
||||
deletions: number
|
||||
}
|
||||
|
||||
const MAX_LCS_MATCH_PAIRS = 200_000
|
||||
|
||||
export function splitNormalizedLines(text: string): string[] {
|
||||
if (!text) return []
|
||||
const lines = text.split("\n")
|
||||
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
||||
lines.pop()
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
function contiguousChangedLineStats(
|
||||
oldLines: string[],
|
||||
newLines: string[]
|
||||
): LineChangeStats {
|
||||
let prefix = 0
|
||||
while (
|
||||
prefix < oldLines.length &&
|
||||
prefix < newLines.length &&
|
||||
oldLines[prefix] === newLines[prefix]
|
||||
) {
|
||||
prefix += 1
|
||||
}
|
||||
|
||||
let suffix = 0
|
||||
while (
|
||||
suffix < oldLines.length - prefix &&
|
||||
suffix < newLines.length - prefix &&
|
||||
oldLines[oldLines.length - 1 - suffix] ===
|
||||
newLines[newLines.length - 1 - suffix]
|
||||
) {
|
||||
suffix += 1
|
||||
}
|
||||
|
||||
return {
|
||||
additions: Math.max(0, newLines.length - prefix - suffix),
|
||||
deletions: Math.max(0, oldLines.length - prefix - suffix),
|
||||
}
|
||||
}
|
||||
|
||||
function lowerBound(values: number[], target: number): number {
|
||||
let left = 0
|
||||
let right = values.length
|
||||
while (left < right) {
|
||||
const mid = left + ((right - left) >> 1)
|
||||
if (values[mid] < target) {
|
||||
left = mid + 1
|
||||
} else {
|
||||
right = mid
|
||||
}
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
function exceedsLcsPairBudget(oldLines: string[], newLines: string[]): boolean {
|
||||
if (oldLines.length === 0 || newLines.length === 0) return false
|
||||
|
||||
const oldFreq = new Map<string, number>()
|
||||
for (const line of oldLines) {
|
||||
oldFreq.set(line, (oldFreq.get(line) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const newFreq = new Map<string, number>()
|
||||
for (const line of newLines) {
|
||||
newFreq.set(line, (newFreq.get(line) ?? 0) + 1)
|
||||
}
|
||||
|
||||
let pairs = 0
|
||||
for (const [line, oldCount] of oldFreq) {
|
||||
const newCount = newFreq.get(line)
|
||||
if (!newCount) continue
|
||||
pairs += oldCount * newCount
|
||||
if (pairs > MAX_LCS_MATCH_PAIRS) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function lcsLengthByLine(oldLines: string[], newLines: string[]): number {
|
||||
const positions = new Map<string, number[]>()
|
||||
for (let i = 0; i < newLines.length; i += 1) {
|
||||
const line = newLines[i]
|
||||
const bucket = positions.get(line)
|
||||
if (bucket) {
|
||||
bucket.push(i)
|
||||
} else {
|
||||
positions.set(line, [i])
|
||||
}
|
||||
}
|
||||
|
||||
const lis: number[] = []
|
||||
for (const line of oldLines) {
|
||||
const bucket = positions.get(line)
|
||||
if (!bucket || bucket.length === 0) continue
|
||||
|
||||
for (let i = bucket.length - 1; i >= 0; i -= 1) {
|
||||
const pos = bucket[i]
|
||||
const at = lowerBound(lis, pos)
|
||||
if (at === lis.length) {
|
||||
lis.push(pos)
|
||||
} else {
|
||||
lis[at] = pos
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lis.length
|
||||
}
|
||||
|
||||
export function estimateChangedLineStats(
|
||||
oldText: string,
|
||||
newText: string
|
||||
): LineChangeStats {
|
||||
const oldLines = splitNormalizedLines(oldText)
|
||||
const newLines = splitNormalizedLines(newText)
|
||||
|
||||
if (oldLines.length === 0 && newLines.length === 0) {
|
||||
return { additions: 0, deletions: 0 }
|
||||
}
|
||||
if (oldLines.length === 0) {
|
||||
return { additions: newLines.length, deletions: 0 }
|
||||
}
|
||||
if (newLines.length === 0) {
|
||||
return { additions: 0, deletions: oldLines.length }
|
||||
}
|
||||
|
||||
if (exceedsLcsPairBudget(oldLines, newLines)) {
|
||||
return contiguousChangedLineStats(oldLines, newLines)
|
||||
}
|
||||
|
||||
const lcs = lcsLengthByLine(oldLines, newLines)
|
||||
return {
|
||||
additions: Math.max(0, newLines.length - lcs),
|
||||
deletions: Math.max(0, oldLines.length - lcs),
|
||||
}
|
||||
}
|
||||
|
||||
export function countUnifiedDiffLineChanges(text: string): LineChangeStats {
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
for (const line of text.split("\n")) {
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) additions += 1
|
||||
if (line.startsWith("-") && !line.startsWith("---")) deletions += 1
|
||||
}
|
||||
return { additions, deletions }
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client"
|
||||
|
||||
import type { AgentType } from "@/lib/types"
|
||||
|
||||
interface PersistedDraftState {
|
||||
text: string
|
||||
}
|
||||
|
||||
const STORAGE_PREFIX = "codeg:message-input-draft:v1"
|
||||
const draftTextCache = new Map<string, string>()
|
||||
const pendingPersistDrafts = new Map<string, string>()
|
||||
let idlePersistHandle: number | null = null
|
||||
let persistenceListenersBound = false
|
||||
|
||||
function storageKeyForDraftKey(draftKey: string): string {
|
||||
return `${STORAGE_PREFIX}:${draftKey}`
|
||||
}
|
||||
|
||||
function flushPendingDraftPersistence(): void {
|
||||
if (typeof window === "undefined") return
|
||||
if (pendingPersistDrafts.size === 0) {
|
||||
idlePersistHandle = null
|
||||
return
|
||||
}
|
||||
|
||||
const entries = Array.from(pendingPersistDrafts.entries())
|
||||
pendingPersistDrafts.clear()
|
||||
idlePersistHandle = null
|
||||
|
||||
for (const [draftKey, text] of entries) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
storageKeyForDraftKey(draftKey),
|
||||
JSON.stringify({ text })
|
||||
)
|
||||
} catch {
|
||||
// Ignore storage quota/permission failures.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cancelScheduledDraftPersistence(): void {
|
||||
if (typeof window === "undefined") return
|
||||
if (idlePersistHandle == null) return
|
||||
if ("cancelIdleCallback" in window) {
|
||||
window.cancelIdleCallback(idlePersistHandle)
|
||||
}
|
||||
idlePersistHandle = null
|
||||
}
|
||||
|
||||
function ensurePersistenceListeners(): void {
|
||||
if (typeof window === "undefined") return
|
||||
if (persistenceListenersBound) return
|
||||
persistenceListenersBound = true
|
||||
|
||||
const flushNow = () => {
|
||||
cancelScheduledDraftPersistence()
|
||||
flushPendingDraftPersistence()
|
||||
}
|
||||
|
||||
window.addEventListener("pagehide", flushNow)
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
flushNow()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function scheduleDraftPersistence(): void {
|
||||
if (typeof window === "undefined") return
|
||||
if (idlePersistHandle != null) return
|
||||
|
||||
ensurePersistenceListeners()
|
||||
if ("requestIdleCallback" in window) {
|
||||
idlePersistHandle = window.requestIdleCallback(() => {
|
||||
flushPendingDraftPersistence()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback for runtimes without requestIdleCallback.
|
||||
flushPendingDraftPersistence()
|
||||
}
|
||||
|
||||
export function buildConversationDraftStorageKey(
|
||||
agentType: AgentType,
|
||||
conversationId: number
|
||||
): string {
|
||||
return `conv:${agentType}:${conversationId}`
|
||||
}
|
||||
|
||||
export function buildNewConversationDraftStorageKey(params: {
|
||||
folderId: number
|
||||
}): string {
|
||||
return `new:${params.folderId}`
|
||||
}
|
||||
|
||||
export function loadMessageInputDraft(draftKey: string): string | null {
|
||||
const cached = draftTextCache.get(draftKey)
|
||||
if (typeof cached === "string") return cached
|
||||
if (typeof window === "undefined") return null
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKeyForDraftKey(draftKey))
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as Partial<PersistedDraftState>
|
||||
if (typeof parsed.text !== "string") return null
|
||||
draftTextCache.set(draftKey, parsed.text)
|
||||
return parsed.text
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function saveMessageInputDraft(draftKey: string, text: string): void {
|
||||
if (text.length === 0) {
|
||||
clearMessageInputDraft(draftKey)
|
||||
return
|
||||
}
|
||||
|
||||
if (draftTextCache.get(draftKey) === text) return
|
||||
draftTextCache.set(draftKey, text)
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
pendingPersistDrafts.set(draftKey, text)
|
||||
scheduleDraftPersistence()
|
||||
}
|
||||
|
||||
export function clearMessageInputDraft(draftKey: string): void {
|
||||
draftTextCache.delete(draftKey)
|
||||
pendingPersistDrafts.delete(draftKey)
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
try {
|
||||
localStorage.removeItem(storageKeyForDraftKey(draftKey))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function moveMessageInputDraft(
|
||||
fromDraftKey: string,
|
||||
toDraftKey: string
|
||||
): void {
|
||||
if (fromDraftKey === toDraftKey) return
|
||||
|
||||
const sourceText = loadMessageInputDraft(fromDraftKey)
|
||||
const targetText = loadMessageInputDraft(toDraftKey)
|
||||
if (sourceText && !targetText) {
|
||||
saveMessageInputDraft(toDraftKey, sourceText)
|
||||
}
|
||||
clearMessageInputDraft(fromDraftKey)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import type { BeforeMount } from "@monaco-editor/react"
|
||||
|
||||
export const MONACO_LIGHT_THEME = "codeg-light"
|
||||
export const MONACO_DARK_THEME = "codeg-dark"
|
||||
|
||||
export const monacoTokenRules = {
|
||||
light: [
|
||||
{ token: "diff.header", foreground: "52525B", fontStyle: "bold" },
|
||||
{ token: "diff.meta", foreground: "71717A" },
|
||||
{ token: "diff.range", foreground: "0369A1", fontStyle: "bold" },
|
||||
{ token: "diff.file", foreground: "334155" },
|
||||
{ token: "diff.inserted", foreground: "166534" },
|
||||
{ token: "diff.deleted", foreground: "991B1B" },
|
||||
{ token: "diff.context", foreground: "3F3F46" },
|
||||
],
|
||||
dark: [
|
||||
{ token: "diff.header", foreground: "D4D4D8", fontStyle: "bold" },
|
||||
{ token: "diff.meta", foreground: "A1A1AA" },
|
||||
{ token: "diff.range", foreground: "7DD3FC", fontStyle: "bold" },
|
||||
{ token: "diff.file", foreground: "D4D4D8" },
|
||||
{ token: "diff.inserted", foreground: "86EFAC" },
|
||||
{ token: "diff.deleted", foreground: "FDA4AF" },
|
||||
{ token: "diff.context", foreground: "E4E4E7" },
|
||||
],
|
||||
}
|
||||
|
||||
export const monacoThemeColors = {
|
||||
light: {
|
||||
focusBorder: "#a1a1aa",
|
||||
"editor.background": "#ffffff",
|
||||
"editor.foreground": "#09090b",
|
||||
"editorGutter.background": "#ffffff",
|
||||
"editorLineNumber.foreground": "#a1a1aa",
|
||||
"editorLineNumber.activeForeground": "#18181b",
|
||||
"editor.lineHighlightBackground": "#f4f4f5",
|
||||
"editor.selectionBackground": "#e4e4e7",
|
||||
"editor.inactiveSelectionBackground": "#f4f4f5",
|
||||
"editorWidget.background": "#ffffff",
|
||||
"editorWidget.foreground": "#09090b",
|
||||
"editorWidget.border": "#e4e4e7",
|
||||
"editorHoverWidget.background": "#ffffff",
|
||||
"editorHoverWidget.foreground": "#09090b",
|
||||
"editorHoverWidget.border": "#e4e4e7",
|
||||
"editorHoverWidget.statusBarBackground": "#f4f4f5",
|
||||
"editorSuggestWidget.background": "#ffffff",
|
||||
"editorSuggestWidget.border": "#e4e4e7",
|
||||
"editorSuggestWidget.foreground": "#09090b",
|
||||
"editorSuggestWidget.highlightForeground": "#18181b",
|
||||
"editorSuggestWidget.selectedBackground": "#f4f4f5",
|
||||
"menu.background": "#ffffff",
|
||||
"menu.foreground": "#09090b",
|
||||
"menu.selectionBackground": "#f4f4f5",
|
||||
"menu.selectionForeground": "#09090b",
|
||||
"menu.separatorBackground": "#e4e4e7",
|
||||
"menu.border": "#e4e4e7",
|
||||
"input.background": "#ffffff",
|
||||
"input.foreground": "#09090b",
|
||||
"input.border": "#e4e4e7",
|
||||
"dropdown.background": "#ffffff",
|
||||
"dropdown.foreground": "#09090b",
|
||||
"dropdown.border": "#e4e4e7",
|
||||
"list.hoverBackground": "#f4f4f5",
|
||||
"list.activeSelectionBackground": "#f4f4f5",
|
||||
"list.activeSelectionForeground": "#09090b",
|
||||
"list.inactiveSelectionBackground": "#f4f4f5",
|
||||
"list.inactiveSelectionForeground": "#09090b",
|
||||
"list.focusOutline": "#a1a1aa",
|
||||
"peekView.border": "#e4e4e7",
|
||||
"peekViewEditor.background": "#ffffff",
|
||||
"peekViewEditor.matchHighlightBackground": "#e4e4e7",
|
||||
"peekViewEditorGutter.background": "#ffffff",
|
||||
"peekViewResult.background": "#ffffff",
|
||||
"peekViewResult.fileForeground": "#09090b",
|
||||
"peekViewResult.lineForeground": "#71717a",
|
||||
"peekViewResult.matchHighlightBackground": "#e4e4e7",
|
||||
"peekViewResult.selectionBackground": "#f4f4f5",
|
||||
"peekViewResult.selectionForeground": "#09090b",
|
||||
"peekViewTitle.background": "#f4f4f5",
|
||||
"peekViewTitleLabel.foreground": "#09090b",
|
||||
"peekViewTitleDescription.foreground": "#71717a",
|
||||
},
|
||||
dark: {
|
||||
focusBorder: "#71717a",
|
||||
"editor.background": "#171717",
|
||||
"editor.foreground": "#fafafa",
|
||||
"editorGutter.background": "#171717",
|
||||
"editorLineNumber.foreground": "#71717a",
|
||||
"editorLineNumber.activeForeground": "#fafafa",
|
||||
"editor.lineHighlightBackground": "#27272a",
|
||||
"editor.selectionBackground": "#3f3f46",
|
||||
"editor.inactiveSelectionBackground": "#27272a",
|
||||
"editorWidget.background": "#18181b",
|
||||
"editorWidget.foreground": "#fafafa",
|
||||
"editorWidget.border": "#27272a",
|
||||
"editorHoverWidget.background": "#18181b",
|
||||
"editorHoverWidget.foreground": "#fafafa",
|
||||
"editorHoverWidget.border": "#27272a",
|
||||
"editorHoverWidget.statusBarBackground": "#27272a",
|
||||
"editorSuggestWidget.background": "#18181b",
|
||||
"editorSuggestWidget.border": "#27272a",
|
||||
"editorSuggestWidget.foreground": "#fafafa",
|
||||
"editorSuggestWidget.highlightForeground": "#ffffff",
|
||||
"editorSuggestWidget.selectedBackground": "#27272a",
|
||||
"menu.background": "#18181b",
|
||||
"menu.foreground": "#fafafa",
|
||||
"menu.selectionBackground": "#27272a",
|
||||
"menu.selectionForeground": "#fafafa",
|
||||
"menu.separatorBackground": "#3f3f46",
|
||||
"menu.border": "#27272a",
|
||||
"input.background": "#18181b",
|
||||
"input.foreground": "#fafafa",
|
||||
"input.border": "#27272a",
|
||||
"dropdown.background": "#18181b",
|
||||
"dropdown.foreground": "#fafafa",
|
||||
"dropdown.border": "#27272a",
|
||||
"list.hoverBackground": "#27272a",
|
||||
"list.activeSelectionBackground": "#27272a",
|
||||
"list.activeSelectionForeground": "#fafafa",
|
||||
"list.inactiveSelectionBackground": "#27272a",
|
||||
"list.inactiveSelectionForeground": "#fafafa",
|
||||
"list.focusOutline": "#71717a",
|
||||
"peekView.border": "#27272a",
|
||||
"peekViewEditor.background": "#171717",
|
||||
"peekViewEditor.matchHighlightBackground": "#3f3f46",
|
||||
"peekViewEditorGutter.background": "#171717",
|
||||
"peekViewResult.background": "#18181b",
|
||||
"peekViewResult.fileForeground": "#fafafa",
|
||||
"peekViewResult.lineForeground": "#a1a1aa",
|
||||
"peekViewResult.matchHighlightBackground": "#3f3f46",
|
||||
"peekViewResult.selectionBackground": "#27272a",
|
||||
"peekViewResult.selectionForeground": "#fafafa",
|
||||
"peekViewTitle.background": "#27272a",
|
||||
"peekViewTitleLabel.foreground": "#fafafa",
|
||||
"peekViewTitleDescription.foreground": "#a1a1aa",
|
||||
},
|
||||
}
|
||||
|
||||
export const defineDiffLanguage: BeforeMount = (monaco) => {
|
||||
const hasDiffLanguage = monaco.languages
|
||||
.getLanguages()
|
||||
.some((language: { id: string }) => language.id === "diff")
|
||||
|
||||
if (!hasDiffLanguage) {
|
||||
monaco.languages.register({ id: "diff" })
|
||||
}
|
||||
|
||||
monaco.languages.setMonarchTokensProvider("diff", {
|
||||
defaultToken: "diff.context",
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/^diff --git .*$/, "diff.header"],
|
||||
[/^index .*$/, "diff.meta"],
|
||||
[/^@@ .*@@.*$/, "diff.range"],
|
||||
[/^(?:\+\+\+|---) .*$/, "diff.file"],
|
||||
[/^\+.*$/, "diff.inserted"],
|
||||
[/^-.*$/, "diff.deleted"],
|
||||
[/^\\ No newline at end of file$/, "diff.meta"],
|
||||
[/^Binary files .* differ$/, "diff.meta"],
|
||||
[/^.*$/, "diff.context"],
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const defineMonacoThemes: BeforeMount = (monaco) => {
|
||||
defineDiffLanguage(monaco)
|
||||
|
||||
monaco.editor.defineTheme(MONACO_LIGHT_THEME, {
|
||||
base: "vs",
|
||||
inherit: true,
|
||||
rules: monacoTokenRules.light,
|
||||
colors: monacoThemeColors.light,
|
||||
})
|
||||
|
||||
monaco.editor.defineTheme(MONACO_DARK_THEME, {
|
||||
base: "vs-dark",
|
||||
inherit: true,
|
||||
rules: monacoTokenRules.dark,
|
||||
colors: monacoThemeColors.dark,
|
||||
})
|
||||
}
|
||||
|
||||
export function useMonacoThemeSync() {
|
||||
const [theme, setTheme] = useState(MONACO_LIGHT_THEME)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const root = document.documentElement
|
||||
|
||||
const syncTheme = () => {
|
||||
setTheme(
|
||||
root.classList.contains("dark") ? MONACO_DARK_THEME : MONACO_LIGHT_THEME
|
||||
)
|
||||
}
|
||||
|
||||
syncTheme()
|
||||
|
||||
const observer = new MutationObserver(syncTheme)
|
||||
observer.observe(root, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
})
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return theme
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client"
|
||||
|
||||
export interface PersistedPanelState {
|
||||
isOpen: boolean
|
||||
width: number
|
||||
}
|
||||
|
||||
export function loadPersistedPanelState(
|
||||
storageKey: string
|
||||
): PersistedPanelState | null {
|
||||
if (typeof window === "undefined") return null
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as Partial<PersistedPanelState>
|
||||
if (typeof parsed.isOpen !== "boolean") return null
|
||||
if (typeof parsed.width !== "number" || Number.isNaN(parsed.width)) {
|
||||
return null
|
||||
}
|
||||
return { isOpen: parsed.isOpen, width: parsed.width }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function savePersistedPanelState(
|
||||
storageKey: string,
|
||||
state: PersistedPanelState
|
||||
) {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(state))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
const pendingPromptTextByContextKey = new Map<string, string>()
|
||||
|
||||
export function setPendingPromptText(contextKey: string, text: string): void {
|
||||
const normalized = text.trim()
|
||||
if (!normalized) {
|
||||
pendingPromptTextByContextKey.delete(contextKey)
|
||||
return
|
||||
}
|
||||
pendingPromptTextByContextKey.set(contextKey, normalized)
|
||||
}
|
||||
|
||||
export function getPendingPromptText(contextKey: string): string | null {
|
||||
return pendingPromptTextByContextKey.get(contextKey) ?? null
|
||||
}
|
||||
|
||||
export function clearPendingPromptText(contextKey: string): void {
|
||||
pendingPromptTextByContextKey.delete(contextKey)
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
import { normalizeToolName } from "@/lib/tool-call-normalization"
|
||||
import {
|
||||
countUnifiedDiffLineChanges,
|
||||
estimateChangedLineStats,
|
||||
splitNormalizedLines,
|
||||
} from "@/lib/line-change-stats"
|
||||
|
||||
type ObjectLike = Record<string, unknown>
|
||||
|
||||
export interface PermissionFileChange {
|
||||
path: string
|
||||
oldText: string
|
||||
newText: string
|
||||
unifiedDiff?: string
|
||||
}
|
||||
|
||||
export interface PermissionPlanEntry {
|
||||
text: string
|
||||
status: string | null
|
||||
}
|
||||
|
||||
export interface ParsedPermissionToolCall {
|
||||
title: string
|
||||
normalizedKind: string
|
||||
command: string | null
|
||||
cwd: string | null
|
||||
fileChanges: PermissionFileChange[]
|
||||
additions: number
|
||||
deletions: number
|
||||
diffPreview: string | null
|
||||
planEntries: PermissionPlanEntry[]
|
||||
planExplanation: string | null
|
||||
modeTarget: string | null
|
||||
jsonPreview: string
|
||||
}
|
||||
|
||||
function asObject(value: unknown): ObjectLike | null {
|
||||
if (!value) return null
|
||||
if (typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as ObjectLike
|
||||
}
|
||||
if (typeof value !== "string") return null
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value)
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as ObjectLike)
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] | null {
|
||||
return Array.isArray(value) ? value : null
|
||||
}
|
||||
|
||||
function pickValue(record: ObjectLike | null, keys: string[]): unknown {
|
||||
if (!record) return null
|
||||
for (const key of keys) {
|
||||
if (!(key in record)) continue
|
||||
const value = record[key]
|
||||
if (value !== undefined && value !== null) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function pickString(record: ObjectLike | null, keys: string[]): string | null {
|
||||
const value = pickValue(record, keys)
|
||||
if (typeof value !== "string") return null
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
function joinStringArray(values: unknown): string | null {
|
||||
if (!Array.isArray(values)) return null
|
||||
const parts = values.filter(
|
||||
(item): item is string => typeof item === "string" && item.trim().length > 0
|
||||
)
|
||||
return parts.length > 0 ? parts.join(" ") : null
|
||||
}
|
||||
|
||||
function unescapeInlineEscapes(text: string): string {
|
||||
return text
|
||||
.replace(/\\r\\n/g, "\n")
|
||||
.replace(/\\n/g, "\n")
|
||||
.replace(/\\t/g, "\t")
|
||||
}
|
||||
|
||||
function looksLikeDiffPayload(input: string): boolean {
|
||||
const normalized = unescapeInlineEscapes(input)
|
||||
return (
|
||||
normalized.includes("*** Begin Patch") ||
|
||||
normalized.includes("*** Update File:") ||
|
||||
/^diff --git /m.test(normalized) ||
|
||||
(/^--- .+/m.test(normalized) && /^\+\+\+ .+/m.test(normalized)) ||
|
||||
/^@@ /m.test(normalized)
|
||||
)
|
||||
}
|
||||
|
||||
function buildCompactDiffFromTexts(
|
||||
path: string,
|
||||
oldText: string,
|
||||
newText: string,
|
||||
contextLines: number = 2
|
||||
): string | null {
|
||||
const oldLines = splitNormalizedLines(oldText)
|
||||
const newLines = splitNormalizedLines(newText)
|
||||
|
||||
let prefix = 0
|
||||
while (
|
||||
prefix < oldLines.length &&
|
||||
prefix < newLines.length &&
|
||||
oldLines[prefix] === newLines[prefix]
|
||||
) {
|
||||
prefix += 1
|
||||
}
|
||||
|
||||
let suffix = 0
|
||||
while (
|
||||
suffix < oldLines.length - prefix &&
|
||||
suffix < newLines.length - prefix &&
|
||||
oldLines[oldLines.length - 1 - suffix] ===
|
||||
newLines[newLines.length - 1 - suffix]
|
||||
) {
|
||||
suffix += 1
|
||||
}
|
||||
|
||||
const removed = oldLines.slice(prefix, oldLines.length - suffix)
|
||||
const added = newLines.slice(prefix, newLines.length - suffix)
|
||||
if (removed.length === 0 && added.length === 0) return null
|
||||
|
||||
const before = oldLines.slice(Math.max(0, prefix - contextLines), prefix)
|
||||
const after = oldLines.slice(
|
||||
oldLines.length - suffix,
|
||||
Math.min(oldLines.length, oldLines.length - suffix + contextLines)
|
||||
)
|
||||
|
||||
const parts: string[] = [`--- ${path}`, `+++ ${path}`]
|
||||
for (const line of before) parts.push(` ${line}`)
|
||||
for (const line of removed) parts.push(`-${line}`)
|
||||
for (const line of added) parts.push(`+${line}`)
|
||||
for (const line of after) parts.push(` ${line}`)
|
||||
|
||||
return parts.join("\n")
|
||||
}
|
||||
|
||||
function buildDiffPreviewFromChanges(
|
||||
changes: PermissionFileChange[],
|
||||
maxFiles: number = 8,
|
||||
maxLines: number = 1200
|
||||
): string | null {
|
||||
const meaningful = changes.filter((change) => {
|
||||
if (
|
||||
typeof change.unifiedDiff === "string" &&
|
||||
change.unifiedDiff.trim().length > 0
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return change.oldText.length > 0 || change.newText.length > 0
|
||||
})
|
||||
if (meaningful.length === 0) return null
|
||||
|
||||
const limited = meaningful.slice(0, maxFiles)
|
||||
const lines: string[] = []
|
||||
let lineCount = 0
|
||||
let truncated = false
|
||||
|
||||
const pushLine = (line: string) => {
|
||||
if (lineCount >= maxLines) {
|
||||
truncated = true
|
||||
return
|
||||
}
|
||||
lines.push(line)
|
||||
lineCount += 1
|
||||
}
|
||||
|
||||
for (const change of limited) {
|
||||
const block =
|
||||
typeof change.unifiedDiff === "string" &&
|
||||
change.unifiedDiff.trim().length > 0
|
||||
? change.unifiedDiff.trim()
|
||||
: buildCompactDiffFromTexts(change.path, change.oldText, change.newText)
|
||||
if (!block) continue
|
||||
|
||||
for (const line of block.split("\n")) {
|
||||
pushLine(line)
|
||||
if (truncated) break
|
||||
}
|
||||
if (truncated) break
|
||||
pushLine("")
|
||||
}
|
||||
|
||||
if (meaningful.length > limited.length) {
|
||||
lines.push(`# ... ${meaningful.length - limited.length} more files omitted`)
|
||||
}
|
||||
if (truncated) {
|
||||
lines.push("# ... diff preview truncated")
|
||||
}
|
||||
|
||||
const preview = lines.join("\n").trim()
|
||||
return preview.length > 0 ? preview : null
|
||||
}
|
||||
|
||||
function stringifyJson(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return String(value ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
function extractCommandFromUnknownValue(
|
||||
value: unknown,
|
||||
depth: number = 0
|
||||
): string | null {
|
||||
if (depth > 4 || value === null || value === undefined) return null
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed || looksLikeDiffPayload(trimmed)) return null
|
||||
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
|
||||
return trimmed
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed)
|
||||
return extractCommandFromUnknownValue(parsed, depth + 1)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const joined = joinStringArray(value)
|
||||
return joined && joined.trim().length > 0 ? joined.trim() : null
|
||||
}
|
||||
|
||||
if (typeof value !== "object") return null
|
||||
const obj = value as ObjectLike
|
||||
|
||||
const directKeys = [
|
||||
"command",
|
||||
"cmd",
|
||||
"script",
|
||||
"args",
|
||||
"argv",
|
||||
"command_args",
|
||||
]
|
||||
for (const key of directKeys) {
|
||||
const direct = extractCommandFromUnknownValue(obj[key], depth + 1)
|
||||
if (direct) return direct
|
||||
}
|
||||
|
||||
const nestedKeys = [
|
||||
"rawInput",
|
||||
"raw_input",
|
||||
"input",
|
||||
"arguments",
|
||||
"params",
|
||||
"payload",
|
||||
]
|
||||
for (const key of nestedKeys) {
|
||||
const nested = extractCommandFromUnknownValue(obj[key], depth + 1)
|
||||
if (nested) return nested
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractDiffPreview(
|
||||
rawInput: unknown,
|
||||
rawInputObj: ObjectLike | null
|
||||
): string | null {
|
||||
const candidates: unknown[] = [rawInput]
|
||||
if (rawInputObj) {
|
||||
candidates.push(
|
||||
rawInputObj.patch,
|
||||
rawInputObj.diff,
|
||||
rawInputObj.unified_diff,
|
||||
rawInputObj.unifiedDiff
|
||||
)
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== "string") continue
|
||||
const normalized = unescapeInlineEscapes(candidate).trim()
|
||||
if (!normalized) continue
|
||||
if (looksLikeDiffPayload(normalized)) return normalized
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function parseChangeRecord(
|
||||
path: string,
|
||||
value: unknown
|
||||
): PermissionFileChange | null {
|
||||
const normalizedPath = path.trim()
|
||||
if (!normalizedPath) return null
|
||||
|
||||
if (typeof value === "string") {
|
||||
return {
|
||||
path: normalizedPath,
|
||||
oldText: "",
|
||||
newText: value,
|
||||
unifiedDiff: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const record = asObject(value)
|
||||
if (!record) {
|
||||
return {
|
||||
path: normalizedPath,
|
||||
oldText: "",
|
||||
newText: "",
|
||||
}
|
||||
}
|
||||
|
||||
const oldText =
|
||||
pickString(record, [
|
||||
"old_string",
|
||||
"oldString",
|
||||
"old_text",
|
||||
"oldText",
|
||||
"old",
|
||||
"before",
|
||||
]) ?? ""
|
||||
const newText =
|
||||
pickString(record, [
|
||||
"new_string",
|
||||
"newString",
|
||||
"new_text",
|
||||
"newText",
|
||||
"new",
|
||||
"after",
|
||||
"content",
|
||||
"text",
|
||||
"new_source",
|
||||
"newSource",
|
||||
]) ?? ""
|
||||
const unifiedDiff =
|
||||
pickString(record, ["unifiedDiff", "unified_diff", "diff", "patch"]) ??
|
||||
undefined
|
||||
|
||||
return {
|
||||
path: normalizedPath,
|
||||
oldText,
|
||||
newText,
|
||||
unifiedDiff,
|
||||
}
|
||||
}
|
||||
|
||||
function extractRawInputFileChanges(
|
||||
rawInputObj: ObjectLike | null
|
||||
): PermissionFileChange[] {
|
||||
if (!rawInputObj) return []
|
||||
|
||||
const changes: PermissionFileChange[] = []
|
||||
const byChangesObject = asObject(rawInputObj.changes)
|
||||
if (byChangesObject) {
|
||||
for (const [path, value] of Object.entries(byChangesObject)) {
|
||||
const parsed = parseChangeRecord(path, value)
|
||||
if (parsed) changes.push(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
const directPath =
|
||||
pickString(rawInputObj, [
|
||||
"file_path",
|
||||
"filePath",
|
||||
"path",
|
||||
"notebook_path",
|
||||
"target_file",
|
||||
"targetFile",
|
||||
]) ?? null
|
||||
|
||||
if (directPath) {
|
||||
const oldText =
|
||||
pickString(rawInputObj, [
|
||||
"old_string",
|
||||
"oldString",
|
||||
"old_text",
|
||||
"oldText",
|
||||
]) ?? ""
|
||||
const newText =
|
||||
pickString(rawInputObj, [
|
||||
"new_string",
|
||||
"newString",
|
||||
"new_text",
|
||||
"newText",
|
||||
"content",
|
||||
"text",
|
||||
"new_source",
|
||||
]) ?? ""
|
||||
|
||||
if (oldText || newText || changes.length === 0) {
|
||||
changes.push({
|
||||
path: directPath,
|
||||
oldText,
|
||||
newText,
|
||||
unifiedDiff: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
function extractContentDiffChanges(
|
||||
toolCallObj: ObjectLike | null
|
||||
): PermissionFileChange[] {
|
||||
if (!toolCallObj) return []
|
||||
const content = asArray(toolCallObj.content)
|
||||
if (!content) return []
|
||||
|
||||
const changes: PermissionFileChange[] = []
|
||||
for (const item of content) {
|
||||
const record = asObject(item)
|
||||
if (!record) continue
|
||||
const type = pickString(record, ["type"])?.toLowerCase()
|
||||
if (type !== "diff") continue
|
||||
|
||||
const path = pickString(record, ["path"])
|
||||
if (!path) continue
|
||||
changes.push({
|
||||
path,
|
||||
oldText: pickString(record, ["old_text", "oldText"]) ?? "",
|
||||
newText: pickString(record, ["new_text", "newText"]) ?? "",
|
||||
unifiedDiff: undefined,
|
||||
})
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
function collectLocationPaths(toolCallObj: ObjectLike | null): string[] {
|
||||
if (!toolCallObj) return []
|
||||
const locations = asArray(toolCallObj.locations)
|
||||
if (!locations) return []
|
||||
|
||||
const paths: string[] = []
|
||||
for (const item of locations) {
|
||||
const record = asObject(item)
|
||||
if (!record) continue
|
||||
const path = pickString(record, ["path"])
|
||||
if (path) paths.push(path)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
function collectDiffPaths(diffText: string | null): string[] {
|
||||
if (!diffText) return []
|
||||
const paths = new Set<string>()
|
||||
for (const line of diffText.split("\n")) {
|
||||
if (line.startsWith("*** Add File: ")) {
|
||||
paths.add(line.slice(14).trim())
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("*** Update File: ")) {
|
||||
paths.add(line.slice(17).trim())
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("*** Delete File: ")) {
|
||||
paths.add(line.slice(17).trim())
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("+++ ")) {
|
||||
const path = line.slice(4).replace(/^b\//, "").trim()
|
||||
if (path && path !== "/dev/null") paths.add(path)
|
||||
}
|
||||
}
|
||||
return Array.from(paths)
|
||||
}
|
||||
|
||||
function mergeFileChanges(
|
||||
changes: PermissionFileChange[]
|
||||
): PermissionFileChange[] {
|
||||
const merged = new Map<string, PermissionFileChange>()
|
||||
for (const change of changes) {
|
||||
const path = change.path.trim()
|
||||
if (!path) continue
|
||||
const prev = merged.get(path)
|
||||
if (!prev) {
|
||||
merged.set(path, { ...change, path })
|
||||
continue
|
||||
}
|
||||
|
||||
const oldText = prev.oldText || change.oldText
|
||||
const newText = prev.newText || change.newText
|
||||
const unifiedDiff = prev.unifiedDiff || change.unifiedDiff
|
||||
merged.set(path, { path, oldText, newText, unifiedDiff })
|
||||
}
|
||||
return Array.from(merged.values())
|
||||
}
|
||||
|
||||
function parsePlanEntries(
|
||||
rawInputObj: ObjectLike | null
|
||||
): PermissionPlanEntry[] {
|
||||
if (!rawInputObj) return []
|
||||
|
||||
const candidates = [
|
||||
pickValue(rawInputObj, ["plan"]),
|
||||
pickValue(rawInputObj, ["entries"]),
|
||||
pickValue(rawInputObj, ["steps"]),
|
||||
pickValue(rawInputObj, ["todos"]),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const list = asArray(candidate)
|
||||
if (!list || list.length === 0) continue
|
||||
const entries: PermissionPlanEntry[] = []
|
||||
for (const item of list) {
|
||||
const record = asObject(item)
|
||||
if (!record) continue
|
||||
const text =
|
||||
pickString(record, [
|
||||
"step",
|
||||
"content",
|
||||
"title",
|
||||
"task",
|
||||
"description",
|
||||
]) ?? null
|
||||
if (!text) continue
|
||||
entries.push({
|
||||
text,
|
||||
status: pickString(record, ["status", "state"]),
|
||||
})
|
||||
}
|
||||
if (entries.length > 0) return entries
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function formatFallbackTitle(kind: string): string {
|
||||
const normalized = kind.replace(/_/g, " ").trim()
|
||||
if (!normalized) return "Permission Request"
|
||||
return normalized
|
||||
.split(/\s+/)
|
||||
.map((part) => part[0].toUpperCase() + part.slice(1))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
export function parsePermissionToolCall(
|
||||
toolCall: unknown
|
||||
): ParsedPermissionToolCall {
|
||||
const toolCallObj = asObject(toolCall)
|
||||
const rawKind =
|
||||
pickString(toolCallObj, [
|
||||
"kind",
|
||||
"tool_name",
|
||||
"toolName",
|
||||
"name",
|
||||
"type",
|
||||
]) ?? "tool"
|
||||
const normalizedKind = normalizeToolName(rawKind)
|
||||
|
||||
const rawInputValue =
|
||||
pickValue(toolCallObj, [
|
||||
"rawInput",
|
||||
"raw_input",
|
||||
"input",
|
||||
"arguments",
|
||||
"params",
|
||||
"payload",
|
||||
]) ?? null
|
||||
const rawInputObj = asObject(rawInputValue)
|
||||
|
||||
const command =
|
||||
extractCommandFromUnknownValue(rawInputValue) ??
|
||||
extractCommandFromUnknownValue(toolCallObj)
|
||||
|
||||
const cwd =
|
||||
pickString(rawInputObj, [
|
||||
"cwd",
|
||||
"workdir",
|
||||
"working_directory",
|
||||
"workingDirectory",
|
||||
]) ??
|
||||
pickString(toolCallObj, [
|
||||
"cwd",
|
||||
"workdir",
|
||||
"working_directory",
|
||||
"workingDirectory",
|
||||
])
|
||||
|
||||
const explicitDiffPreview = extractDiffPreview(rawInputValue, rawInputObj)
|
||||
const rawInputFileChanges = extractRawInputFileChanges(rawInputObj)
|
||||
const contentDiffChanges = extractContentDiffChanges(toolCallObj)
|
||||
const locationPaths = collectLocationPaths(toolCallObj)
|
||||
const diffPaths = collectDiffPaths(explicitDiffPreview)
|
||||
|
||||
const combinedChanges = mergeFileChanges([
|
||||
...rawInputFileChanges,
|
||||
...contentDiffChanges,
|
||||
...locationPaths.map((path) => ({
|
||||
path,
|
||||
oldText: "",
|
||||
newText: "",
|
||||
unifiedDiff: undefined,
|
||||
})),
|
||||
...diffPaths.map((path) => ({
|
||||
path,
|
||||
oldText: "",
|
||||
newText: "",
|
||||
unifiedDiff: undefined,
|
||||
})),
|
||||
])
|
||||
const diffPreview =
|
||||
explicitDiffPreview ?? buildDiffPreviewFromChanges(combinedChanges)
|
||||
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
if (diffPreview) {
|
||||
const stats = countUnifiedDiffLineChanges(diffPreview)
|
||||
additions = stats.additions
|
||||
deletions = stats.deletions
|
||||
} else {
|
||||
for (const change of combinedChanges) {
|
||||
if (
|
||||
typeof change.unifiedDiff === "string" &&
|
||||
change.unifiedDiff.trim().length > 0
|
||||
) {
|
||||
const stats = countUnifiedDiffLineChanges(change.unifiedDiff)
|
||||
additions += stats.additions
|
||||
deletions += stats.deletions
|
||||
continue
|
||||
}
|
||||
const stats = estimateChangedLineStats(change.oldText, change.newText)
|
||||
additions += stats.additions
|
||||
deletions += stats.deletions
|
||||
}
|
||||
}
|
||||
|
||||
const planEntries = parsePlanEntries(rawInputObj)
|
||||
const planExplanation = pickString(rawInputObj, ["explanation"])
|
||||
const modeTarget =
|
||||
pickString(rawInputObj, [
|
||||
"mode_id",
|
||||
"modeId",
|
||||
"target_mode",
|
||||
"targetMode",
|
||||
]) ?? null
|
||||
|
||||
const title =
|
||||
pickString(toolCallObj, ["title", "tool_name", "toolName", "name"]) ??
|
||||
formatFallbackTitle(normalizedKind)
|
||||
|
||||
return {
|
||||
title,
|
||||
normalizedKind,
|
||||
command,
|
||||
cwd,
|
||||
fileChanges: combinedChanges,
|
||||
additions,
|
||||
deletions,
|
||||
diffPreview,
|
||||
planEntries,
|
||||
planExplanation,
|
||||
modeTarget,
|
||||
jsonPreview: stringifyJson(toolCallObj ?? toolCall),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type {
|
||||
AdaptedContentPart,
|
||||
UserResourceDisplay,
|
||||
} from "@/lib/adapters/ai-elements-adapter"
|
||||
import type { PromptDraft, PromptInputBlock } from "@/lib/types"
|
||||
|
||||
function isResourceLinkBlock(
|
||||
block: PromptInputBlock
|
||||
): block is Extract<PromptInputBlock, { type: "resource_link" }> {
|
||||
return block.type === "resource_link"
|
||||
}
|
||||
|
||||
export function getPromptDraftDisplayText(draft: PromptDraft): string {
|
||||
const trimmed = draft.displayText.trim()
|
||||
return trimmed || "Attached resources"
|
||||
}
|
||||
|
||||
export function buildUserMessageTextPartsFromDraft(
|
||||
draft: PromptDraft
|
||||
): AdaptedContentPart[] {
|
||||
return [{ type: "text", text: getPromptDraftDisplayText(draft) }]
|
||||
}
|
||||
|
||||
export function extractUserResourcesFromDraft(
|
||||
draft: PromptDraft
|
||||
): UserResourceDisplay[] {
|
||||
return draft.blocks.filter(isResourceLinkBlock).map((resource) => ({
|
||||
name: resource.name,
|
||||
uri: resource.uri,
|
||||
mime_type: resource.mime_type ?? null,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export const ATTACH_FILE_TO_SESSION_EVENT = "codeg:attach-file-to-session"
|
||||
|
||||
export interface AttachFileToSessionDetail {
|
||||
tabId: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export function emitAttachFileToSession(
|
||||
detail: AttachFileToSessionDetail
|
||||
): void {
|
||||
if (typeof window === "undefined") return
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<AttachFileToSessionDetail>(ATTACH_FILE_TO_SESSION_EVENT, {
|
||||
detail,
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,955 @@
|
||||
import type { MessageTurn } from "./types"
|
||||
import { normalizeToolName } from "./tool-call-normalization"
|
||||
|
||||
export type FileOperation = "read" | "edit" | "write" | "apply_patch"
|
||||
|
||||
export interface SessionFileChange {
|
||||
path: string
|
||||
operations: FileOperation[]
|
||||
}
|
||||
|
||||
export interface FileChangeStat {
|
||||
id: string
|
||||
path: string
|
||||
additions: number
|
||||
deletions: number
|
||||
diff: string | null
|
||||
}
|
||||
|
||||
export interface UserMessageGroup {
|
||||
userTurnId: string
|
||||
userMessage: string
|
||||
timestamp: string
|
||||
files: FileChangeStat[]
|
||||
}
|
||||
|
||||
interface DiffStat {
|
||||
additions: number
|
||||
deletions: number
|
||||
}
|
||||
|
||||
interface EditChangePreview {
|
||||
oldText: string
|
||||
newText: string
|
||||
unifiedDiff: string | null
|
||||
}
|
||||
|
||||
interface DiffSection extends DiffStat {
|
||||
chunk: string
|
||||
}
|
||||
|
||||
const WRITE_OPS = new Set<string>(["edit", "write", "apply_patch"])
|
||||
const FILE_OPS = new Set<string>(["read", "edit", "write", "apply_patch"])
|
||||
|
||||
const NESTED_PAYLOAD_KEYS = ["input", "arguments", "params", "payload"]
|
||||
|
||||
const PATH_KEYS = [
|
||||
"file_path",
|
||||
"filePath",
|
||||
"path",
|
||||
"target_file",
|
||||
"targetFile",
|
||||
"filename",
|
||||
"notebook_path",
|
||||
] as const
|
||||
|
||||
const PATCH_TEXT_KEYS = [
|
||||
"patch",
|
||||
"content",
|
||||
"diff",
|
||||
"unified_diff",
|
||||
"unifiedDiff",
|
||||
"input",
|
||||
"command",
|
||||
"cmd",
|
||||
"script",
|
||||
] as const
|
||||
|
||||
const EDIT_CHANGE_OLD_KEYS = [
|
||||
"old_string",
|
||||
"oldString",
|
||||
"old_text",
|
||||
"oldText",
|
||||
"old",
|
||||
"before",
|
||||
"source",
|
||||
"original",
|
||||
]
|
||||
|
||||
const EDIT_CHANGE_NEW_KEYS = [
|
||||
"new_string",
|
||||
"newString",
|
||||
"new_text",
|
||||
"newText",
|
||||
"new_content",
|
||||
"newContent",
|
||||
"new",
|
||||
"replacement",
|
||||
"after",
|
||||
"after_text",
|
||||
"afterText",
|
||||
"updated",
|
||||
"updated_text",
|
||||
"updatedText",
|
||||
"content",
|
||||
"new_source",
|
||||
"newSource",
|
||||
"text",
|
||||
]
|
||||
|
||||
const EDIT_CHANGE_DIFF_KEYS = ["diff", "patch", "unified_diff", "unifiedDiff"]
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.replace(/\\/g, "/")
|
||||
}
|
||||
|
||||
function asObjectLike(value: unknown): Record<string, unknown> | null {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
if (typeof value !== "string") return null
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed.startsWith("{")) return null
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
return typeof parsed === "object" &&
|
||||
parsed !== null &&
|
||||
!Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseInputObject(
|
||||
inputPreview: string | null
|
||||
): Record<string, unknown> | null {
|
||||
if (!inputPreview) return null
|
||||
return asObjectLike(inputPreview)
|
||||
}
|
||||
|
||||
function findStringFieldDeep(
|
||||
value: unknown,
|
||||
keys: readonly string[],
|
||||
depth: number = 0
|
||||
): string | null {
|
||||
if (depth > 4) return null
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const found = findStringFieldDeep(item, keys, depth + 1)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const obj = asObjectLike(value)
|
||||
if (!obj) return null
|
||||
|
||||
for (const key of keys) {
|
||||
const direct = obj[key]
|
||||
if (typeof direct === "string" && direct.trim().length > 0) {
|
||||
return direct
|
||||
}
|
||||
}
|
||||
|
||||
for (const nestedKey of NESTED_PAYLOAD_KEYS) {
|
||||
const found = findStringFieldDeep(obj[nestedKey], keys, depth + 1)
|
||||
if (found) return found
|
||||
}
|
||||
|
||||
for (const nestedValue of Object.values(obj)) {
|
||||
const found = findStringFieldDeep(nestedValue, keys, depth + 1)
|
||||
if (found) return found
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function findObjectFieldDeep(
|
||||
value: unknown,
|
||||
key: string,
|
||||
depth: number = 0
|
||||
): Record<string, unknown> | null {
|
||||
if (depth > 4) return null
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const found = findObjectFieldDeep(item, key, depth + 1)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const obj = asObjectLike(value)
|
||||
if (!obj) return null
|
||||
|
||||
const direct = asObjectLike(obj[key])
|
||||
if (direct) return direct
|
||||
|
||||
for (const nestedKey of NESTED_PAYLOAD_KEYS) {
|
||||
const found = findObjectFieldDeep(obj[nestedKey], key, depth + 1)
|
||||
if (found) return found
|
||||
}
|
||||
|
||||
for (const nestedValue of Object.values(obj)) {
|
||||
const found = findObjectFieldDeep(nestedValue, key, depth + 1)
|
||||
if (found) return found
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function firstStringField(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[]
|
||||
): string | null {
|
||||
for (const key of keys) {
|
||||
const field = value[key]
|
||||
if (typeof field === "string") {
|
||||
return field
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function unescapeInlineEscapes(text: string): string {
|
||||
return text
|
||||
.replace(/\\r\\n/g, "\n")
|
||||
.replace(/\\n/g, "\n")
|
||||
.replace(/\\t/g, "\t")
|
||||
}
|
||||
|
||||
function decodeJsonEscapedString(value: string): string {
|
||||
return value.replace(/\\"/g, '"').replace(/\\\//g, "/").replace(/\\\\/g, "\\")
|
||||
}
|
||||
|
||||
function normalizeDiffPath(rawPath: string | null): string | null {
|
||||
if (!rawPath) return null
|
||||
|
||||
const trimmed = rawPath.trim()
|
||||
if (!trimmed || trimmed === "/dev/null") return null
|
||||
|
||||
if (trimmed.startsWith("a/") || trimmed.startsWith("b/")) {
|
||||
return normalizePath(trimmed.slice(2))
|
||||
}
|
||||
|
||||
return normalizePath(trimmed)
|
||||
}
|
||||
|
||||
function isDiffAddedLine(line: string): boolean {
|
||||
return line.startsWith("+") && !line.startsWith("+++")
|
||||
}
|
||||
|
||||
function isDiffDeletedLine(line: string): boolean {
|
||||
return line.startsWith("-") && !line.startsWith("---")
|
||||
}
|
||||
|
||||
function countLines(s: string): number {
|
||||
if (!s) return 0
|
||||
return s.split("\n").length
|
||||
}
|
||||
|
||||
function countDiffLines(text: string): DiffStat {
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
|
||||
for (const line of text.split("\n")) {
|
||||
if (isDiffAddedLine(line)) additions += 1
|
||||
if (isDiffDeletedLine(line)) deletions += 1
|
||||
}
|
||||
|
||||
return { additions, deletions }
|
||||
}
|
||||
|
||||
function createHunkHeader(oldLineCount: number, newLineCount: number): string {
|
||||
const oldStart = oldLineCount === 0 ? 0 : 1
|
||||
const newStart = newLineCount === 0 ? 0 : 1
|
||||
return `@@ -${oldStart},${oldLineCount} +${newStart},${newLineCount} @@`
|
||||
}
|
||||
|
||||
function buildUnifiedDiff(
|
||||
filePath: string,
|
||||
oldText: string,
|
||||
newText: string
|
||||
): string | null {
|
||||
if (!oldText && !newText) return null
|
||||
|
||||
const oldLines = oldText ? oldText.split("\n") : []
|
||||
const newLines = newText ? newText.split("\n") : []
|
||||
|
||||
const lines: string[] = [
|
||||
`--- a/${filePath}`,
|
||||
`+++ b/${filePath}`,
|
||||
createHunkHeader(oldLines.length, newLines.length),
|
||||
]
|
||||
|
||||
for (const line of oldLines) lines.push(`-${line}`)
|
||||
for (const line of newLines) lines.push(`+${line}`)
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
function parseEditChangeValue(value: unknown): EditChangePreview | null {
|
||||
if (typeof value === "string") {
|
||||
return {
|
||||
oldText: "",
|
||||
newText: value,
|
||||
unifiedDiff: null,
|
||||
}
|
||||
}
|
||||
|
||||
const record = asObjectLike(value)
|
||||
if (!record) return null
|
||||
|
||||
const oldText =
|
||||
firstStringField(record, EDIT_CHANGE_OLD_KEYS) ??
|
||||
findStringFieldDeep(record, [
|
||||
"old_string",
|
||||
"old_text",
|
||||
"before_text",
|
||||
"old",
|
||||
]) ??
|
||||
""
|
||||
|
||||
const newText =
|
||||
firstStringField(record, EDIT_CHANGE_NEW_KEYS) ??
|
||||
findStringFieldDeep(record, [
|
||||
"new_string",
|
||||
"new_text",
|
||||
"after_text",
|
||||
"new",
|
||||
]) ??
|
||||
""
|
||||
|
||||
const unifiedDiff =
|
||||
firstStringField(record, EDIT_CHANGE_DIFF_KEYS) ??
|
||||
findStringFieldDeep(record, ["diff"]) ??
|
||||
null
|
||||
|
||||
if (!oldText && !newText && !unifiedDiff) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
oldText,
|
||||
newText,
|
||||
unifiedDiff:
|
||||
unifiedDiff && unifiedDiff.trim().length > 0 ? unifiedDiff : null,
|
||||
}
|
||||
}
|
||||
|
||||
function collectEditChangeValues(value: unknown): EditChangePreview[] {
|
||||
if (value === null || value === undefined) return []
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((item) => collectEditChangeValues(item))
|
||||
}
|
||||
|
||||
const single = parseEditChangeValue(value)
|
||||
if (single) return [single]
|
||||
|
||||
const record = asObjectLike(value)
|
||||
if (!record) return []
|
||||
|
||||
return Object.values(record).flatMap((item) => collectEditChangeValues(item))
|
||||
}
|
||||
|
||||
function pickValueByNormalizedPath(
|
||||
record: Record<string, unknown>,
|
||||
filePath: string
|
||||
): unknown {
|
||||
if (filePath in record) return record[filePath]
|
||||
|
||||
const escapedPath = filePath.replace(/\//g, "\\/")
|
||||
if (escapedPath in record) return record[escapedPath]
|
||||
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
const normalizedKey = normalizePath(decodeJsonEscapedString(key.trim()))
|
||||
if (normalizedKey === filePath) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function buildChunkFromEditChange(
|
||||
filePath: string,
|
||||
change: EditChangePreview
|
||||
): string | null {
|
||||
if (change.unifiedDiff) {
|
||||
return change.unifiedDiff.trim()
|
||||
}
|
||||
|
||||
return buildUnifiedDiff(filePath, change.oldText, change.newText)
|
||||
}
|
||||
|
||||
function extractPatchTextFromInputPreview(
|
||||
inputPreview: string | null
|
||||
): string | null {
|
||||
if (!inputPreview) return null
|
||||
|
||||
const parsed = parseInputObject(inputPreview)
|
||||
const parsedPatchText = parsed
|
||||
? findStringFieldDeep(parsed, PATCH_TEXT_KEYS)
|
||||
: null
|
||||
|
||||
const candidates = [parsedPatchText, inputPreview]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue
|
||||
|
||||
const normalized = unescapeInlineEscapes(candidate.trim())
|
||||
if (!normalized) continue
|
||||
|
||||
const block = normalized.match(
|
||||
/(\*\*\* Begin Patch[\s\S]*?\*\*\* End Patch(?:\n|$))/m
|
||||
)?.[1]
|
||||
if (block) return block.trim()
|
||||
|
||||
if (normalized.includes("*** Update File:")) return normalized
|
||||
if (/^diff --git /m.test(normalized)) return normalized
|
||||
if (/^--- .+/m.test(normalized) && /^\+\+\+ .+/m.test(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function parseApplyPatchSections(patchText: string): Map<string, DiffSection> {
|
||||
const sections = new Map<string, DiffSection>()
|
||||
|
||||
let currentPath: string | null = null
|
||||
let currentLines: string[] = []
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
|
||||
const flush = () => {
|
||||
if (!currentPath || currentLines.length === 0) return
|
||||
|
||||
const chunk = currentLines.join("\n").trim()
|
||||
if (!chunk) return
|
||||
|
||||
const existing = sections.get(currentPath)
|
||||
if (!existing) {
|
||||
sections.set(currentPath, { chunk, additions, deletions })
|
||||
return
|
||||
}
|
||||
|
||||
sections.set(currentPath, {
|
||||
chunk: `${existing.chunk}\n${chunk}`,
|
||||
additions: existing.additions + additions,
|
||||
deletions: existing.deletions + deletions,
|
||||
})
|
||||
}
|
||||
|
||||
const beginMarkers = [
|
||||
"*** Update File: ",
|
||||
"*** Add File: ",
|
||||
"*** Delete File: ",
|
||||
]
|
||||
|
||||
for (const line of patchText.split("\n")) {
|
||||
const marker = beginMarkers.find((prefix) => line.startsWith(prefix))
|
||||
if (marker) {
|
||||
flush()
|
||||
|
||||
currentPath = normalizeDiffPath(line.slice(marker.length))
|
||||
currentLines = [line]
|
||||
additions = 0
|
||||
deletions = 0
|
||||
continue
|
||||
}
|
||||
|
||||
if (!currentPath) continue
|
||||
|
||||
currentLines.push(line)
|
||||
if (line.startsWith("*** Move to: ")) {
|
||||
const movedPath = normalizeDiffPath(line.slice(13))
|
||||
if (movedPath) currentPath = movedPath
|
||||
continue
|
||||
}
|
||||
if (isDiffAddedLine(line)) additions += 1
|
||||
if (isDiffDeletedLine(line)) deletions += 1
|
||||
}
|
||||
|
||||
flush()
|
||||
return sections
|
||||
}
|
||||
|
||||
function parseUnifiedDiffSections(diffText: string): Map<string, DiffSection> {
|
||||
const sections = new Map<string, DiffSection>()
|
||||
|
||||
let currentPath: string | null = null
|
||||
let currentLines: string[] = []
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
|
||||
const flush = () => {
|
||||
if (!currentPath || currentLines.length === 0) return
|
||||
|
||||
const chunk = currentLines.join("\n").trim()
|
||||
if (!chunk) return
|
||||
|
||||
const existing = sections.get(currentPath)
|
||||
if (!existing) {
|
||||
sections.set(currentPath, { chunk, additions, deletions })
|
||||
return
|
||||
}
|
||||
|
||||
sections.set(currentPath, {
|
||||
chunk: `${existing.chunk}\n${chunk}`,
|
||||
additions: existing.additions + additions,
|
||||
deletions: existing.deletions + deletions,
|
||||
})
|
||||
}
|
||||
|
||||
for (const line of diffText.split("\n")) {
|
||||
if (line.startsWith("diff --git ")) {
|
||||
flush()
|
||||
|
||||
const match = line.match(/^diff --git\s+a\/(.+?)\s+b\/(.+)$/)
|
||||
currentPath = normalizeDiffPath(match?.[2] ?? null)
|
||||
currentLines = [line]
|
||||
additions = 0
|
||||
deletions = 0
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith("--- ")) {
|
||||
const maybePath = normalizeDiffPath(line.slice(4))
|
||||
if (!currentPath && maybePath) {
|
||||
flush()
|
||||
currentPath = maybePath
|
||||
currentLines = []
|
||||
additions = 0
|
||||
deletions = 0
|
||||
}
|
||||
|
||||
if (currentPath) currentLines.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith("+++ ")) {
|
||||
const maybePath = normalizeDiffPath(line.slice(4))
|
||||
if (maybePath && maybePath !== currentPath) {
|
||||
flush()
|
||||
currentPath = maybePath
|
||||
currentLines = []
|
||||
additions = 0
|
||||
deletions = 0
|
||||
}
|
||||
|
||||
if (currentPath) currentLines.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!currentPath) continue
|
||||
|
||||
currentLines.push(line)
|
||||
if (isDiffAddedLine(line)) additions += 1
|
||||
if (isDiffDeletedLine(line)) deletions += 1
|
||||
}
|
||||
|
||||
flush()
|
||||
return sections
|
||||
}
|
||||
|
||||
function extractPathsFromPatchText(patchText: string): string[] {
|
||||
const applyPatchSections = parseApplyPatchSections(patchText)
|
||||
if (applyPatchSections.size > 0) {
|
||||
return Array.from(applyPatchSections.keys())
|
||||
}
|
||||
|
||||
const unifiedDiffSections = parseUnifiedDiffSections(patchText)
|
||||
if (unifiedDiffSections.size > 0) {
|
||||
return Array.from(unifiedDiffSections.keys())
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function extractFilePaths(inputPreview: string | null): string[] {
|
||||
if (!inputPreview) return []
|
||||
|
||||
const paths = new Set<string>()
|
||||
const parsed = parseInputObject(inputPreview)
|
||||
|
||||
if (parsed) {
|
||||
const directPath = findStringFieldDeep(parsed, PATH_KEYS)
|
||||
if (directPath) {
|
||||
paths.add(normalizePath(directPath))
|
||||
}
|
||||
|
||||
const editChanges = findObjectFieldDeep(parsed, "changes")
|
||||
if (editChanges) {
|
||||
for (const path of Object.keys(editChanges)) {
|
||||
const normalized = normalizePath(path.trim())
|
||||
if (normalized) paths.add(normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const patchText = extractPatchTextFromInputPreview(inputPreview)
|
||||
if (patchText) {
|
||||
for (const path of extractPathsFromPatchText(patchText)) {
|
||||
paths.add(path)
|
||||
}
|
||||
}
|
||||
|
||||
if (paths.size === 0) {
|
||||
const match = inputPreview.match(
|
||||
/"(?:file_path|filePath|path|target_file|targetFile|notebook_path)"\s*:\s*"((?:[^"\\]|\\.)+)"/
|
||||
)
|
||||
if (match?.[1]) {
|
||||
paths.add(normalizePath(decodeJsonEscapedString(match[1])))
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(paths)
|
||||
}
|
||||
|
||||
function computeLineDiff(
|
||||
op: string,
|
||||
inputPreview: string | null,
|
||||
filePath?: string
|
||||
): DiffStat | null {
|
||||
if (!inputPreview) return null
|
||||
|
||||
const normalizedPath = filePath ? normalizePath(filePath) : null
|
||||
const parsed = parseInputObject(inputPreview)
|
||||
|
||||
if (op === "edit") {
|
||||
if (parsed && normalizedPath) {
|
||||
const changes = findObjectFieldDeep(parsed, "changes")
|
||||
const changeValue = changes
|
||||
? pickValueByNormalizedPath(changes, normalizedPath)
|
||||
: undefined
|
||||
const changeValues = collectEditChangeValues(changeValue)
|
||||
|
||||
if (changeValues.length > 0) {
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
|
||||
for (const change of changeValues) {
|
||||
if (change.unifiedDiff) {
|
||||
const diff = countDiffLines(change.unifiedDiff)
|
||||
additions += diff.additions
|
||||
deletions += diff.deletions
|
||||
continue
|
||||
}
|
||||
|
||||
additions += countLines(change.newText)
|
||||
deletions += countLines(change.oldText)
|
||||
}
|
||||
|
||||
return { additions, deletions }
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsed) return null
|
||||
|
||||
const oldStr =
|
||||
typeof parsed.old_string === "string" ? parsed.old_string : ""
|
||||
const newStr =
|
||||
typeof parsed.new_string === "string" ? parsed.new_string : ""
|
||||
|
||||
if (!oldStr && !newStr) return null
|
||||
|
||||
return {
|
||||
additions: countLines(newStr),
|
||||
deletions: countLines(oldStr),
|
||||
}
|
||||
}
|
||||
|
||||
if (op === "write") {
|
||||
if (!parsed) return null
|
||||
|
||||
const content = typeof parsed.content === "string" ? parsed.content : ""
|
||||
if (!content) return null
|
||||
|
||||
return {
|
||||
additions: countLines(content),
|
||||
deletions: 0,
|
||||
}
|
||||
}
|
||||
|
||||
if (op === "apply_patch") {
|
||||
const patchText = extractPatchTextFromInputPreview(inputPreview)
|
||||
if (!patchText) return null
|
||||
|
||||
const applyPatchSections = parseApplyPatchSections(patchText)
|
||||
if (normalizedPath && applyPatchSections.has(normalizedPath)) {
|
||||
const section = applyPatchSections.get(normalizedPath)
|
||||
if (section) {
|
||||
return {
|
||||
additions: section.additions,
|
||||
deletions: section.deletions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unifiedDiffSections = parseUnifiedDiffSections(patchText)
|
||||
if (normalizedPath && unifiedDiffSections.has(normalizedPath)) {
|
||||
const section = unifiedDiffSections.get(normalizedPath)
|
||||
if (section) {
|
||||
return {
|
||||
additions: section.additions,
|
||||
deletions: section.deletions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const total = countDiffLines(patchText)
|
||||
if (total.additions === 0 && total.deletions === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractUserMessage(turn: MessageTurn): string {
|
||||
for (const block of turn.blocks) {
|
||||
if (block.type === "text" && block.text) {
|
||||
const text = block.text.trim()
|
||||
if (text.length > 80) return `${text.slice(0, 77)}...`
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
return "User message"
|
||||
}
|
||||
|
||||
export function extractSessionFilesGrouped(
|
||||
turns: MessageTurn[]
|
||||
): UserMessageGroup[] {
|
||||
const groups: UserMessageGroup[] = []
|
||||
let currentUserTurn: MessageTurn | null = null
|
||||
let currentFiles: FileChangeStat[] = []
|
||||
|
||||
const flushGroup = () => {
|
||||
if (!currentUserTurn) return
|
||||
if (currentFiles.length === 0) return
|
||||
|
||||
groups.push({
|
||||
userTurnId: currentUserTurn.id,
|
||||
userMessage: extractUserMessage(currentUserTurn),
|
||||
timestamp: currentUserTurn.timestamp,
|
||||
files: currentFiles,
|
||||
})
|
||||
}
|
||||
|
||||
for (const turn of turns) {
|
||||
if (turn.role === "user") {
|
||||
flushGroup()
|
||||
currentUserTurn = turn
|
||||
currentFiles = []
|
||||
continue
|
||||
}
|
||||
|
||||
if (turn.role !== "assistant") continue
|
||||
|
||||
for (const block of turn.blocks) {
|
||||
if (block.type !== "tool_use") continue
|
||||
|
||||
const normalized = normalizeToolName(block.tool_name)
|
||||
if (!WRITE_OPS.has(normalized)) continue
|
||||
|
||||
const filePaths = extractFilePaths(block.input_preview)
|
||||
if (filePaths.length === 0) continue
|
||||
|
||||
for (const [fileIndex, filePath] of filePaths.entries()) {
|
||||
const normalizedPath = normalizePath(filePath)
|
||||
const diff = computeLineDiff(
|
||||
normalized,
|
||||
block.input_preview,
|
||||
normalizedPath
|
||||
)
|
||||
const diffChunk = buildDiffChunk(
|
||||
normalized,
|
||||
block.input_preview,
|
||||
normalizedPath
|
||||
)
|
||||
|
||||
currentFiles.push({
|
||||
id: `${turn.id}:${block.tool_use_id ?? "tool"}:${fileIndex}:${currentFiles.length}`,
|
||||
path: normalizedPath,
|
||||
additions: diff?.additions ?? 0,
|
||||
deletions: diff?.deletions ?? 0,
|
||||
diff: diffChunk?.trim() ? diffChunk.trim() : null,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flushGroup()
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a unified-diff string for a specific file within a user-message group.
|
||||
* Scans from `userTurnId` forward through assistant turns until the next user
|
||||
* turn, collecting all edit/write/apply_patch operations on `filePath`.
|
||||
*/
|
||||
export function buildSessionFileDiff(
|
||||
turns: MessageTurn[],
|
||||
userTurnId: string,
|
||||
filePath: string
|
||||
): string {
|
||||
let inGroup = false
|
||||
const chunks: string[] = []
|
||||
const normalizedTargetPath = normalizePath(filePath)
|
||||
|
||||
for (const turn of turns) {
|
||||
if (turn.role === "user") {
|
||||
if (turn.id === userTurnId) {
|
||||
inGroup = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (inGroup) {
|
||||
break
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (!inGroup || turn.role !== "assistant") continue
|
||||
|
||||
for (const block of turn.blocks) {
|
||||
if (block.type !== "tool_use") continue
|
||||
|
||||
const normalized = normalizeToolName(block.tool_name)
|
||||
if (!WRITE_OPS.has(normalized)) continue
|
||||
|
||||
const blockPaths = extractFilePaths(block.input_preview).map(
|
||||
normalizePath
|
||||
)
|
||||
if (!blockPaths.includes(normalizedTargetPath)) continue
|
||||
|
||||
const chunk = buildDiffChunk(
|
||||
normalized,
|
||||
block.input_preview,
|
||||
normalizedTargetPath
|
||||
)
|
||||
if (chunk && chunk.trim().length > 0) chunks.push(chunk.trim())
|
||||
}
|
||||
}
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return `No diff data available for ${filePath}`
|
||||
}
|
||||
|
||||
return chunks.join("\n\n")
|
||||
}
|
||||
|
||||
function buildDiffChunk(
|
||||
op: string,
|
||||
inputPreview: string | null,
|
||||
filePath: string
|
||||
): string | null {
|
||||
if (!inputPreview) return null
|
||||
|
||||
const parsed = parseInputObject(inputPreview)
|
||||
|
||||
if (op === "edit") {
|
||||
if (parsed) {
|
||||
const changes = findObjectFieldDeep(parsed, "changes")
|
||||
const changeValue = changes
|
||||
? pickValueByNormalizedPath(changes, filePath)
|
||||
: undefined
|
||||
const changeValues = collectEditChangeValues(changeValue)
|
||||
|
||||
if (changeValues.length > 0) {
|
||||
const chunks = changeValues
|
||||
.map((change) => buildChunkFromEditChange(filePath, change))
|
||||
.filter((chunk): chunk is string => Boolean(chunk?.trim()))
|
||||
if (chunks.length > 0) return chunks.join("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsed) return null
|
||||
|
||||
const oldStr =
|
||||
typeof parsed.old_string === "string" ? parsed.old_string : ""
|
||||
const newStr =
|
||||
typeof parsed.new_string === "string" ? parsed.new_string : ""
|
||||
|
||||
return buildUnifiedDiff(filePath, oldStr, newStr)
|
||||
}
|
||||
|
||||
if (op === "write") {
|
||||
if (!parsed) return null
|
||||
|
||||
const content = typeof parsed.content === "string" ? parsed.content : ""
|
||||
if (!content) return null
|
||||
|
||||
const newLines = content.split("\n")
|
||||
const lines: string[] = [
|
||||
"--- /dev/null",
|
||||
`+++ b/${filePath}`,
|
||||
`@@ -0,0 +1,${newLines.length} @@`,
|
||||
]
|
||||
|
||||
for (const line of newLines) lines.push(`+${line}`)
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
if (op === "apply_patch") {
|
||||
const patchText = extractPatchTextFromInputPreview(inputPreview)
|
||||
if (!patchText) return null
|
||||
|
||||
const applyPatchSections = parseApplyPatchSections(patchText)
|
||||
if (applyPatchSections.has(filePath)) {
|
||||
return applyPatchSections.get(filePath)?.chunk ?? null
|
||||
}
|
||||
|
||||
const unifiedDiffSections = parseUnifiedDiffSections(patchText)
|
||||
if (unifiedDiffSections.has(filePath)) {
|
||||
return unifiedDiffSections.get(filePath)?.chunk ?? null
|
||||
}
|
||||
|
||||
return patchText
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function extractSessionFiles(turns: MessageTurn[]): SessionFileChange[] {
|
||||
const fileMap = new Map<string, Set<FileOperation>>()
|
||||
|
||||
for (const turn of turns) {
|
||||
for (const block of turn.blocks) {
|
||||
if (block.type !== "tool_use") continue
|
||||
|
||||
const normalized = normalizeToolName(block.tool_name)
|
||||
if (!FILE_OPS.has(normalized)) continue
|
||||
|
||||
const filePaths = extractFilePaths(block.input_preview)
|
||||
if (filePaths.length === 0) continue
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
if (!fileMap.has(filePath)) {
|
||||
fileMap.set(filePath, new Set())
|
||||
}
|
||||
|
||||
fileMap.get(filePath)?.add(normalized as FileOperation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(fileMap.entries()).map(([path, operations]) => ({
|
||||
path,
|
||||
operations: Array.from(operations),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,852 @@
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import type {
|
||||
AgentType,
|
||||
ConversationSummary,
|
||||
ConversationDetail,
|
||||
DbConversationDetail,
|
||||
FolderInfo,
|
||||
AgentStats,
|
||||
SidebarData,
|
||||
ConnectionInfo,
|
||||
AcpAgentInfo,
|
||||
AgentSkillScope,
|
||||
AgentSkillLayout,
|
||||
AgentSkillItem,
|
||||
AgentSkillsListResult,
|
||||
AgentSkillContent,
|
||||
FolderHistoryEntry,
|
||||
FolderDetail,
|
||||
DbConversationSummary,
|
||||
ImportResult,
|
||||
OpenedConversation,
|
||||
GitStatusEntry,
|
||||
GitBranchList,
|
||||
GitPullResult,
|
||||
GitPushResult,
|
||||
GitMergeResult,
|
||||
GitCommitResult,
|
||||
PreflightResult,
|
||||
FolderCommand,
|
||||
TerminalInfo,
|
||||
PromptInputBlock,
|
||||
FileTreeNode,
|
||||
FilePreviewContent,
|
||||
FileEditContent,
|
||||
FileSaveResult,
|
||||
GitLogEntry,
|
||||
SystemProxySettings,
|
||||
McpAppType,
|
||||
LocalMcpServer,
|
||||
McpMarketplaceProvider,
|
||||
McpMarketplaceItem,
|
||||
McpMarketplaceServerDetail,
|
||||
} from "./types"
|
||||
|
||||
export async function listConversations(params?: {
|
||||
agent_type?: AgentType | null
|
||||
search?: string | null
|
||||
sort_by?: string | null
|
||||
folder_path?: string | null
|
||||
}): Promise<ConversationSummary[]> {
|
||||
return invoke("list_conversations", {
|
||||
agentType: params?.agent_type ?? null,
|
||||
search: params?.search ?? null,
|
||||
sortBy: params?.sort_by ?? null,
|
||||
folderPath: params?.folder_path ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getConversation(
|
||||
agentType: AgentType,
|
||||
conversationId: string
|
||||
): Promise<ConversationDetail> {
|
||||
return invoke("get_conversation", { agentType, conversationId })
|
||||
}
|
||||
|
||||
export async function listFolders(): Promise<FolderInfo[]> {
|
||||
return invoke("list_folders")
|
||||
}
|
||||
|
||||
export async function getStats(): Promise<AgentStats> {
|
||||
return invoke("get_stats")
|
||||
}
|
||||
|
||||
export async function getSidebarData(): Promise<SidebarData> {
|
||||
return invoke("get_sidebar_data")
|
||||
}
|
||||
|
||||
// ACP commands
|
||||
|
||||
export async function acpConnect(
|
||||
agentType: AgentType,
|
||||
workingDir?: string,
|
||||
sessionId?: string
|
||||
): Promise<string> {
|
||||
return invoke("acp_connect", {
|
||||
agentType,
|
||||
workingDir: workingDir ?? null,
|
||||
sessionId: sessionId ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function acpPrompt(
|
||||
connectionId: string,
|
||||
blocks: PromptInputBlock[]
|
||||
): Promise<void> {
|
||||
return invoke("acp_prompt", { connectionId, blocks })
|
||||
}
|
||||
|
||||
export async function acpSetMode(
|
||||
connectionId: string,
|
||||
modeId: string
|
||||
): Promise<void> {
|
||||
return invoke("acp_set_mode", { connectionId, modeId })
|
||||
}
|
||||
|
||||
export async function acpSetConfigOption(
|
||||
connectionId: string,
|
||||
configId: string,
|
||||
valueId: string
|
||||
): Promise<void> {
|
||||
return invoke("acp_set_config_option", { connectionId, configId, valueId })
|
||||
}
|
||||
|
||||
export async function acpCancel(connectionId: string): Promise<void> {
|
||||
return invoke("acp_cancel", { connectionId })
|
||||
}
|
||||
|
||||
export async function acpRespondPermission(
|
||||
connectionId: string,
|
||||
requestId: string,
|
||||
optionId: string
|
||||
): Promise<void> {
|
||||
return invoke("acp_respond_permission", {
|
||||
connectionId,
|
||||
requestId,
|
||||
optionId,
|
||||
})
|
||||
}
|
||||
|
||||
export async function acpDisconnect(connectionId: string): Promise<void> {
|
||||
return invoke("acp_disconnect", { connectionId })
|
||||
}
|
||||
|
||||
export async function acpListConnections(): Promise<ConnectionInfo[]> {
|
||||
return invoke("acp_list_connections")
|
||||
}
|
||||
|
||||
export async function acpListAgents(): Promise<AcpAgentInfo[]> {
|
||||
return invoke("acp_list_agents")
|
||||
}
|
||||
|
||||
export async function acpClearBinaryCache(agentType: AgentType): Promise<void> {
|
||||
return invoke("acp_clear_binary_cache", { agentType })
|
||||
}
|
||||
|
||||
export async function acpDownloadAgentBinary(
|
||||
agentType: AgentType
|
||||
): Promise<void> {
|
||||
return invoke("acp_download_agent_binary", { agentType })
|
||||
}
|
||||
|
||||
export async function acpDetectAgentLocalVersion(
|
||||
agentType: AgentType
|
||||
): Promise<string | null> {
|
||||
return invoke("acp_detect_agent_local_version", { agentType })
|
||||
}
|
||||
|
||||
export async function acpPrepareNpxAgent(
|
||||
agentType: AgentType,
|
||||
registryVersion?: string | null
|
||||
): Promise<string> {
|
||||
return invoke("acp_prepare_npx_agent", {
|
||||
agentType,
|
||||
registryVersion: registryVersion ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function acpPrepareUvxAgent(
|
||||
agentType: AgentType,
|
||||
registryVersion?: string | null
|
||||
): Promise<string> {
|
||||
return invoke("acp_prepare_uvx_agent", {
|
||||
agentType,
|
||||
registryVersion: registryVersion ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function acpUninstallAgent(agentType: AgentType): Promise<void> {
|
||||
return invoke("acp_uninstall_agent", { agentType })
|
||||
}
|
||||
|
||||
export async function acpUpdateAgentPreferences(
|
||||
agentType: AgentType,
|
||||
params: {
|
||||
enabled: boolean
|
||||
env: Record<string, string>
|
||||
config_json?: string | null
|
||||
opencode_auth_json?: string | null
|
||||
codex_auth_json?: string | null
|
||||
codex_config_toml?: string | null
|
||||
}
|
||||
): Promise<void> {
|
||||
return invoke("acp_update_agent_preferences", {
|
||||
agentType,
|
||||
enabled: params.enabled,
|
||||
env: params.env,
|
||||
configJson: params.config_json ?? null,
|
||||
opencodeAuthJson: params.opencode_auth_json ?? null,
|
||||
codexAuthJson: params.codex_auth_json ?? null,
|
||||
codexConfigToml: params.codex_config_toml ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function acpReorderAgents(agentTypes: AgentType[]): Promise<void> {
|
||||
return invoke("acp_reorder_agents", { agentTypes })
|
||||
}
|
||||
|
||||
export async function acpPreflight(
|
||||
agentType: AgentType
|
||||
): Promise<PreflightResult> {
|
||||
return invoke("acp_preflight", { agentType })
|
||||
}
|
||||
|
||||
export async function acpListAgentSkills(params: {
|
||||
agentType: AgentType
|
||||
workspacePath?: string | null
|
||||
}): Promise<AgentSkillsListResult> {
|
||||
return invoke("acp_list_agent_skills", {
|
||||
agentType: params.agentType,
|
||||
workspacePath: params.workspacePath ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function acpReadAgentSkill(params: {
|
||||
agentType: AgentType
|
||||
scope: AgentSkillScope
|
||||
skillId: string
|
||||
workspacePath?: string | null
|
||||
}): Promise<AgentSkillContent> {
|
||||
return invoke("acp_read_agent_skill", {
|
||||
agentType: params.agentType,
|
||||
scope: params.scope,
|
||||
skillId: params.skillId,
|
||||
workspacePath: params.workspacePath ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function acpSaveAgentSkill(params: {
|
||||
agentType: AgentType
|
||||
scope: AgentSkillScope
|
||||
skillId: string
|
||||
content: string
|
||||
workspacePath?: string | null
|
||||
layout?: AgentSkillLayout | null
|
||||
}): Promise<AgentSkillItem> {
|
||||
return invoke("acp_save_agent_skill", {
|
||||
agentType: params.agentType,
|
||||
scope: params.scope,
|
||||
skillId: params.skillId,
|
||||
content: params.content,
|
||||
workspacePath: params.workspacePath ?? null,
|
||||
layout: params.layout ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function acpDeleteAgentSkill(params: {
|
||||
agentType: AgentType
|
||||
scope: AgentSkillScope
|
||||
skillId: string
|
||||
workspacePath?: string | null
|
||||
}): Promise<void> {
|
||||
return invoke("acp_delete_agent_skill", {
|
||||
agentType: params.agentType,
|
||||
scope: params.scope,
|
||||
skillId: params.skillId,
|
||||
workspacePath: params.workspacePath ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getSystemProxySettings(): Promise<SystemProxySettings> {
|
||||
return invoke("get_system_proxy_settings")
|
||||
}
|
||||
|
||||
export async function updateSystemProxySettings(
|
||||
settings: SystemProxySettings
|
||||
): Promise<SystemProxySettings> {
|
||||
return invoke("update_system_proxy_settings", { settings })
|
||||
}
|
||||
|
||||
export async function mcpScanLocal(): Promise<LocalMcpServer[]> {
|
||||
return invoke("mcp_scan_local")
|
||||
}
|
||||
|
||||
export async function mcpListMarketplaces(): Promise<McpMarketplaceProvider[]> {
|
||||
return invoke("mcp_list_marketplaces")
|
||||
}
|
||||
|
||||
export async function mcpSearchMarketplace(params: {
|
||||
providerId: string
|
||||
query?: string | null
|
||||
limit?: number | null
|
||||
}): Promise<McpMarketplaceItem[]> {
|
||||
return invoke("mcp_search_marketplace", {
|
||||
providerId: params.providerId,
|
||||
query: params.query ?? null,
|
||||
limit: params.limit ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function mcpGetMarketplaceServerDetail(params: {
|
||||
providerId: string
|
||||
serverId: string
|
||||
}): Promise<McpMarketplaceServerDetail> {
|
||||
return invoke("mcp_get_marketplace_server_detail", {
|
||||
providerId: params.providerId,
|
||||
serverId: params.serverId,
|
||||
})
|
||||
}
|
||||
|
||||
export async function mcpInstallFromMarketplace(params: {
|
||||
providerId: string
|
||||
serverId: string
|
||||
apps: McpAppType[]
|
||||
specOverride?: Record<string, unknown> | null
|
||||
optionId?: string | null
|
||||
protocol?: string | null
|
||||
parameterValues?: Record<string, unknown> | null
|
||||
}): Promise<LocalMcpServer> {
|
||||
return invoke("mcp_install_from_marketplace", {
|
||||
providerId: params.providerId,
|
||||
serverId: params.serverId,
|
||||
apps: params.apps,
|
||||
specOverride: params.specOverride ?? null,
|
||||
optionId: params.optionId ?? null,
|
||||
protocol: params.protocol ?? null,
|
||||
parameterValues: params.parameterValues ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function mcpUpsertLocalServer(params: {
|
||||
serverId: string
|
||||
spec: Record<string, unknown>
|
||||
apps: McpAppType[]
|
||||
}): Promise<LocalMcpServer> {
|
||||
return invoke("mcp_upsert_local_server", {
|
||||
serverId: params.serverId,
|
||||
spec: params.spec,
|
||||
apps: params.apps,
|
||||
})
|
||||
}
|
||||
|
||||
export async function mcpSetServerApps(
|
||||
serverId: string,
|
||||
apps: McpAppType[]
|
||||
): Promise<LocalMcpServer | null> {
|
||||
return invoke("mcp_set_server_apps", { serverId, apps })
|
||||
}
|
||||
|
||||
export async function mcpRemoveServer(
|
||||
serverId: string,
|
||||
apps?: McpAppType[] | null
|
||||
): Promise<boolean> {
|
||||
return invoke("mcp_remove_server", {
|
||||
serverId,
|
||||
apps: apps ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
// Folder history commands
|
||||
|
||||
export async function loadFolderHistory(): Promise<FolderHistoryEntry[]> {
|
||||
return invoke("load_folder_history")
|
||||
}
|
||||
|
||||
export async function getFolder(folderId: number): Promise<FolderDetail> {
|
||||
return invoke("get_folder", { folderId })
|
||||
}
|
||||
|
||||
export async function listFolderConversations(params: {
|
||||
folder_id: number
|
||||
agent_type?: AgentType | null
|
||||
search?: string | null
|
||||
sort_by?: string | null
|
||||
status?: string | null
|
||||
}): Promise<DbConversationSummary[]> {
|
||||
return invoke("list_folder_conversations", {
|
||||
folderId: params.folder_id,
|
||||
agentType: params.agent_type ?? null,
|
||||
search: params.search ?? null,
|
||||
sortBy: params.sort_by ?? null,
|
||||
status: params.status ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function importLocalConversations(
|
||||
folderId: number
|
||||
): Promise<ImportResult> {
|
||||
return invoke("import_local_conversations", { folderId })
|
||||
}
|
||||
|
||||
export async function getFolderConversation(
|
||||
conversationId: number
|
||||
): Promise<DbConversationDetail> {
|
||||
return invoke("get_folder_conversation", { conversationId })
|
||||
}
|
||||
|
||||
export async function saveFolderOpenedConversations(
|
||||
folderId: number,
|
||||
items: OpenedConversation[]
|
||||
): Promise<void> {
|
||||
return invoke("save_folder_opened_conversations", { folderId, items })
|
||||
}
|
||||
|
||||
export async function setFolderParentBranch(
|
||||
path: string,
|
||||
parentBranch: string | null
|
||||
): Promise<void> {
|
||||
return invoke("set_folder_parent_branch", {
|
||||
path,
|
||||
parentBranch,
|
||||
})
|
||||
}
|
||||
|
||||
export async function removeFolderFromHistory(path: string): Promise<void> {
|
||||
return invoke("remove_folder_from_history", { path })
|
||||
}
|
||||
|
||||
export async function createFolderDirectory(path: string): Promise<void> {
|
||||
return invoke("create_folder_directory", { path })
|
||||
}
|
||||
|
||||
export async function cloneRepository(
|
||||
url: string,
|
||||
targetDir: string
|
||||
): Promise<void> {
|
||||
return invoke("clone_repository", { url, targetDir })
|
||||
}
|
||||
|
||||
export async function getGitBranch(path: string): Promise<string | null> {
|
||||
return invoke("get_git_branch", { path })
|
||||
}
|
||||
|
||||
export async function gitInit(path: string): Promise<void> {
|
||||
return invoke("git_init", { path })
|
||||
}
|
||||
|
||||
export async function gitPull(path: string): Promise<GitPullResult> {
|
||||
return invoke("git_pull", { path })
|
||||
}
|
||||
|
||||
export async function gitFetch(path: string): Promise<string> {
|
||||
return invoke("git_fetch", { path })
|
||||
}
|
||||
|
||||
export async function gitPush(path: string): Promise<GitPushResult> {
|
||||
return invoke("git_push", { path })
|
||||
}
|
||||
|
||||
export async function gitNewBranch(
|
||||
path: string,
|
||||
branchName: string,
|
||||
startPoint?: string
|
||||
): Promise<void> {
|
||||
return invoke("git_new_branch", {
|
||||
path,
|
||||
branchName,
|
||||
startPoint: startPoint ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function gitWorktreeAdd(
|
||||
path: string,
|
||||
branchName: string,
|
||||
worktreePath: string
|
||||
): Promise<void> {
|
||||
return invoke("git_worktree_add", { path, branchName, worktreePath })
|
||||
}
|
||||
|
||||
export async function gitCheckout(
|
||||
path: string,
|
||||
branchName: string
|
||||
): Promise<void> {
|
||||
return invoke("git_checkout", { path, branchName })
|
||||
}
|
||||
|
||||
export async function gitListBranches(path: string): Promise<string[]> {
|
||||
return invoke("git_list_branches", { path })
|
||||
}
|
||||
|
||||
export async function gitListAllBranches(path: string): Promise<GitBranchList> {
|
||||
return invoke("git_list_all_branches", { path })
|
||||
}
|
||||
|
||||
export async function gitMerge(
|
||||
path: string,
|
||||
branchName: string
|
||||
): Promise<GitMergeResult> {
|
||||
return invoke("git_merge", { path, branchName })
|
||||
}
|
||||
|
||||
export async function gitRebase(
|
||||
path: string,
|
||||
branchName: string
|
||||
): Promise<string> {
|
||||
return invoke("git_rebase", { path, branchName })
|
||||
}
|
||||
|
||||
export async function gitDeleteBranch(
|
||||
path: string,
|
||||
branchName: string,
|
||||
force = false
|
||||
): Promise<string> {
|
||||
return invoke("git_delete_branch", { path, branchName, force })
|
||||
}
|
||||
|
||||
export async function gitStash(path: string): Promise<string> {
|
||||
return invoke("git_stash", { path })
|
||||
}
|
||||
|
||||
export async function gitStashPop(path: string): Promise<string> {
|
||||
return invoke("git_stash_pop", { path })
|
||||
}
|
||||
|
||||
export async function gitStatus(path: string): Promise<GitStatusEntry[]> {
|
||||
return invoke("git_status", { path })
|
||||
}
|
||||
|
||||
export async function gitDiff(path: string, file?: string): Promise<string> {
|
||||
return invoke("git_diff", { path, file: file ?? null })
|
||||
}
|
||||
|
||||
export async function gitDiffWithBranch(
|
||||
path: string,
|
||||
branch: string,
|
||||
file?: string
|
||||
): Promise<string> {
|
||||
return invoke("git_diff_with_branch", {
|
||||
path,
|
||||
branch,
|
||||
file: file ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function gitShowDiff(
|
||||
path: string,
|
||||
commit: string,
|
||||
file?: string
|
||||
): Promise<string> {
|
||||
return invoke("git_show_diff", { path, commit, file: file ?? null })
|
||||
}
|
||||
|
||||
export async function gitShowFile(
|
||||
path: string,
|
||||
file: string,
|
||||
refName?: string
|
||||
): Promise<string> {
|
||||
return invoke("git_show_file", {
|
||||
path,
|
||||
file,
|
||||
refName: refName ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function gitIsTracked(
|
||||
path: string,
|
||||
file: string
|
||||
): Promise<boolean> {
|
||||
return invoke("git_is_tracked", { path, file })
|
||||
}
|
||||
|
||||
export async function gitCommit(
|
||||
path: string,
|
||||
message: string,
|
||||
files: string[]
|
||||
): Promise<GitCommitResult> {
|
||||
return invoke("git_commit", { path, message, files })
|
||||
}
|
||||
|
||||
export async function gitRollbackFile(
|
||||
path: string,
|
||||
file: string
|
||||
): Promise<void> {
|
||||
return invoke("git_rollback_file", { path, file })
|
||||
}
|
||||
|
||||
export async function gitAddFiles(
|
||||
path: string,
|
||||
files: string[]
|
||||
): Promise<void> {
|
||||
return invoke("git_add_files", { path, files })
|
||||
}
|
||||
|
||||
// Window management commands
|
||||
|
||||
export async function openFolderWindow(path: string): Promise<void> {
|
||||
return invoke("open_folder_window", { path })
|
||||
}
|
||||
|
||||
export async function openCommitWindow(folderId: number): Promise<void> {
|
||||
return invoke("open_commit_window", { folderId })
|
||||
}
|
||||
|
||||
export type SettingsSection =
|
||||
| "appearance"
|
||||
| "agents"
|
||||
| "mcp"
|
||||
| "skills"
|
||||
| "shortcuts"
|
||||
| "system"
|
||||
|
||||
interface OpenSettingsWindowOptions {
|
||||
agentType?: AgentType | null
|
||||
}
|
||||
|
||||
export async function openSettingsWindow(
|
||||
section?: SettingsSection,
|
||||
options?: OpenSettingsWindowOptions
|
||||
): Promise<void> {
|
||||
return invoke("open_settings_window", {
|
||||
section: section ?? null,
|
||||
agentType: options?.agentType ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function listOpenFolders(): Promise<FolderHistoryEntry[]> {
|
||||
return invoke("list_open_folders")
|
||||
}
|
||||
|
||||
export async function focusFolderWindow(folderId: number): Promise<void> {
|
||||
return invoke("focus_folder_window", { folderId })
|
||||
}
|
||||
|
||||
// Conversation CRUD commands
|
||||
|
||||
export async function createConversation(
|
||||
folderId: number,
|
||||
agentType: AgentType,
|
||||
title?: string
|
||||
): Promise<number> {
|
||||
return invoke("create_conversation", {
|
||||
folderId,
|
||||
agentType,
|
||||
title: title ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateConversationStatus(
|
||||
conversationId: number,
|
||||
status: string
|
||||
): Promise<void> {
|
||||
return invoke("update_conversation_status", { conversationId, status })
|
||||
}
|
||||
|
||||
export async function updateConversationTitle(
|
||||
conversationId: number,
|
||||
title: string
|
||||
): Promise<void> {
|
||||
return invoke("update_conversation_title", { conversationId, title })
|
||||
}
|
||||
|
||||
export async function updateConversationExternalId(
|
||||
conversationId: number,
|
||||
externalId: string
|
||||
): Promise<void> {
|
||||
return invoke("update_conversation_external_id", {
|
||||
conversationId,
|
||||
externalId,
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteConversation(
|
||||
conversationId: number
|
||||
): Promise<void> {
|
||||
return invoke("delete_conversation", { conversationId })
|
||||
}
|
||||
|
||||
// Folder command management
|
||||
|
||||
export async function listFolderCommands(
|
||||
folderId: number
|
||||
): Promise<FolderCommand[]> {
|
||||
return invoke("list_folder_commands", { folderId })
|
||||
}
|
||||
|
||||
export async function createFolderCommand(
|
||||
folderId: number,
|
||||
name: string,
|
||||
command: string
|
||||
): Promise<FolderCommand> {
|
||||
return invoke("create_folder_command", { folderId, name, command })
|
||||
}
|
||||
|
||||
export async function updateFolderCommand(
|
||||
id: number,
|
||||
name?: string,
|
||||
command?: string,
|
||||
sortOrder?: number
|
||||
): Promise<FolderCommand> {
|
||||
return invoke("update_folder_command", {
|
||||
id,
|
||||
name: name ?? null,
|
||||
command: command ?? null,
|
||||
sortOrder: sortOrder ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteFolderCommand(id: number): Promise<void> {
|
||||
return invoke("delete_folder_command", { id })
|
||||
}
|
||||
|
||||
export async function reorderFolderCommands(
|
||||
folderId: number,
|
||||
ids: number[]
|
||||
): Promise<void> {
|
||||
return invoke("reorder_folder_commands", { folderId, ids })
|
||||
}
|
||||
|
||||
export async function bootstrapFolderCommandsFromPackageJson(
|
||||
folderId: number,
|
||||
folderPath: string
|
||||
): Promise<FolderCommand[]> {
|
||||
return invoke("bootstrap_folder_commands_from_package_json", {
|
||||
folderId,
|
||||
folderPath,
|
||||
})
|
||||
}
|
||||
|
||||
// File tree and git log commands
|
||||
|
||||
export async function getFileTree(
|
||||
path: string,
|
||||
maxDepth?: number
|
||||
): Promise<FileTreeNode[]> {
|
||||
return invoke("get_file_tree", { path, maxDepth: maxDepth ?? null })
|
||||
}
|
||||
|
||||
export async function startFileTreeWatch(rootPath: string): Promise<void> {
|
||||
return invoke("start_file_tree_watch", { rootPath })
|
||||
}
|
||||
|
||||
export async function stopFileTreeWatch(rootPath: string): Promise<void> {
|
||||
return invoke("stop_file_tree_watch", { rootPath })
|
||||
}
|
||||
|
||||
export async function readFilePreview(
|
||||
rootPath: string,
|
||||
path: string,
|
||||
maxBytes?: number
|
||||
): Promise<FilePreviewContent> {
|
||||
return invoke("read_file_preview", {
|
||||
rootPath,
|
||||
path,
|
||||
maxBytes: maxBytes ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function readFileForEdit(
|
||||
rootPath: string,
|
||||
path: string,
|
||||
maxBytes?: number
|
||||
): Promise<FileEditContent> {
|
||||
return invoke("read_file_for_edit", {
|
||||
rootPath,
|
||||
path,
|
||||
maxBytes: maxBytes ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function saveFileContent(
|
||||
rootPath: string,
|
||||
path: string,
|
||||
content: string,
|
||||
expectedEtag?: string | null
|
||||
): Promise<FileSaveResult> {
|
||||
return invoke("save_file_content", {
|
||||
rootPath,
|
||||
path,
|
||||
content,
|
||||
expectedEtag: expectedEtag ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function saveFileCopy(
|
||||
rootPath: string,
|
||||
path: string,
|
||||
content: string
|
||||
): Promise<FileSaveResult> {
|
||||
return invoke("save_file_copy", {
|
||||
rootPath,
|
||||
path,
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
export async function renameFileTreeEntry(
|
||||
rootPath: string,
|
||||
path: string,
|
||||
newName: string
|
||||
): Promise<string> {
|
||||
return invoke("rename_file_tree_entry", { rootPath, path, newName })
|
||||
}
|
||||
|
||||
export async function deleteFileTreeEntry(
|
||||
rootPath: string,
|
||||
path: string
|
||||
): Promise<void> {
|
||||
return invoke("delete_file_tree_entry", { rootPath, path })
|
||||
}
|
||||
|
||||
export async function gitLog(
|
||||
path: string,
|
||||
limit?: number,
|
||||
branch?: string
|
||||
): Promise<GitLogEntry[]> {
|
||||
return invoke("git_log", {
|
||||
path,
|
||||
limit: limit ?? null,
|
||||
branch: branch ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function gitCommitBranches(
|
||||
path: string,
|
||||
commit: string
|
||||
): Promise<string[]> {
|
||||
return invoke("git_commit_branches", { path, commit })
|
||||
}
|
||||
|
||||
// Terminal commands
|
||||
|
||||
export async function terminalSpawn(
|
||||
workingDir: string,
|
||||
initialCommand?: string
|
||||
): Promise<string> {
|
||||
return invoke("terminal_spawn", {
|
||||
workingDir,
|
||||
initialCommand: initialCommand ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function terminalWrite(
|
||||
terminalId: string,
|
||||
data: string
|
||||
): Promise<void> {
|
||||
return invoke("terminal_write", { terminalId, data })
|
||||
}
|
||||
|
||||
export async function terminalResize(
|
||||
terminalId: string,
|
||||
cols: number,
|
||||
rows: number
|
||||
): Promise<void> {
|
||||
return invoke("terminal_resize", { terminalId, cols, rows })
|
||||
}
|
||||
|
||||
export async function terminalKill(terminalId: string): Promise<void> {
|
||||
return invoke("terminal_kill", { terminalId })
|
||||
}
|
||||
|
||||
export async function terminalList(): Promise<TerminalInfo[]> {
|
||||
return invoke("terminal_list")
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
const EXACT_TOOL_NAME_ALIASES: Record<string, string> = {
|
||||
shell_command: "bash",
|
||||
"functions.shell_command": "bash",
|
||||
exec_command: "exec_command",
|
||||
"functions.exec_command": "exec_command",
|
||||
"functions.read": "read",
|
||||
"functions.edit": "edit",
|
||||
"functions.write": "write",
|
||||
"functions.apply_patch": "apply_patch",
|
||||
change: "edit",
|
||||
"functions.change": "edit",
|
||||
changes: "edit",
|
||||
write_stdin: "bash",
|
||||
read_file: "read",
|
||||
read_text_file: "read",
|
||||
readfile: "read",
|
||||
"read file": "read",
|
||||
edit_file: "edit",
|
||||
update_file: "edit",
|
||||
write_file: "write",
|
||||
mcp__acp__read: "read",
|
||||
mcp__acp__edit: "edit",
|
||||
mcp__acp__write: "write",
|
||||
todowrite: "todowrite",
|
||||
todo_write: "todowrite",
|
||||
task_update: "taskupdate",
|
||||
task_create: "taskcreate",
|
||||
task_list: "tasklist",
|
||||
enter_plan_mode: "enterplanmode",
|
||||
exit_plan_mode: "exitplanmode",
|
||||
web_fetch: "webfetch",
|
||||
web_search: "websearch",
|
||||
context7_query_docs: "context7_query-docs",
|
||||
context7_resolve_library_id: "context7_resolve-library-id",
|
||||
execute: "bash",
|
||||
search: "grep",
|
||||
fetch: "webfetch",
|
||||
think: "task",
|
||||
switch_mode: "switch_mode",
|
||||
other: "tool",
|
||||
}
|
||||
|
||||
function canonicalizeToolName(input: string): string {
|
||||
return input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[().]/g, "_")
|
||||
.replace(/[\s-]+/g, "_")
|
||||
}
|
||||
|
||||
function inferFromFreeformName(input: string): string | null {
|
||||
const normalized = input.trim().toLowerCase()
|
||||
if (!normalized) return null
|
||||
|
||||
if (
|
||||
/\b(?:shell|bash|exec(?:ute)?)\s*[_-]?(?:command|cmd)?\b/.test(normalized)
|
||||
)
|
||||
return "bash"
|
||||
if (/apply\s*[_-]?patch/.test(normalized)) return "apply_patch"
|
||||
if (/^change(?:$|[\s:/_-])/.test(normalized)) return "edit"
|
||||
if (/^read(?:$|[\s:/-])/.test(normalized)) return "read"
|
||||
if (/^edit(?:$|[\s:/-])/.test(normalized)) return "edit"
|
||||
if (/^write(?:$|[\s:/-])/.test(normalized)) return "write"
|
||||
if (/^grep(?:\b|[_\s:-])/.test(normalized)) return "grep"
|
||||
if (/^glob(?:\b|[_\s:-])/.test(normalized)) return "glob"
|
||||
if (/^webfetch(?:\b|[_\s:-])/.test(normalized)) return "webfetch"
|
||||
if (/^websearch(?:\b|[_\s:-])/.test(normalized)) return "websearch"
|
||||
if (/^todowrite(?:\b|[_\s:-])/.test(normalized)) return "todowrite"
|
||||
if (/^taskupdate(?:\b|[_\s:-])/.test(normalized)) return "taskupdate"
|
||||
if (/^taskcreate(?:\b|[_\s:-])/.test(normalized)) return "taskcreate"
|
||||
if (/^tasklist(?:\b|[_\s:-])/.test(normalized)) return "tasklist"
|
||||
if (/^task(?:\b|[_\s:-])/.test(normalized)) return "task"
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractToolNameFromLiveCallTitle(input: string): string | null {
|
||||
const match = input.match(
|
||||
/^[::'"`“”‘’\s]*([a-z0-9_.-]+)(?:\s*[::])?\s*call[\w-]*['"`“”‘’\s]*$/i
|
||||
)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
function tryParseInputObject(rawInput: string | null | undefined) {
|
||||
if (!rawInput) return null
|
||||
try {
|
||||
const parsed = JSON.parse(rawInput)
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null
|
||||
}
|
||||
return parsed as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function hasAnyKey(obj: Record<string, unknown>, keys: string[]): boolean {
|
||||
return keys.some(
|
||||
(key) => key in obj && obj[key] !== null && obj[key] !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
function inferFromInput(
|
||||
rawInput: string | null | undefined,
|
||||
kind: string | null | undefined,
|
||||
title: string | null | undefined
|
||||
): string | null {
|
||||
if (!rawInput) return null
|
||||
|
||||
const normalizedKind = normalizeToolName(kind ?? "")
|
||||
const normalizedTitle = normalizeToolName(title ?? "")
|
||||
|
||||
if (rawInput.includes("*** Begin Patch")) {
|
||||
return "apply_patch"
|
||||
}
|
||||
|
||||
const trimmed = rawInput.trim()
|
||||
if (
|
||||
trimmed.length > 0 &&
|
||||
!trimmed.startsWith("{") &&
|
||||
!trimmed.startsWith("[") &&
|
||||
(normalizedKind === "bash" ||
|
||||
normalizedKind === "exec_command" ||
|
||||
normalizedKind === "tool" ||
|
||||
normalizedTitle === "bash" ||
|
||||
normalizedTitle === "exec_command")
|
||||
) {
|
||||
return "bash"
|
||||
}
|
||||
|
||||
const parsed = tryParseInputObject(rawInput)
|
||||
if (!parsed) return null
|
||||
|
||||
if (
|
||||
hasAnyKey(parsed, [
|
||||
"command",
|
||||
"cmd",
|
||||
"script",
|
||||
"args",
|
||||
"argv",
|
||||
"command_args",
|
||||
])
|
||||
)
|
||||
return "bash"
|
||||
if (hasAnyKey(parsed, ["old_string", "new_string", "replace_all"]))
|
||||
return "edit"
|
||||
if (hasAnyKey(parsed, ["changes"])) return "edit"
|
||||
if (hasAnyKey(parsed, ["todos"])) return "todowrite"
|
||||
if (hasAnyKey(parsed, ["query"])) return "websearch"
|
||||
if (hasAnyKey(parsed, ["url"])) return "webfetch"
|
||||
|
||||
const hasPattern = hasAnyKey(parsed, ["pattern"])
|
||||
const hasGlob = hasAnyKey(parsed, ["glob"])
|
||||
if (hasPattern) return hasGlob ? "glob" : "grep"
|
||||
if (hasGlob) return "glob"
|
||||
|
||||
if (hasAnyKey(parsed, ["subagent_type", "taskId", "task_id", "subject"])) {
|
||||
return "task"
|
||||
}
|
||||
|
||||
const hasPath = hasAnyKey(parsed, ["file_path", "notebook_path", "path"])
|
||||
if (hasPath) {
|
||||
if (
|
||||
normalizedKind === "read" ||
|
||||
normalizedKind === "edit" ||
|
||||
normalizedKind === "write" ||
|
||||
normalizedKind === "delete" ||
|
||||
normalizedKind === "move"
|
||||
) {
|
||||
return normalizedKind
|
||||
}
|
||||
if (
|
||||
normalizedTitle === "read" ||
|
||||
normalizedTitle === "edit" ||
|
||||
normalizedTitle === "write"
|
||||
) {
|
||||
return normalizedTitle
|
||||
}
|
||||
if (
|
||||
hasAnyKey(parsed, ["content", "new_source", "cell_type", "edit_mode"])
|
||||
) {
|
||||
return "write"
|
||||
}
|
||||
return "read"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function normalizeToolName(toolName: string): string {
|
||||
const trimmed = toolName
|
||||
.trim()
|
||||
.replace(/^[::'"`“”‘’\s]+/, "")
|
||||
.replace(/['"`“”‘’\s]+$/, "")
|
||||
if (!trimmed) return "tool"
|
||||
|
||||
const exact = EXACT_TOOL_NAME_ALIASES[trimmed.toLowerCase()]
|
||||
if (exact) return exact
|
||||
|
||||
const canonical = canonicalizeToolName(trimmed)
|
||||
const alias = EXACT_TOOL_NAME_ALIASES[canonical]
|
||||
if (alias) return alias
|
||||
|
||||
const freeform = inferFromFreeformName(trimmed)
|
||||
if (freeform) return freeform
|
||||
|
||||
const liveTitleToolName = extractToolNameFromLiveCallTitle(trimmed)
|
||||
if (liveTitleToolName) {
|
||||
const fromLiveTitle = normalizeToolName(liveTitleToolName)
|
||||
if (fromLiveTitle !== "tool") return fromLiveTitle
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export function inferLiveToolName(params: {
|
||||
title?: string | null
|
||||
kind?: string | null
|
||||
rawInput?: string | null
|
||||
}): string {
|
||||
const byInput = inferFromInput(params.rawInput, params.kind, params.title)
|
||||
if (byInput) return byInput
|
||||
|
||||
const byTitle = normalizeToolName(params.title ?? "")
|
||||
if (byTitle !== "tool") return byTitle
|
||||
|
||||
const byKind = normalizeToolName(params.kind ?? "")
|
||||
if (byKind !== "tool") return byKind
|
||||
|
||||
return "tool"
|
||||
}
|
||||
@@ -0,0 +1,711 @@
|
||||
export type AgentType =
|
||||
| "claude_code"
|
||||
| "codex"
|
||||
| "open_code"
|
||||
| "auggie"
|
||||
| "autohand"
|
||||
| "cline"
|
||||
| "codebuddy_code"
|
||||
| "corust_agent"
|
||||
| "gemini"
|
||||
| "github_copilot"
|
||||
| "goose"
|
||||
| "junie"
|
||||
| "qoder"
|
||||
| "qwen_code"
|
||||
| "factory_droid"
|
||||
| "kimi"
|
||||
| "minion_code"
|
||||
| "mistral_vibe"
|
||||
| "open_claw"
|
||||
| "stakpak"
|
||||
|
||||
export interface ConversationSummary {
|
||||
id: string
|
||||
agent_type: AgentType
|
||||
folder_path: string | null
|
||||
folder_name: string | null
|
||||
title: string | null
|
||||
started_at: string
|
||||
ended_at: string | null
|
||||
message_count: number
|
||||
model: string | null
|
||||
git_branch: string | null
|
||||
}
|
||||
|
||||
export type MessageRole = "user" | "assistant" | "system" | "tool"
|
||||
|
||||
export type ContentBlock =
|
||||
| { type: "text"; text: string }
|
||||
| {
|
||||
type: "tool_use"
|
||||
tool_use_id: string | null
|
||||
tool_name: string
|
||||
input_preview: string | null
|
||||
}
|
||||
| {
|
||||
type: "tool_result"
|
||||
tool_use_id: string | null
|
||||
output_preview: string | null
|
||||
is_error: boolean
|
||||
}
|
||||
| { type: "thinking"; text: string }
|
||||
|
||||
export interface UnifiedMessage {
|
||||
id: string
|
||||
role: MessageRole
|
||||
content: ContentBlock[]
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export type TurnRole = "user" | "assistant" | "system"
|
||||
|
||||
export interface TurnUsage {
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_creation_input_tokens: number
|
||||
cache_read_input_tokens: number
|
||||
}
|
||||
|
||||
export interface SessionStats {
|
||||
total_usage: TurnUsage | null
|
||||
total_tokens?: number | null
|
||||
total_duration_ms: number
|
||||
context_window_used_tokens?: number | null
|
||||
context_window_max_tokens?: number | null
|
||||
context_window_usage_percent?: number | null
|
||||
}
|
||||
|
||||
export interface MessageTurn {
|
||||
id: string
|
||||
role: TurnRole
|
||||
blocks: ContentBlock[]
|
||||
timestamp: string
|
||||
usage?: TurnUsage | null
|
||||
duration_ms?: number | null
|
||||
model?: string | null
|
||||
}
|
||||
|
||||
export interface ConversationDetail {
|
||||
summary: ConversationSummary
|
||||
turns: MessageTurn[]
|
||||
session_stats?: SessionStats | null
|
||||
}
|
||||
|
||||
export interface FolderInfo {
|
||||
path: string
|
||||
name: string
|
||||
agent_types: AgentType[]
|
||||
conversation_count: number
|
||||
}
|
||||
|
||||
export interface AgentConversationCount {
|
||||
agent_type: AgentType
|
||||
conversation_count: number
|
||||
}
|
||||
|
||||
export interface AgentStats {
|
||||
total_conversations: number
|
||||
total_messages: number
|
||||
by_agent: AgentConversationCount[]
|
||||
}
|
||||
|
||||
export interface SidebarData {
|
||||
folders: FolderInfo[]
|
||||
stats: AgentStats
|
||||
}
|
||||
|
||||
export interface FolderHistoryEntry {
|
||||
id: number
|
||||
path: string
|
||||
name: string
|
||||
last_opened_at: string
|
||||
}
|
||||
|
||||
export interface FolderDetail {
|
||||
id: number
|
||||
name: string
|
||||
path: string
|
||||
git_branch: string | null
|
||||
parent_branch: string | null
|
||||
default_agent_type: AgentType | null
|
||||
last_opened_at: string
|
||||
opened_conversations: OpenedConversation[]
|
||||
}
|
||||
|
||||
export interface OpenedConversation {
|
||||
conversation_id: number
|
||||
agent_type: AgentType
|
||||
position: number
|
||||
is_active: boolean
|
||||
is_pinned: boolean
|
||||
}
|
||||
|
||||
export interface DbConversationSummary {
|
||||
id: number
|
||||
folder_id: number
|
||||
title: string | null
|
||||
agent_type: AgentType
|
||||
status: string
|
||||
model: string | null
|
||||
git_branch: string | null
|
||||
external_id: string | null
|
||||
message_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
imported: number
|
||||
skipped: number
|
||||
}
|
||||
|
||||
export interface DbConversationDetail {
|
||||
summary: DbConversationSummary
|
||||
turns: MessageTurn[]
|
||||
session_stats?: SessionStats | null
|
||||
}
|
||||
|
||||
export type ConversationStatus =
|
||||
| "in_progress"
|
||||
| "pending_review"
|
||||
| "completed"
|
||||
| "cancelled"
|
||||
|
||||
export const STATUS_ORDER: ConversationStatus[] = [
|
||||
"in_progress",
|
||||
"pending_review",
|
||||
"completed",
|
||||
"cancelled",
|
||||
]
|
||||
|
||||
export const STATUS_LABELS: Record<ConversationStatus, string> = {
|
||||
in_progress: "In Progress",
|
||||
pending_review: "Review",
|
||||
completed: "Completed",
|
||||
cancelled: "Cancelled",
|
||||
}
|
||||
|
||||
export const STATUS_COLORS: Record<ConversationStatus, string> = {
|
||||
in_progress: "bg-blue-500",
|
||||
pending_review: "bg-orange-500",
|
||||
completed: "bg-green-500",
|
||||
cancelled: "bg-red-500",
|
||||
}
|
||||
|
||||
export const AGENT_DISPLAY_ORDER: AgentType[] = [
|
||||
"codex",
|
||||
"claude_code",
|
||||
"open_code",
|
||||
"auggie",
|
||||
"autohand",
|
||||
"cline",
|
||||
"codebuddy_code",
|
||||
"corust_agent",
|
||||
"gemini",
|
||||
"github_copilot",
|
||||
"goose",
|
||||
"junie",
|
||||
"qoder",
|
||||
"qwen_code",
|
||||
"factory_droid",
|
||||
"kimi",
|
||||
"minion_code",
|
||||
"mistral_vibe",
|
||||
"open_claw",
|
||||
"stakpak",
|
||||
]
|
||||
|
||||
const AGENT_DISPLAY_ORDER_INDEX = new Map(
|
||||
AGENT_DISPLAY_ORDER.map((agent, index) => [agent, index])
|
||||
)
|
||||
|
||||
export function compareAgentType(a: AgentType, b: AgentType): number {
|
||||
const aIndex = AGENT_DISPLAY_ORDER_INDEX.get(a) ?? Number.MAX_SAFE_INTEGER
|
||||
const bIndex = AGENT_DISPLAY_ORDER_INDEX.get(b) ?? Number.MAX_SAFE_INTEGER
|
||||
return aIndex - bIndex
|
||||
}
|
||||
|
||||
export const AGENT_LABELS: Record<AgentType, string> = {
|
||||
claude_code: "Claude Code",
|
||||
codex: "Codex",
|
||||
open_code: "OpenCode",
|
||||
auggie: "Auggie",
|
||||
autohand: "Autohand Code",
|
||||
cline: "Cline",
|
||||
codebuddy_code: "Codebuddy Code",
|
||||
corust_agent: "Corust Agent",
|
||||
gemini: "Gemini CLI",
|
||||
github_copilot: "GitHub Copilot",
|
||||
goose: "goose",
|
||||
junie: "Junie",
|
||||
qoder: "Qoder",
|
||||
qwen_code: "Qwen Code",
|
||||
factory_droid: "Factory Droid",
|
||||
kimi: "Kimi",
|
||||
minion_code: "Minion Code",
|
||||
mistral_vibe: "Mistral Vibe",
|
||||
open_claw: "OpenClaw",
|
||||
stakpak: "Stakpak",
|
||||
}
|
||||
|
||||
export const AGENT_COLORS: Record<AgentType, string> = {
|
||||
claude_code: "bg-orange-500",
|
||||
codex: "bg-green-500",
|
||||
open_code: "bg-blue-500",
|
||||
auggie: "bg-purple-500",
|
||||
autohand: "bg-emerald-500",
|
||||
cline: "bg-rose-500",
|
||||
codebuddy_code: "bg-violet-500",
|
||||
corust_agent: "bg-amber-500",
|
||||
gemini: "bg-blue-400",
|
||||
github_copilot: "bg-gray-700",
|
||||
goose: "bg-lime-500",
|
||||
junie: "bg-pink-500",
|
||||
qoder: "bg-teal-500",
|
||||
qwen_code: "bg-indigo-500",
|
||||
factory_droid: "bg-yellow-600",
|
||||
kimi: "bg-cyan-500",
|
||||
minion_code: "bg-fuchsia-500",
|
||||
mistral_vibe: "bg-red-500",
|
||||
open_claw: "bg-emerald-600",
|
||||
stakpak: "bg-slate-600",
|
||||
}
|
||||
|
||||
// ACP connection status (matches Rust ConnectionStatus)
|
||||
export type ConnectionStatus =
|
||||
| "connecting"
|
||||
| "downloading"
|
||||
| "connected"
|
||||
| "prompting"
|
||||
| "disconnected"
|
||||
| "error"
|
||||
|
||||
export type PromptInputBlock =
|
||||
| { type: "text"; text: string }
|
||||
| {
|
||||
type: "resource_link"
|
||||
uri: string
|
||||
name: string
|
||||
mime_type?: string | null
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export interface PromptDraft {
|
||||
blocks: PromptInputBlock[]
|
||||
displayText: string
|
||||
}
|
||||
|
||||
// Permission option info from agent
|
||||
export interface PermissionOptionInfo {
|
||||
option_id: string
|
||||
name: string
|
||||
kind: string
|
||||
}
|
||||
|
||||
export interface SessionModeInfo {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export interface SessionModeStateInfo {
|
||||
current_mode_id: string
|
||||
available_modes: SessionModeInfo[]
|
||||
}
|
||||
|
||||
export interface SessionConfigSelectOptionInfo {
|
||||
value: string
|
||||
name: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export interface SessionConfigSelectGroupInfo {
|
||||
group: string
|
||||
name: string
|
||||
options: SessionConfigSelectOptionInfo[]
|
||||
}
|
||||
|
||||
export interface SessionConfigSelectInfo {
|
||||
current_value: string
|
||||
options: SessionConfigSelectOptionInfo[]
|
||||
groups: SessionConfigSelectGroupInfo[]
|
||||
}
|
||||
|
||||
export type SessionConfigKindInfo = { type: "select" } & SessionConfigSelectInfo
|
||||
|
||||
export interface SessionConfigOptionInfo {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
category?: string | null
|
||||
kind: SessionConfigKindInfo
|
||||
}
|
||||
|
||||
export interface PlanEntryInfo {
|
||||
content: string
|
||||
priority: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface AvailableCommandInfo {
|
||||
name: string
|
||||
description: string
|
||||
input_hint?: string | null
|
||||
}
|
||||
|
||||
// ACP events pushed from Rust backend (discriminated by "type" field)
|
||||
export type AcpEvent =
|
||||
| { type: "content_delta"; connection_id: string; text: string }
|
||||
| { type: "thinking"; connection_id: string; text: string }
|
||||
| {
|
||||
type: "tool_call"
|
||||
connection_id: string
|
||||
tool_call_id: string
|
||||
title: string
|
||||
kind: string
|
||||
status: string
|
||||
content: string | null
|
||||
raw_input: string | null
|
||||
raw_output: string | null
|
||||
}
|
||||
| {
|
||||
type: "tool_call_update"
|
||||
connection_id: string
|
||||
tool_call_id: string
|
||||
title: string | null
|
||||
status: string | null
|
||||
content: string | null
|
||||
raw_input: string | null
|
||||
raw_output: string | null
|
||||
raw_output_append?: boolean
|
||||
}
|
||||
| {
|
||||
type: "permission_request"
|
||||
connection_id: string
|
||||
request_id: string
|
||||
tool_call: unknown
|
||||
options: PermissionOptionInfo[]
|
||||
}
|
||||
| { type: "turn_complete"; connection_id: string; stop_reason: string }
|
||||
| {
|
||||
type: "session_started"
|
||||
connection_id: string
|
||||
session_id: string
|
||||
}
|
||||
| {
|
||||
type: "session_modes"
|
||||
connection_id: string
|
||||
modes: SessionModeStateInfo
|
||||
}
|
||||
| {
|
||||
type: "session_config_options"
|
||||
connection_id: string
|
||||
config_options: SessionConfigOptionInfo[]
|
||||
}
|
||||
| {
|
||||
type: "selectors_ready"
|
||||
connection_id: string
|
||||
}
|
||||
| {
|
||||
type: "mode_changed"
|
||||
connection_id: string
|
||||
mode_id: string
|
||||
}
|
||||
| {
|
||||
type: "plan_update"
|
||||
connection_id: string
|
||||
entries: PlanEntryInfo[]
|
||||
}
|
||||
| {
|
||||
type: "status_changed"
|
||||
connection_id: string
|
||||
status: ConnectionStatus
|
||||
}
|
||||
| { type: "error"; connection_id: string; message: string }
|
||||
| {
|
||||
type: "available_commands"
|
||||
connection_id: string
|
||||
commands: AvailableCommandInfo[]
|
||||
}
|
||||
|
||||
// Connection info returned by acp_list_connections
|
||||
export interface ConnectionInfo {
|
||||
id: string
|
||||
agent_type: AgentType
|
||||
status: ConnectionStatus
|
||||
}
|
||||
|
||||
// ACP agent info returned by acp_list_agents
|
||||
export interface AcpAgentInfo {
|
||||
agent_type: AgentType
|
||||
registry_id: string
|
||||
registry_version: string | null
|
||||
name: string
|
||||
description: string
|
||||
available: boolean
|
||||
distribution_type: string
|
||||
enabled: boolean
|
||||
sort_order: number
|
||||
installed_version: string | null
|
||||
env: Record<string, string>
|
||||
config_json: string | null
|
||||
config_file_path: string | null
|
||||
opencode_auth_json: string | null
|
||||
codex_auth_json: string | null
|
||||
codex_config_toml: string | null
|
||||
}
|
||||
|
||||
export type AgentSkillScope = "global" | "project"
|
||||
export type AgentSkillLayout = "markdown_file" | "skill_directory"
|
||||
|
||||
export interface AgentSkillLocation {
|
||||
scope: AgentSkillScope
|
||||
path: string
|
||||
exists: boolean
|
||||
}
|
||||
|
||||
export interface AgentSkillItem {
|
||||
id: string
|
||||
name: string
|
||||
scope: AgentSkillScope
|
||||
layout: AgentSkillLayout
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface AgentSkillsListResult {
|
||||
supported: boolean
|
||||
message: string | null
|
||||
locations: AgentSkillLocation[]
|
||||
skills: AgentSkillItem[]
|
||||
}
|
||||
|
||||
export interface AgentSkillContent {
|
||||
skill: AgentSkillItem
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface SystemProxySettings {
|
||||
enabled: boolean
|
||||
proxy_url: string | null
|
||||
}
|
||||
|
||||
export type McpAppType = "claude_code" | "codex" | "open_code"
|
||||
|
||||
export interface LocalMcpServer {
|
||||
id: string
|
||||
spec: Record<string, unknown>
|
||||
apps: McpAppType[]
|
||||
}
|
||||
|
||||
export interface McpMarketplaceProvider {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface McpMarketplaceItem {
|
||||
provider_id: string
|
||||
server_id: string
|
||||
name: string
|
||||
description: string
|
||||
homepage: string | null
|
||||
remote: boolean
|
||||
verified: boolean
|
||||
icon_url: string | null
|
||||
latest_version: string | null
|
||||
protocols: string[]
|
||||
owner: string | null
|
||||
namespace: string | null
|
||||
downloads: number | null
|
||||
score: number | null
|
||||
is_deployed: boolean | null
|
||||
}
|
||||
|
||||
export interface McpMarketplaceInstallParameter {
|
||||
key: string
|
||||
label: string
|
||||
description: string | null
|
||||
required: boolean
|
||||
secret: boolean
|
||||
kind: string
|
||||
default_value: unknown | null
|
||||
placeholder: string | null
|
||||
enum_values: string[]
|
||||
location: string | null
|
||||
}
|
||||
|
||||
export interface McpMarketplaceInstallOption {
|
||||
id: string
|
||||
protocol: string
|
||||
label: string
|
||||
description: string | null
|
||||
spec: Record<string, unknown>
|
||||
parameters: McpMarketplaceInstallParameter[]
|
||||
}
|
||||
|
||||
export interface McpMarketplaceServerDetail {
|
||||
provider_id: string
|
||||
server_id: string
|
||||
name: string
|
||||
description: string
|
||||
homepage: string | null
|
||||
remote: boolean
|
||||
verified: boolean
|
||||
icon_url: string | null
|
||||
latest_version: string | null
|
||||
protocols: string[]
|
||||
owner: string | null
|
||||
namespace: string | null
|
||||
downloads: number | null
|
||||
score: number | null
|
||||
is_deployed: boolean | null
|
||||
default_option_id: string | null
|
||||
install_options: McpMarketplaceInstallOption[]
|
||||
spec: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface FolderCommand {
|
||||
id: number
|
||||
folder_id: number
|
||||
name: string
|
||||
command: string
|
||||
sort_order: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface GitStatusEntry {
|
||||
status: string
|
||||
file: string
|
||||
}
|
||||
|
||||
export interface GitBranchList {
|
||||
local: string[]
|
||||
remote: string[]
|
||||
worktree_branches: string[]
|
||||
}
|
||||
|
||||
export interface GitPullResult {
|
||||
updated_files: number
|
||||
}
|
||||
|
||||
export interface GitPushResult {
|
||||
pushed_commits: number
|
||||
upstream_set: boolean
|
||||
}
|
||||
|
||||
export interface GitMergeResult {
|
||||
merged_commits: number
|
||||
}
|
||||
|
||||
export interface GitCommitResult {
|
||||
committed_files: number
|
||||
}
|
||||
|
||||
export type FileTreeNode =
|
||||
| { kind: "file"; name: string; path: string }
|
||||
| { kind: "dir"; name: string; path: string; children: FileTreeNode[] }
|
||||
|
||||
export interface FilePreviewContent {
|
||||
path: string
|
||||
content: string
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export interface FileEditContent {
|
||||
path: string
|
||||
content: string
|
||||
etag: string
|
||||
mtime_ms: number | null
|
||||
readonly: boolean
|
||||
truncated: boolean
|
||||
line_ending: "lf" | "crlf" | "mixed" | "none"
|
||||
}
|
||||
|
||||
export interface FileSaveResult {
|
||||
path: string
|
||||
etag: string
|
||||
mtime_ms: number | null
|
||||
readonly: boolean
|
||||
line_ending: "lf" | "crlf" | "mixed" | "none"
|
||||
}
|
||||
|
||||
export interface FileTreeChangedEvent {
|
||||
root_path: string
|
||||
changed_paths: string[]
|
||||
kind: "create" | "modify" | "remove" | "access" | "any" | "other"
|
||||
full_reload: boolean
|
||||
refresh_git_status: boolean
|
||||
}
|
||||
|
||||
export interface GitLogEntry {
|
||||
hash: string
|
||||
full_hash: string
|
||||
author: string
|
||||
date: string
|
||||
message: string
|
||||
files: GitLogFileChange[]
|
||||
pushed: boolean | null
|
||||
}
|
||||
|
||||
export interface GitLogFileChange {
|
||||
path: string
|
||||
status: string
|
||||
additions: number
|
||||
deletions: number
|
||||
}
|
||||
|
||||
// Terminal types
|
||||
export interface TerminalInfo {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface TerminalEvent {
|
||||
terminal_id: string
|
||||
data: string
|
||||
}
|
||||
|
||||
export interface TokenBreakdown {
|
||||
input: number
|
||||
output: number
|
||||
cache_input: number
|
||||
cache_output: number
|
||||
}
|
||||
|
||||
export interface DailyTokenStats {
|
||||
date: string
|
||||
breakdown: TokenBreakdown
|
||||
total: number
|
||||
}
|
||||
|
||||
// Preflight check types
|
||||
export type FixActionKind =
|
||||
| "open_url"
|
||||
| "redownload_binary"
|
||||
| "retry_connection"
|
||||
| "open_agents_settings"
|
||||
|
||||
export interface FixAction {
|
||||
label: string
|
||||
kind: FixActionKind
|
||||
payload: string
|
||||
}
|
||||
|
||||
export type CheckStatus = "pass" | "fail" | "warn"
|
||||
|
||||
export interface CheckItem {
|
||||
check_id: string
|
||||
label: string
|
||||
status: CheckStatus
|
||||
message: string
|
||||
fixes: FixAction[]
|
||||
}
|
||||
|
||||
export interface PreflightResult {
|
||||
agent_type: AgentType
|
||||
agent_name: string
|
||||
passed: boolean
|
||||
checks: CheckItem[]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getVersion } from "@tauri-apps/api/app"
|
||||
import { relaunch } from "@tauri-apps/plugin-process"
|
||||
import { check, type Update } from "@tauri-apps/plugin-updater"
|
||||
|
||||
export interface AppUpdateCheckResult {
|
||||
currentVersion: string
|
||||
update: Update | null
|
||||
}
|
||||
|
||||
export async function getCurrentAppVersion(): Promise<string> {
|
||||
return getVersion()
|
||||
}
|
||||
|
||||
export async function checkAppUpdate(): Promise<AppUpdateCheckResult> {
|
||||
const [currentVersion, update] = await Promise.all([getVersion(), check()])
|
||||
return { currentVersion, update }
|
||||
}
|
||||
|
||||
export async function installAppUpdate(update: Update): Promise<void> {
|
||||
await update.downloadAndInstall()
|
||||
}
|
||||
|
||||
export async function relaunchApp(): Promise<void> {
|
||||
await relaunch()
|
||||
}
|
||||
|
||||
export async function closeAppUpdate(update: Update): Promise<void> {
|
||||
await update.close()
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
const WORKSPACE_MODES = ["conversation", "fusion", "files"] as const
|
||||
|
||||
export type PersistedWorkspaceMode = (typeof WORKSPACE_MODES)[number]
|
||||
|
||||
interface PersistedWorkspaceState {
|
||||
mode: PersistedWorkspaceMode
|
||||
}
|
||||
|
||||
function isWorkspaceMode(value: unknown): value is PersistedWorkspaceMode {
|
||||
return WORKSPACE_MODES.includes(value as PersistedWorkspaceMode)
|
||||
}
|
||||
|
||||
export function loadPersistedWorkspaceMode(
|
||||
storageKey: string
|
||||
): PersistedWorkspaceMode | null {
|
||||
if (typeof window === "undefined") return null
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as Partial<PersistedWorkspaceState>
|
||||
if (!isWorkspaceMode(parsed.mode)) return null
|
||||
return parsed.mode
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function savePersistedWorkspaceMode(
|
||||
storageKey: string,
|
||||
mode: PersistedWorkspaceMode
|
||||
) {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify({ mode }))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user