mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
fix(desktop): restore skill command history (#926)
Preserve user-invoked skill command metadata during session restore, WebSocket replay, and session search while continuing to hide malformed command breadcrumbs. Tested: - bun test src/server/__tests__/sessions.test.ts - bun test src/server/__tests__/ws-memory-events.test.ts - bun test src/server/__tests__/searchService.sessions.test.ts - cd desktop && bun run test -- --run src/stores/chatStore.test.ts - bun run check:server - cd desktop && bun run build - git diff --check Not-tested: - bun run check:desktop did not complete because an existing MessageList timestamp assertion fails independently of this change. Confidence: high Scope-risk: narrow
This commit is contained in:
parent
547f95a599
commit
c6be08e471
@ -572,7 +572,7 @@ describe('chatStore history mapping', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('does not restore internal slash-command breadcrumbs as user history bubbles', () => {
|
||||
it('restores slash-command metadata as readable history while skipping malformed breadcrumbs', () => {
|
||||
const messages: MessageEntry[] = [
|
||||
{
|
||||
id: 'agent-command-string',
|
||||
@ -599,6 +599,12 @@ describe('chatStore history mapping', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'malformed-command',
|
||||
type: 'user',
|
||||
timestamp: '2026-06-15T03:32:14.500Z',
|
||||
content: '<command-name>/agent</command-name> malformed breadcrumb',
|
||||
},
|
||||
{
|
||||
id: 'transcript-user-1',
|
||||
type: 'user',
|
||||
@ -608,6 +614,16 @@ describe('chatStore history mapping', () => {
|
||||
]
|
||||
|
||||
expect(mapHistoryMessagesToUiMessages(messages)).toMatchObject([
|
||||
{
|
||||
id: 'agent-command-string',
|
||||
type: 'user_text',
|
||||
content: '/agent Plan 222',
|
||||
},
|
||||
{
|
||||
id: 'agent-command-array',
|
||||
type: 'user_text',
|
||||
content: '/agent Plan 333',
|
||||
},
|
||||
{
|
||||
id: 'transcript-user-1',
|
||||
type: 'user_text',
|
||||
@ -616,6 +632,33 @@ describe('chatStore history mapping', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('restores user-invoked skill command metadata as readable user history', () => {
|
||||
const messages: MessageEntry[] = [
|
||||
{
|
||||
id: 'skill-command-user',
|
||||
type: 'user',
|
||||
timestamp: '2026-06-26T14:59:44.000Z',
|
||||
content: [
|
||||
'<command-message>frontend-design</command-message>',
|
||||
'<command-name>/frontend-design</command-name>',
|
||||
'<command-args>redesign the settings page</command-args>',
|
||||
].join('\n'),
|
||||
},
|
||||
]
|
||||
|
||||
const mapped = mapHistoryMessagesToUiMessages(messages)
|
||||
|
||||
expect(mapped).toMatchObject([
|
||||
{
|
||||
id: 'skill-command-user',
|
||||
type: 'user_text',
|
||||
content: '/frontend-design redesign the settings page',
|
||||
transcriptMessageId: 'skill-command-user',
|
||||
},
|
||||
])
|
||||
expect(mapped[0]?.type === 'user_text' ? mapped[0].content : '').not.toContain('<command-message>')
|
||||
})
|
||||
|
||||
it('restores persisted image user messages as renderable attachments without exposing image metadata text', () => {
|
||||
const messages: MessageEntry[] = [
|
||||
{
|
||||
|
||||
@ -2569,14 +2569,74 @@ function extractHistoryTextBlocks(content: unknown): string[] {
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function isInternalCommandBreadcrumbContent(content: unknown): boolean {
|
||||
const textBlocks = extractHistoryTextBlocks(content)
|
||||
return textBlocks.length > 0 && textBlocks.every((text) => (
|
||||
const COMMAND_METADATA_TAGS = new Set([
|
||||
'command-name',
|
||||
'command-message',
|
||||
'command-args',
|
||||
'local-command-caveat',
|
||||
'skill-format',
|
||||
])
|
||||
const COMMAND_METADATA_BLOCK_RE = /<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>\s*/gi
|
||||
|
||||
function hasCommandMetadataTag(text: string): boolean {
|
||||
return (
|
||||
text.includes('<command-name>') ||
|
||||
text.includes('<command-message>') ||
|
||||
text.includes('<command-args>') ||
|
||||
text.includes('<local-command-caveat>')
|
||||
text.includes('<local-command-caveat>') ||
|
||||
text.includes('<skill-format>')
|
||||
)
|
||||
}
|
||||
|
||||
function isOnlyKnownCommandMetadata(text: string): boolean {
|
||||
const remainder = text.replace(COMMAND_METADATA_BLOCK_RE, (match, tag: string) => (
|
||||
COMMAND_METADATA_TAGS.has(tag.toLowerCase()) ? '' : match
|
||||
))
|
||||
return remainder.trim().length === 0
|
||||
}
|
||||
|
||||
function formatCommandMetadataDisplayText(
|
||||
commandName: string,
|
||||
args: string,
|
||||
skillFormat: boolean,
|
||||
commandMessage?: string,
|
||||
): string {
|
||||
if (skillFormat) {
|
||||
return `Skill(${commandMessage || commandName.replace(/^\//, '')})`
|
||||
}
|
||||
|
||||
const normalizedName = commandName.startsWith('/') ? commandName : `/${commandName}`
|
||||
return [normalizedName, args.trim()].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function parseCommandMetadataText(text: string): string | null {
|
||||
const trimmed = text.trim()
|
||||
if (!hasCommandMetadataTag(trimmed)) return null
|
||||
if (!isOnlyKnownCommandMetadata(trimmed)) return null
|
||||
|
||||
const commandName = readXmlTag(trimmed, 'command-name')
|
||||
if (!commandName) return null
|
||||
|
||||
const args = readXmlTag(trimmed, 'command-args') ?? ''
|
||||
const commandMessage = readXmlTag(trimmed, 'command-message')
|
||||
const skillFormat = readXmlTag(trimmed, 'skill-format') === 'true'
|
||||
return formatCommandMetadataDisplayText(commandName, args, skillFormat, commandMessage)
|
||||
}
|
||||
|
||||
function getCommandMetadataDisplayText(content: unknown): string | null {
|
||||
const textBlocks = extractHistoryTextBlocks(content)
|
||||
if (textBlocks.length === 0) return null
|
||||
|
||||
const displayBlocks = textBlocks.map(parseCommandMetadataText)
|
||||
if (displayBlocks.some((text) => text === null)) return null
|
||||
return displayBlocks.join('\n')
|
||||
}
|
||||
|
||||
function shouldHideCommandMetadataContent(content: unknown): boolean {
|
||||
const textBlocks = extractHistoryTextBlocks(content)
|
||||
if (textBlocks.length === 0) return false
|
||||
if (!textBlocks.some(hasCommandMetadataTag)) return false
|
||||
return getCommandMetadataDisplayText(content) === null
|
||||
}
|
||||
|
||||
function isTaskNotificationContent(content: unknown): boolean {
|
||||
@ -3298,8 +3358,22 @@ export function mapHistoryMessagesToUiMessages(
|
||||
suppressTaskNotificationResponse = true
|
||||
continue
|
||||
}
|
||||
if (msg.type === 'user' && isInternalCommandBreadcrumbContent(msg.content)) {
|
||||
continue
|
||||
if (msg.type === 'user') {
|
||||
const commandDisplayText = getCommandMetadataDisplayText(msg.content)
|
||||
if (commandDisplayText) {
|
||||
uiMessages.push({
|
||||
id: msg.id || nextId(),
|
||||
type: 'user_text',
|
||||
content: commandDisplayText,
|
||||
...(msg.id ? { transcriptMessageId: msg.id } : {}),
|
||||
timestamp: new Date(msg.timestamp).getTime(),
|
||||
})
|
||||
suppressTaskNotificationResponse = false
|
||||
continue
|
||||
}
|
||||
if (shouldHideCommandMetadataContent(msg.content)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (msg.type === 'user') {
|
||||
suppressTaskNotificationResponse = false
|
||||
|
||||
@ -163,6 +163,26 @@ describe('SearchService.searchSessions', () => {
|
||||
expect(results).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('indexes readable command metadata entries', async () => {
|
||||
await writeSessionFile('proj-a', 'session-7', [
|
||||
{
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
'<command-message>frontend-design</command-message>',
|
||||
'<command-name>/frontend-design</command-name>',
|
||||
'<command-args>redesign settings page</command-args>',
|
||||
].join('\n'),
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const { results } = await service.searchSessions('redesign')
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]!.matches[0]!.snippet).toContain('/frontend-design redesign settings page')
|
||||
})
|
||||
|
||||
it('resolves the real session title (custom-title wins) instead of the UUID', async () => {
|
||||
await writeSessionFile('proj-a', 'titled-session', [
|
||||
{ type: 'user', uuid: 'u1', message: { role: 'user', content: 'discuss searchword topic' } },
|
||||
|
||||
@ -888,7 +888,7 @@ describe('SessionService', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should hide synthetic interruption, no-response, and command breadcrumb transcript entries', async () => {
|
||||
it('should hide synthetic interruption, no-response, and malformed command breadcrumb transcript entries', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
await writeSessionFile('-tmp-project', sessionId, [
|
||||
makeSnapshotEntry(),
|
||||
@ -935,19 +935,64 @@ describe('SessionService', () => {
|
||||
uuid: crypto.randomUUID(),
|
||||
timestamp: '2026-01-01T00:00:05.000Z',
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: '<command-name>/agent</command-name> malformed breadcrumb',
|
||||
},
|
||||
uuid: crypto.randomUUID(),
|
||||
timestamp: '2026-01-01T00:00:06.000Z',
|
||||
},
|
||||
makeAssistantEntry('正常助手消息', crypto.randomUUID()),
|
||||
])
|
||||
|
||||
const messages = await service.getSessionMessages(sessionId)
|
||||
|
||||
expect(messages).toHaveLength(2)
|
||||
expect(messages).toHaveLength(4)
|
||||
expect(messages[0]).toMatchObject({ type: 'user', content: '正常用户消息' })
|
||||
expect(messages[1]).toMatchObject({
|
||||
type: 'user',
|
||||
content: '<command-name>/exit</command-name>\n<command-message>exit</command-message>\n<command-args></command-args>',
|
||||
})
|
||||
expect(messages[2]).toMatchObject({
|
||||
type: 'user',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: '<command-name>/agent</command-name>\n<command-message>agent</command-message>\n<command-args>Plan 222</command-args>',
|
||||
}],
|
||||
})
|
||||
expect(messages[3]).toMatchObject({
|
||||
type: 'assistant',
|
||||
content: [{ type: 'text', text: '正常助手消息' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep user-invoked skill command metadata for desktop history restore', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
await writeSessionFile('-tmp-project', sessionId, [
|
||||
makeSnapshotEntry(),
|
||||
makeUserEntry([
|
||||
'<command-message>frontend-design</command-message>',
|
||||
'<command-name>/frontend-design</command-name>',
|
||||
'<command-args>redesign the settings page</command-args>',
|
||||
].join('\n'), 'skill-command-user'),
|
||||
makeAssistantEntry('正常助手消息', 'skill-command-user'),
|
||||
])
|
||||
|
||||
const messages = await service.getSessionMessages(sessionId)
|
||||
|
||||
expect(messages).toHaveLength(2)
|
||||
const skillCommandContent = String(messages[0]!.content)
|
||||
expect(messages[0]).toMatchObject({
|
||||
id: 'skill-command-user',
|
||||
type: 'user',
|
||||
content: expect.stringContaining('<command-name>/frontend-design</command-name>'),
|
||||
})
|
||||
expect(skillCommandContent).toContain('<command-args>redesign the settings page</command-args>')
|
||||
expect(messages[1]).toMatchObject({ type: 'assistant' })
|
||||
})
|
||||
|
||||
it('should keep /goal local command transcript entries for desktop history restore', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
await writeSessionFile('-tmp-project', sessionId, [
|
||||
|
||||
@ -27,7 +27,7 @@ describe('WebSocket memory events', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('does not replay internal slash-command breadcrumbs as user messages', () => {
|
||||
it('replays slash-command breadcrumbs as readable user messages', () => {
|
||||
expect(translateCliMessage({
|
||||
type: 'user',
|
||||
isReplay: true,
|
||||
@ -39,7 +39,9 @@ describe('WebSocket memory events', () => {
|
||||
'<command-args>Plan 222</command-args>',
|
||||
].join('\n'),
|
||||
},
|
||||
}, 'session-1')).toEqual([])
|
||||
}, 'session-1')).toEqual([
|
||||
{ type: 'user_message_replay', content: '/agent Plan 222' },
|
||||
])
|
||||
|
||||
expect(translateCliMessage({
|
||||
type: 'user',
|
||||
@ -57,6 +59,17 @@ describe('WebSocket memory events', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
}, 'session-1')).toEqual([
|
||||
{ type: 'user_message_replay', content: '/agent Plan 222' },
|
||||
])
|
||||
|
||||
expect(translateCliMessage({
|
||||
type: 'user',
|
||||
isReplay: true,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: '<command-name>/agent</command-name> malformed breadcrumb',
|
||||
},
|
||||
}, 'session-1')).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@ -10,6 +10,10 @@ import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import { ApiError } from '../middleware/errorHandler.js'
|
||||
import { sessionService } from './sessionService.js'
|
||||
import {
|
||||
getCommandMetadataDisplayText,
|
||||
shouldHideCommandMetadataContent,
|
||||
} from '../../utils/commandMetadata.js'
|
||||
|
||||
export type SearchResult = {
|
||||
file: string
|
||||
@ -390,7 +394,11 @@ export class SearchService {
|
||||
if (entry.type !== 'user' && entry.type !== 'assistant') return []
|
||||
|
||||
const content = entry.message?.content
|
||||
if (this.isInternalCommandBreadcrumb(content)) return []
|
||||
const commandDisplayText = getCommandMetadataDisplayText(content)
|
||||
if (commandDisplayText) {
|
||||
return [{ role: 'user', text: commandDisplayText }]
|
||||
}
|
||||
if (shouldHideCommandMetadataContent(content)) return []
|
||||
|
||||
const role: SessionMatchRole =
|
||||
entry.type === 'assistant' || entry.message?.role === 'assistant'
|
||||
@ -421,19 +429,6 @@ export class SearchService {
|
||||
return out
|
||||
}
|
||||
|
||||
private isInternalCommandBreadcrumb(content: unknown): boolean {
|
||||
const textBlocks = this.extractPlainTextBlocks(content)
|
||||
return (
|
||||
textBlocks.length > 0 &&
|
||||
textBlocks.every((text) =>
|
||||
text.includes('<command-name>') ||
|
||||
text.includes('<command-message>') ||
|
||||
text.includes('<command-args>') ||
|
||||
text.includes('<local-command-caveat>'),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** Window a single match into a one-line, highlighted snippet. */
|
||||
private buildSnippet(
|
||||
text: string,
|
||||
|
||||
@ -44,6 +44,7 @@ import {
|
||||
roughTokenCountEstimationForMessage,
|
||||
} from '../../services/tokenEstimation.js'
|
||||
import { ProviderService } from './providerService.js'
|
||||
import { shouldHideCommandMetadataContent } from '../../utils/commandMetadata.js'
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@ -843,19 +844,6 @@ export class SessionService {
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
private isInternalCommandBreadcrumb(content: unknown): boolean {
|
||||
const textBlocks = this.extractTextBlocks(content)
|
||||
return (
|
||||
textBlocks.length > 0 &&
|
||||
textBlocks.every((text) =>
|
||||
text.includes('<command-name>') ||
|
||||
text.includes('<command-message>') ||
|
||||
text.includes('<command-args>') ||
|
||||
text.includes('<local-command-caveat>')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private isSyntheticUserInterruption(content: unknown): boolean {
|
||||
const textBlocks = this.extractTextBlocks(content)
|
||||
return (
|
||||
@ -950,7 +938,7 @@ export class SessionService {
|
||||
|
||||
if (role === 'user') {
|
||||
return (
|
||||
this.isInternalCommandBreadcrumb(content) ||
|
||||
shouldHideCommandMetadataContent(content) ||
|
||||
this.isSyntheticUserInterruption(content) ||
|
||||
this.isTaskNotificationContent(content)
|
||||
)
|
||||
|
||||
@ -29,13 +29,14 @@ import {
|
||||
} from '../services/titleService.js'
|
||||
import { parseSlashCommand } from '../../utils/slashCommandParsing.js'
|
||||
import {
|
||||
COMMAND_ARGS_TAG,
|
||||
COMMAND_MESSAGE_TAG,
|
||||
COMMAND_NAME_TAG,
|
||||
LOCAL_COMMAND_CAVEAT_TAG,
|
||||
LOCAL_COMMAND_STDERR_TAG,
|
||||
LOCAL_COMMAND_STDOUT_TAG,
|
||||
} from '../../constants/xml.js'
|
||||
import {
|
||||
getCommandMetadataDisplayText,
|
||||
shouldHideCommandMetadataContent,
|
||||
} from '../../utils/commandMetadata.js'
|
||||
import { shouldCreateWorktreeForSessionLaunch } from '../services/repositoryLaunchService.js'
|
||||
import { getDisconnectGraceMs } from './disconnectGraceConfig.js'
|
||||
|
||||
@ -2347,34 +2348,12 @@ function hasToolResultBlock(content: unknown): boolean {
|
||||
(block as { type?: unknown }).type === 'tool_result')
|
||||
}
|
||||
|
||||
function isInternalCommandBreadcrumb(content: unknown): boolean {
|
||||
const textBlocks = typeof content === 'string'
|
||||
? [content]
|
||||
: Array.isArray(content)
|
||||
? content.flatMap((block) => {
|
||||
if (!block || typeof block !== 'object') return []
|
||||
const typedBlock = block as { type?: unknown; text?: unknown }
|
||||
return typedBlock.type === 'text' && typeof typedBlock.text === 'string'
|
||||
? [typedBlock.text]
|
||||
: []
|
||||
})
|
||||
: []
|
||||
|
||||
return textBlocks.length > 0 && textBlocks.every((text) => {
|
||||
const trimmed = text.trim()
|
||||
return (
|
||||
trimmed.includes(`<${COMMAND_NAME_TAG}>`) ||
|
||||
trimmed.includes(`<${COMMAND_MESSAGE_TAG}>`) ||
|
||||
trimmed.includes(`<${COMMAND_ARGS_TAG}>`) ||
|
||||
trimmed.includes(`<${LOCAL_COMMAND_CAVEAT_TAG}>`)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function extractReplayUserText(cliMsg: any): string | null {
|
||||
if (cliMsg?.isReplay !== true) return null
|
||||
const content = cliMsg.message?.content
|
||||
if (isInternalCommandBreadcrumb(content)) return null
|
||||
const commandDisplayText = getCommandMetadataDisplayText(content)
|
||||
if (commandDisplayText) return commandDisplayText
|
||||
if (shouldHideCommandMetadataContent(content)) return null
|
||||
if (isCompactSummaryMessageContent(content)) return null
|
||||
if (hasToolResultBlock(content)) return null
|
||||
if (extractLocalCommandOutput(content)) return null
|
||||
|
||||
103
src/utils/commandMetadata.ts
Normal file
103
src/utils/commandMetadata.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import {
|
||||
COMMAND_ARGS_TAG,
|
||||
COMMAND_MESSAGE_TAG,
|
||||
COMMAND_NAME_TAG,
|
||||
LOCAL_COMMAND_CAVEAT_TAG,
|
||||
} from '../constants/xml.js'
|
||||
|
||||
const COMMAND_METADATA_TAGS = new Set([
|
||||
COMMAND_NAME_TAG,
|
||||
COMMAND_MESSAGE_TAG,
|
||||
COMMAND_ARGS_TAG,
|
||||
LOCAL_COMMAND_CAVEAT_TAG,
|
||||
'skill-format',
|
||||
])
|
||||
|
||||
const XML_TAG_BLOCK_PATTERN = /<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>\s*/gi
|
||||
|
||||
function decodeXmlText(text: string): string {
|
||||
return text
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&')
|
||||
}
|
||||
|
||||
function readXmlTag(xml: string, tag: string): string | undefined {
|
||||
const match = xml.match(new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, 'i'))
|
||||
const value = match?.[1]?.trim()
|
||||
return value ? decodeXmlText(value) : undefined
|
||||
}
|
||||
|
||||
function extractTextBlocks(content: unknown): string[] {
|
||||
if (typeof content === 'string') return [content]
|
||||
if (!Array.isArray(content)) return []
|
||||
|
||||
return content
|
||||
.flatMap((block) => {
|
||||
if (!block || typeof block !== 'object') return []
|
||||
const record = block as Record<string, unknown>
|
||||
return record.type === 'text' && typeof record.text === 'string'
|
||||
? [record.text]
|
||||
: []
|
||||
})
|
||||
.map((text) => text.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function hasCommandMetadataTag(text: string): boolean {
|
||||
return (
|
||||
text.includes(`<${COMMAND_NAME_TAG}>`) ||
|
||||
text.includes(`<${COMMAND_MESSAGE_TAG}>`) ||
|
||||
text.includes(`<${COMMAND_ARGS_TAG}>`) ||
|
||||
text.includes(`<${LOCAL_COMMAND_CAVEAT_TAG}>`) ||
|
||||
text.includes('<skill-format>')
|
||||
)
|
||||
}
|
||||
|
||||
function isOnlyKnownCommandMetadata(text: string): boolean {
|
||||
const remainder = text.replace(XML_TAG_BLOCK_PATTERN, (match, tag: string) => (
|
||||
COMMAND_METADATA_TAGS.has(tag.toLowerCase()) ? '' : match
|
||||
))
|
||||
return remainder.trim().length === 0
|
||||
}
|
||||
|
||||
function formatCommandDisplayText(commandName: string, args: string, skillFormat: boolean, commandMessage?: string): string {
|
||||
if (skillFormat) {
|
||||
return `Skill(${commandMessage || commandName.replace(/^\//, '')})`
|
||||
}
|
||||
|
||||
const normalizedName = commandName.startsWith('/') ? commandName : `/${commandName}`
|
||||
return [normalizedName, args.trim()].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function parseCommandMetadataText(text: string): string | null {
|
||||
const trimmed = text.trim()
|
||||
if (!hasCommandMetadataTag(trimmed)) return null
|
||||
if (!isOnlyKnownCommandMetadata(trimmed)) return null
|
||||
|
||||
const commandName = readXmlTag(trimmed, COMMAND_NAME_TAG)
|
||||
if (!commandName) return null
|
||||
|
||||
const args = readXmlTag(trimmed, COMMAND_ARGS_TAG) ?? ''
|
||||
const commandMessage = readXmlTag(trimmed, COMMAND_MESSAGE_TAG)
|
||||
const skillFormat = readXmlTag(trimmed, 'skill-format') === 'true'
|
||||
return formatCommandDisplayText(commandName, args, skillFormat, commandMessage)
|
||||
}
|
||||
|
||||
export function getCommandMetadataDisplayText(content: unknown): string | null {
|
||||
const textBlocks = extractTextBlocks(content)
|
||||
if (textBlocks.length === 0) return null
|
||||
|
||||
const displayBlocks = textBlocks.map(parseCommandMetadataText)
|
||||
if (displayBlocks.some((text) => text === null)) return null
|
||||
return displayBlocks.join('\n')
|
||||
}
|
||||
|
||||
export function shouldHideCommandMetadataContent(content: unknown): boolean {
|
||||
const textBlocks = extractTextBlocks(content)
|
||||
if (textBlocks.length === 0) return false
|
||||
if (!textBlocks.some(hasCommandMetadataTag)) return false
|
||||
return getCommandMetadataDisplayText(content) === null
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user