From fc74c83e3e6281f3d2448f4fcd70983d3087bb5d Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sun, 17 May 2026 00:06:10 -0700 Subject: [PATCH 1/5] docs(mcp): default split-pane to left/right, clarify editor for text files - Add instruction that split-pane defaults to vertical (left/right) panes - Expand editor pane guidance: use editor for any text file with syntax highlighting; reserve terminals for interactive commands only - Update HELP_TEXT direction docs to clarify horizontal=top/bottom, vertical=left/right - Fix pre-existing escaped backtick build error in template literals (cherry picked from commit 1a9066ede8438c7474fef5eb3d472ee14281b385) --- server/mcp/freshell-tool.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/server/mcp/freshell-tool.ts b/server/mcp/freshell-tool.ts index 085dfad00..db8224ffe 100644 --- a/server/mcp/freshell-tool.ts +++ b/server/mcp/freshell-tool.ts @@ -53,8 +53,9 @@ FRESHELL_URL and FRESHELL_TOKEN are already set in your environment. ## Choosing the right action - **split-pane vs new-tab:** When the user says "pane", "split", "alongside", "next to", or "side by side", use split-pane. Use new-tab only when the user explicitly says "tab", "window", or "new [thing]" with no spatial reference. When unsure, split-pane is the safer default -- it keeps work in one tab. +- **split-pane defaults to side-by-side (left/right):** By default, split-pane splits vertically to create left/right panes. Use direction: "horizontal" when you want stacked (top/bottom) panes instead. - **Prefer specialized pane types:** Do NOT open a terminal to run cat/vim/nano/curl/wget when a dedicated pane type is a better fit. - - "open/edit/show a file" -> split-pane({ editor: "/absolute/path" }) or new-tab({ editor: "/absolute/path" }) + - "open/edit/show a file" -> split-pane({ editor: "/absolute/path" }) or new-tab({ editor: "/absolute/path" }). Use the editor pane type for any file that can be displayed as text (source code, markdown, configs, logs, etc.). The editor renders files with syntax highlighting. Only open a terminal to edit a file when you need to run interactive commands; for passive file viewing, prefer the editor pane. - "open/show a URL" or "view a webpage" -> split-pane({ browser: "https://..." }) or open-browser({ url: "https://..." }) - "run a command" or "use a CLI tool" -> split-pane({ mode: "shell" }) or new-tab({ mode: "shell" }) - **Sending text:** Always use literal: true with send-keys for natural-language prompts or multi-word text. Token mode (default) treats special words like ENTER as control sequences and mangles prose. Do NOT append the word "ENTER" as literal text -- use keys: ["ENTER"] as a separate send-keys call instead. @@ -321,7 +322,7 @@ User says... | Action | Key param ──────────────────────────────────────────────────────────────────────── "open a pane / split" | split-pane | (no target = split your own pane) "open a tab / window" | new-tab | -"open/edit/show a file" | split-pane | editor: "/absolute/path" +"open/edit/view a text file" | split-pane | editor: "/absolute/path" (for any text file) "open/show a URL" | split-pane | browser: "https://..." "view a webpage (new tab)" | open-browser | url: "https://..." "run a command" | split-pane | mode: "shell" @@ -349,8 +350,8 @@ Tab commands: prev-tab Switch to the previous tab. Pane commands: - split-pane Split a pane. Params: target?, direction (horizontal|vertical, default vertical), mode?, shell?, cwd?, browser?, editor? - Omit target to split your own pane (the pane where this MCP server was spawned). Returns { paneId, tabId }. + split-pane Split a pane. Params: target?, direction? (horizontal=top/bottom, vertical=left/right; defaults to vertical → left/right), mode?, shell?, cwd?, browser?, editor? + Omit target to split your own pane (the pane where this MCP server was spawned). Returns { paneId, tabId }. list-panes List panes. Params: target? (tab ID or title to filter by). Returns { panes: [...] }. select-pane Activate a pane. Params: target (pane ID or index) kill-pane Close a pane. Params: target @@ -431,7 +432,11 @@ Meta: freshell({ action: "wait-for", params: { target: paneId, stable: 8, timeout: 1800 } }) freshell({ action: "capture-pane", params: { target: paneId, S: -120 } }) -## Playbook: open file in editor pane +## Playbook: open file in editor pane (for text files) + + // Use the editor pane type for any file that can be displayed as text: + // source code, markdown, config files, logs, CSVs, etc. + // The editor renders with syntax highlighting and line numbers. // Split current pane with editor (preferred) freshell({ action: "split-pane", params: { editor: "/absolute/path/to/README.md" } }) From 4443fe4b66eab42c0de7cf610da6f9fc2c981ddf Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sun, 17 May 2026 00:16:30 -0700 Subject: [PATCH 2/5] feat: add line wrap toggle to editor pane, default on - Add WrapText button to EditorToolbar with wordWrap state - Monaco editor defaults to wordWrap: 'on' - Toggle button uses secondary variant when wrap is on - Add WrapText to lucide-react mocks in affected tests (cherry picked from commit 4c0b8b96adc92d99443e3eed0a30acd64a8d84f4) --- src/components/panes/EditorPane.tsx | 8 ++++++++ src/components/panes/EditorToolbar.tsx | 17 ++++++++++++++++- test/integration/client/editor-pane.test.tsx | 3 +++ .../components/panes/PaneContainer.test.tsx | 3 +++ 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/components/panes/EditorPane.tsx b/src/components/panes/EditorPane.tsx index 49ad254b5..2f4561c27 100644 --- a/src/components/panes/EditorPane.tsx +++ b/src/components/panes/EditorPane.tsx @@ -162,6 +162,7 @@ export default function EditorPane({ const [editorValue, setEditorValue] = useState(content) const [currentLanguage, setCurrentLanguage] = useState(language) const [currentViewMode, setCurrentViewMode] = useState<'source' | 'preview'>(viewMode) + const [wordWrap, setWordWrap] = useState(true) const [terminalCwds, setTerminalCwds] = useState>({}) const [filePickerMessage, setFilePickerMessage] = useState(null) @@ -692,6 +693,10 @@ export default function EditorPane({ updateContent({ viewMode: nextMode }) }, [currentViewMode, updateContent]) + const handleToggleWordWrap = useCallback(() => { + setWordWrap((prev) => !prev) + }, []) + const handleReloadFromDisk = useCallback(() => { if (!conflictState) return if (autoSaveTimer.current) { @@ -830,6 +835,8 @@ export default function EditorPane({ showViewToggle={showPreviewToggle} defaultBrowseRoot={defaultBrowseRoot} inputRef={pathInputRef} + wordWrap={wordWrap} + onWordWrapToggle={handleToggleWordWrap} /> @@ -906,6 +913,7 @@ export default function EditorPane({ automaticLayout: true, tabSize: 2, readOnly, + wordWrap: wordWrap ? 'on' : 'off', }} /> )} diff --git a/src/components/panes/EditorToolbar.tsx b/src/components/panes/EditorToolbar.tsx index 27226065b..ff2b16f8d 100644 --- a/src/components/panes/EditorToolbar.tsx +++ b/src/components/panes/EditorToolbar.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect, type MutableRefObject } from 'react' -import { FolderOpen, Eye, Code } from 'lucide-react' +import { FolderOpen, Eye, Code, WrapText } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { cn } from '@/lib/utils' @@ -16,6 +16,8 @@ export interface EditorToolbarProps { showViewToggle: boolean defaultBrowseRoot?: string | null inputRef?: MutableRefObject + wordWrap?: boolean + onWordWrapToggle?: () => void } function withTrailingSeparator(value: string): string { @@ -35,6 +37,8 @@ export default function EditorToolbar({ showViewToggle, defaultBrowseRoot, inputRef, + wordWrap = true, + onWordWrapToggle, }: EditorToolbarProps) { const [inputValue, setInputValue] = useState(filePath || '') const [showSuggestions, setShowSuggestions] = useState(false) @@ -205,6 +209,17 @@ export default function EditorToolbar({ {viewMode === 'source' ? : } )} + {onWordWrapToggle && ( + + )} ) } diff --git a/test/integration/client/editor-pane.test.tsx b/test/integration/client/editor-pane.test.tsx index 6e09a6423..db4c4831f 100644 --- a/test/integration/client/editor-pane.test.tsx +++ b/test/integration/client/editor-pane.test.tsx @@ -75,6 +75,9 @@ vi.mock('lucide-react', () => ({ Code: ({ className }: { className?: string }) => ( ), + WrapText: ({ className }: { className?: string }) => ( + + ), Circle: ({ className }: { className?: string }) => ( ), diff --git a/test/unit/client/components/panes/PaneContainer.test.tsx b/test/unit/client/components/panes/PaneContainer.test.tsx index 8fcdde4b8..5064c1f58 100644 --- a/test/unit/client/components/panes/PaneContainer.test.tsx +++ b/test/unit/client/components/panes/PaneContainer.test.tsx @@ -115,6 +115,9 @@ vi.mock('lucide-react', () => ({ Code: ({ className }: { className?: string }) => ( ), + WrapText: ({ className }: { className?: string }) => ( + + ), FileText: ({ className }: { className?: string }) => ( ), From a1892463a1fcd307c3ce553d83e3d0632fb8b8e9 Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sun, 17 May 2026 00:43:34 -0700 Subject: [PATCH 3/5] fix: correct split direction semantics and persist wordWrap preference Fresheyes review fixes: - MCP docs had direction backwards; horizontal=left/right (flex-row), vertical=top/bottom (flex-col). Fixed INSTRUCTIONS and HELP_TEXT. - Changed REST API default direction from vertical to horizontal so split-pane defaults to left/right (user request). - wordWrap is now part of EditorPaneContent and persisted through Redux, localStorage, and tab-registry snapshots. - Added EditorPane tests for wrap toggle rendering and defaults. - Added PaneContainer test verifying wordWrap Redux dispatch. (cherry picked from commit 6137c7b472e6a4e47b929f70577a5aead7d309bc) --- server/agent-api/layout-store.ts | 2 +- server/agent-api/router.ts | 6 +- server/mcp/freshell-tool.ts | 4 +- src/components/TabContent.tsx | 1 + src/components/TabsView.tsx | 1 + src/components/TerminalView.tsx | 1 + .../context-menu/ContextMenuProvider.tsx | 1 + src/components/panes/EditorPane.tsx | 12 ++-- src/components/panes/PaneContainer.tsx | 2 + src/components/panes/PaneLayout.tsx | 2 +- src/lib/tab-registry-snapshot.ts | 1 + src/store/paneTypes.ts | 2 + .../components/panes/EditorPane.test.tsx | 58 +++++++++++++++++++ .../components/panes/PaneContainer.test.tsx | 47 +++++++++++++++ 14 files changed, 129 insertions(+), 11 deletions(-) diff --git a/server/agent-api/layout-store.ts b/server/agent-api/layout-store.ts index 9803fb63a..5c286d16a 100644 --- a/server/agent-api/layout-store.ts +++ b/server/agent-api/layout-store.ts @@ -265,7 +265,7 @@ export class LayoutStore { return { kind: 'browser', url: opts.browser, devToolsOpen: false } } if (opts.editor) { - return { kind: 'editor', filePath: opts.editor, language: null, readOnly: false, content: '', viewMode: 'source' } + return { kind: 'editor', filePath: opts.editor, language: null, readOnly: false, content: '', viewMode: 'source', wordWrap: true } } return { kind: 'terminal', terminalId: opts.terminalId } } diff --git a/server/agent-api/router.ts b/server/agent-api/router.ts index b2fb02db4..99a30d164 100644 --- a/server/agent-api/router.ts +++ b/server/agent-api/router.ts @@ -370,7 +370,7 @@ export function createAgentApiRouter({ if (wantsBrowser) { paneContent = { kind: 'browser', url: browser, devToolsOpen: false } } else if (wantsEditor) { - paneContent = { kind: 'editor', filePath: editor, language: null, readOnly: false, content: '', viewMode: 'source' } + paneContent = { kind: 'editor', filePath: editor, language: null, readOnly: false, content: '', viewMode: 'source', wordWrap: true } } else { const effectiveMode = mode || 'shell' assertTerminalAdmission() @@ -820,7 +820,7 @@ export function createAgentApiRouter({ const resolved = resolvePaneTarget(rawPaneId) if (rejectPaneTargetError(res, resolved)) return const paneId = resolved.paneId || rawPaneId - const direction = req.body?.direction || 'vertical' + const direction = req.body?.direction || 'horizontal' const wantsBrowser = !!req.body?.browser const wantsEditor = !!req.body?.editor if (!wantsBrowser && !wantsEditor) { @@ -847,7 +847,7 @@ export function createAgentApiRouter({ if (wantsBrowser) { content = { kind: 'browser', url: req.body.browser, devToolsOpen: false } } else if (wantsEditor) { - content = { kind: 'editor', filePath: req.body.editor, language: null, readOnly: false, content: '', viewMode: 'source' } + content = { kind: 'editor', filePath: req.body.editor, language: null, readOnly: false, content: '', viewMode: 'source', wordWrap: true } } else { const splitMode = req.body?.mode || 'shell' launch = await resolveSpawnProviderSettings( diff --git a/server/mcp/freshell-tool.ts b/server/mcp/freshell-tool.ts index db8224ffe..f428c1c20 100644 --- a/server/mcp/freshell-tool.ts +++ b/server/mcp/freshell-tool.ts @@ -53,7 +53,7 @@ FRESHELL_URL and FRESHELL_TOKEN are already set in your environment. ## Choosing the right action - **split-pane vs new-tab:** When the user says "pane", "split", "alongside", "next to", or "side by side", use split-pane. Use new-tab only when the user explicitly says "tab", "window", or "new [thing]" with no spatial reference. When unsure, split-pane is the safer default -- it keeps work in one tab. -- **split-pane defaults to side-by-side (left/right):** By default, split-pane splits vertically to create left/right panes. Use direction: "horizontal" when you want stacked (top/bottom) panes instead. +- **split-pane defaults to side-by-side (left/right):** By default, split-pane splits horizontally to create left/right panes. Use direction: "vertical" when you want stacked (top/bottom) panes instead. - **Prefer specialized pane types:** Do NOT open a terminal to run cat/vim/nano/curl/wget when a dedicated pane type is a better fit. - "open/edit/show a file" -> split-pane({ editor: "/absolute/path" }) or new-tab({ editor: "/absolute/path" }). Use the editor pane type for any file that can be displayed as text (source code, markdown, configs, logs, etc.). The editor renders files with syntax highlighting. Only open a terminal to edit a file when you need to run interactive commands; for passive file viewing, prefer the editor pane. - "open/show a URL" or "view a webpage" -> split-pane({ browser: "https://..." }) or open-browser({ url: "https://..." }) @@ -350,7 +350,7 @@ Tab commands: prev-tab Switch to the previous tab. Pane commands: - split-pane Split a pane. Params: target?, direction? (horizontal=top/bottom, vertical=left/right; defaults to vertical → left/right), mode?, shell?, cwd?, browser?, editor? + split-pane Split a pane. Params: target?, direction? (horizontal=left/right, vertical=top/bottom; defaults to horizontal = left/right), mode?, shell?, cwd?, browser?, editor? Omit target to split your own pane (the pane where this MCP server was spawned). Returns { paneId, tabId }. list-panes List panes. Params: target? (tab ID or title to filter by). Returns { panes: [...] }. select-pane Activate a pane. Params: target (pane ID or index) diff --git a/src/components/TabContent.tsx b/src/components/TabContent.tsx index b60604ac6..af4b603c4 100644 --- a/src/components/TabContent.tsx +++ b/src/components/TabContent.tsx @@ -65,6 +65,7 @@ export default function TabContent({ tabId, hidden }: TabContentProps) { readOnly: false, content: '', viewMode: 'source', + wordWrap: true, } } else { // 'shell' or default diff --git a/src/components/TabsView.tsx b/src/components/TabsView.tsx index 5b036b353..81901a9f5 100644 --- a/src/components/TabsView.tsx +++ b/src/components/TabsView.tsx @@ -136,6 +136,7 @@ function sanitizePaneSnapshot( readOnly: !!payload.readOnly, content: '', viewMode: (payload.viewMode as 'source' | 'preview') || 'source', + wordWrap: payload.wordWrap !== false, } } if (snapshot.kind === 'agent-chat') { diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index def853dc9..434c2755c 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -1241,6 +1241,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) readOnly: false, content: '', viewMode: 'source', + wordWrap: true, }) }, }))) diff --git a/src/components/context-menu/ContextMenuProvider.tsx b/src/components/context-menu/ContextMenuProvider.tsx index 128bc47dc..4bd1b5e48 100644 --- a/src/components/context-menu/ContextMenuProvider.tsx +++ b/src/components/context-menu/ContextMenuProvider.tsx @@ -203,6 +203,7 @@ export function ContextMenuProvider({ readOnly: false, content: '', viewMode: 'source', + wordWrap: true, }, })) return diff --git a/src/components/panes/EditorPane.tsx b/src/components/panes/EditorPane.tsx index 2f4561c27..8aa3597cb 100644 --- a/src/components/panes/EditorPane.tsx +++ b/src/components/panes/EditorPane.tsx @@ -134,6 +134,7 @@ interface EditorPaneProps { readOnly?: boolean content: string viewMode?: 'source' | 'preview' + wordWrap?: boolean } export default function EditorPane({ @@ -144,6 +145,7 @@ export default function EditorPane({ readOnly = false, content, viewMode = 'source', + wordWrap = true, }: EditorPaneProps) { const dispatch = useAppDispatch() const monacoTheme = useMonacoTheme() @@ -162,7 +164,6 @@ export default function EditorPane({ const [editorValue, setEditorValue] = useState(content) const [currentLanguage, setCurrentLanguage] = useState(language) const [currentViewMode, setCurrentViewMode] = useState<'source' | 'preview'>(viewMode) - const [wordWrap, setWordWrap] = useState(true) const [terminalCwds, setTerminalCwds] = useState>({}) const [filePickerMessage, setFilePickerMessage] = useState(null) @@ -326,6 +327,7 @@ export default function EditorPane({ content: string readOnly: boolean viewMode: 'source' | 'preview' + wordWrap: boolean }>) => { const nextContent: EditorPaneContent = { kind: 'editor', @@ -334,6 +336,7 @@ export default function EditorPane({ readOnly: updates.readOnly !== undefined ? updates.readOnly : readOnly, content: updates.content !== undefined ? updates.content : editorValue, viewMode: updates.viewMode !== undefined ? updates.viewMode : currentViewMode, + wordWrap: updates.wordWrap !== undefined ? updates.wordWrap : wordWrap, } dispatch( @@ -344,7 +347,7 @@ export default function EditorPane({ }) ) }, - [dispatch, tabId, paneId, filePath, currentLanguage, readOnly, editorValue, currentViewMode] + [dispatch, tabId, paneId, filePath, currentLanguage, readOnly, editorValue, currentViewMode, wordWrap] ) const handlePathSelect = useCallback( @@ -694,8 +697,9 @@ export default function EditorPane({ }, [currentViewMode, updateContent]) const handleToggleWordWrap = useCallback(() => { - setWordWrap((prev) => !prev) - }, []) + const next = !wordWrap + updateContent({ wordWrap: next }) + }, [wordWrap, updateContent]) const handleReloadFromDisk = useCallback(() => { if (!conflictState) return diff --git a/src/components/panes/PaneContainer.tsx b/src/components/panes/PaneContainer.tsx index a6c35edf2..d8cd453b6 100644 --- a/src/components/panes/PaneContainer.tsx +++ b/src/components/panes/PaneContainer.tsx @@ -620,6 +620,7 @@ function PickerWrapper({ readOnly: false, content: '', viewMode: 'source', + wordWrap: true, } default: throw new Error(`Unsupported pane type: ${String(type)}`) @@ -744,6 +745,7 @@ function renderContent( readOnly={content.readOnly} content={content.content} viewMode={content.viewMode} + wordWrap={content.wordWrap} /> diff --git a/src/components/panes/PaneLayout.tsx b/src/components/panes/PaneLayout.tsx index edd779a3a..0e7b94407 100644 --- a/src/components/panes/PaneLayout.tsx +++ b/src/components/panes/PaneLayout.tsx @@ -38,7 +38,7 @@ export default function PaneLayout({ tabId, defaultContent, hidden }: PaneLayout const defaultNewPane = settings.panes?.defaultNewPane || 'ask' if (defaultNewPane === 'ask') return { kind: 'picker' } if (defaultNewPane === 'browser') return { kind: 'browser', url: '', devToolsOpen: false } - if (defaultNewPane === 'editor') return { kind: 'editor', filePath: null, language: null, readOnly: false, content: '', viewMode: 'source' } + if (defaultNewPane === 'editor') return { kind: 'editor', filePath: null, language: null, readOnly: false, content: '', viewMode: 'source', wordWrap: true } return { kind: 'terminal', mode: 'shell', shell: 'system', initialCwd: settings.defaultCwd } }, [settings]) diff --git a/src/lib/tab-registry-snapshot.ts b/src/lib/tab-registry-snapshot.ts index 7f77757f4..ede073057 100644 --- a/src/lib/tab-registry-snapshot.ts +++ b/src/lib/tab-registry-snapshot.ts @@ -36,6 +36,7 @@ function stripPanePayload(content: PaneContent, serverInstanceId: string): Recor language: content.language, readOnly: content.readOnly, viewMode: content.viewMode, + wordWrap: content.wordWrap, } case 'agent-chat': { diff --git a/src/store/paneTypes.ts b/src/store/paneTypes.ts index 122aff019..f442a39f5 100644 --- a/src/store/paneTypes.ts +++ b/src/store/paneTypes.ts @@ -97,6 +97,8 @@ export type EditorPaneContent = { content: string /** View mode: source editor or rendered preview */ viewMode: 'source' | 'preview' + /** Line wrap toggle (default true) */ + wordWrap: boolean } /** diff --git a/test/unit/client/components/panes/EditorPane.test.tsx b/test/unit/client/components/panes/EditorPane.test.tsx index d719b2ca1..ae9246dd0 100644 --- a/test/unit/client/components/panes/EditorPane.test.tsx +++ b/test/unit/client/components/panes/EditorPane.test.tsx @@ -634,4 +634,62 @@ describe('EditorPane', () => { expect(screen.getByTestId('monaco-mock').getAttribute('data-theme')).toBe('vs') }) }) + + describe('word wrap', () => { + it('renders the wrap toggle button with disable label when wrap is on', () => { + render( + + + + ) + + expect(screen.getByRole('button', { name: /disable line wrap/i })).toBeInTheDocument() + }) + + it('renders the wrap toggle button with enable label when wrap is off', () => { + render( + + + + ) + + expect(screen.getByRole('button', { name: /enable line wrap/i })).toBeInTheDocument() + }) + + it('defaults wordWrap to true', () => { + render( + + + + ) + + // Defaults to true, so button should say "disable" (can turn it off) + expect(screen.getByRole('button', { name: /disable line wrap/i })).toBeInTheDocument() + }) + }) }) diff --git a/test/unit/client/components/panes/PaneContainer.test.tsx b/test/unit/client/components/panes/PaneContainer.test.tsx index 5064c1f58..afa326c0f 100644 --- a/test/unit/client/components/panes/PaneContainer.test.tsx +++ b/test/unit/client/components/panes/PaneContainer.test.tsx @@ -1283,6 +1283,53 @@ describe('PaneContainer', () => { expect(screen.getByRole('status')).toHaveTextContent('Loading editor...') expect(await screen.findByTestId('monaco-mock')).toBeInTheDocument() }) + + it('renders word wrap toggle and dispatches wordWrap update to store when clicked', async () => { + const editorContent: EditorPaneContent = { + kind: 'editor', + filePath: '/test.ts', + language: 'typescript', + readOnly: false, + content: 'code', + viewMode: 'source', + wordWrap: true, + } + + const node: PaneNode = { + type: 'leaf', + id: 'pane-1', + content: editorContent, + } + + const store = createStore({ + layouts: { 'tab-1': node }, + activePane: { 'tab-1': 'pane-1' }, + }) + + renderWithStore( + , + store + ) + + expect(await screen.findByTestId('monaco-mock')).toBeInTheDocument() + + const wrapButton = screen.getByRole('button', { name: /disable line wrap/i }) + expect(wrapButton).toBeInTheDocument() + + fireEvent.click(wrapButton) + + await waitFor(() => { + const state = store.getState() + const layout = state.panes.layouts['tab-1'] + expect(layout).toBeDefined() + if (layout?.type === 'leaf') { + const content = layout.content + if (content.kind === 'editor') { + expect(content.wordWrap).toBe(false) + } + } + }) + }) }) describe('PickerWrapper shell type handling', () => { From df731e7fb29ea841bf0f5f9817178604ef5cf7dd Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sun, 17 May 2026 23:43:50 -0700 Subject: [PATCH 4/5] test: stabilize editor pane lazy loading assertions --- .../components/panes/PaneContainer.test.tsx | 104 +++++++++++++++++- 1 file changed, 103 insertions(+), 1 deletion(-) diff --git a/test/unit/client/components/panes/PaneContainer.test.tsx b/test/unit/client/components/panes/PaneContainer.test.tsx index afa326c0f..2aeb03359 100644 --- a/test/unit/client/components/panes/PaneContainer.test.tsx +++ b/test/unit/client/components/panes/PaneContainer.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react' +import { render, screen, fireEvent, cleanup, waitFor, act } from '@testing-library/react' import { configureStore } from '@reduxjs/toolkit' import { Provider } from 'react-redux' import PaneContainer from '@/components/panes/PaneContainer' @@ -44,6 +44,7 @@ const { mockApiPost, mockApiPatch, saveServerSettingsPatchSpy, + editorPaneMockState, } = vi.hoisted(() => ({ mockSend: vi.fn(), mockTerminalView: vi.fn(({ tabId, paneId, hidden }: { tabId: string; paneId: string; hidden?: boolean }) => ( @@ -62,6 +63,38 @@ const { type: 'settings/saveServerSettingsPatch', payload: patch, })), + editorPaneMockState: (() => { + let shouldSuspend = false + let resolvePending: (() => void) | null = null + let pendingPromise: Promise | null = null + + return { + suspendNextRender() { + shouldSuspend = true + pendingPromise = new Promise((resolve) => { + resolvePending = () => { + shouldSuspend = false + pendingPromise = null + resolvePending = null + resolve() + } + }) + }, + maybeSuspend() { + if (shouldSuspend && pendingPromise) { + throw pendingPromise + } + }, + resolve() { + resolvePending?.() + }, + reset() { + shouldSuspend = false + pendingPromise = null + resolvePending = null + }, + } + })(), })) // Mock the ws-client module @@ -187,6 +220,70 @@ vi.mock('@/components/panes/BrowserPane', () => ({ }, })) +vi.mock('@/components/panes/EditorPane', async () => { + const React = await import('react') + const { useDispatch } = await import('react-redux') + const { updatePaneContent } = await import('@/store/panesSlice') + + function MockEditorPane({ + paneId, + tabId, + filePath, + language, + readOnly = false, + content, + viewMode = 'source', + wordWrap = true, + }: { + paneId: string + tabId: string + filePath: string | null + language: string | null + readOnly?: boolean + content: string + viewMode?: 'source' | 'preview' + wordWrap?: boolean + }) { + editorPaneMockState.maybeSuspend() + + const dispatch = useDispatch() + + return React.createElement( + 'div', + { 'data-testid': 'editor-pane' }, + React.createElement('textarea', { + 'data-testid': 'monaco-mock', + readOnly: true, + value: content, + onChange: () => undefined, + }), + React.createElement( + 'button', + { + type: 'button', + 'aria-label': wordWrap ? 'Disable line wrap' : 'Enable line wrap', + onClick: () => dispatch(updatePaneContent({ + tabId, + paneId, + content: { + kind: 'editor', + filePath, + language, + readOnly, + content, + viewMode, + wordWrap: !wordWrap, + }, + })), + }, + React.createElement('svg', { 'data-testid': 'wrap-text-icon' }), + ), + ) + } + + return { default: MockEditorPane } +}) + // Mock Monaco editor vi.mock('@monaco-editor/react', () => { const MockEditor = ({ value, onChange }: any) => { @@ -310,6 +407,7 @@ describe('PaneContainer', () => { mockApiPost.mockReset() mockApiPatch.mockReset() saveServerSettingsPatchSpy.mockClear() + editorPaneMockState.reset() mockApiGet.mockResolvedValue({ directories: [] }) mockApiPost.mockResolvedValue({ valid: true, resolvedPath: '/resolved/path' }) mockApiPatch.mockResolvedValue({}) @@ -1274,6 +1372,7 @@ describe('PaneContainer', () => { activePane: { 'tab-1': 'pane-1' }, }) + editorPaneMockState.suspendNextRender() renderWithStore( , store @@ -1281,6 +1380,9 @@ describe('PaneContainer', () => { expect(screen.getByTestId('editor-pane-loading')).toBeInTheDocument() expect(screen.getByRole('status')).toHaveTextContent('Loading editor...') + await act(async () => { + editorPaneMockState.resolve() + }) expect(await screen.findByTestId('monaco-mock')).toBeInTheDocument() }) From c49156e4ed485fdc6235f8b2b3b46661ab6975b6 Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sun, 17 May 2026 23:44:31 -0700 Subject: [PATCH 5/5] fix: escape MCP guidance inline code --- server/mcp/freshell-tool.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/mcp/freshell-tool.ts b/server/mcp/freshell-tool.ts index f428c1c20..7aadd0486 100644 --- a/server/mcp/freshell-tool.ts +++ b/server/mcp/freshell-tool.ts @@ -71,7 +71,7 @@ FRESHELL_URL and FRESHELL_TOKEN are already set in your environment. ## Key gotchas - **Tab and pane IDs are ephemeral.** IDs from open-browser, new-tab, and split-pane are valid only within the current session. If the Freshell server restarts or the agent conversation resumes after a disconnect, previously returned IDs may no longer exist. Always call open-browser or list-tabs fresh rather than reusing stale IDs. -- **Always screenshot with `screenshot({ scope: "tab", target: tabId })` after open-browser.** Network errors, CORS issues, or server problems can cause blank pages. open-browser returns a tabId — use it immediately to screenshot and confirm the page rendered before proceeding. +- **Always screenshot with \`screenshot({ scope: "tab", target: tabId })\` after open-browser.** Network errors, CORS issues, or server problems can cause blank pages. open-browser returns a tabId — use it immediately to screenshot and confirm the page rendered before proceeding. - send-keys: use literal mode (literal: true + keys as a string) for natural-language prompts or multi-word text. Do NOT append "ENTER" as literal text -- send the command with literal:true, then send ["ENTER"] as a separate call in token mode. - wait-for with stable (seconds of no output) is more reliable than pattern matching across different CLI providers. - Editor panes show "Loading..." until the tab is visited in the browser. When screenshotting multiple tabs, visit each tab first (select-tab), then loop back for screenshots. @@ -474,7 +474,7 @@ Meta: ## Screenshot guidance -- **Always screenshot with `screenshot({ scope: "tab", target: tabId })` after open-browser.** Network errors, blank pages, and CORS failures are silent unless you look. open-browser returns a tabId — use it immediately to confirm the page rendered before acting on it. +- **Always screenshot with \`screenshot({ scope: "tab", target: tabId })\` after open-browser.** Network errors, blank pages, and CORS failures are silent unless you look. open-browser returns a tabId — use it immediately to confirm the page rendered before acting on it. - Tab and pane IDs from earlier in a session may become stale after reconnections or server restarts. If screenshot fails to find a tab/pane, call list-tabs or list-panes to get fresh IDs rather than reusing old ones. - Use a dedicated canary tab when validating screenshot behavior so live project panes are not contaminated. - Close temporary tabs/panes after verification unless user asked to keep them open.