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 085dfad00..7aadd0486 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 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" }) + - "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. @@ -70,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. @@ -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=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) 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" } }) @@ -469,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. 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 49ad254b5..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() @@ -325,6 +327,7 @@ export default function EditorPane({ content: string readOnly: boolean viewMode: 'source' | 'preview' + wordWrap: boolean }>) => { const nextContent: EditorPaneContent = { kind: 'editor', @@ -333,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( @@ -343,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( @@ -692,6 +696,11 @@ export default function EditorPane({ updateContent({ viewMode: nextMode }) }, [currentViewMode, updateContent]) + const handleToggleWordWrap = useCallback(() => { + const next = !wordWrap + updateContent({ wordWrap: next }) + }, [wordWrap, updateContent]) + const handleReloadFromDisk = useCallback(() => { if (!conflictState) return if (autoSaveTimer.current) { @@ -830,6 +839,8 @@ export default function EditorPane({ showViewToggle={showPreviewToggle} defaultBrowseRoot={defaultBrowseRoot} inputRef={pathInputRef} + wordWrap={wordWrap} + onWordWrapToggle={handleToggleWordWrap} /> @@ -906,6 +917,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/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/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/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 8fcdde4b8..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 @@ -115,6 +148,9 @@ vi.mock('lucide-react', () => ({ Code: ({ className }: { className?: string }) => ( ), + WrapText: ({ className }: { className?: string }) => ( + + ), FileText: ({ className }: { className?: string }) => ( ), @@ -184,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) => { @@ -307,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({}) @@ -1271,6 +1372,7 @@ describe('PaneContainer', () => { activePane: { 'tab-1': 'pane-1' }, }) + editorPaneMockState.suspendNextRender() renderWithStore( , store @@ -1278,8 +1380,58 @@ 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() }) + + 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', () => {