diff --git a/server/modules/providers/list/claude/claude-sessions.provider.ts b/server/modules/providers/list/claude/claude-sessions.provider.ts index 94215257..f803d92c 100644 --- a/server/modules/providers/list/claude/claude-sessions.provider.ts +++ b/server/modules/providers/list/claude/claude-sessions.provider.ts @@ -200,17 +200,18 @@ async function getSessionMessages( } /** - * Claude writes internal command and system reminder entries into history. - * Those are useful for the CLI but should not appear in the user-facing chat. + * Claude writes a mix of truly internal transcript rows and "UI-hidden" local + * command artifacts into the same JSONL stream. + * + * Important distinction: + * - system reminders / caveats / interruption banners should stay hidden + * - local command payloads (`...`) and stdout wrappers + * (`...`) should be remapped into normal chat messages + * instead of being discarded as internal content */ const INTERNAL_CONTENT_PREFIXES = [ - '', - '', - '', - '', '', 'Caveat:', - 'This session is being continued from a previous', '[Request interrupted', ] as const; @@ -218,6 +219,73 @@ function isInternalContent(content: string): boolean { return INTERNAL_CONTENT_PREFIXES.some((prefix) => content.startsWith(prefix)); } +/** + * Claude wraps local slash-command metadata in lightweight XML-like tags inside + * a plain string payload. We intentionally parse only the small tag surface we + * care about instead of introducing a generic XML parser for untrusted history. + */ +function extractTaggedContent(content: string, tagName: string): string | null { + const escapedTagName = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = new RegExp(`<${escapedTagName}>([\\s\\S]*?)<\\/${escapedTagName}>`).exec(content); + return match ? match[1] : null; +} + +type ClaudeLocalCommandPayload = { + commandName: string; + commandMessage: string; + commandArgs: string; +}; + +/** + * Converts Claude's hidden local command wrapper into structured metadata. + * + * The three tags often coexist in one string payload. Returning `null` lets the + * normal text path continue untouched for unrelated messages. + */ +function parseLocalCommandPayload(content: string): ClaudeLocalCommandPayload | null { + const commandName = extractTaggedContent(content, 'command-name'); + const commandMessage = extractTaggedContent(content, 'command-message'); + const commandArgs = extractTaggedContent(content, 'command-args'); + + if (commandName === null && commandMessage === null && commandArgs === null) { + return null; + } + + return { + commandName: commandName ?? '', + commandMessage: commandMessage ?? '', + commandArgs: commandArgs ?? '', + }; +} + +/** + * Produces the short user-visible command string that should appear in chat. + * + * We prefer the slash-prefixed command name because that most closely matches + * what the user actually typed, and only fall back to the message body when the + * command name is unavailable in older transcript variants. + */ +function buildLocalCommandDisplayText(payload: ClaudeLocalCommandPayload): string { + const commandName = payload.commandName.trim(); + const commandMessage = payload.commandMessage.trim(); + const commandArgs = payload.commandArgs.trim(); + const baseCommand = commandName || commandMessage; + + if (!baseCommand) { + return ''; + } + + return commandArgs ? `${baseCommand} ${commandArgs}` : baseCommand; +} + +/** + * Claude local-command stdout may contain ANSI styling codes because it was + * captured from the terminal. The web chat should receive readable plain text. + */ +function stripAnsiFormatting(text: string): string { + return text.replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, ''); +} + export class ClaudeSessionsProvider implements IProviderSessions { /** * Normalizes one Claude JSONL entry or live SDK stream event into the shared @@ -293,6 +361,80 @@ export class ClaudeSessionsProvider implements IProviderSessions { } } else if (typeof raw.message.content === 'string') { const text = raw.message.content; + + /** + * Claude stores compact summaries as synthetic "user" rows so the CLI + * can resume the next session turn with the summary in-context. + * + * For the web UI this is much more useful as assistant-authored summary + * text; otherwise it is both filtered by the generic internal-prefix + * check and visually mislabeled as a user message. + */ + if (raw.isCompactSummary === true && text.trim()) { + messages.push(createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'assistant', + content: text, + isCompactSummary: true, + })); + return messages; + } + + /** + * Local slash commands are serialized as tagged text even though they + * are semantically a user action. Expose the parsed fields to the + * frontend and emit a plain user-visible command string so the command + * no longer disappears from history. + */ + const localCommandPayload = parseLocalCommandPayload(text); + if (localCommandPayload) { + const displayText = buildLocalCommandDisplayText(localCommandPayload); + if (displayText) { + messages.push(createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'user', + content: displayText, + commandName: localCommandPayload.commandName, + commandMessage: localCommandPayload.commandMessage, + commandArgs: localCommandPayload.commandArgs, + isLocalCommand: true, + })); + } + return messages; + } + + /** + * Local command stdout is also written as a "user" row in Claude's + * transcript, but it is terminal output produced in response to the + * command. Re-label it as assistant text so the chat transcript matches + * the actual conversational flow seen by the user. + */ + const localCommandStdout = extractTaggedContent(text, 'local-command-stdout'); + if (localCommandStdout !== null) { + const stdoutText = stripAnsiFormatting(localCommandStdout).trim(); + if (stdoutText) { + messages.push(createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'assistant', + content: stdoutText, + isLocalCommandStdout: true, + })); + } + return messages; + } + if (text && !isInternalContent(text)) { messages.push(createNormalizedMessage({ id: baseId, diff --git a/server/modules/providers/services/session-conversations-search.service.ts b/server/modules/providers/services/session-conversations-search.service.ts index afc8bdac..38492201 100644 --- a/server/modules/providers/services/session-conversations-search.service.ts +++ b/server/modules/providers/services/session-conversations-search.service.ts @@ -89,13 +89,8 @@ const RIPGREP_CHUNK_CONCURRENCY = 6; const UNKNOWN_PROJECT_KEY = '__unknown_project__'; const INTERNAL_CONTENT_PREFIXES = [ - '', - '', - '', - '', '', 'Caveat:', - 'This session is being continued from a previous', 'Invalid API key', '[Request interrupted', ] as const; @@ -302,6 +297,135 @@ function extractClaudeText(content: unknown): string { .join(' '); } +function extractTaggedContent(content: string, tagName: string): string | null { + const escapedTagName = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = new RegExp(`<${escapedTagName}>([\\s\\S]*?)<\\/${escapedTagName}>`).exec(content); + return match ? match[1] : null; +} + +type ClaudeLocalCommandPayload = { + commandName: string; + commandMessage: string; + commandArgs: string; +}; + +function parseClaudeLocalCommandPayload(content: string): ClaudeLocalCommandPayload | null { + const commandName = extractTaggedContent(content, 'command-name'); + const commandMessage = extractTaggedContent(content, 'command-message'); + const commandArgs = extractTaggedContent(content, 'command-args'); + + if (commandName === null && commandMessage === null && commandArgs === null) { + return null; + } + + return { + commandName: commandName ?? '', + commandMessage: commandMessage ?? '', + commandArgs: commandArgs ?? '', + }; +} + +function buildClaudeLocalCommandDisplayText(payload: ClaudeLocalCommandPayload): string { + const commandName = payload.commandName.trim(); + const commandMessage = payload.commandMessage.trim(); + const commandArgs = payload.commandArgs.trim(); + const baseCommand = commandName || commandMessage; + + if (!baseCommand) { + return ''; + } + + return commandArgs ? `${baseCommand} ${commandArgs}` : baseCommand; +} + +function stripAnsiFormatting(text: string): string { + return text.replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, ''); +} + +type ClaudeSearchableMessage = { + text: string; + role: 'user' | 'assistant'; +}; + +/** + * Claude mixes visible chat, compact summaries, and local command wrappers into + * the same transcript stream. Search should operate on the user-visible meaning + * of those rows rather than the raw wrapper syntax. + */ +function extractClaudeSearchableMessage(entry: AnyRecord): ClaudeSearchableMessage | null { + if (!entry.message?.content || entry.isApiErrorMessage) { + return null; + } + + const rawRole = entry.message.role; + if (rawRole !== 'user' && rawRole !== 'assistant') { + return null; + } + + if (typeof entry.message.content === 'string') { + const content = String(entry.message.content); + + if (entry.isCompactSummary === true && content.trim()) { + return { + text: content, + role: 'assistant', + }; + } + + const localCommand = parseClaudeLocalCommandPayload(content); + if (localCommand) { + const displayText = buildClaudeLocalCommandDisplayText(localCommand); + return displayText + ? { + text: displayText, + role: 'user', + } + : null; + } + + const localCommandStdout = extractTaggedContent(content, 'local-command-stdout'); + if (localCommandStdout !== null) { + const stdoutText = stripAnsiFormatting(localCommandStdout).trim(); + return stdoutText + ? { + text: stdoutText, + role: 'assistant', + } + : null; + } + + if (!content || isInternalContent(content)) { + return null; + } + + return { + text: content, + role: rawRole, + }; + } + + const text = extractClaudeText(entry.message.content); + if (!text) { + return null; + } + + if (entry.isCompactSummary === true) { + return { + text, + role: 'assistant', + }; + } + + if (isInternalContent(text)) { + return null; + } + + return { + text, + role: rawRole, + }; +} + function extractCodexText(content: unknown): string { if (typeof content === 'string') { return content; @@ -733,18 +857,21 @@ async function parseClaudeSessionMatches( } } - if (!entry.message?.content || entry.isApiErrorMessage) { + const searchableMessage = extractClaudeSearchableMessage(entry); + if (!searchableMessage) { continue; } - const role = entry.message.role; - if (role !== 'user' && role !== 'assistant') { - continue; - } + const { text, role } = searchableMessage; - const text = extractClaudeText(entry.message.content); - if (!text || isInternalContent(text)) { - continue; + /** + * Claude compact summaries are the most faithful session-summary source + * after a `/compact` because they describe the post-compaction state that + * the resumed session actually continues from. Prefer them over generic + * fallback user text when present. + */ + if (entry.isCompactSummary === true) { + state.resolvedSummary = text; } if (role === 'user') { diff --git a/server/shared/types.ts b/server/shared/types.ts index d15f69e7..af09abf2 100644 --- a/server/shared/types.ts +++ b/server/shared/types.ts @@ -102,6 +102,21 @@ export type NormalizedMessage = { kind: MessageKind; role?: 'user' | 'assistant'; content?: string; + /** + * Optional display-oriented metadata used by providers that need to expose + * richer transcript artifacts without introducing a brand-new message kind. + * + * Current Claude usage: + * - local slash commands expose parsed command fields + * - compact summaries are flagged so the UI can treat them differently later + */ + displayText?: string; + commandName?: string; + commandMessage?: string; + commandArgs?: string; + isLocalCommand?: boolean; + isLocalCommandStdout?: boolean; + isCompactSummary?: boolean; images?: unknown; toolName?: string; toolInput?: unknown; diff --git a/src/components/chat/hooks/useChatMessages.ts b/src/components/chat/hooks/useChatMessages.ts index 8f417de5..1590c4af 100644 --- a/src/components/chat/hooks/useChatMessages.ts +++ b/src/components/chat/hooks/useChatMessages.ts @@ -11,8 +11,9 @@ import { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText } * Convert NormalizedMessage[] from the session store into ChatMessage[] * that the existing UI components expect. * - * Internal/system content (e.g. , ) is already - * filtered server-side by the Claude provider module. + * Truly internal/system content is already filtered server-side. Some Claude + * transcript artifacts such as local slash commands and compact summaries are + * intentionally preserved and annotated so they can render like normal chat. */ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMessage[] { const converted: ChatMessage[] = []; @@ -26,6 +27,16 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes } for (const msg of messages) { + const sharedMetadata = { + displayText: msg.displayText, + commandName: msg.commandName, + commandMessage: msg.commandMessage, + commandArgs: msg.commandArgs, + isLocalCommand: msg.isLocalCommand, + isLocalCommandStdout: msg.isLocalCommandStdout, + isCompactSummary: msg.isCompactSummary, + }; + switch (msg.kind) { case 'text': { const content = msg.content || ''; @@ -42,12 +53,14 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes timestamp: msg.timestamp, isTaskNotification: true, taskStatus: taskNotifMatch[1]?.trim() || 'completed', + ...sharedMetadata, }); } else { converted.push({ type: 'user', content: unescapeWithMathProtection(decodeHtmlEntities(content)), timestamp: msg.timestamp, + ...sharedMetadata, }); } } else { @@ -58,6 +71,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes type: 'assistant', content: text, timestamp: msg.timestamp, + ...sharedMetadata, }); } break; @@ -106,6 +120,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes isComplete: Boolean(toolResult), } : undefined, + ...sharedMetadata, }); break; } @@ -117,6 +132,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes content: unescapeWithMathProtection(msg.content), timestamp: msg.timestamp, isThinking: true, + ...sharedMetadata, }); } break; @@ -126,6 +142,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes type: 'error', content: msg.content || 'Unknown error', timestamp: msg.timestamp, + ...sharedMetadata, }); break; @@ -135,6 +152,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes content: msg.content || '', timestamp: msg.timestamp, isInteractivePrompt: true, + ...sharedMetadata, }); break; @@ -145,6 +163,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes timestamp: msg.timestamp, isTaskNotification: true, taskStatus: msg.status || 'completed', + ...sharedMetadata, }); break; @@ -155,6 +174,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes content: msg.content, timestamp: msg.timestamp, isStreaming: true, + ...sharedMetadata, }); } break; diff --git a/src/components/chat/types/types.ts b/src/components/chat/types/types.ts index f8d492e5..474f23e1 100644 --- a/src/components/chat/types/types.ts +++ b/src/components/chat/types/types.ts @@ -28,6 +28,7 @@ export interface SubagentChildTool { export interface ChatMessage { type: string; content?: string; + displayText?: string; timestamp: string | number | Date; images?: ChatImage[]; reasoning?: string; @@ -40,6 +41,12 @@ export interface ChatMessage { toolResult?: ToolResult | null; toolId?: string; toolCallId?: string; + commandName?: string; + commandMessage?: string; + commandArgs?: string; + isLocalCommand?: boolean; + isLocalCommandStdout?: boolean; + isCompactSummary?: boolean; isSubagentContainer?: boolean; subagentState?: { childTools: SubagentChildTool[]; diff --git a/src/stores/useSessionStore.ts b/src/stores/useSessionStore.ts index 86925048..1a720d3d 100644 --- a/src/stores/useSessionStore.ts +++ b/src/stores/useSessionStore.ts @@ -40,6 +40,20 @@ export interface NormalizedMessage { // kind-specific fields (flat for simplicity) role?: 'user' | 'assistant'; content?: string; + /** + * Mirrors optional transcript metadata from the server. + * + * These fields are currently used by Claude history normalization so local + * slash commands, local stdout, and compact summaries do not disappear when + * the session store hydrates from REST history. + */ + displayText?: string; + commandName?: string; + commandMessage?: string; + commandArgs?: string; + isLocalCommand?: boolean; + isLocalCommandStdout?: boolean; + isCompactSummary?: boolean; images?: string[]; toolName?: string; toolInput?: unknown;