Skip to content

Markdown viewer: path awareness, copy, source/preview toggle, and in-place editing #116

Description

@kevmtt

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:

  1. marked.parse(content) with { gfm: true, breaks: true }
  2. DOMPurify.sanitize(...) with FORBID_TAGS: ['style','form','input','button','textarea','select']
  3. dumps the result through dangerouslySetInnerHTML into .markdown-pane__content
  4. 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.

Where content comes from

Entry point Code Sets file name? Sets file path?
wmux markdown set <id> --content <text> src/cli/wmux.ts:241-247markdown.set_content (src/main/v2-bridge.ts:88-90) no no
wmux markdown set <id> --file <path> src/cli/wmux.ts:248-251markdown.load_file (src/main/index.ts:608-637) yes (basename) no
wmux markdown <file> (one-shot) src/cli/wmux.ts:256-262 — creates the surface, then markdown.load_file yes (basename) no
Command palette → "Open Markdown File…" src/renderer/components/CommandPalette/CommandPalette.tsx:86-108markdown:open-file IPC (src/main/ipc-handlers.ts:331-366) yes (derived in renderer) 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).
  • Read guards already centralized twice (extension whitelist + 5 MB cap):
    src/main/index.ts:616-628 and src/main/ipc-handlers.ts:336-359.
  • 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 Explorershell.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)
markdown:open-file palette flow (CommandPalette.tsx:94-102) stop discarding res.filePath; forward it
markdown.set_content (src/main/v2-bridge.ts:88-90) unchanged — no path by design

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 markdownContentnot 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.

Implementation sketch

const copy = useCallback(async (text: string) => {
  if (!text) return;
  await window.wmux?.clipboard?.writeText?.(text);   // preload/index.ts:100
  setCopied(true);
  window.setTimeout(() => setCopied(false), 1200);
}, []);

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.

Implementation sketch

type ViewMode = 'preview' | 'source';

// source view
<pre className="markdown-pane__source">
  {content.split('\n').map((line, i) => (
    <div className="markdown-pane__source-line" key={i}>
      <span className="markdown-pane__source-gutter">{i + 1}</span>
      <span className="markdown-pane__source-text">{line || ' '}</span>
    </div>
  ))}
</pre>

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:

setMarkdownContent(surfaceId, content, { fileName, filePath, mtimeMs, dirty }?)

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.

New write channel

// src/shared/types.ts → IPC_CHANNELS
MARKDOWN_SAVE_FILE: 'markdown:save-file',   // write in place
MARKDOWN_SAVE_AS:   'markdown:save-as',     // native dialog + write
// src/preload/index.ts (next to markdown.openFile, line 185-189)
markdown: {
  openFile: () => ipcRenderer.invoke(IPC_CHANNELS.MARKDOWN_OPEN_FILE),
  saveFile: (filePath: string, content: string, expectedMtimeMs?: number) =>
    ipcRenderer.invoke(IPC_CHANNELS.MARKDOWN_SAVE_FILE, filePath, content, expectedMtimeMs),
  saveAs: (content: string, suggestedName?: string) =>
    ipcRenderer.invoke(IPC_CHANNELS.MARKDOWN_SAVE_AS, content, suggestedName),
}

Main-process handler requirements (src/main/ipc-handlers.ts):

  1. 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.
  2. Path must be in the grant set (see below).
  3. 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.
  4. Optimistic-concurrency check: if expectedMtimeMs is provided and the
    file's current mtimeMs differs, return
    { conflict: true, currentMtimeMs } and write nothing.
  5. 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.
  6. 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-webContents grant 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.

┌──────────────────────────────────────────────────────────────┐
│ docs/PLAN.md ⧉   [Preview|Source] [Edit] [Copy] [⋯]          │  ← sticky, ~32px
├──────────────────────────────────────────────────────────────┤
│  ↑ path chip: click = copy path, ⧉ = reveal in explorer      │
│    ⋯ = Copy path / Copy relative path / Reload / Reveal /    │
│        Open in default app / Revert                          │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│   rendered markdown / source / textarea (scrolls)            │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Required restructure:

.markdown-pane { display: flex; flex-direction: column; overflow: hidden; padding: 0; }
.markdown-pane__toolbar { flex: 0 0 auto; /* diff-pane__sidebar-header tokens */ }
.markdown-pane__body { flex: 1 1 auto; overflow: auto; padding: 24px 32px; }

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:

const t = e.target as HTMLElement | null;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) {
  // let the editor have it, except for explicitly global combos
  if (!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.


Testing

Unit (Vitest, tests/unit/):

  • markdown-view.test.ts — mode toggle reducer, dirty tracking, list-prefix
    auto-continuation, SOURCE_VIRTUALIZE_THRESHOLD fallback.
  • 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:

  1. 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.
  2. Toggle to Source → double-click one line → paste → exactly that line.
  3. Edit a word, Ctrl+S, git diff → only that word changed, file still ends
    with a single trailing newline, CRLF/LF unchanged.
  4. Ctrl+Shift+M → type → Ctrl+S → Save As dialog → file created.
  5. Open a file, echo x >> file from a terminal pane, refocus the markdown pane
    → conflict banner appears; Save is blocked.
  6. Restart wmux with a dirty buffer → content restored, conflict state correct.
  7. 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.

Suggested sequencing

  1. PR 1 — editable-focus guard in isSafeToIntercept (small, independent, fixes
    existing latent bugs).
  2. 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.
  3. 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.
  4. 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.
  5. PR 4 — F3: textarea editor, dirty state, MARKDOWN_SAVE_FILE /
    MARKDOWN_SAVE_AS with grant set + atomic write, conflict banner,
    close-confirm.
  6. 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)?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions