cc-haha/desktop/src/components/chat/composerUtils.ts
程序员阿江(Relakkes) 7ae885a114 fix: add safe Doctor rescue path for desktop upgrades
Online upgrades can strand users on stale desktop UI state or malformed local persistence. This adds a deny-by-default Doctor path that resets only regenerable desktop UI state, reports protected local files with redacted metadata, and keeps protected repair as a dry-run no-op until a reviewed backup-first flow exists.

Constraint: Chat transcripts, model/provider config, Skills, MCP, IM bindings, adapter sessions, OAuth tokens, plugins, and team/session records are user-owned protected state.
Rejected: Automatically rewrite malformed protected JSON | unsafe without schema-specific migrations and backups.
Rejected: Continue relying only on startup migrations | users need an explicit recovery action after a white screen.
Confidence: high
Scope-risk: moderate
Directive: Keep Doctor repair deny-by-default; do not mutate protected state without an explicit reviewed backup-first manual repair flow.
Tested: bun test src/server/__tests__/doctor-service.test.ts
Tested: cd desktop && bun run test src/components/ErrorBoundary.test.tsx src/lib/doctorRepair.test.ts src/__tests__/diagnosticsSettings.test.tsx src/components/layout/StartupErrorView.test.tsx
Tested: cd desktop && bun run lint
Tested: bun run check:server
Tested: bun run verify (failed only existing agent-utils coverage baseline; changed-lines coverage 97.62%)
Not-tested: Packaged desktop manual Doctor click path.
2026-05-07 00:04:06 +08:00

193 lines
6.1 KiB
TypeScript

import type { SettingsTab } from '../../stores/uiStore'
export const PANEL_SLASH_COMMANDS = [
{ name: 'mcp', description: 'Open available MCP tools for the current chat context' },
{ name: 'skills', description: 'Browse user-invocable skills for the current chat context' },
{ name: 'help', description: 'Show available desktop and agent commands' },
{ name: 'status', description: 'Show session status, usage, and context' },
{ name: 'cost', description: 'Show session usage and costs' },
{ name: 'context', description: 'Show current context usage' },
] as const
export const SETTINGS_SLASH_COMMANDS = [
{ name: 'plugin', description: 'Open desktop plugin controls in Settings', tab: 'plugins' as const },
{ name: 'doctor', description: 'Open Doctor in Diagnostics', tab: 'diagnostics' as const },
] as const
export const SLASH_COMMAND_ALIASES = [
{ name: 'plugins', target: 'plugin' },
] as const
export const FALLBACK_SLASH_COMMANDS = [
...PANEL_SLASH_COMMANDS,
...SETTINGS_SLASH_COMMANDS.map(({ name, description }) => ({ name, description })),
{ name: 'compact', description: 'Compact conversation context' },
{ name: 'clear', description: 'Clear conversation history' },
{ name: 'review', description: 'Review code changes' },
{ name: 'commit', description: 'Create a git commit' },
{ name: 'pr', description: 'Create a pull request' },
{ name: 'init', description: 'Initialize project CLAUDE.md' },
{ name: 'bug', description: 'Report a bug' },
{ name: 'config', description: 'Open configuration' },
{ name: 'login', description: 'Switch Anthropic accounts' },
{ name: 'logout', description: 'Sign out of current account' },
{ name: 'memory', description: 'Edit CLAUDE.md memory files' },
{ name: 'model', description: 'Switch AI model' },
{ name: 'permissions', description: 'View or manage tool permissions' },
{ name: 'terminal-setup', description: 'Set up terminal integration' },
{ name: 'vim', description: 'Toggle vim editing mode' },
]
export type SlashCommandOption = {
name: string
description: string
}
export type SlashUiAction =
| {
type: 'panel'
command: typeof PANEL_SLASH_COMMANDS[number]['name']
}
| {
type: 'settings'
tab: SettingsTab
}
export function resolveSlashUiAction(value: string): SlashUiAction | null {
const normalizedValue = SLASH_COMMAND_ALIASES.find((alias) => alias.name === value)?.target ?? value
const panelCommand = PANEL_SLASH_COMMANDS.find((command) => command.name === normalizedValue)
if (panelCommand) {
return { type: 'panel', command: panelCommand.name }
}
const settingsCommand = SETTINGS_SLASH_COMMANDS.find((command) => command.name === normalizedValue)
if (settingsCommand) {
return { type: 'settings', tab: settingsCommand.tab }
}
return null
}
export function mergeSlashCommands(
preferred: ReadonlyArray<SlashCommandOption>,
fallback: ReadonlyArray<SlashCommandOption> = FALLBACK_SLASH_COMMANDS,
): SlashCommandOption[] {
const merged = new Map<string, SlashCommandOption>()
for (const command of preferred) {
if (!command?.name) continue
merged.set(command.name, {
name: command.name,
description: command.description?.trim() || '',
})
}
for (const command of fallback) {
if (!command?.name) continue
const existing = merged.get(command.name)
if (existing) {
if (!existing.description && command.description) {
merged.set(command.name, {
...existing,
description: command.description,
})
}
continue
}
merged.set(command.name, command)
}
return [...merged.values()]
}
export type SlashTrigger = {
slashPos: number
filter: string
}
export function findSlashTrigger(value: string, cursorPos: number): SlashTrigger | null {
const textBeforeCursor = value.slice(0, cursorPos)
let slashPos = -1
for (let i = textBeforeCursor.length - 1; i >= 0; i--) {
const ch = textBeforeCursor[i]!
if (ch === '/') {
if (i === 0 || /\s/.test(textBeforeCursor[i - 1]!)) {
slashPos = i
break
}
break
}
if (/\s/.test(ch)) {
break
}
}
if (slashPos < 0) return null
const filter = textBeforeCursor.slice(slashPos + 1)
if (/\s/.test(filter)) return null
return { slashPos, filter }
}
export function replaceSlashToken(
input: string,
cursorPos: number,
command: string,
options?: { trailingSpace?: boolean },
): { value: string; cursorPos: number } {
const trigger = findSlashTrigger(input, cursorPos)
if (!trigger) {
const prefix = input && !/\s$/.test(input) ? `${input} ` : input
const token = `/${command}`
const suffix = options?.trailingSpace !== false ? ' ' : ''
const value = `${prefix}${token}${suffix}`
return { value, cursorPos: value.length }
}
const before = input.slice(0, trigger.slashPos)
const after = input.slice(cursorPos)
const token = `/${command}`
const suffix = options?.trailingSpace !== false ? ' ' : ''
const value = `${before}${token}${suffix}${after}`
const nextCursorPos = before.length + token.length + suffix.length
return { value, cursorPos: nextCursorPos }
}
export type SlashToken = {
start: number
filter: string
}
export function findSlashToken(value: string, cursorPos: number): SlashToken | null {
const trigger = findSlashTrigger(value, cursorPos)
if (!trigger) return null
return { start: trigger.slashPos, filter: trigger.filter }
}
export function replaceSlashCommand(
value: string,
cursorPos: number,
command: string,
): { value: string; cursorPos: number } | null {
const trigger = findSlashTrigger(value, cursorPos)
if (!trigger) return null
return replaceSlashToken(value, cursorPos, command, { trailingSpace: true })
}
export function insertSlashTrigger(
value: string,
cursorPos: number,
): { value: string; cursorPos: number } {
const before = value.slice(0, cursorPos)
const after = value.slice(cursorPos)
const needsLeadingSpace = before.length > 0 && !/\s$/.test(before)
const token = `${needsLeadingSpace ? ' ' : ''}/`
return {
value: `${before}${token}${after}`,
cursorPos: before.length + token.length,
}
}