Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/ui/src/components/message-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,11 @@ export default function MessageItem(props: MessageItemProps) {
return typeof firstText?.id === "string" ? firstText.id : null
}

const primaryUserPromptDisplayMetadata = () => {
if (!isUser()) return undefined
return props.record.clientPromptDisplayMetadata
}

const fileAttachments = () =>
messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")

Expand Down Expand Up @@ -598,6 +603,7 @@ export default function MessageItem(props: MessageItemProps) {
instanceId={props.instanceId}
sessionId={props.sessionId}
primaryUserTextPartId={primaryUserTextPartId()}
displayMetadataOverride={part.id === primaryUserTextPartId() ? primaryUserPromptDisplayMetadata() : undefined}
onRendered={props.onContentRendered}
/>
</div>
Expand Down
85 changes: 74 additions & 11 deletions packages/ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { Match, Show, Suspense, Switch, lazy } from "solid-js"
import { For, Match, Show, Suspense, Switch, createMemo, lazy } from "solid-js"
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
import { Markdown } from "./markdown"
import { useTheme } from "../lib/theme"
import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message"
import { useI18n } from "../lib/i18n"
import { splitHiddenPromptSections, type HiddenPromptDisplayMetadata } from "../lib/hidden-prompt-sections"

type ToolCallPart = Extract<ClientPart, { type: "tool" }>

Expand All @@ -16,11 +18,13 @@ interface MessagePartProps {
// For user messages, keep the primary prompt text visible even when synthetic (optimistic).
// Other synthetic text parts (tool traces, read outputs, etc.) should be hidden.
primaryUserTextPartId?: string | null
displayMetadataOverride?: HiddenPromptDisplayMetadata
onRendered?: () => void
}

export default function MessagePart(props: MessagePartProps) {

const { t } = useI18n()
const { isDark } = useTheme()
const partType = () => props.part?.type || ""
const reasoningId = () => `reasoning-${props.part?.id || ""}`
Expand Down Expand Up @@ -52,6 +56,14 @@ export default function MessagePart(props: MessagePartProps) {
return typeof id === "string" && id.length > 0
}

const hiddenPromptSegments = createMemo(() => {
if (props.messageType !== "user") return null
if (props.part?.type !== "text") return null
if (typeof props.part.text !== "string") return null

return splitHiddenPromptSections(props.part.text, props.displayMetadataOverride)
})

function reasoningSegmentHasText(segment: unknown): boolean {
if (typeof segment === "string") {
return segment.trim().length > 0
Expand Down Expand Up @@ -111,6 +123,15 @@ export default function MessagePart(props: MessagePartProps) {
}
}

function createSegmentTextPart(text: string, index: number): TextPart {
return {
id: `${String((props.part as { id?: string }).id ?? "text")}:display:${index}`,
type: "text",
text,
synthetic: false,
}
}

function handleReasoningClick(e: Event) {
e.preventDefault()
toggleItemExpanded(reasoningId())
Expand All @@ -127,16 +148,58 @@ export default function MessagePart(props: MessagePartProps) {
data-part-type="text"
data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined}
>
<Show when={canRenderMarkdown()} fallback={<span class="text-primary" dir="auto">{plainTextContent()}</span>}>
<Markdown
part={createTextPartForMarkdown()}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"}
escapeRawHtml={props.messageType === "user"}
onRendered={props.onRendered}
/>
<Show
when={hiddenPromptSegments()}
fallback={
<Show when={canRenderMarkdown()} fallback={<span class="text-primary" dir="auto">{plainTextContent()}</span>}>
<Markdown
part={createTextPartForMarkdown()}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"}
escapeRawHtml={props.messageType === "user"}
onRendered={props.onRendered}
/>
</Show>
}
>
{(segments) => (
<div class="flex flex-col gap-2">
<For each={segments().filter((segment) => segment.text.length > 0)}>
{(segment, index) =>
segment.hidden ? (
<details class="rounded-md border border-base bg-surface-secondary px-3 py-2">
<summary class="cursor-pointer select-none text-xs font-medium text-secondary">
{t("messagePart.hiddenPrompt.summary")}
</summary>
<div class="pt-2">
<Markdown
part={createSegmentTextPart(segment.text, index())}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()}
size="base"
escapeRawHtml
onRendered={props.onRendered}
/>
</div>
</details>
) : (
<Markdown
part={createSegmentTextPart(segment.text, index())}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()}
size="base"
escapeRawHtml
onRendered={props.onRendered}
/>
)
}
</For>
</div>
)}
</Show>
</div>
</Show>
Expand Down
59 changes: 59 additions & 0 deletions packages/ui/src/lib/hidden-prompt-sections.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"

import { preparePromptDisplayText, splitHiddenPromptSections } from "./hidden-prompt-sections"

describe("preparePromptDisplayText", () => {
it("strips wrapped hidden markers before sending while preserving display metadata", () => {
const result = preparePromptDisplayText("Visible\n<codenomad:hide>Hidden\nPlan</codenomad:hide>\nDone")

assert.equal(result.promptToSend, "Visible\nHidden\nPlan\nDone")
assert.deepEqual(result.displayMetadata, {
segments: [
{ hidden: false, length: 8 },
{ hidden: true, length: 11 },
{ hidden: false, length: 5 },
],
})
})

it("leaves prompts without markers unchanged", () => {
const result = preparePromptDisplayText("Visible only")

assert.equal(result.promptToSend, "Visible only")
assert.equal(result.displayMetadata, undefined)
})

it("treats malformed markers as plain text for both display and send", () => {
const result = preparePromptDisplayText("Intro<codenomad:hide>Secret")

assert.equal(result.promptToSend, "Intro<codenomad:hide>Secret")
assert.equal(result.displayMetadata, undefined)
})
})

describe("splitHiddenPromptSections", () => {
const wrapped = preparePromptDisplayText("Intro<codenomad:hide>Secret</codenomad:hide>Outro")

it("splits wrapped hidden prompt sections", () => {
assert.deepEqual(splitHiddenPromptSections(wrapped.promptToSend, wrapped.displayMetadata), [
{ hidden: false, text: "Intro" },
{ hidden: true, text: "Secret" },
{ hidden: false, text: "Outro" },
])
})

it("supports explicit start/end hide markers", () => {
const result = preparePromptDisplayText("Intro<codenomad:start-hide />Secret<codenomad:end-hide />Outro")

assert.deepEqual(splitHiddenPromptSections(result.promptToSend, result.displayMetadata), [
{ hidden: false, text: "Intro" },
{ hidden: true, text: "Secret" },
{ hidden: false, text: "Outro" },
])
})

it("returns null when metadata does not match the text", () => {
assert.equal(splitHiddenPromptSections("Too short", wrapped.displayMetadata), null)
})
})
134 changes: 134 additions & 0 deletions packages/ui/src/lib/hidden-prompt-sections.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
export interface HiddenPromptSectionSegment {
hidden: boolean
text: string
}

export interface HiddenPromptDisplaySegmentMetadata {
hidden: boolean
length: number
}

export interface HiddenPromptDisplayMetadata {
segments: HiddenPromptDisplaySegmentMetadata[]
}

export interface PreparedPromptDisplayText {
promptToSend: string
displayMetadata?: HiddenPromptDisplayMetadata
}

const HIDDEN_PROMPT_TOKEN_REGEX = /<\/codenomad:hide>|<codenomad:hide>|<codenomad:start-hide\s*\/>|<codenomad:end-hide\s*\/>/gi

function normalizeHiddenPromptToken(token: string): string {
return token.toLowerCase().replace(/\s+/g, "")
}

function isHiddenPromptOpenToken(token: string): boolean {
return token === "<codenomad:hide>" || token === "<codenomad:start-hide/>"
}

function isHiddenPromptCloseToken(token: string): boolean {
return token === "</codenomad:hide>" || token === "<codenomad:end-hide/>"
}

function hasHiddenPromptMarkers(text: string): boolean {
HIDDEN_PROMPT_TOKEN_REGEX.lastIndex = 0
return HIDDEN_PROMPT_TOKEN_REGEX.test(text)
}

function pushHiddenPromptSectionSegment(segments: HiddenPromptSectionSegment[], hidden: boolean, text: string): void {
if (!text) return
const previous = segments[segments.length - 1]
if (previous && previous.hidden === hidden) {
previous.text += text
return
}
segments.push({ hidden, text })
}

export function preparePromptDisplayText(text: string): PreparedPromptDisplayText {
if (!hasHiddenPromptMarkers(text)) {
return { promptToSend: text }
}

HIDDEN_PROMPT_TOKEN_REGEX.lastIndex = 0
const segments: HiddenPromptSectionSegment[] = []
let currentHidden = false
let currentText = ""
let lastIndex = 0
let foundHiddenSegment = false

for (const match of text.matchAll(HIDDEN_PROMPT_TOKEN_REGEX)) {
const token = match[0]
const start = match.index ?? 0
currentText += text.slice(lastIndex, start)

const normalizedToken = normalizeHiddenPromptToken(token)
if (isHiddenPromptOpenToken(normalizedToken) && !currentHidden) {
pushHiddenPromptSectionSegment(segments, false, currentText)
currentHidden = true
currentText = ""
} else if (isHiddenPromptCloseToken(normalizedToken) && currentHidden) {
pushHiddenPromptSectionSegment(segments, true, currentText)
foundHiddenSegment = true
currentHidden = false
currentText = ""
} else {
return { promptToSend: text }
}

lastIndex = start + token.length
}

currentText += text.slice(lastIndex)

if (currentHidden) {
return { promptToSend: text }
}

pushHiddenPromptSectionSegment(segments, false, currentText)

if (!foundHiddenSegment) {
return { promptToSend: text }
}

const promptToSend = segments.map((segment) => segment.text).join("")
const displayMetadata: HiddenPromptDisplayMetadata = {
segments: segments.map((segment) => ({ hidden: segment.hidden, length: segment.text.length })),
}

return {
promptToSend,
displayMetadata,
}
}

export function splitHiddenPromptSections(
text: string,
metadata: HiddenPromptDisplayMetadata | undefined,
): HiddenPromptSectionSegment[] | null {
if (!metadata || !Array.isArray(metadata.segments) || metadata.segments.length === 0) {
return null
}

const segments: HiddenPromptSectionSegment[] = []
let offset = 0

for (const segment of metadata.segments) {
if (!segment || typeof segment.length !== "number" || segment.length < 0) {
return null
}
const nextOffset = offset + segment.length
if (nextOffset > text.length) {
return null
}
pushHiddenPromptSectionSegment(segments, Boolean(segment.hidden), text.slice(offset, nextOffset))
offset = nextOffset
}

if (offset !== text.length) {
return null
}

return segments
}
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/en/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export const messagingMessages = {
"messagePart.actions.deleteTitle": "Delete this item",
"messagePart.actions.deleteFailedTitle": "Delete failed",
"messagePart.actions.deleteFailedMessage": "Failed to delete item",
"messagePart.hiddenPrompt.summary": "Hidden prompt section",
"messageItem.attachment.defaultName": "attachment",
"messageItem.attachment.downloadAriaLabel": "Download {name}",
"messageItem.agentMeta.agentLabel": "Agent: {agent}",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/es/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export const messagingMessages = {
"messagePart.actions.deleteTitle": "Eliminar este elemento",
"messagePart.actions.deleteFailedTitle": "Error al eliminar",
"messagePart.actions.deleteFailedMessage": "No se pudo eliminar el elemento",
"messagePart.hiddenPrompt.summary": "Sección de prompt oculta",
"messageItem.attachment.defaultName": "adjunto",
"messageItem.attachment.downloadAriaLabel": "Descargar {name}",
"messageItem.agentMeta.agentLabel": "Agente: {agent}",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/fr/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export const messagingMessages = {
"messagePart.actions.deleteTitle": "Supprimer cet élément",
"messagePart.actions.deleteFailedTitle": "Échec de suppression",
"messagePart.actions.deleteFailedMessage": "Impossible de supprimer l'élément",
"messagePart.hiddenPrompt.summary": "Section de prompt masquée",
"messageItem.attachment.defaultName": "piece-jointe",
"messageItem.attachment.downloadAriaLabel": "Télécharger {name}",
"messageItem.agentMeta.agentLabel": "Agent : {agent}",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/he/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export const messagingMessages = {
"messagePart.actions.deleteTitle": "מחק פריט זה",
"messagePart.actions.deleteFailedTitle": "המחיקה נכשלה",
"messagePart.actions.deleteFailedMessage": "מחיקת הפריט נכשלה",
"messagePart.hiddenPrompt.summary": "מקטע פרומפט מוסתר",
"messageItem.attachment.defaultName": "קובץ מצורף",
"messageItem.attachment.downloadAriaLabel": "הורד {name}",
"messageItem.agentMeta.agentLabel": "סוכן: {agent}",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/ja/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export const messagingMessages = {
"messagePart.actions.deleteTitle": "この項目を削除",
"messagePart.actions.deleteFailedTitle": "削除に失敗しました",
"messagePart.actions.deleteFailedMessage": "項目の削除に失敗しました",
"messagePart.hiddenPrompt.summary": "非表示のプロンプトセクション",
"messageItem.attachment.defaultName": "添付ファイル",
"messageItem.attachment.downloadAriaLabel": "{name} をダウンロード",
"messageItem.agentMeta.agentLabel": "エージェント: {agent}",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/ru/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export const messagingMessages = {
"messagePart.actions.deleteTitle": "Удалить этот элемент",
"messagePart.actions.deleteFailedTitle": "Ошибка удаления",
"messagePart.actions.deleteFailedMessage": "Не удалось удалить элемент",
"messagePart.hiddenPrompt.summary": "Скрытый раздел промпта",
"messageItem.attachment.defaultName": "вложение",
"messageItem.attachment.downloadAriaLabel": "Скачать {name}",
"messageItem.agentMeta.agentLabel": "Агент: {agent}",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export const messagingMessages = {
"messagePart.actions.deleteTitle": "删除此项",
"messagePart.actions.deleteFailedTitle": "删除失败",
"messagePart.actions.deleteFailedMessage": "删除失败",
"messagePart.hiddenPrompt.summary": "已隐藏的提示区段",
"messageItem.attachment.defaultName": "附件",
"messageItem.attachment.downloadAriaLabel": "下载 {name}",
"messageItem.agentMeta.agentLabel": "智能体:{agent}",
Expand Down
Loading
Loading