diff --git a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md index 2efb7c7a..3946f014 100644 --- a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md @@ -85,6 +85,7 @@ Use this doc when you ask an agent to change tool/header/description behavior. - `read` and `skill` are **static navigation tools** and render via `StaticToolRow`. - Every other tool, including search/fetch, OpenCode built-ins, custom tools, plugins, and MCP tools, is **expandable** and renders through `ToolPart`. - The managed `openchamber` plugin tool uses the expandable path and hides its broad protocol input. The plugin supplies the selected action's human description as the native tool title; the UI renders that metadata without owning an action map. The full versioned result envelope renders through the same neutral JSON summary/tree/raw views as other tools, without a tool-specific output card. +- Selecting a JSON summary, tree, or raw view saves that mode in the persisted UI settings. New and refreshed JSON tool outputs read the saved mode across sessions; missing or invalid preferences use Summary. - `ToolPart` defers expanded content after a user toggle, preventing large tool input/output payloads from mounting during the initial chat render. - The rich tool diff preview lives in `ToolPartDiffPreview.tsx` and is lazy-loaded from `ToolPart`. It is the only tool-card piece that imports the `@pierre/diffs` + Shiki rendering stack, keeping that stack out of the eager chat startup graph. While its chunk loads (first rendered diff only) the plain-text patch from `PlainDiffFallback.tsx` renders as the Suspense fallback, mirroring the preview's error fallback. Patches over 256 KiB or 2,000 lines skip rich parsing and use a bounded plain-text preview; navigation keeps the original patch. `ToolPart` itself must not statically import `@pierre/diffs` runtime modules or `@/lib/shiki/appThemeRegistry`. - The `@pierre/diffs` stack is knowingly unprotected against the JS/TS `template-call` backtracking that OOM'd the renderer in openchamber/openchamber#2587. Our own markdown Shiki worker sanitizes every grammar it loads (`@/lib/shiki/sanitizeTemplateCallGrammar`), but the diff worker pool runs `preferredHighlighter: 'shiki-wasm'` (`DiffWorkerProvider.tsx`) and resolves its languages by id through `@pierre/diffs`' own registry — `langs` accepts `SupportedLanguages` strings only, so there is no seam to hand it a pre-sanitized `LanguageRegistration`. A pathological template literal inside a rendered diff can therefore still hang that pool's Oniguruma engine. The available levers are upstream (a `langs` overload accepting grammar objects) or switching that pool to the JS regex engine; neither is done. diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 64dad914..6cb0cdaa 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -72,6 +72,8 @@ import { getToolDescriptionFallback } from './toolRenderUtils'; import { ApplyPatchFileButtons } from './ApplyPatchFileButtons'; import { openApplyPatchFileInEditor } from './applyPatchEditorAction'; +type ToolJsonViewMode = 'summary' | 'formatted' | 'raw'; + const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-5 sm:!leading-6 tracking-normal'; const TOOL_ROW_TITLE_CLASS = cn('typography-meta font-medium', TOOL_ROW_TEXT_CLASS); const TOOL_ROW_DESCRIPTION_CLASS = cn('typography-meta', TOOL_ROW_TEXT_CLASS); @@ -651,28 +653,23 @@ const StreamingPlainTextOutput: React.FC<{ output: string }> = ({ output }) => { ); }; -const ToolScrollableTextOutput: React.FC<{ - output: string; - part: ToolPartType; - metadata: Record | undefined; - input: Record | undefined; - isStreaming?: boolean; -}> = ({ output, part, metadata, input, isStreaming = false }) => { +type JsonOutputResult = ReturnType; + +const JsonToolOutput: React.FC<{ + jsonResult: JsonOutputResult; + renderedOutput: string; +}> = ({ jsonResult, renderedOutput }) => { const { t } = useI18n(); - const renderedOutput = getToolOutputText(output, part, metadata); - const outputLanguage = getToolOutputLanguage(output, part, metadata, input); - const jsonResult = React.useMemo(() => tryParseJsonOutput(renderedOutput), [renderedOutput]); - const [jsonViewMode, setJsonViewMode] = React.useState<'summary' | 'formatted' | 'raw'>('summary'); + const jsonViewMode = useUIStore((state) => state.toolJsonViewMode); const [copiedJson, setCopiedJson] = React.useState(false); React.useEffect(() => { - setJsonViewMode('summary'); setCopiedJson(false); }, [renderedOutput]); - const handleJsonViewChange = React.useCallback((view: 'summary' | 'formatted' | 'raw', event: React.MouseEvent) => { + const handleJsonViewChange = React.useCallback((view: ToolJsonViewMode, event: React.MouseEvent) => { event.stopPropagation(); - setJsonViewMode(view); + useUIStore.getState().setToolJsonViewMode(view); }, []); const handleCopyOutput = React.useCallback(async (event: React.MouseEvent) => { @@ -688,6 +685,88 @@ const ToolScrollableTextOutput: React.FC<{ } }, [renderedOutput, t]); + return ( +
+
+ + + + +
+ {jsonViewMode === 'summary' ? ( + + ) : jsonViewMode === 'formatted' ? ( + + ) : ( +
+ +
+ )} +
+ ); +}; + +const ToolScrollableTextOutput: React.FC<{ + output: string; + part: ToolPartType; + metadata: Record | undefined; + input: Record | undefined; + isStreaming?: boolean; +}> = ({ output, part, metadata, input, isStreaming = false }) => { + const renderedOutput = getToolOutputText(output, part, metadata); + const outputLanguage = getToolOutputLanguage(output, part, metadata, input); + const jsonResult = React.useMemo(() => tryParseJsonOutput(renderedOutput), [renderedOutput]); + if (part.tool === 'bash' && isStreaming) { return (
@@ -697,75 +776,7 @@ const ToolScrollableTextOutput: React.FC<{ } if (jsonResult.isJson) { - return ( -
-
- - - - -
- {jsonViewMode === 'summary' ? ( - - ) : jsonViewMode === 'formatted' ? ( - - ) : ( -
- -
- )} -
- ); + return ; } return ( diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index ca616f6d..61b31ba5 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -748,6 +748,7 @@ export interface SettingsPayload { shortcutOverrides?: Record; diffLayoutPreference?: 'dynamic' | 'inline' | 'side-by-side'; gitChangesViewMode?: 'flat' | 'tree'; + toolJsonViewMode?: 'summary' | 'formatted' | 'raw'; directoryShowHidden?: boolean; filesViewShowGitignored?: boolean; openInAppId?: string; diff --git a/packages/ui/src/lib/appearanceAutoSave.ts b/packages/ui/src/lib/appearanceAutoSave.ts index 44fda25a..e2cfb83f 100644 --- a/packages/ui/src/lib/appearanceAutoSave.ts +++ b/packages/ui/src/lib/appearanceAutoSave.ts @@ -50,6 +50,7 @@ type AppearanceSlice = { mobileKeyboardMode: MobileKeyboardMode; diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side'; gitChangesViewMode: 'flat' | 'tree'; + toolJsonViewMode: 'summary' | 'formatted' | 'raw'; }; let initialized = false; @@ -101,6 +102,7 @@ export const startAppearanceAutoSave = (): void => { mobileKeyboardMode: useUIStore.getState().mobileKeyboardMode, diffLayoutPreference: useUIStore.getState().diffLayoutPreference, gitChangesViewMode: useUIStore.getState().gitChangesViewMode, + toolJsonViewMode: useUIStore.getState().toolJsonViewMode, }; useUIStore.subscribe((state) => { @@ -144,6 +146,7 @@ export const startAppearanceAutoSave = (): void => { mobileKeyboardMode: state.mobileKeyboardMode, diffLayoutPreference: state.diffLayoutPreference, gitChangesViewMode: state.gitChangesViewMode, + toolJsonViewMode: state.toolJsonViewMode, }; const diff: Partial = {}; @@ -267,6 +270,9 @@ export const startAppearanceAutoSave = (): void => { if (current.gitChangesViewMode !== previous.gitChangesViewMode) { diff.gitChangesViewMode = current.gitChangesViewMode; } + if (current.toolJsonViewMode !== previous.toolJsonViewMode) { + diff.toolJsonViewMode = current.toolJsonViewMode; + } previous = current; diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 3da4af7f..4dea9bef 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -203,6 +203,7 @@ export type DesktopSettings = { recentEfforts?: Record; diffLayoutPreference?: 'dynamic' | 'inline' | 'side-by-side'; gitChangesViewMode?: 'flat' | 'tree'; + toolJsonViewMode?: 'summary' | 'formatted' | 'raw'; directoryShowHidden?: boolean; filesViewShowGitignored?: boolean; diff --git a/packages/ui/src/lib/persistence.test.ts b/packages/ui/src/lib/persistence.test.ts index 0fa1adb0..e5fba633 100644 --- a/packages/ui/src/lib/persistence.test.ts +++ b/packages/ui/src/lib/persistence.test.ts @@ -401,6 +401,7 @@ describe('updateDesktopSettings', () => { showReasoningTraces: false, terminalShell: 'fish', favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-sonnet-4' }], + toolJsonViewMode: 'raw', followUpBehavior: 'steer', draftStarters: [{ type: 'command', name: 'runtime-a' }], draftStartersVisible: false, @@ -413,6 +414,7 @@ describe('updateDesktopSettings', () => { expect(useUIStore.getState().showReasoningTraces).toBe(false); expect(useUIStore.getState().terminalShell).toBe('fish'); expect(useUIStore.getState().favoriteModels).toHaveLength(1); + expect(useUIStore.getState().toolJsonViewMode).toBe('raw'); expect(useUIStore.getState().globalDraftStarters).toEqual([{ type: 'command', name: 'runtime-a' }]); expect(useUIStore.getState().draftStartersVisible).toBe(false); expect(useMessageQueueStore.getState().followUpBehavior).toBe('steer'); @@ -427,6 +429,7 @@ describe('updateDesktopSettings', () => { expect(useUIStore.getState().showReasoningTraces).toBe(true); expect(useUIStore.getState().terminalShell).toBe('auto'); expect(useUIStore.getState().favoriteModels).toEqual([]); + expect(useUIStore.getState().toolJsonViewMode).toBe('summary'); expect(useUIStore.getState().globalDraftStarters).toBeNull(); expect(useUIStore.getState().draftStartersVisible).toBe(true); expect(useMessageQueueStore.getState().followUpBehavior).toBe('queue'); @@ -445,6 +448,18 @@ describe('updateDesktopSettings', () => { expect(localStorage.getItem('selectedThemeId')).toBe('existing-theme'); }); + test('ignores an invalid JSON view mode in a settings save response', async () => { + getWindow(); + useUIStore.getState().setToolJsonViewMode('formatted'); + const invalidSettings: SettingsPayload = {}; + Object.defineProperty(invalidSettings, 'toolJsonViewMode', { value: 'invalid', enumerable: true }); + registerSettingsSave(async () => invalidSettings); + + await updateDesktopSettings({ showReasoningTraces: false }); + + expect(useUIStore.getState().toolJsonViewMode).toBe('formatted'); + }); + test('applies authoritative shared sidebar preferences without replacing local-only sidebar state', async () => { getWindow(); useSessionDisplayStore.setState({ @@ -756,7 +771,7 @@ describe('updateDesktopSettings', () => { } }); - test('autosaves terminal shell changes to shared settings', async () => { + test('autosaves appearance preferences to shared settings', async () => { getWindow(); useUIStore.getState().setTerminalShell('auto'); useUIStore.getState().setTerminalLoginShells([]); @@ -769,10 +784,12 @@ describe('updateDesktopSettings', () => { useUIStore.getState().setTerminalShell('zsh'); useUIStore.getState().setTerminalLoginShells(['zsh']); + useUIStore.getState().setToolJsonViewMode('formatted'); await delay(500); expect(saveCalls.some((changes) => changes.terminalShell === 'zsh')).toBe(true); expect(saveCalls.some((changes) => changes.terminalLoginShells?.includes('zsh'))).toBe(true); + expect(saveCalls.some((changes) => changes.toolJsonViewMode === 'formatted')).toBe(true); }); test('applies persisted autoSaveEnabled from server settings', async () => { diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index c5750464..352e24ea 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -619,6 +619,7 @@ const materializeAuthoritativeUiSettings = (settings: DesktopSettings): DesktopS recentEfforts: defaults.recentEfforts, diffLayoutPreference: defaults.diffLayoutPreference, gitChangesViewMode: defaults.gitChangesViewMode, + toolJsonViewMode: defaults.toolJsonViewMode, directoryShowHidden: true, filesViewShowGitignored: false, dictationEnabled: true, @@ -1044,6 +1045,15 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { store.setGitChangesViewMode(settings.gitChangesViewMode); } } + switch (settings.toolJsonViewMode) { + case 'summary': + case 'formatted': + case 'raw': + if (settings.toolJsonViewMode !== store.toolJsonViewMode) { + store.setToolJsonViewMode(settings.toolJsonViewMode); + } + break; + } if (typeof settings.directoryShowHidden === 'boolean') { setDirectoryShowHidden(settings.directoryShowHidden, { persist: false }); } @@ -1619,6 +1629,13 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { ) { result.gitChangesViewMode = candidate.gitChangesViewMode; } + switch (candidate.toolJsonViewMode) { + case 'summary': + case 'formatted': + case 'raw': + result.toolJsonViewMode = candidate.toolJsonViewMode; + break; + } if (typeof candidate.directoryShowHidden === 'boolean') { result.directoryShowHidden = candidate.directoryShowHidden; } diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 12a999f0..fe3aeeef 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -875,6 +875,7 @@ interface UIStore { /** Width of the walkthrough table of contents, in pixels. */ walkthroughTocWidth: number; gitChangesViewMode: 'flat' | 'tree'; + toolJsonViewMode: 'summary' | 'formatted' | 'raw'; linearIssueListStatus: LinearIssueListStatus; linearIssueListAssignee: LinearIssueListAssignee; /** @@ -1086,6 +1087,7 @@ interface UIStore { setDiffWrapLines: (wrap: boolean) => void; setWalkthroughTocWidth: (width: number) => void; setGitChangesViewMode: (mode: 'flat' | 'tree') => void; + setToolJsonViewMode: (mode: 'summary' | 'formatted' | 'raw') => void; setLinearIssueListStatus: (status: LinearIssueListStatus) => void; setLinearIssueListAssignee: (assignee: LinearIssueListAssignee) => void; setLinearIssueListTeamId: (teamId: string) => void; @@ -1251,6 +1253,7 @@ export const useUIStore = create()( diffWrapLines: false, walkthroughTocWidth: 224, gitChangesViewMode: 'flat', + toolJsonViewMode: 'summary', linearIssueListStatus: 'all', linearIssueListAssignee: 'any', linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS, @@ -2197,6 +2200,10 @@ export const useUIStore = create()( set({ gitChangesViewMode: mode }); }, + setToolJsonViewMode: (mode) => { + set({ toolJsonViewMode: mode }); + }, + setLinearIssueListStatus: (status) => { set({ linearIssueListStatus: sanitizeLinearIssueListStatus(status) }); }, @@ -2243,7 +2250,6 @@ export const useUIStore = create()( const trimmed = identifier?.trim() ?? ''; set({ linearIssueFocus: trimmed || null }); }, - setInputBarOffset: (offset) => { set({ inputBarOffset: offset }); }, @@ -2912,6 +2918,12 @@ export const useUIStore = create()( state.fileEditorKeymap = normalizeFileEditorKeymap(state.fileEditorKeymap); state.largeTextPasteBehavior = normalizeLargeTextPasteBehavior(state.largeTextPasteBehavior); + if (state.toolJsonViewMode !== 'summary' + && state.toolJsonViewMode !== 'formatted' + && state.toolJsonViewMode !== 'raw') { + state.toolJsonViewMode = 'summary'; + } + if (typeof state.autoSaveEnabled !== 'boolean') { state.autoSaveEnabled = true; } @@ -2986,6 +2998,7 @@ export const useUIStore = create()( diffWrapLines: state.diffWrapLines, walkthroughTocWidth: state.walkthroughTocWidth, gitChangesViewMode: state.gitChangesViewMode, + toolJsonViewMode: state.toolJsonViewMode, linearIssueListStatus: state.linearIssueListStatus, linearIssueListAssignee: state.linearIssueListAssignee, linearIssueListTeamIdByRuntime: state.linearIssueListTeamIdByRuntime, diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index 68b7aa85..58861943 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -687,6 +687,13 @@ export const createSettingsHelpers = (dependencies) => { result.gitChangesViewMode = mode; } } + switch (candidate.toolJsonViewMode) { + case 'summary': + case 'formatted': + case 'raw': + result.toolJsonViewMode = candidate.toolJsonViewMode; + break; + } if (typeof candidate.directoryShowHidden === 'boolean') { result.directoryShowHidden = candidate.directoryShowHidden; } diff --git a/packages/web/server/lib/opencode/settings-helpers.test.js b/packages/web/server/lib/opencode/settings-helpers.test.js index a58f9a6e..88a7d3e4 100644 --- a/packages/web/server/lib/opencode/settings-helpers.test.js +++ b/packages/web/server/lib/opencode/settings-helpers.test.js @@ -88,6 +88,15 @@ describe('settings helpers', () => { })).toEqual({}); }); + it('persists valid tool JSON view modes', () => { + const helpers = createTestHelpers(); + + expect(helpers.sanitizeSettingsUpdate({ toolJsonViewMode: 'summary' })).toEqual({ toolJsonViewMode: 'summary' }); + expect(helpers.sanitizeSettingsUpdate({ toolJsonViewMode: 'formatted' })).toEqual({ toolJsonViewMode: 'formatted' }); + expect(helpers.sanitizeSettingsUpdate({ toolJsonViewMode: 'raw' })).toEqual({ toolJsonViewMode: 'raw' }); + expect(helpers.sanitizeSettingsUpdate({ toolJsonViewMode: 'unknown' })).toEqual({}); + }); + it('accepts only booleans for wide chat layout', () => { const helpers = createTestHelpers();