mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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.
193 lines
6.1 KiB
TypeScript
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,
|
|
}
|
|
}
|