From 39a3b595f7827983083d5461ec315ce63e59471d Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Fri, 14 Aug 2026 15:53:26 +0000 Subject: [PATCH 1/5] feat(app): add expand-active thinking display option and format markdown --- packages/app/src/agent-stream/view.tsx | 11 +++- packages/app/src/components/message.tsx | 5 ++ .../app/src/components/tool-call-details.tsx | 25 +++++++- .../src/hooks/use-settings/storage.test.ts | 39 ++++++++++++ .../app/src/hooks/use-settings/storage.ts | 34 +++++++++-- packages/app/src/i18n/resources/ar.ts | 10 ++- packages/app/src/i18n/resources/en.ts | 10 ++- packages/app/src/i18n/resources/es.ts | 11 +++- packages/app/src/i18n/resources/fr.ts | 10 ++- packages/app/src/i18n/resources/ja.ts | 10 ++- packages/app/src/i18n/resources/ko.ts | 10 ++- packages/app/src/i18n/resources/pt-BR.ts | 11 +++- packages/app/src/i18n/resources/ru.ts | 11 +++- packages/app/src/i18n/resources/zh-CN.ts | 10 ++- .../appearance/appearance-section.tsx | 59 ++++++++++++++++-- .../src/utils/thinking-text-formatter.test.ts | 61 +++++++++++++++++++ .../app/src/utils/thinking-text-formatter.ts | 35 +++++++++++ 17 files changed, 328 insertions(+), 34 deletions(-) create mode 100644 packages/app/src/utils/thinking-text-formatter.test.ts create mode 100644 packages/app/src/utils/thinking-text-formatter.ts diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 58174eae19..f40d89d4a4 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"; @@ -663,16 +664,20 @@ const AgentStreamViewComponent = forwardRef) => { + const isThoughtActive = item.status !== "ready"; + const isExpanded = + autoExpandReasoning === "expanded" || + (autoExpandReasoning === "expand_active" && isThoughtActive); return ( ); }, 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..e61dd239fb 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -21,6 +21,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; @@ -506,6 +508,21 @@ function ScrollablePlainTextSection({ text, ds }: { text: string; ds: DetailStyl ); } +function ScrollableMarkdownSection({ text, ds }: { text: string; ds: DetailStyles }) { + return ( + + + + + + ); +} + interface SearchDetail { query?: string; content?: string; @@ -582,7 +599,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..093935e5b7 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -385,6 +385,45 @@ 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_active", + ); + + 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..64a410e708 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_active" | "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_active", + "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_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,21 @@ export function normalizeAppSettings(value: unknown): AppSettings { }; } +function parseThinkingDisplayDetail(stored: StoredAppSettings): ThinkingDisplayDetail | null { + if (stored.autoExpandReasoning !== undefined) { + 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 +385,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 0247a2ba44..b9c223707d 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1841,8 +1841,14 @@ export const ar: TranslationResources = { accessibilityLabel: "خطوط التمرير Terminal", }, autoExpandReasoning: { - label: "عرض التفكير دائماً", - description: "إظهار تفكير الوكيل وخطوات الاستدلال بشكل كامل بشكل افتراضي", + label: "عرض التفكير", + description: "كيفية ظهور كتل التفكير في المخطط الزمني", + accessibilityLabel: "تحديد عرض التفكير ({{value}})", + options: { + collapsed: "مطوي", + expandActive: "توسيع النشط", + expanded: "توسيع دائماً", + }, }, toolCallDetail: { label: "عرض استدعاءات الأدوات", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index b655cd3970..7bf7ab8fff 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1851,8 +1851,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", + expandActive: "Expand Active", + 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 911c3977fd..11cdee06aa 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1887,9 +1887,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", + expandActive: "Expandir activo", + 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 ac39cbd044..56ac506e6c 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1890,8 +1890,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", + expandActive: "Développer si actif", + 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 915bb6aaf0..9025c60dd1 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1856,8 +1856,14 @@ export const ja: TranslationResources = { accessibilityLabel: "ターミナルスクロールバック行数", }, autoExpandReasoning: { - label: "常に思考プロセスを展開", - description: "デフォルトでAIのエージェント思考・推論ブロックを完全に展開して表示します", + label: "思考プロセスの表示", + description: "タイムラインでの思考ブロックの表示方法", + accessibilityLabel: "思考プロセスの表示を選択 ({{value}})", + options: { + collapsed: "折りたたむ", + expandActive: "実行中のみ展開", + expanded: "常に展開", + }, }, toolCallDetail: { label: "ツール呼び出しの表示", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 17bb85dbbb..edcd8aa16f 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1852,8 +1852,14 @@ export const ko: TranslationResources = { accessibilityLabel: "터미널 스크롤백 줄 수", }, autoExpandReasoning: { - label: "추론 항상 펼치기", - description: "에이전트의 사고 및 추론 블록을 기본적으로 모두 펼쳐 표시합니다.", + label: "사고 과정 표시", + description: "타임라인에 에이전트 사고 블록이 표시되는 방식", + accessibilityLabel: "사고 과정 표시 선택({{value}})", + options: { + collapsed: "접음", + expandActive: "실행 중일 때만 펼치기", + expanded: "항상 펼치기", + }, }, toolCallDetail: { label: "도구 호출 표시", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index fea908d4b1..ebdece6aef 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1871,9 +1871,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", + expandActive: "Expandir ativo", + 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 3305774cba..6ad8f0be17 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1875,9 +1875,14 @@ export const ru: TranslationResources = { accessibilityLabel: "Линии прокрутки Terminal", }, autoExpandReasoning: { - label: "Всегда разворачивать размышления", - description: - "По умолчанию показывать блоки размышлений и логики агента полностью развернутыми", + label: "Отображение мышления", + description: "Как блоки рассуждений агента отображаются в таймлайне", + accessibilityLabel: "Выбор отображения мышления ({{value}})", + options: { + collapsed: "Свернуто", + expandActive: "Разворачивать активный", + expanded: "Всегда разворачивать", + }, }, toolCallDetail: { label: "Отображение вызовов инструментов", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index bc02aa5bf9..dabfba4942 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1819,8 +1819,14 @@ export const zhCN: TranslationResources = { accessibilityLabel: "终端回滚行数", }, autoExpandReasoning: { - label: "始终展开推理过程", - description: "默认情况下完全展开 AI 的思考和推理过程", + label: "思考过程显示", + description: "智能体思考过程在时间线中的显示方式", + accessibilityLabel: "选择思考过程显示方式({{value}})", + options: { + collapsed: "折叠", + expandActive: "仅展开当前活动", + 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..9fb996d36d 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_active", + "expanded", +]; + +function getThinkingDisplayDetailLabel( + t: TFunction, + value: AppSettings["autoExpandReasoning"], +): string { + const optionKey = value === "expand_active" ? "expandActive" : 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/utils/thinking-text-formatter.test.ts b/packages/app/src/utils/thinking-text-formatter.test.ts new file mode 100644 index 0000000000..ef5c59e405 --- /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 bold headers preceded by sentence punctuation without a newline", () => { + expect(formatThinkingText("I am thinking about this.**Next step**")).toBe( + "I am thinking about this.\n\n**Next step**", + ); + expect(formatThinkingText("Let's see. **Finding files**")).toBe( + "Let's see.\n\n**Finding files**", + ); + }); + + it("separates numbered headers", () => { + expect(formatThinkingText("**1. Analyze goal****2. Execute plan**")).toBe( + "**1. Analyze goal**\n\n**2. Execute plan**", + ); + }); + + it("separates bold header from immediately following text", () => { + expect(formatThinkingText("**Searching codebase**I will look for files.")).toBe( + "**Searching codebase**\n\nI will look for files.", + ); + }); + + 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); + }); + + 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..32676c2119 --- /dev/null +++ b/packages/app/src/utils/thinking-text-formatter.ts @@ -0,0 +1,35 @@ +/** + * 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**` or `text.**title2**`. + */ +export function formatThinkingText(text: string): string { + if (!text || typeof text !== "string") { + return ""; + } + + let formatted = text; + + // 1. Separate adjacent bold blocks (e.g. **title1****title2** -> **title1**\n\n**title2**) + // Also handles streaming when the second bold tag is opened: **title1****streaming... + formatted = formatted.replace(/(\*\*[^*\s\n](?:[^*\n]*?[^*\s\n])?\*\*)\s*(?=\*\*)/g, "$1\n\n"); + + // 2. Separate bold header from preceding sentence punctuation when not preceded by newline + // E.g. "Some reasoning.**Next step**" -> "Some reasoning.\n\n**Next step**" + formatted = formatted.replace( + /([.!?:])\s*(\*\*(?:[A-Z0-9#_]|(?:\d+\.))[^*\n]*?[^*\s\n]\*\*)/g, + "$1\n\n$2", + ); + + // 3. Separate bold header from immediately following text when attached without space/newline + // E.g. "**Header**Let's check" -> "**Header**\n\nLet's check" + formatted = formatted.replace( + /(\*\*[^*\s\n](?:[^*\n]*?[^*\s\n])?\*\*)(?=[A-Za-z0-9])/g, + "$1\n\n", + ); + + // 4. Collapse 3+ consecutive newlines to at most 2 newlines + formatted = formatted.replace(/\n{3,}/g, "\n\n"); + + return formatted; +} From a815141727ff89653e62334ff5226af8c00484dc Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Fri, 14 Aug 2026 16:21:31 +0000 Subject: [PATCH 2/5] feat(app): expand last thinking block instead of active-only --- packages/app/src/agent-stream/view.tsx | 22 ++++++++++++++++--- .../src/hooks/use-settings/storage.test.ts | 11 +++++++--- .../app/src/hooks/use-settings/storage.ts | 10 ++++++--- packages/app/src/i18n/resources/ar.ts | 2 +- packages/app/src/i18n/resources/en.ts | 2 +- packages/app/src/i18n/resources/es.ts | 2 +- packages/app/src/i18n/resources/fr.ts | 2 +- packages/app/src/i18n/resources/ja.ts | 2 +- packages/app/src/i18n/resources/ko.ts | 2 +- packages/app/src/i18n/resources/pt-BR.ts | 2 +- packages/app/src/i18n/resources/ru.ts | 2 +- packages/app/src/i18n/resources/zh-CN.ts | 2 +- .../appearance/appearance-section.tsx | 4 ++-- 13 files changed, 45 insertions(+), 20 deletions(-) diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index f40d89d4a4..d5e5e5d479 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -662,12 +662,28 @@ 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 isThoughtActive = item.status !== "ready"; + const isLastThought = item.id === latestThoughtId; const isExpanded = autoExpandReasoning === "expanded" || - (autoExpandReasoning === "expand_active" && isThoughtActive); + (autoExpandReasoning === "expand_last" && isLastThought); return ( ); }, - [autoExpandReasoning, setInlineDetailsExpanded], + [autoExpandReasoning, latestThoughtId, setInlineDetailsExpanded], ); const renderSingleToolCallItem = useCallback( diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index 093935e5b7..fa34d4a8d8 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -405,9 +405,14 @@ describe("appearance settings", () => { [APP_SETTINGS_KEY]: JSON.stringify({ autoExpandReasoning: "expand_active" }), }), }); - expect((await loadAppSettingsFromStorage(depsActive)).autoExpandReasoning).toBe( - "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({ diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index 64a410e708..30c09b074b 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -27,7 +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_active" | "expanded"; +export type ThinkingDisplayDetail = "collapsed" | "expand_last" | "expanded"; export type ToolCallDetailLevel = "overview" | "detailed"; const VALID_THEMES = new Set(THEME_OPTIONS.map((option) => option.name)); @@ -42,7 +42,7 @@ const VALID_SIDEBAR_WORKSPACE_TRAILINGS = new Set([ const VALID_TOOL_CALL_DETAIL_LEVELS = new Set(["overview", "detailed"]); const VALID_THINKING_DISPLAY_DETAILS = new Set([ "collapsed", - "expand_active", + "expand_last", "expanded", ]); export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000; @@ -110,7 +110,7 @@ const StoredAppSettingsSchema = z.strictObject({ sidebarRowItems: SidebarRowItemsSchema.optional(), sidebarChecksDisplay: z.enum(["iconAndText", "icon", "none"]).optional(), autoExpandReasoning: z - .union([z.enum(["collapsed", "expand_active", "expanded"]), z.boolean()]) + .union([z.enum(["collapsed", "expand_last", "expand_active", "expanded"]), z.boolean()]) .optional(), toolCallDetailLevel: z.enum(["overview", "detailed"]).optional(), compactToolCalls: z.boolean().optional(), @@ -252,6 +252,10 @@ 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) diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index b9c223707d..6a3fd6af2f 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1846,7 +1846,7 @@ export const ar: TranslationResources = { accessibilityLabel: "تحديد عرض التفكير ({{value}})", options: { collapsed: "مطوي", - expandActive: "توسيع النشط", + expandLast: "توسيع الأخير", expanded: "توسيع دائماً", }, }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 7bf7ab8fff..2477054078 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1856,7 +1856,7 @@ export const en = { accessibilityLabel: "Select thinking display ({{value}})", options: { collapsed: "Collapsed", - expandActive: "Expand Active", + expandLast: "Expand Last", expanded: "Always expand", }, }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 11cdee06aa..341206d127 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1892,7 +1892,7 @@ export const es: TranslationResources = { accessibilityLabel: "Seleccionar visualización de pensamiento ({{value}})", options: { collapsed: "Plegado", - expandActive: "Expandir activo", + expandLast: "Expandir último", expanded: "Expandir siempre", }, }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 56ac506e6c..f4701be103 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1895,7 +1895,7 @@ export const fr: TranslationResources = { accessibilityLabel: "Sélectionner l'affichage de la réflexion ({{value}})", options: { collapsed: "Réduit", - expandActive: "Développer si actif", + expandLast: "Développer le dernier", expanded: "Toujours développer", }, }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 9025c60dd1..519837adf4 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1861,7 +1861,7 @@ export const ja: TranslationResources = { accessibilityLabel: "思考プロセスの表示を選択 ({{value}})", options: { collapsed: "折りたたむ", - expandActive: "実行中のみ展開", + expandLast: "最新のみ展開", expanded: "常に展開", }, }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index edcd8aa16f..21528750bd 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1857,7 +1857,7 @@ export const ko: TranslationResources = { accessibilityLabel: "사고 과정 표시 선택({{value}})", options: { collapsed: "접음", - expandActive: "실행 중일 때만 펼치기", + expandLast: "마지막 항목만 펼치기", expanded: "항상 펼치기", }, }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index ebdece6aef..a2a1ced422 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1876,7 +1876,7 @@ export const ptBR: TranslationResources = { accessibilityLabel: "Selecionar exibição de pensamento ({{value}})", options: { collapsed: "Recolhido", - expandActive: "Expandir ativo", + expandLast: "Expandir último", expanded: "Sempre expandir", }, }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 6ad8f0be17..bcc24c2e77 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1880,7 +1880,7 @@ export const ru: TranslationResources = { accessibilityLabel: "Выбор отображения мышления ({{value}})", options: { collapsed: "Свернуто", - expandActive: "Разворачивать активный", + expandLast: "Разворачивать последний", expanded: "Всегда разворачивать", }, }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index dabfba4942..72fab8f101 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1824,7 +1824,7 @@ export const zhCN: TranslationResources = { accessibilityLabel: "选择思考过程显示方式({{value}})", options: { collapsed: "折叠", - expandActive: "仅展开当前活动", + expandLast: "仅展开最新", expanded: "始终展开", }, }, diff --git a/packages/app/src/screens/settings/appearance/appearance-section.tsx b/packages/app/src/screens/settings/appearance/appearance-section.tsx index 9fb996d36d..114558c7b5 100644 --- a/packages/app/src/screens/settings/appearance/appearance-section.tsx +++ b/packages/app/src/screens/settings/appearance/appearance-section.tsx @@ -174,7 +174,7 @@ function ThemeRow({ value, onChange }: ThemeRowProps) { const THINKING_DISPLAY_DETAILS: readonly AppSettings["autoExpandReasoning"][] = [ "collapsed", - "expand_active", + "expand_last", "expanded", ]; @@ -182,7 +182,7 @@ function getThinkingDisplayDetailLabel( t: TFunction, value: AppSettings["autoExpandReasoning"], ): string { - const optionKey = value === "expand_active" ? "expandActive" : value; + const optionKey = value === "expand_last" ? "expandLast" : value; return t(`settings.general.autoExpandReasoning.options.${optionKey}`); } From b61f47c06e48beea7cb42b895c606fc2970caf39 Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Fri, 14 Aug 2026 16:31:42 +0000 Subject: [PATCH 3/5] fix(app): use bold font weight for markdown strong elements --- packages/app/src/styles/markdown-styles.test.ts | 3 +++ packages/app/src/styles/markdown-styles.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) 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: { From a1fc517d3c001e07861ed76c0ff09864feb125a0 Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Fri, 14 Aug 2026 16:42:44 +0000 Subject: [PATCH 4/5] fix(app): pin scrollable thinking sections to bottom during streaming --- .../app/src/components/tool-call-details.tsx | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index e61dd239fb..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"; @@ -492,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} @@ -509,13 +532,34 @@ 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 ( From 46448b4418110e309f74c2e8d9a42c5f77d7b636 Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Fri, 14 Aug 2026 16:56:53 +0000 Subject: [PATCH 5/5] fix(app): preserve code blocks and prose bold spans in thinking formatter --- .../src/utils/thinking-text-formatter.test.ts | 30 +++++++-------- .../app/src/utils/thinking-text-formatter.ts | 37 +++++++------------ 2 files changed, 29 insertions(+), 38 deletions(-) diff --git a/packages/app/src/utils/thinking-text-formatter.test.ts b/packages/app/src/utils/thinking-text-formatter.test.ts index ef5c59e405..d24c481f37 100644 --- a/packages/app/src/utils/thinking-text-formatter.test.ts +++ b/packages/app/src/utils/thinking-text-formatter.test.ts @@ -20,27 +20,12 @@ describe("formatThinkingText", () => { expect(formatThinkingText("**title1** **title2**")).toBe("**title1**\n\n**title2**"); }); - it("separates bold headers preceded by sentence punctuation without a newline", () => { - expect(formatThinkingText("I am thinking about this.**Next step**")).toBe( - "I am thinking about this.\n\n**Next step**", - ); - expect(formatThinkingText("Let's see. **Finding files**")).toBe( - "Let's see.\n\n**Finding files**", - ); - }); - it("separates numbered headers", () => { expect(formatThinkingText("**1. Analyze goal****2. Execute plan**")).toBe( "**1. Analyze goal**\n\n**2. Execute plan**", ); }); - it("separates bold header from immediately following text", () => { - expect(formatThinkingText("**Searching codebase**I will look for files.")).toBe( - "**Searching codebase**\n\nI will look for files.", - ); - }); - 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**"); @@ -52,6 +37,21 @@ describe("formatThinkingText", () => { 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", () => { diff --git a/packages/app/src/utils/thinking-text-formatter.ts b/packages/app/src/utils/thinking-text-formatter.ts index 32676c2119..6f95d164da 100644 --- a/packages/app/src/utils/thinking-text-formatter.ts +++ b/packages/app/src/utils/thinking-text-formatter.ts @@ -1,35 +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**` or `text.**title2**`. + * producing jammed headers like `**title1****title2**`. */ export function formatThinkingText(text: string): string { if (!text || typeof text !== "string") { return ""; } - let formatted = text; + // Preserve code blocks (including in-progress streaming code blocks) and inline code spans + const parts = text.split(/(```[\s\S]*?(?:```|$)|`[^`\n]+`)/g); - // 1. Separate adjacent bold blocks (e.g. **title1****title2** -> **title1**\n\n**title2**) - // Also handles streaming when the second bold tag is opened: **title1****streaming... - formatted = formatted.replace(/(\*\*[^*\s\n](?:[^*\n]*?[^*\s\n])?\*\*)\s*(?=\*\*)/g, "$1\n\n"); + return parts + .map((part, index) => { + // Odd indices are code blocks or inline code spans - leave them completely intact + if (index % 2 === 1) { + return part; + } - // 2. Separate bold header from preceding sentence punctuation when not preceded by newline - // E.g. "Some reasoning.**Next step**" -> "Some reasoning.\n\n**Next step**" - formatted = formatted.replace( - /([.!?:])\s*(\*\*(?:[A-Z0-9#_]|(?:\d+\.))[^*\n]*?[^*\s\n]\*\*)/g, - "$1\n\n$2", - ); - - // 3. Separate bold header from immediately following text when attached without space/newline - // E.g. "**Header**Let's check" -> "**Header**\n\nLet's check" - formatted = formatted.replace( - /(\*\*[^*\s\n](?:[^*\n]*?[^*\s\n])?\*\*)(?=[A-Za-z0-9])/g, - "$1\n\n", - ); - - // 4. Collapse 3+ consecutive newlines to at most 2 newlines - formatted = formatted.replace(/\n{3,}/g, "\n\n"); - - return formatted; + // 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(""); }