Initial commit

This commit is contained in:
xggz
2026-03-06 22:56:13 +08:00
commit 54d1097b41
273 changed files with 92457 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+7
View File
@@ -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 []
}
+187
View File
@@ -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("|")
}
+2
View File
@@ -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
+331
View File
@@ -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}`
}
+42
View File
@@ -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"
}
}
+151
View File
@@ -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 }
}
+153
View File
@@ -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)
}
+212
View File
@@ -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
}
+38
View File
@@ -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 */
}
}
+18
View File
@@ -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)
}
+660
View File
@@ -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),
}
}
+32
View File
@@ -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,
}))
}
+17
View File
@@ -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,
})
)
}
+955
View File
@@ -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),
}))
}
+852
View File
@@ -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")
}
+231
View File
@@ -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"
}
+711
View File
@@ -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[]
}
+29
View File
@@ -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()
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+42
View File
@@ -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 */
}
}