diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 58174eae19..d5e5e5d479 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -63,6 +63,7 @@ import { prepareToolCallHistory, projectToolCallDetailLevel, } from "@/tool-calls/detail-level/projection"; +import { formatThinkingText } from "@/utils/thinking-text-formatter"; import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view"; import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model"; import { resolveStreamRenderStrategy } from "./strategy-resolver"; @@ -661,22 +662,42 @@ const AgentStreamViewComponent = forwardRef { + const head = projectedToolCalls.head; + for (let i = head.length - 1; i >= 0; i -= 1) { + if (head[i]?.kind === "thought") { + return head[i]?.id ?? null; + } + } + const tail = projectedToolCalls.tail; + for (let i = tail.length - 1; i >= 0; i -= 1) { + if (tail[i]?.kind === "thought") { + return tail[i]?.id ?? null; + } + } + return null; + }, [projectedToolCalls.head, projectedToolCalls.tail]); + const renderThoughtItem = useCallback( (layoutItem: StreamLayoutItem, item: Extract) => { + const isLastThought = item.id === latestThoughtId; + const isExpanded = + autoExpandReasoning === "expanded" || + (autoExpandReasoning === "expand_last" && isLastThought); return ( ); }, - [autoExpandReasoning, setInlineDetailsExpanded], + [autoExpandReasoning, latestThoughtId, setInlineDetailsExpanded], ); const renderSingleToolCallItem = useCallback( diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 8058320f99..dc032c44fb 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -3061,6 +3061,11 @@ export const ToolCall = memo(function ToolCall({ }: ToolCallProps) { const { openToolCall } = useToolCallSheet(); const [isExpanded, setIsExpanded] = useState(defaultExpanded ?? false); + const [prevDefaultExpanded, setPrevDefaultExpanded] = useState(defaultExpanded); + if (prevDefaultExpanded !== defaultExpanded) { + setPrevDefaultExpanded(defaultExpanded); + setIsExpanded(defaultExpanded ?? false); + } const isMobile = useIsCompactFormFactor(); const shouldRenderInline = !isMobile || forceInline; diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index 41006c4f2e..988f2ea185 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -1,8 +1,10 @@ -import React, { useMemo, type ReactNode } from "react"; +import React, { useCallback, useMemo, useRef, type ReactNode } from "react"; import { View, Text, ScrollView as RNScrollView, + type NativeScrollEvent, + type NativeSyntheticEvent, type StyleProp, type ViewStyle, } from "react-native"; @@ -21,6 +23,8 @@ import { HighlightedLines } from "./highlighted-content"; import { DiffViewer } from "./diff-viewer"; import { getCodeInsets } from "./code-insets"; import { isWeb } from "@/constants/platform"; +import { formatThinkingText } from "@/utils/thinking-text-formatter"; +import { MarkdownRenderer } from "./markdown/renderer"; const ScrollView = isWeb ? RNScrollView : GHScrollView; @@ -490,13 +494,34 @@ function FetchDetailSection({ url, result, ds }: FetchDetailProps) { } function ScrollablePlainTextSection({ text, ds }: { text: string; ds: DetailStyles }) { + const scrollRef = useRef(null); + const isNearBottomRef = useRef(true); + + const handleScroll = useCallback((event: NativeSyntheticEvent) => { + const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent; + const paddingToBottom = 32; + const isBottom = + layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom; + isNearBottomRef.current = isBottom; + }, []); + + const handleContentSizeChange = useCallback(() => { + if (isNearBottomRef.current) { + scrollRef.current?.scrollToEnd({ animated: false }); + } + }, []); + return ( {text} @@ -506,6 +531,42 @@ function ScrollablePlainTextSection({ text, ds }: { text: string; ds: DetailStyl ); } +function ScrollableMarkdownSection({ text, ds }: { text: string; ds: DetailStyles }) { + const scrollRef = useRef(null); + const isNearBottomRef = useRef(true); + + const handleScroll = useCallback((event: NativeSyntheticEvent) => { + const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent; + const paddingToBottom = 32; + const isBottom = + layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom; + isNearBottomRef.current = isBottom; + }, []); + + const handleContentSizeChange = useCallback(() => { + if (isNearBottomRef.current) { + scrollRef.current?.scrollToEnd({ animated: false }); + } + }, []); + + return ( + + + + + + ); +} + interface SearchDetail { query?: string; content?: string; @@ -582,7 +643,13 @@ function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles, t: TFunct typeof detail.input === "string" && detail.output === null ? detail.input : null; if (plainInputText !== null) { - return []; + return [ + , + ]; } const sectionsFromTopLevel = [ diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index 87fdd300f1..fa34d4a8d8 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -385,6 +385,50 @@ describe("appearance settings", () => { expect(result.toolCallDetailLevel).toBe("detailed"); }); + it("loads thinking display detail enum values or migrates legacy boolean values", async () => { + const depsTrue = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ autoExpandReasoning: true }), + }), + }); + expect((await loadAppSettingsFromStorage(depsTrue)).autoExpandReasoning).toBe("expanded"); + + const depsFalse = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ autoExpandReasoning: false }), + }), + }); + expect((await loadAppSettingsFromStorage(depsFalse)).autoExpandReasoning).toBe("collapsed"); + + const depsActive = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ autoExpandReasoning: "expand_active" }), + }), + }); + expect((await loadAppSettingsFromStorage(depsActive)).autoExpandReasoning).toBe("expand_last"); + + const depsLast = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ autoExpandReasoning: "expand_last" }), + }), + }); + expect((await loadAppSettingsFromStorage(depsLast)).autoExpandReasoning).toBe("expand_last"); + + const depsExpanded = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ autoExpandReasoning: "expanded" }), + }), + }); + expect((await loadAppSettingsFromStorage(depsExpanded)).autoExpandReasoning).toBe("expanded"); + + const depsInvalid = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ autoExpandReasoning: "invalid" }), + }), + }); + expect((await loadAppSettingsFromStorage(depsInvalid)).autoExpandReasoning).toBe("collapsed"); + }); + it("migrates the enabled compact tool call preference to overview", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index 0b7a733de0..30c09b074b 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -27,6 +27,7 @@ export type ServiceUrlBehavior = "ask" | "in-app" | "external"; export type WorkspaceTitleSource = "title" | "branch"; /** What a sidebar workspace row shows in the space to the right of its title. */ export type SidebarWorkspaceTrailing = "diff" | "timestamp" | "none"; +export type ThinkingDisplayDetail = "collapsed" | "expand_last" | "expanded"; export type ToolCallDetailLevel = "overview" | "detailed"; const VALID_THEMES = new Set(THEME_OPTIONS.map((option) => option.name)); @@ -39,6 +40,11 @@ const VALID_SIDEBAR_WORKSPACE_TRAILINGS = new Set([ "none", ]); const VALID_TOOL_CALL_DETAIL_LEVELS = new Set(["overview", "detailed"]); +const VALID_THINKING_DISPLAY_DETAILS = new Set([ + "collapsed", + "expand_last", + "expanded", +]); export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000; export const MIN_TERMINAL_SCROLLBACK_LINES = 0; export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000; @@ -66,7 +72,7 @@ export interface AppSettings { sidebarWorkspaceTrailing: SidebarWorkspaceTrailing; sidebarRowItems: SidebarRowItems; sidebarChecksDisplay: SidebarChecksDisplay; - autoExpandReasoning: boolean; + autoExpandReasoning: ThinkingDisplayDetail; toolCallDetailLevel: ToolCallDetailLevel; chatOutlineEnabled: boolean; vimKeybindings: boolean; @@ -103,7 +109,9 @@ const StoredAppSettingsSchema = z.strictObject({ sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).optional(), sidebarRowItems: SidebarRowItemsSchema.optional(), sidebarChecksDisplay: z.enum(["iconAndText", "icon", "none"]).optional(), - autoExpandReasoning: z.boolean().optional(), + autoExpandReasoning: z + .union([z.enum(["collapsed", "expand_last", "expand_active", "expanded"]), z.boolean()]) + .optional(), toolCallDetailLevel: z.enum(["overview", "detailed"]).optional(), compactToolCalls: z.boolean().optional(), chatOutlineEnabled: z.boolean().optional(), @@ -133,7 +141,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = { sidebarWorkspaceTrailing: "diff", sidebarRowItems: DEFAULT_SIDEBAR_ROW_ITEMS, sidebarChecksDisplay: DEFAULT_SIDEBAR_CHECKS_DISPLAY, - autoExpandReasoning: false, + autoExpandReasoning: "collapsed", toolCallDetailLevel: "detailed", chatOutlineEnabled: true, vimKeybindings: false, @@ -242,6 +250,25 @@ export function normalizeAppSettings(value: unknown): AppSettings { }; } +function parseThinkingDisplayDetail(stored: StoredAppSettings): ThinkingDisplayDetail | null { + if (stored.autoExpandReasoning !== undefined) { + if (stored.autoExpandReasoning === "expand_active") { + // COMPAT(autoExpandReasoningExpandActive): migrated to expand_last in v0.4.0. + return "expand_last"; + } + if ( + typeof stored.autoExpandReasoning === "string" && + VALID_THINKING_DISPLAY_DETAILS.has(stored.autoExpandReasoning as ThinkingDisplayDetail) + ) { + return stored.autoExpandReasoning as ThinkingDisplayDetail; + } + if (typeof stored.autoExpandReasoning === "boolean") { + return stored.autoExpandReasoning ? "expanded" : "collapsed"; + } + } + return null; +} + function parseToolCallDetailLevel(stored: StoredAppSettings): ToolCallDetailLevel | null { if (stored.toolCallDetailLevel !== undefined) { if ( @@ -362,8 +389,9 @@ function pickAppSettings(stored: StoredAppSettings): Partial { result.codeFontSize = codeFontSize; } Object.assign(result, pickBooleanAppSettings(stored)); - if (typeof stored.autoExpandReasoning === "boolean") { - result.autoExpandReasoning = stored.autoExpandReasoning; + const thinkingDisplayDetail = parseThinkingDisplayDetail(stored); + if (thinkingDisplayDetail !== null) { + result.autoExpandReasoning = thinkingDisplayDetail; } const toolCallDetailLevel = parseToolCallDetailLevel(stored); if (toolCallDetailLevel !== null) { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 9236429686..eeb163a45c 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1845,8 +1845,14 @@ export const ar: TranslationResources = { accessibilityLabel: "خطوط التمرير Terminal", }, autoExpandReasoning: { - label: "عرض التفكير دائماً", - description: "إظهار تفكير الوكيل وخطوات الاستدلال بشكل كامل بشكل افتراضي", + label: "عرض التفكير", + description: "كيفية ظهور كتل التفكير في المخطط الزمني", + accessibilityLabel: "تحديد عرض التفكير ({{value}})", + options: { + collapsed: "مطوي", + expandLast: "توسيع الأخير", + expanded: "توسيع دائماً", + }, }, toolCallDetail: { label: "عرض استدعاءات الأدوات", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index c12ab440de..3440e70a09 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1900,8 +1900,14 @@ export const en = { accessibilityLabel: "Terminal scrollback lines", }, autoExpandReasoning: { - label: "Always expand reasoning", - description: "Show agent thinking and chain-of-thought blocks fully expanded by default", + label: "Thinking display", + description: "How agent thinking and chain-of-thought blocks appear in the timeline", + accessibilityLabel: "Select thinking display ({{value}})", + options: { + collapsed: "Collapsed", + expandLast: "Expand Last", + expanded: "Always expand", + }, }, toolCallDetail: { label: "Tool call display", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index f28516005a..d4afc44a9e 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1891,9 +1891,14 @@ export const es: TranslationResources = { accessibilityLabel: "Líneas del historial de terminal", }, autoExpandReasoning: { - label: "Siempre expandir razonamiento", - description: - "Mostrar los bloques de pensamiento y razonamiento del agente totalmente expandidos de forma predeterminada", + label: "Visualización de pensamiento", + description: "Cómo aparecen los bloques de pensamiento del agente en la cronología", + accessibilityLabel: "Seleccionar visualización de pensamiento ({{value}})", + options: { + collapsed: "Plegado", + expandLast: "Expandir último", + expanded: "Expandir siempre", + }, }, toolCallDetail: { label: "Visualización de llamadas a herramientas", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 0bda488b2a..d4ae5129ac 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1894,8 +1894,14 @@ export const fr: TranslationResources = { accessibilityLabel: "Lignes de défilementTerminal", }, autoExpandReasoning: { - label: "Toujours afficher le raisonnement", - description: "Afficher le raisonnement de l'agent entièrement développé par défaut", + label: "Affichage de la réflexion", + description: "Comment les blocs de réflexion apparaissent dans la chronologie", + accessibilityLabel: "Sélectionner l'affichage de la réflexion ({{value}})", + options: { + collapsed: "Réduit", + expandLast: "Développer le dernier", + expanded: "Toujours développer", + }, }, toolCallDetail: { label: "Affichage des appels d’outils", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index c20a905be4..cc75a7d753 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1860,8 +1860,14 @@ export const ja: TranslationResources = { accessibilityLabel: "ターミナルスクロールバック行数", }, autoExpandReasoning: { - label: "常に思考プロセスを展開", - description: "デフォルトでAIのエージェント思考・推論ブロックを完全に展開して表示します", + label: "思考プロセスの表示", + description: "タイムラインでの思考ブロックの表示方法", + accessibilityLabel: "思考プロセスの表示を選択 ({{value}})", + options: { + collapsed: "折りたたむ", + expandLast: "最新のみ展開", + expanded: "常に展開", + }, }, toolCallDetail: { label: "ツール呼び出しの表示", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 99f4278337..837715e930 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1856,8 +1856,14 @@ export const ko: TranslationResources = { accessibilityLabel: "터미널 스크롤백 줄 수", }, autoExpandReasoning: { - label: "추론 항상 펼치기", - description: "에이전트의 사고 및 추론 블록을 기본적으로 모두 펼쳐 표시합니다.", + label: "사고 과정 표시", + description: "타임라인에 에이전트 사고 블록이 표시되는 방식", + accessibilityLabel: "사고 과정 표시 선택({{value}})", + options: { + collapsed: "접음", + expandLast: "마지막 항목만 펼치기", + expanded: "항상 펼치기", + }, }, toolCallDetail: { label: "도구 호출 표시", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index f0a09e4d81..5900d76d73 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1875,9 +1875,14 @@ export const ptBR: TranslationResources = { accessibilityLabel: "Linhas do scrollback do terminal", }, autoExpandReasoning: { - label: "Sempre expandir raciocínio", - description: - "Mostrar os blocos de pensamento e raciocínio do agente totalmente expandidos por padrão", + label: "Exibição de pensamento", + description: "Como os blocos de pensamento aparecem na linha do tempo", + accessibilityLabel: "Selecionar exibição de pensamento ({{value}})", + options: { + collapsed: "Recolhido", + expandLast: "Expandir último", + expanded: "Sempre expandir", + }, }, toolCallDetail: { label: "Exibição de chamadas de ferramentas", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 430a1448c3..10cda60ae4 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1879,9 +1879,14 @@ export const ru: TranslationResources = { accessibilityLabel: "Линии прокрутки Terminal", }, autoExpandReasoning: { - label: "Всегда разворачивать размышления", - description: - "По умолчанию показывать блоки размышлений и логики агента полностью развернутыми", + label: "Отображение мышления", + description: "Как блоки рассуждений агента отображаются в таймлайне", + accessibilityLabel: "Выбор отображения мышления ({{value}})", + options: { + collapsed: "Свернуто", + expandLast: "Разворачивать последний", + expanded: "Всегда разворачивать", + }, }, toolCallDetail: { label: "Отображение вызовов инструментов", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 333fd8a0c1..ea6a5ab1e4 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1823,8 +1823,14 @@ export const zhCN: TranslationResources = { accessibilityLabel: "终端回滚行数", }, autoExpandReasoning: { - label: "始终展开推理过程", - description: "默认情况下完全展开 AI 的思考和推理过程", + label: "思考过程显示", + description: "智能体思考过程在时间线中的显示方式", + accessibilityLabel: "选择思考过程显示方式({{value}})", + options: { + collapsed: "折叠", + expandLast: "仅展开最新", + expanded: "始终展开", + }, }, toolCallDetail: { label: "工具调用显示", diff --git a/packages/app/src/screens/settings/appearance/appearance-section.tsx b/packages/app/src/screens/settings/appearance/appearance-section.tsx index 8dfda23538..114558c7b5 100644 --- a/packages/app/src/screens/settings/appearance/appearance-section.tsx +++ b/packages/app/src/screens/settings/appearance/appearance-section.tsx @@ -172,13 +172,44 @@ function ThemeRow({ value, onChange }: ThemeRowProps) { ); } +const THINKING_DISPLAY_DETAILS: readonly AppSettings["autoExpandReasoning"][] = [ + "collapsed", + "expand_last", + "expanded", +]; + +function getThinkingDisplayDetailLabel( + t: TFunction, + value: AppSettings["autoExpandReasoning"], +): string { + const optionKey = value === "expand_last" ? "expandLast" : value; + return t(`settings.general.autoExpandReasoning.options.${optionKey}`); +} + +interface ThinkingDisplayMenuItemProps { + value: AppSettings["autoExpandReasoning"]; + selected: boolean; + onChange: (value: AppSettings["autoExpandReasoning"]) => void; +} + +function ThinkingDisplayMenuItem({ value, selected, onChange }: ThinkingDisplayMenuItemProps) { + const { t } = useTranslation(); + const handleSelect = useCallback(() => onChange(value), [onChange, value]); + return ( + + {getThinkingDisplayDetailLabel(t, value)} + + ); +} + interface AutoExpandReasoningRowProps { - value: boolean; - onChange: (value: boolean) => void; + value: AppSettings["autoExpandReasoning"]; + onChange: (value: AppSettings["autoExpandReasoning"]) => void; } function AutoExpandReasoningRow({ value, onChange }: AutoExpandReasoningRowProps) { const { t } = useTranslation(); + const selectedLabel = getThinkingDisplayDetailLabel(t, value); return ( @@ -189,7 +220,27 @@ function AutoExpandReasoningRow({ value, onChange }: AutoExpandReasoningRowProps {t("settings.general.autoExpandReasoning.description")} - + + + {selectedLabel} + + + + {THINKING_DISPLAY_DETAILS.map((option) => ( + + ))} + + ); } @@ -496,7 +547,7 @@ export function AppearanceSection() { ); const handleAutoExpandReasoningChange = useCallback( - (autoExpandReasoning: boolean) => { + (autoExpandReasoning: AppSettings["autoExpandReasoning"]) => { void updateSettings({ autoExpandReasoning }); }, [updateSettings], diff --git a/packages/app/src/styles/markdown-styles.test.ts b/packages/app/src/styles/markdown-styles.test.ts index 44ae3791f2..3b4e976569 100644 --- a/packages/app/src/styles/markdown-styles.test.ts +++ b/packages/app/src/styles/markdown-styles.test.ts @@ -68,6 +68,9 @@ describe("createMarkdownStyles", () => { expect(styles.ordered_list_icon).toMatchObject({ userSelect: "text", }); + expect(styles.strong).toMatchObject({ + fontWeight: darkTheme.fontWeight.bold, + }); }); it("uses the mono font-size token directly for inline and block code", () => { diff --git a/packages/app/src/styles/markdown-styles.ts b/packages/app/src/styles/markdown-styles.ts index d7f78fb2ae..089b634004 100644 --- a/packages/app/src/styles/markdown-styles.ts +++ b/packages/app/src/styles/markdown-styles.ts @@ -126,7 +126,7 @@ export function createMarkdownStyles(theme: Theme) { strong: { ...webSelectableTextStyle, - fontWeight: theme.fontWeight.medium, + fontWeight: theme.fontWeight.bold, }, em: { diff --git a/packages/app/src/utils/thinking-text-formatter.test.ts b/packages/app/src/utils/thinking-text-formatter.test.ts new file mode 100644 index 0000000000..d24c481f37 --- /dev/null +++ b/packages/app/src/utils/thinking-text-formatter.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { formatThinkingText } from "./thinking-text-formatter"; + +describe("formatThinkingText", () => { + it("handles empty or invalid inputs", () => { + expect(formatThinkingText("")).toBe(""); + expect(formatThinkingText(null as unknown as string)).toBe(""); + expect(formatThinkingText(undefined as unknown as string)).toBe(""); + }); + + it("separates adjacent bold titles without newlines", () => { + expect(formatThinkingText("**title****title2**")).toBe("**title**\n\n**title2**"); + expect(formatThinkingText("**title1****title2****title3**")).toBe( + "**title1**\n\n**title2**\n\n**title3**", + ); + }); + + it("separates adjacent bold titles separated only by spaces", () => { + expect(formatThinkingText("**title1** **title2**")).toBe("**title1**\n\n**title2**"); + expect(formatThinkingText("**title1** **title2**")).toBe("**title1**\n\n**title2**"); + }); + + it("separates numbered headers", () => { + expect(formatThinkingText("**1. Analyze goal****2. Execute plan**")).toBe( + "**1. Analyze goal**\n\n**2. Execute plan**", + ); + }); + + it("handles in-progress streaming where second bold tag is opened", () => { + expect(formatThinkingText("**title1****title2")).toBe("**title1**\n\n**title2"); + expect(formatThinkingText("**title1****")).toBe("**title1**\n\n**"); + }); + + it("preserves regular markdown bold within prose without altering intended formatting", () => { + const prose = "This function uses **foo** and **bar** as arguments."; + expect(formatThinkingText(prose)).toBe(prose); + + const uppercaseProse = "I need to inspect **AgentStreamView** before editing it."; + expect(formatThinkingText(uppercaseProse)).toBe(uppercaseProse); + + const afterPunctuation = + 'I looked at config.json, which had key "version". **Note** that this is deprecated.'; + expect(formatThinkingText(afterPunctuation)).toBe(afterPunctuation); + }); + + it("preserves fenced code blocks and inline code completely intact", () => { + const codeWithBold = "```python\ndef foo():\n\n\n # check **a****b**\n```"; + expect(formatThinkingText(codeWithBold)).toBe(codeWithBold); + + const inlineCode = "Use `**bold**` inside inline code."; + expect(formatThinkingText(inlineCode)).toBe(inlineCode); + + const streamingCodeBlock = "```typescript\nconst x = **test**;\n"; + expect(formatThinkingText(streamingCodeBlock)).toBe(streamingCodeBlock); + }); + + it("preserves already well-spaced thinking blocks without adding excess newlines", () => { + const wellSpaced = "**Title 1**\n\nSome thought text.\n\n**Title 2**\n\nMore thought text."; + expect(formatThinkingText(wellSpaced)).toBe(wellSpaced); + }); +}); diff --git a/packages/app/src/utils/thinking-text-formatter.ts b/packages/app/src/utils/thinking-text-formatter.ts new file mode 100644 index 0000000000..6f95d164da --- /dev/null +++ b/packages/app/src/utils/thinking-text-formatter.ts @@ -0,0 +1,26 @@ +/** + * Normalizes thinking and reasoning text emitted by models (e.g. Codex, OpenAI reasoning models) + * where bold headers (like **title**) may be streamed without separating newlines, + * producing jammed headers like `**title1****title2**`. + */ +export function formatThinkingText(text: string): string { + if (!text || typeof text !== "string") { + return ""; + } + + // Preserve code blocks (including in-progress streaming code blocks) and inline code spans + const parts = text.split(/(```[\s\S]*?(?:```|$)|`[^`\n]+`)/g); + + return parts + .map((part, index) => { + // Odd indices are code blocks or inline code spans - leave them completely intact + if (index % 2 === 1) { + return part; + } + + // Separate adjacent bold blocks (e.g. **title1****title2** or **title1** **title2**) + // Also handles streaming when the second bold tag is opened: **title1****streaming... + return part.replace(/(\*\*[^*\s\n](?:[^*\n]*?[^*\s\n])?\*\*)\s*(?=\*\*)/g, "$1\n\n"); + }) + .join(""); +}