Files
claude-code-haha/desktop/src/components/chat/composerUtils.ts
T
程序员阿江(Relakkes) 635a966c3e fix(desktop): unblock rollout with reliable session and IM flows
This folds together the desktop-side fixes needed before broader rollout.
Session resume no longer deadlocks waiting on init, Mermaid and inline image
output render inside chat, task and sub-agent state stay visible during
execution, local build/release paths are safer, and Feishu/Telegram now expose
lightweight mobile commands (/help, /status, /clear) without adding a new
adapter-specific protocol.

Constraint: Desktop releases must publish updater artifacts from non-draft GitHub releases
Constraint: IM commands need short, phone-friendly responses and low operational complexity
Rejected: Add a dedicated IM command API surface | re-used existing slash commands and session/task REST endpoints to keep adapters thin
Rejected: Wait for task_update push events in WebUI | added low-risk polling because the current frontend ignores that event path
Confidence: medium
Scope-risk: broad
Reversibility: clean
Directive: Keep IM command replies terse and mobile-first, and merge local fallback slash commands when server-provided lists are partial
Tested: cd desktop && bun x vitest run src/components/chat/MermaidRenderer.test.tsx src/components/markdown/MarkdownRenderer.test.tsx
Tested: cd desktop && bun x vitest run src/components/chat/composerUtils.test.ts src/pages/ActiveSession.test.tsx src/stores/chatStore.test.ts
Tested: cd desktop && bun run lint
Tested: bun test src/server/__tests__/conversations.test.ts --test-name-pattern "SDK init arrives only after the first user turn" --timeout 60000
Tested: cd adapters && bun test common/ feishu/ telegram/
Tested: cd adapters && bunx tsc --noEmit
Not-tested: Full GitHub Actions release run on all three desktop platforms
Not-tested: Local DMG packaging end-to-end on Apple Silicon
Not-tested: Real Feishu/Telegram device sessions against a live adapter process
2026-04-10 16:41:59 +08:00

150 lines
4.6 KiB
TypeScript

export const FALLBACK_SLASH_COMMANDS = [
{ name: 'compact', description: 'Compact conversation context' },
{ name: 'clear', description: 'Clear conversation history' },
{ name: 'help', description: 'Show available commands' },
{ 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: 'cost', description: 'Show token usage and costs' },
{ name: 'doctor', description: 'Diagnose installation issues' },
{ 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: 'status', description: 'Show project and session status' },
{ name: 'terminal-setup', description: 'Set up terminal integration' },
{ name: 'vim', description: 'Toggle vim editing mode' },
]
export type SlashCommandOption = {
name: string
description: string
}
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,
}
}