You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Markdown viewer: copy, source/preview toggle, and in-place editing
Summary
The markdown surface is currently a one-way, read-only render target: content
goes in via CLI/pipe/file-picker, gets converted to sanitized HTML, and that's it.
There is no way to get the text back out (other than mouse-selecting rendered
HTML, which loses markdown syntax), no way to look at the raw source, and no way
to fix a typo in the file you're reading.
That makes the viewer good for consuming agent output (its original purpose,
issue #54) but useless for the far more common loop: agent writes a plan/spec →
user reads it in the pane → user wants to copy it into a ticket, quote one line
into chat, or tweak two sentences and save.
It also means the surface forgets where the content came from: the full path is
dropped at every entry point, so the pane can't tell you which file you're
reading, can't hand the path to the terminal next door, and can't save back to it.
This issue proposes four features, in priority order:
#
Feature
Priority
Rough size
F0
Path-aware surfaces — a markdown surface knows, shows, and can hand out the path of the file it came from
must have
small
F1
Copy Markdown — copy the raw markdown source to the clipboard (all, a selection, or a code block) — and the file path
must have
small
F2
Toggle Text/Markdown view — switch the pane between rendered preview and raw, line-selectable source
must have
small
F3
Edit & Save — edit the source in place and write it back to the backing file
nice to have
medium
F0–F2 are pure-renderer changes (F0 needs one field plumbed through existing
producers) with no new IPC and no new trust surface. F3 introduces the first write path to the filesystem from the markdown surface and needs a deliberate
security design (see Security).
Current state
Rendering
src/renderer/components/Markdown/MarkdownPane.tsx:12-51 is the whole component
(51 lines). It:
marked.parse(content) with { gfm: true, breaks: true }
DOMPurify.sanitize(...) with FORBID_TAGS: ['style','form','input','button','textarea','select']
dumps the result through dangerouslySetInnerHTML into .markdown-pane__content
intercepts clicks on <a> and routes them to the wmux browser panel
No toolbar, no state, no modes. The raw content prop is available in the
component but never shown to the user.
no (main returns filePath, renderer throws it away at line 100-101)
Ctrl+Shift+M (openMarkdownPanel)
src/renderer/hooks/useKeyboardShortcuts.ts:322
n/a (empty surface)
n/a
All of them funnel into setMarkdownContent(surfaceId, content, fileName?)
(src/renderer/store/surface-slice.ts:427-442), which patches the surface in the
split tree.
State model
SurfaceRef (src/shared/types.ts:14-36) carries:
markdownContent?: string;// raw markdown, persisted
markdownFileName?: string;// basename only — used as the tab label
Two consequences that matter here:
The full path is never stored.markdownFileName is a basename
(src/renderer/components/SplitPane/surface-label.ts:36), so today the
renderer literally cannot know what file to save back to. F3 requires adding
the path.
Content is persisted into the session snapshot.src/renderer/App.tsx:666-692
serializes the entire splitTree on the 30 s autosave, so markdownContent
(including, once F3 lands, unsaved edits) survives restarts. That's a feature
(crash safety) and a hazard (silent divergence from disk) — addressed below.
Available plumbing we can reuse
Clipboard: window.wmux.clipboard.writeText/readText (src/preload/index.ts:98-102).
Preferred over navigator.clipboard in this codebase — see the comments at src/renderer/hooks/useTerminal.ts:620-636 (no user-gesture requirement, and
correct handling of non-UTF-8 Windows clipboard payloads).
A toolbar-button precedent with matching CSS tokens: diff-pane__refresh-btn
(src/renderer/components/Diff/DiffPane.tsx:242-248, src/renderer/styles/diff.css:92-110).
Shortcut registry: ShortcutAction union + DEFAULT_SHORTCUTS
(src/renderer/store/settings-slice.ts:160-161, 216-217).
i18n: three dicts (en/fr/zh) in src/renderer/i18n/core.ts:24, 99, 165.
Use cases
UC-1 — Copy an agent's plan into a ticket. Claude writes PLAN.md, wmux
renders it. The user wants the markdown (headings, bullets, code fences) in
Jira/Linear/GitHub. Selecting the rendered HTML yields plain text with the syntax
stripped and code blocks mangled. Today the only workaround is cat PLAN.md in a
terminal pane and copying from xterm.
UC-2 — Quote one line into chat. The user wants a single bullet, or one line
of a fenced code block, verbatim. Rendered-HTML selection is unreliable
(soft-wrapped <li>, inline <code> padding) and there is no line granularity.
UC-3 — Verify what the agent actually wrote. Rendering hides syntax errors:
a broken table, an unclosed fence, a literal \n that should have been a newline
(a real class of bug for content arriving via markdown.set_content, which passes
through JSON.stringify in the bridge). The user needs to see the source to debug
the producer.
UC-4 — Fix a typo without leaving the pane. Reviewing a spec, spotting a
wrong word, and needing an editor pane / nvim in another tab to change three
characters is disproportionate friction. Read → edit → save should happen where
the reading happens.
UC-5 — Write a short note.Ctrl+Shift+M opens an empty markdown surface.
Today it stays empty forever (it can only be filled from outside). With F3 it
becomes a scratchpad, with "Save As" to materialize it.
UC-6 — Hand the path to something else. The user is reading docs/config.md
in the pane and wants to run nvim docs/config.md, git log -- docs/config.md,
or paste the path into a Claude prompt in the neighbouring terminal. Today the
pane shows only the basename on the tab; the path has to be reconstructed by
hand — and for a file opened via the palette's native dialog, the user may not
know where it lives at all.
UC-7 — Agent-and-human co-editing. An agent regenerates a doc on disk while
the user has it open. The pane should notice and offer to reload rather than
silently overwrite the agent's work on next save (see Conflict handling).
F0 — Path-aware markdown surfaces
Today a markdown surface is content-only; the path is dropped at every entry
point (see the table above). That's the root cause behind UC-6, it blocks F3
entirely, and it makes the "which file am I even looking at?" question
unanswerable for palette-opened files. Fixing it first makes the rest cheap.
Behaviour
Every surface populated from a file carries its absolute path.
The toolbar shows the path, not just the basename:
relative to the workspace cwd when the file is inside it
(ws.cwd is already on the workspace and persisted — src/renderer/App.tsx:682),
~/…-shortened when inside the home directory,
otherwise absolute; middle-ellipsized to fit, with the full path in the title tooltip.
The tab tooltip gets the full path too (the label stays the basename — src/renderer/components/SplitPane/surface-label.ts:36).
Copy file path / Copy relative path in the toolbar overflow menu (see F1).
Reveal in File Explorer — shell.showItemInFolder, and Open in default
app — shell.openPath. Both are main-process one-liners; gate them on a
path being present. (system.openExternal already exists in the preload
surface; these are the filesystem-side equivalents.)
Surfaces with no backing path (markdown.set_content, Ctrl+Shift+M) show
no path chip and disable the path-dependent actions. This must stay a
first-class state, not an error case — pushed agent content is the original use
case for the surface.
Drag and drop a file onto a markdown pane opens it there. The renderer can
already resolve a dropped File to a real path via window.wmux.shell.getPathForFile (src/preload/index.ts:103-108, added for
terminal drag-and-drop in issue Drag and Drop of Files does nothing #33), so this is a drop handler plus the same
guarded read the palette uses.
Reload from disk button, active whenever a path is present — useful before
F3 even exists, since agents rewrite files under the pane constantly.
Implementation
Add markdownFilePath to SurfaceRef (full definition in the F3 state block
below) and pass it from every producer:
Producer
Change
markdown.load_file (src/main/index.ts:632-634)
pass filePath alongside the existing path.basename(filePath)
setMarkdownContent grows an options object rather than a fourth positional
argument (see F3). Keep the existing "don't clobber on content-only updates"
guard (src/renderer/store/surface-slice.ts:434-436) and extend it to the path: wmux markdown set <id> --content … against a file-backed surface must not
silently re-point or clear the path.
One decision to make explicit: pushing new content into a file-backed surface
means the buffer no longer matches disk. Treat it exactly like a user edit —
keep the path, mark the surface dirty (once F3 lands). Do not auto-write.
F1 — Copy Markdown
Behaviour
Toolbar button Copy (clipboard icon) in the pane header. Copies the full
raw markdownContent — not the rendered text.
Transient confirmation: the button label/icon flips to "Copied" for ~1.2 s
(no toast infra needed).
Disabled when content is empty.
Bonus, cheap: per-code-block copy buttons. After the sanitized HTML is
mounted, walk pre > code nodes and inject a small copy button per block. This
is the single most-requested markdown-viewer affordance and reuses the same
handler.
In source view (F2), if a text selection exists, Copy copies the selection;
otherwise the whole document. In preview view it always copies the whole
document (line-level copy is what source view is for).
Copy file path and Copy relative path (relative to the workspace cwd)
as separate entries in the toolbar overflow menu, greyed out when the surface
has no backing path. Copy-path is the fastest bridge from "reading this doc" to
"acting on it in the terminal next to it" (UC-6) and costs nothing once F0
stores the path.
Windows note: copy the path with backslashes as-is. wmux's own CLI and the
shells it spawns accept both separators, and normalizing to forward slashes
would break cmd.exe consumers.
For the per-block buttons, prefer a delegated click handler on .markdown-pane__content keyed off a data-md-copy attribute added in a useEffect after render, over React portals — the HTML is injected via dangerouslySetInnerHTML, so there are no React nodes to attach to. Note that FORBID_TAGS includes button, so the injected buttons must be created after
sanitization (DOMPurify would strip them if they came from marked), which the useEffect approach naturally does.
F2 — Toggle Text/Markdown view
Behaviour
Two-state segmented control in the toolbar: Preview | Source (icon or ¶ / </>).
Source view renders the raw markdown in a monospace, white-space: pre,
horizontally scrollable block using the terminal font stack
('Cascadia Mono', 'Consolas', monospace — same as markdown.css:47).
Line numbers in a non-selectable gutter (user-select: none) so double-click /
drag selection yields clean lines without numbers. This is what makes UC-2 work.
Optional soft-wrap toggle for long paragraphs (default off — wrapping breaks
line-accurate selection).
Mode is per surface and persisted on SurfaceRef as markdownViewMode?: 'preview' | 'source' so it survives the remounts caused by
split-tree restructures (same rationale as the existing markdownContent
comment at src/shared/types.ts:29-31) and the session snapshot.
Shortcut: Ctrl+Shift+E (currently unbound — verified against DEFAULT_SHORTCUTS) as a new toggleMarkdownSource action, active when the
focused pane's active surface is a markdown surface.
Why not a third "both" mode
Side-by-side preview+source is tempting but doubles the layout work and competes
with wmux's own splitting: a user who wants both can already split the pane and
open the file twice. Keep two modes; revisit later.
Per-line <div>s are fine up to the 5 MB cap in practice, but a 5 MB file is
~100 k lines and will jank. Guard: above a SOURCE_VIRTUALIZE_THRESHOLD
(suggest 5 000 lines) fall back to a single <pre>{content}</pre> without a
gutter rather than pulling in a virtualization dependency (the project has
deliberately minimal deps — see package.json).
F3 — Edit and save
Behaviour
Edit toggle (pencil) enters edit mode; edit mode is source view plus an
editable buffer. Only reachable from source view, so the mental model is
"Preview → Source → Editing".
Plain <textarea> — no CodeMirror/Monaco. The project ships no editor
dependency and adds ~1–3 MB to the bundle for one. A textarea with the
monospace stack, Tab inserting two spaces, and auto-continuation of - / 1. / > list prefixes on Enter covers the "fix a typo, add a bullet" cases
this issue is actually about. Syntax highlighting is explicitly out of scope.
Live preview is not required while editing (that's the "both" mode we
deferred); leaving edit mode re-renders.
Dirty state: markdownDirty?: boolean on SurfaceRef; the tab label gets a • prefix (src/renderer/components/SplitPane/surface-label.ts:36).
Save (Ctrl+S when the pane is focused, plus a toolbar button):
has a backing path → write in place
no backing path (created by Ctrl+Shift+M or markdown.set_content) → open
native Save As dialog, then write and adopt the returned path
Revert (discard buffer, reload from disk) in the toolbar overflow menu.
Closing a dirty surface: confirm before closing. Note reopenClosedSurface
(src/renderer/hooks/useKeyboardShortcuts.ts:339) preserves closed surfaces, so
an accidental close is recoverable — the confirm is still worth it because
"recoverable via a shortcut you may not know" isn't good enough for user text.
State additions
// src/shared/types.ts — SurfaceRef/** Absolute path of the file backing a markdown surface (F0). Shown in the * toolbar, copyable, and required to save edits back to disk; absent for * content pushed via markdown.set_content or an empty scratch surface. */
markdownFilePath?: string;/** mtimeMs of the backing file as of the last successful load/save — used to * detect out-of-band changes before overwriting. */
markdownFileMtime?: number;/** View mode of a markdown surface (F2). */
markdownViewMode?: 'preview'|'source';/** Buffer differs from what's on disk (F3). */
markdownDirty?: boolean;
setMarkdownContent (src/renderer/store/surface-slice.ts:427-442) grows a filePath/mtime parameter — or, cleaner, is refactored to take an options
object so the next addition doesn't add a fifth positional argument:
Every producer must be updated to pass the path: src/main/index.ts:632-634 (the load_file bridge call), src/main/ipc-handlers.ts:362 already returns filePath — the palette caller at src/renderer/components/CommandPalette/CommandPalette.tsx:94-102 just needs to
forward it.
Extension whitelist and 5 MB cap on the content being written, mirroring
the read guards. Factor ALLOWED_MD_EXT / MAX_MD_BYTES out of src/main/index.ts:616,624 and src/main/ipc-handlers.ts:336-337 into a
shared module (e.g. src/main/markdown-file.ts) instead of a third copy.
Path must be in the grant set (see below).
Reject if the target is a symlink or not a regular file
(fs.lstatSync(...).isSymbolicLink()), so a save can't be redirected through
a link planted by whatever produced the markdown.
Optimistic-concurrency check: if expectedMtimeMs is provided and the
file's current mtimeMs differs, return { conflict: true, currentMtimeMs } and write nothing.
Atomic write: write <file>.wmux-tmp-<random> in the same directory, then fs.renameSync over the target. A half-written spec is worse than an
unsaved one.
Return { ok: true, mtimeMs } so the renderer can refresh markdownFileMtime and clear markdownDirty.
Security
This is the first renderer→disk write in this surface, and the markdown content
itself is explicitly treated as untrusted (see the comment at MarkdownPane.tsx:21-24, and the markdown.load_file hardening note in src/shared/instance.ts:33). Two rules:
Never let the write target be an arbitrary renderer-supplied path. The main
process keeps a per-webContentsgrant set of paths that the user or an authenticated pipe client opened this session — populated in MARKDOWN_OPEN_FILE (ipc-handlers.ts:350), in the markdown.load_file
handler (index.ts:611), and by MARKDOWN_SAVE_AS on dialog confirmation. A MARKDOWN_SAVE_FILE for a path not in that set is rejected. This keeps the
blast radius of a renderer-side bug (e.g. prototype pollution via injected
markdown) to "files the user already opened", not ~/.ssh/authorized_keys.
Extension whitelist applies on write too, so a grant can't be laundered
into writing .ps1 / .cmd / .bat. Combined with the whitelist on read,
the reachable set stays .md/.markdown/.mdx/.txt/.text/.rst.
Worth noting explicitly: markdown.load_file is already token-gated on the pipe
(src/shared/instance.ts:28-37); no new pipe method is required for F3 — saving
is renderer-initiated only. Do not add a markdown.save_file pipe method in
this issue; agents already have unrestricted Write, and it would widen the pipe
surface for no gain.
Conflict handling
Situation
Behaviour
Disk unchanged since load, buffer dirty
save in place
Disk changed, buffer clean
show a "changed on disk — Reload" banner in the toolbar (no auto-reload; the user may be mid-read)
Disk changed, buffer dirty
save is blocked; banner offers Overwrite / Reload and lose my edits / Save As copy
No backing path
Save As
Disk-change detection: a lightweight fs.watch on the backing file, or an
mtime re-stat on pane focus. Prefer the mtime-on-focus check for v1 — it
needs no watcher lifecycle management and covers the realistic case ("agent
rewrote the file while I was in another pane"). A watcher can come later if
live-reload-while-visible is wanted.
Session-restore caveat: because unsaved buffers are persisted in the autosave
snapshot (src/renderer/App.tsx:666-692), a restored dirty surface must re-stat
its file and, if the mtime moved, come up with the conflict banner already
showing rather than silently holding a stale buffer.
UI layout
The toolbar has to be added carefully: .markdown-pane currently owns both the
scroll container and the padding (src/renderer/styles/markdown.css:1-11), so a
naive header would scroll away with the content.
Style the toolbar with the theme variables already used by the diff pane
(--ui-bg-2, --ui-text-primary, rgba(var(--ui-overlay-rgb), 0.1) for hover)
rather than the hard-coded hex values that the rest of markdown.css uses — the
existing hardcoded colors are a separate pre-existing wart, not something to
propagate.
Toolbar should auto-hide its labels below a narrow pane width (icon-only), since
markdown surfaces are frequently in a quarter-width pane.
Also worth fixing while we're in here
GFM task-list checkboxes are silently stripped.FORBID_TAGS includes 'input' (MarkdownPane.tsx:28), so - [ ] item renders as a bare bullet —
and markdown.css:117-120 styles a checkbox that can never exist. Either allow input[type=checkbox][disabled] explicitly, or render ☐/☑ glyphs. (Making
checkboxes interactive is an editing feature and belongs with F3, gated on a
backing file.)
markdown.set_content can't set a file name, so every CLI-pushed surface is
labelled "Markdown". A --title flag on wmux markdown set (src/cli/wmux.ts:241-247)
would make multiple agent-pushed docs distinguishable.
No way to read content back out via CLI.wmux markdown get <id> (mirroring read-screen) would let an agent verify what it pushed, and is trivially built
on a markdown.get_content bridge entry next to src/main/v2-bridge.ts:88-90.
Implementation risk: global shortcuts vs. the editor
isSafeToIntercept (src/renderer/hooks/useKeyboardShortcuts.ts:30-52) has no
guard for editable focus targets — it reasons only about modifier combos, on the
assumption that the only keyboard consumer is xterm. Once a <textarea> exists in
a pane, every Ctrl+Shift+<letter> binding fires while the user is typing, and document-level preventDefault at line 367 will swallow keys the textarea
should have received.
Add an early bail:
constt=e.targetasHTMLElement|null;if(t&&(t.tagName==='INPUT'||t.tagName==='TEXTAREA'||t.isContentEditable)){// let the editor have it, except for explicitly global combosif(!isGlobalOverride(e))return;}
This also fixes latent misbehaviour in existing inputs (settings fields, tab
rename) and should probably land as its own small commit before F3.
Ctrl+S specifically: it is not currently in DEFAULT_SHORTCUTS and bare-Ctrl
combos are deliberately not intercepted globally (line 31-51, so the terminal
keeps XOFF). Handle it locally in the markdown pane via a component-level
keydown listener, not by adding a global action.
markdown-file.test.ts (new shared main-process module) — extension whitelist
on write, size cap, symlink rejection, grant-set enforcement, mtime conflict
detection, atomic-rename behaviour, and that a rejected write leaves the
original file byte-identical.
Extend surface-slice coverage for the new setMarkdownContent options object,
including "content-only setters must not clobber markdownFilePath" (the same
class of bug the existing fileName guard at line 434-436 exists to prevent).
markdown-path.test.ts — display-path derivation: inside cwd → relative,
inside home → ~/…, elsewhere → absolute, UNC paths (\\server\share\…) not
mangled, and the middle-ellipsis never dropping the basename.
Manual:
wmux markdown docs/config.md → Copy → paste in a terminal → syntax intact.
1b. Same surface → path chip shows docs/config.md; Copy path → paste → absolute
path with backslashes; Reveal in Explorer selects the file. Then open a file
from outside the workspace via the palette → chip shows ~/… or absolute.
1c. wmux markdown set <id> --content "x" against that surface → path is kept,
not cleared or re-pointed.
1d. Drag a .md file from Explorer onto the pane → it opens, path chip updates.
Toggle to Source → double-click one line → paste → exactly that line.
Edit a word, Ctrl+S, git diff → only that word changed, file still ends
with a single trailing newline, CRLF/LF unchanged.
Ctrl+Shift+M → type → Ctrl+S → Save As dialog → file created.
Open a file, echo x >> file from a terminal pane, refocus the markdown pane
→ conflict banner appears; Save is blocked.
Restart wmux with a dirty buffer → content restored, conflict state correct.
Try to save a surface whose content came from markdown.set_content with a
hand-forged path in the store → rejected by the grant set.
PR 2 — F0: plumb markdownFilePath through every producer, path chip in a
new toolbar shell, copy-path / reveal / open-in-default-app / reload,
drag-and-drop onto the pane, full path in the tab tooltip.
PR 3 — F1 + F2: markdownViewMode on SurfaceRef, Copy (document,
selection, per-code-block), source view with gutter, Ctrl+Shift+E, i18n keys
for en/fr/zh. No new IPC. Ships the two must-haves.
PR 3b — extract src/main/markdown-file.ts with the shared read/write
guards (currently duplicated at main/index.ts:616-628 and ipc-handlers.ts:336-359) and add markdownFileMtime. No user-visible
change — pure groundwork, easy to review.
PR 4 — F3: textarea editor, dirty state, MARKDOWN_SAVE_FILE / MARKDOWN_SAVE_AS with grant set + atomic write, conflict banner,
close-confirm.
PR 5 (optional) — the "also worth fixing" items: task-list checkboxes, --title on markdown set, wmux markdown get.
Open questions
Should Ctrl+Shift+M on a pane that already has a markdown surface focus it
instead of stacking a new one (the way openDiffPanel dedups at useKeyboardShortcuts.ts:326-333)? Diff is a singleton; markdown arguably
isn't — but an empty duplicate scratchpad is rarely what's wanted.
Should edit mode be gated behind a setting (default off) so the read-only
guarantee is preserved for users who only ever consume agent output?
For a surface with no backing file, should Save As default into the workspace cwd (ws.cwd is already persisted — src/renderer/App.tsx:682)?
Markdown viewer: copy, source/preview toggle, and in-place editing
Summary
The markdown surface is currently a one-way, read-only render target: content
goes in via CLI/pipe/file-picker, gets converted to sanitized HTML, and that's it.
There is no way to get the text back out (other than mouse-selecting rendered
HTML, which loses markdown syntax), no way to look at the raw source, and no way
to fix a typo in the file you're reading.
That makes the viewer good for consuming agent output (its original purpose,
issue #54) but useless for the far more common loop: agent writes a plan/spec →
user reads it in the pane → user wants to copy it into a ticket, quote one line
into chat, or tweak two sentences and save.
It also means the surface forgets where the content came from: the full path is
dropped at every entry point, so the pane can't tell you which file you're
reading, can't hand the path to the terminal next door, and can't save back to it.
This issue proposes four features, in priority order:
F0–F2 are pure-renderer changes (F0 needs one field plumbed through existing
producers) with no new IPC and no new trust surface. F3 introduces the first
write path to the filesystem from the markdown surface and needs a deliberate
security design (see Security).
Current state
Rendering
src/renderer/components/Markdown/MarkdownPane.tsx:12-51is the whole component(51 lines). It:
marked.parse(content)with{ gfm: true, breaks: true }DOMPurify.sanitize(...)withFORBID_TAGS: ['style','form','input','button','textarea','select']dangerouslySetInnerHTMLinto.markdown-pane__content<a>and routes them to the wmux browser panelNo toolbar, no state, no modes. The raw
contentprop is available in thecomponent but never shown to the user.
Where content comes from
wmux markdown set <id> --content <text>src/cli/wmux.ts:241-247→markdown.set_content(src/main/v2-bridge.ts:88-90)wmux markdown set <id> --file <path>src/cli/wmux.ts:248-251→markdown.load_file(src/main/index.ts:608-637)wmux markdown <file>(one-shot)src/cli/wmux.ts:256-262— creates the surface, thenmarkdown.load_filesrc/renderer/components/CommandPalette/CommandPalette.tsx:86-108→markdown:open-fileIPC (src/main/ipc-handlers.ts:331-366)filePath, renderer throws it away at line 100-101)openMarkdownPanel)src/renderer/hooks/useKeyboardShortcuts.ts:322All of them funnel into
setMarkdownContent(surfaceId, content, fileName?)(
src/renderer/store/surface-slice.ts:427-442), which patches the surface in thesplit tree.
State model
SurfaceRef(src/shared/types.ts:14-36) carries:Two consequences that matter here:
markdownFileNameis a basename(
src/renderer/components/SplitPane/surface-label.ts:36), so today therenderer literally cannot know what file to save back to. F3 requires adding
the path.
src/renderer/App.tsx:666-692serializes the entire
splitTreeon the 30 s autosave, somarkdownContent(including, once F3 lands, unsaved edits) survives restarts. That's a feature
(crash safety) and a hazard (silent divergence from disk) — addressed below.
Available plumbing we can reuse
window.wmux.clipboard.writeText/readText(src/preload/index.ts:98-102).Preferred over
navigator.clipboardin this codebase — see the comments atsrc/renderer/hooks/useTerminal.ts:620-636(no user-gesture requirement, andcorrect handling of non-UTF-8 Windows clipboard payloads).
src/main/index.ts:616-628andsrc/main/ipc-handlers.ts:336-359.diff-pane__refresh-btn(
src/renderer/components/Diff/DiffPane.tsx:242-248,src/renderer/styles/diff.css:92-110).ShortcutActionunion +DEFAULT_SHORTCUTS(
src/renderer/store/settings-slice.ts:160-161, 216-217).en/fr/zh) insrc/renderer/i18n/core.ts:24, 99, 165.Use cases
UC-1 — Copy an agent's plan into a ticket. Claude writes
PLAN.md, wmuxrenders it. The user wants the markdown (headings, bullets, code fences) in
Jira/Linear/GitHub. Selecting the rendered HTML yields plain text with the syntax
stripped and code blocks mangled. Today the only workaround is
cat PLAN.mdin aterminal pane and copying from xterm.
UC-2 — Quote one line into chat. The user wants a single bullet, or one line
of a fenced code block, verbatim. Rendered-HTML selection is unreliable
(soft-wrapped
<li>, inline<code>padding) and there is no line granularity.UC-3 — Verify what the agent actually wrote. Rendering hides syntax errors:
a broken table, an unclosed fence, a literal
\nthat should have been a newline(a real class of bug for content arriving via
markdown.set_content, which passesthrough
JSON.stringifyin the bridge). The user needs to see the source to debugthe producer.
UC-4 — Fix a typo without leaving the pane. Reviewing a spec, spotting a
wrong word, and needing an editor pane /
nvimin another tab to change threecharacters is disproportionate friction. Read → edit → save should happen where
the reading happens.
UC-5 — Write a short note.
Ctrl+Shift+Mopens an empty markdown surface.Today it stays empty forever (it can only be filled from outside). With F3 it
becomes a scratchpad, with "Save As" to materialize it.
UC-6 — Hand the path to something else. The user is reading
docs/config.mdin the pane and wants to run
nvim docs/config.md,git log -- docs/config.md,or paste the path into a Claude prompt in the neighbouring terminal. Today the
pane shows only the basename on the tab; the path has to be reconstructed by
hand — and for a file opened via the palette's native dialog, the user may not
know where it lives at all.
UC-7 — Agent-and-human co-editing. An agent regenerates a doc on disk while
the user has it open. The pane should notice and offer to reload rather than
silently overwrite the agent's work on next save (see Conflict handling).
F0 — Path-aware markdown surfaces
Today a markdown surface is content-only; the path is dropped at every entry
point (see the table above). That's the root cause behind UC-6, it blocks F3
entirely, and it makes the "which file am I even looking at?" question
unanswerable for palette-opened files. Fixing it first makes the rest cheap.
Behaviour
cwdwhen the file is inside it(
ws.cwdis already on the workspace and persisted —src/renderer/App.tsx:682),~/…-shortened when inside the home directory,titletooltip.src/renderer/components/SplitPane/surface-label.ts:36).shell.showItemInFolder, and Open in defaultapp —
shell.openPath. Both are main-process one-liners; gate them on apath being present. (
system.openExternalalready exists in the preloadsurface; these are the filesystem-side equivalents.)
markdown.set_content,Ctrl+Shift+M) showno path chip and disable the path-dependent actions. This must stay a
first-class state, not an error case — pushed agent content is the original use
case for the surface.
already resolve a dropped
Fileto a real path viawindow.wmux.shell.getPathForFile(src/preload/index.ts:103-108, added forterminal drag-and-drop in issue Drag and Drop of Files does nothing #33), so this is a drop handler plus the same
guarded read the palette uses.
F3 even exists, since agents rewrite files under the pane constantly.
Implementation
Add
markdownFilePathtoSurfaceRef(full definition in the F3 state blockbelow) and pass it from every producer:
markdown.load_file(src/main/index.ts:632-634)filePathalongside the existingpath.basename(filePath)markdown:open-filepalette flow (CommandPalette.tsx:94-102)res.filePath; forward itmarkdown.set_content(src/main/v2-bridge.ts:88-90)setMarkdownContentgrows an options object rather than a fourth positionalargument (see F3). Keep the existing "don't clobber on content-only updates"
guard (
src/renderer/store/surface-slice.ts:434-436) and extend it to the path:wmux markdown set <id> --content …against a file-backed surface must notsilently re-point or clear the path.
One decision to make explicit: pushing new content into a file-backed surface
means the buffer no longer matches disk. Treat it exactly like a user edit —
keep the path, mark the surface dirty (once F3 lands). Do not auto-write.
F1 — Copy Markdown
Behaviour
raw
markdownContent— not the rendered text.(no toast infra needed).
contentis empty.mounted, walk
pre > codenodes and inject a small copy button per block. Thisis the single most-requested markdown-viewer affordance and reuses the same
handler.
otherwise the whole document. In preview view it always copies the whole
document (line-level copy is what source view is for).
cwd)as separate entries in the toolbar overflow menu, greyed out when the surface
has no backing path. Copy-path is the fastest bridge from "reading this doc" to
"acting on it in the terminal next to it" (UC-6) and costs nothing once F0
stores the path.
Windows note: copy the path with backslashes as-is. wmux's own CLI and the
shells it spawns accept both separators, and normalizing to forward slashes
would break
cmd.execonsumers.Implementation sketch
For the per-block buttons, prefer a delegated click handler on
.markdown-pane__contentkeyed off adata-md-copyattribute added in auseEffectafter render, over React portals — the HTML is injected viadangerouslySetInnerHTML, so there are no React nodes to attach to. Note thatFORBID_TAGSincludesbutton, so the injected buttons must be created aftersanitization (DOMPurify would strip them if they came from
marked), which theuseEffectapproach naturally does.F2 — Toggle Text/Markdown view
Behaviour
¶ / </>).white-space: pre,horizontally scrollable block using the terminal font stack
(
'Cascadia Mono', 'Consolas', monospace— same asmarkdown.css:47).user-select: none) so double-click /drag selection yields clean lines without numbers. This is what makes UC-2 work.
line-accurate selection).
SurfaceRefasmarkdownViewMode?: 'preview' | 'source'so it survives the remounts caused bysplit-tree restructures (same rationale as the existing
markdownContentcomment at
src/shared/types.ts:29-31) and the session snapshot.Ctrl+Shift+E(currently unbound — verified againstDEFAULT_SHORTCUTS) as a newtoggleMarkdownSourceaction, active when thefocused pane's active surface is a markdown surface.
Why not a third "both" mode
Side-by-side preview+source is tempting but doubles the layout work and competes
with wmux's own splitting: a user who wants both can already split the pane and
open the file twice. Keep two modes; revisit later.
Implementation sketch
Per-line
<div>s are fine up to the 5 MB cap in practice, but a 5 MB file is~100 k lines and will jank. Guard: above a
SOURCE_VIRTUALIZE_THRESHOLD(suggest 5 000 lines) fall back to a single
<pre>{content}</pre>without agutter rather than pulling in a virtualization dependency (the project has
deliberately minimal deps — see
package.json).F3 — Edit and save
Behaviour
editable buffer. Only reachable from source view, so the mental model is
"Preview → Source → Editing".
<textarea>— no CodeMirror/Monaco. The project ships no editordependency and adds ~1–3 MB to the bundle for one. A
textareawith themonospace stack,
Tabinserting two spaces, and auto-continuation of-/1./>list prefixes on Enter covers the "fix a typo, add a bullet" casesthis issue is actually about. Syntax highlighting is explicitly out of scope.
deferred); leaving edit mode re-renders.
markdownDirty?: booleanonSurfaceRef; the tab label gets a•prefix (src/renderer/components/SplitPane/surface-label.ts:36).Ctrl+Swhen the pane is focused, plus a toolbar button):Ctrl+Shift+Mormarkdown.set_content) → opennative Save As dialog, then write and adopt the returned path
reopenClosedSurface(
src/renderer/hooks/useKeyboardShortcuts.ts:339) preserves closed surfaces, soan accidental close is recoverable — the confirm is still worth it because
"recoverable via a shortcut you may not know" isn't good enough for user text.
State additions
setMarkdownContent(src/renderer/store/surface-slice.ts:427-442) grows afilePath/mtimeparameter — or, cleaner, is refactored to take an optionsobject so the next addition doesn't add a fifth positional argument:
Every producer must be updated to pass the path:
src/main/index.ts:632-634(theload_filebridge call),src/main/ipc-handlers.ts:362already returnsfilePath— the palette caller atsrc/renderer/components/CommandPalette/CommandPalette.tsx:94-102just needs toforward it.
New write channel
Main-process handler requirements (
src/main/ipc-handlers.ts):the read guards. Factor
ALLOWED_MD_EXT/MAX_MD_BYTESout ofsrc/main/index.ts:616,624andsrc/main/ipc-handlers.ts:336-337into ashared module (e.g.
src/main/markdown-file.ts) instead of a third copy.(
fs.lstatSync(...).isSymbolicLink()), so a save can't be redirected througha link planted by whatever produced the markdown.
expectedMtimeMsis provided and thefile's current
mtimeMsdiffers, return{ conflict: true, currentMtimeMs }and write nothing.<file>.wmux-tmp-<random>in the same directory, thenfs.renameSyncover the target. A half-written spec is worse than anunsaved one.
{ ok: true, mtimeMs }so the renderer can refreshmarkdownFileMtimeand clearmarkdownDirty.Security
This is the first renderer→disk write in this surface, and the markdown content
itself is explicitly treated as untrusted (see the comment at
MarkdownPane.tsx:21-24, and themarkdown.load_filehardening note insrc/shared/instance.ts:33). Two rules:process keeps a per-
webContentsgrant set of paths that the user or anauthenticated pipe client opened this session — populated in
MARKDOWN_OPEN_FILE(ipc-handlers.ts:350), in themarkdown.load_filehandler (
index.ts:611), and byMARKDOWN_SAVE_ASon dialog confirmation. AMARKDOWN_SAVE_FILEfor a path not in that set is rejected. This keeps theblast radius of a renderer-side bug (e.g. prototype pollution via injected
markdown) to "files the user already opened", not
~/.ssh/authorized_keys.into writing
.ps1/.cmd/.bat. Combined with the whitelist on read,the reachable set stays
.md/.markdown/.mdx/.txt/.text/.rst.Worth noting explicitly:
markdown.load_fileis already token-gated on the pipe(
src/shared/instance.ts:28-37); no new pipe method is required for F3 — savingis renderer-initiated only. Do not add a
markdown.save_filepipe method inthis issue; agents already have unrestricted
Write, and it would widen the pipesurface for no gain.
Conflict handling
Disk-change detection: a lightweight
fs.watchon the backing file, or anmtime re-stat on pane focus. Prefer the mtime-on-focus check for v1 — it
needs no watcher lifecycle management and covers the realistic case ("agent
rewrote the file while I was in another pane"). A watcher can come later if
live-reload-while-visible is wanted.
Session-restore caveat: because unsaved buffers are persisted in the autosave
snapshot (
src/renderer/App.tsx:666-692), a restored dirty surface must re-statits file and, if the mtime moved, come up with the conflict banner already
showing rather than silently holding a stale buffer.
UI layout
The toolbar has to be added carefully:
.markdown-panecurrently owns both thescroll container and the padding (
src/renderer/styles/markdown.css:1-11), so anaive header would scroll away with the content.
Required restructure:
Style the toolbar with the theme variables already used by the diff pane
(
--ui-bg-2,--ui-text-primary,rgba(var(--ui-overlay-rgb), 0.1)for hover)rather than the hard-coded hex values that the rest of
markdown.cssuses — theexisting hardcoded colors are a separate pre-existing wart, not something to
propagate.
Toolbar should auto-hide its labels below a narrow pane width (icon-only), since
markdown surfaces are frequently in a quarter-width pane.
Also worth fixing while we're in here
FORBID_TAGSincludes'input'(MarkdownPane.tsx:28), so- [ ] itemrenders as a bare bullet —and
markdown.css:117-120styles a checkbox that can never exist. Either allowinput[type=checkbox][disabled]explicitly, or render☐/☑glyphs. (Makingcheckboxes interactive is an editing feature and belongs with F3, gated on a
backing file.)
markdown.set_contentcan't set a file name, so every CLI-pushed surface islabelled "Markdown". A
--titleflag onwmux markdown set(src/cli/wmux.ts:241-247)would make multiple agent-pushed docs distinguishable.
wmux markdown get <id>(mirroringread-screen) would let an agent verify what it pushed, and is trivially builton a
markdown.get_contentbridge entry next tosrc/main/v2-bridge.ts:88-90.Implementation risk: global shortcuts vs. the editor
isSafeToIntercept(src/renderer/hooks/useKeyboardShortcuts.ts:30-52) has noguard for editable focus targets — it reasons only about modifier combos, on the
assumption that the only keyboard consumer is xterm. Once a
<textarea>exists ina pane, every
Ctrl+Shift+<letter>binding fires while the user is typing, anddocument-levelpreventDefaultat line 367 will swallow keys the textareashould have received.
Add an early bail:
This also fixes latent misbehaviour in existing inputs (settings fields, tab
rename) and should probably land as its own small commit before F3.
Ctrl+Sspecifically: it is not currently inDEFAULT_SHORTCUTSand bare-Ctrlcombos are deliberately not intercepted globally (line 31-51, so the terminal
keeps XOFF). Handle it locally in the markdown pane via a component-level
keydown listener, not by adding a global action.
Testing
Unit (Vitest,
tests/unit/):markdown-view.test.ts— mode toggle reducer, dirty tracking, list-prefixauto-continuation,
SOURCE_VIRTUALIZE_THRESHOLDfallback.markdown-file.test.ts(new shared main-process module) — extension whiteliston write, size cap, symlink rejection, grant-set enforcement, mtime conflict
detection, atomic-rename behaviour, and that a rejected write leaves the
original file byte-identical.
surface-slicecoverage for the newsetMarkdownContentoptions object,including "content-only setters must not clobber
markdownFilePath" (the sameclass of bug the existing
fileNameguard at line 434-436 exists to prevent).markdown-path.test.ts— display-path derivation: insidecwd→ relative,inside home →
~/…, elsewhere → absolute, UNC paths (\\server\share\…) notmangled, and the middle-ellipsis never dropping the basename.
Manual:
wmux markdown docs/config.md→ Copy → paste in a terminal → syntax intact.1b. Same surface → path chip shows
docs/config.md; Copy path → paste → absolutepath with backslashes; Reveal in Explorer selects the file. Then open a file
from outside the workspace via the palette → chip shows
~/…or absolute.1c.
wmux markdown set <id> --content "x"against that surface → path is kept,not cleared or re-pointed.
1d. Drag a
.mdfile from Explorer onto the pane → it opens, path chip updates.Ctrl+S,git diff→ only that word changed, file still endswith a single trailing newline, CRLF/LF unchanged.
Ctrl+Shift+M→ type →Ctrl+S→ Save As dialog → file created.echo x >> filefrom a terminal pane, refocus the markdown pane→ conflict banner appears; Save is blocked.
markdown.set_contentwith ahand-forged path in the store → rejected by the grant set.
Suggested sequencing
isSafeToIntercept(small, independent, fixesexisting latent bugs).
markdownFilePaththrough every producer, path chip in anew toolbar shell, copy-path / reveal / open-in-default-app / reload,
drag-and-drop onto the pane, full path in the tab tooltip.
markdownViewModeonSurfaceRef, Copy (document,selection, per-code-block), source view with gutter,
Ctrl+Shift+E, i18n keysfor en/fr/zh. No new IPC. Ships the two must-haves.
src/main/markdown-file.tswith the shared read/writeguards (currently duplicated at
main/index.ts:616-628andipc-handlers.ts:336-359) and addmarkdownFileMtime. No user-visiblechange — pure groundwork, easy to review.
MARKDOWN_SAVE_FILE/MARKDOWN_SAVE_ASwith grant set + atomic write, conflict banner,close-confirm.
--titleonmarkdown set,wmux markdown get.Open questions
Ctrl+Shift+Mon a pane that already has a markdown surface focus itinstead of stacking a new one (the way
openDiffPaneldedups atuseKeyboardShortcuts.ts:326-333)? Diff is a singleton; markdown arguablyisn't — but an empty duplicate scratchpad is rarely what's wanted.
guarantee is preserved for users who only ever consume agent output?
cwd(ws.cwdis already persisted —src/renderer/App.tsx:682)?