chendefine/dsh-composer-markdown

dsh-composer-markdown

DSH Web 输入框的 Markdown 编辑增强:有序与无序列表续行且组内序号恒连续、Tab 层级缩进、行内代码样式、代码块围栏自动闭合;发送的草稿保持字面 Markdown。

未分類

dsh-composer-markdown

中文 · npm · GitHub

In one sentence: a pure client-side plugin for DSH (DeepSeek Harness) Web that adds Markdown editing aids to the conversation composer — list auto-continuation and renumbering, inline-code styling (backticks hidden once rendered), and code-fence auto-closing with atomic block interactions. Every editing gesture rides on Shift+Enter; Enter keeps DSH's native "submit" semantics and is never intercepted. Apart from the ordered-list renumbering, everything is edit-state visuals and key gestures only — the submitted text always stays literal Markdown, byte-for-byte faithful.

  • Platform: web (the DSH Web GUI, dsh plugin --profile web)
  • Shape: a pure client plugin — the host half is a no-op carrier, zero runtime dependencies, toggleable any time in "Settings → Plugins" (设置 → 插件)
  • Tests: 475 assertions passing (a zero-dependency runner: artifact freshness + pure-logic unit tests + bundle-shape assertions + an optional host-contract smoke)
  • License: MIT

Contents

Purpose and use cases

The DSH composer is a Lexical plain-text editor that natively offers only "Enter submits / Shift+Enter soft-breaks" — writing Markdown means typing every literal character by hand. This plugin brings the composer's Markdown editing experience up to the level of mature chat products:

  • Write structured content in the input box: lists, steps, code snippets, code blocks — without leaving the keyboard for a toolbar;
  • Make literal Markdown readable: • bullet markers, monospace numbering, the inline-code background, and paired ``` fences rendering as a real code block — what you see approaches how the sent message renders;
  • Keep the plain-text contract: every enhancement is an edit-state visual layer; what gets sent is exactly the literal Markdown you typed, byte-for-byte — the model side, the message renderer, and the clipboard projection are untouched;
  • Change no native habit: Enter still submits; the / and @ menus, @xxx reference chips, image pastes, and IME input all behave exactly as before.

Not for: rich-text (non-Markdown) editing, or rewriting/beautifying Markdown at send time — this plugin deliberately performs no send-state transformation.

Feature overview

  you type                   edit-state rendering                sent text
─────────────────────────────────────────────────────────────────────
  - apple                  • apple  (bullet marker)             - apple
      ⇧↵ (Shift+Enter)     - ␣      (next line auto-prefixed)    …
  1. step one              1. step one (monospace digits)       1. step one
      ⇧↵                   2. ␣     (auto-incremented, ...)      …
  `code`                   code     (mono+bg, backticks hidden) `code`
  ```ts ⇧↵                ┌─────────────────┐                  ```
                          │ ts badge        │                  (blank)
                          │ ␣ (caret here)  │                  ```
                          └─────────────────┘

By domain:

DomainCapabilities
Listsbullet/ordered continuation (any visual line and any nesting depth, including soft-broken and pasted lines), split-at-caret continuation, empty-item exit, the per-group renumbering invariant (delete closes gaps / breaks restart / merges accumulate / nesting-aware), mid-insert shift-down, the level ladder (Tab sinks, Shift+Tab or Backspace-after-the-atom lifts — whole subtree rides along; a top-level lift unlists back to plain text), atomic atom delete (indent included), atomic arrow hops over atoms (indent included), no resting inside an atom, differentiated marker rendering (• dot / monospace digits)
Inline codepaired-backtick inner text styled as inline code, backticks hidden the instant the pair closes (zero-advance invisibility, real font metrics kept), revealed while a selection genuinely covers them (select-what-you-see)
Code blocks``` / ```ts + Shift+Enter skeleton closing, the type-```-at-a-content-line-head flow that preserves the tail, commit-only paired rendering (whole-block code look + floating language badge + zero-height marker lines), one-keystroke unwrap at block boundaries (markers removed, body kept), ↑/↓ skipping the marker lines, Shift+Enter inside a fence inserts a paragraph, fences open at the head of any line (soft-line promotion — a reference chip's embedded newline blocks only its own boundary, the rest still promote, v0.1.3), orphan close-marker cleanup

Installation

Prerequisites

  • DSH 0.1.7-rc.x (verified against 0.1.7-rc.2; the composer is a Lexical plain-text editor + @lexical/plain-text, lexical 0.49);
  • Node.js ^22.19.0 || >=24.0.0 — the same window DSH itself requires; the package's engines field mirrors it;
  • a modern desktop browser (Chrome / Edge / Firefox / Safari).

Installing the plugin

The package declares a dsh.bundle.patch (cordis.patch.yml: one insert row mounting the host half's no-op entry, which makes the plugin appear in "Settings → Plugins").

From a local checkout (the usual development route):

dsh plugin --profile web add link:/absolute/path/to/dsh-composer-markdown

From the npm registry:

dsh plugin --profile web add dsh-composer-markdown

From GitHub (the built client.js is committed, so the plugin loads even when pnpm skips the prepare build script; pin a commit with #<sha> so a later push cannot change what you run):

dsh plugin --profile web add github:chendefine/dsh-composer-markdown

pnpm ≥ 10 blocks a git dependency's prepare script until allowed. If you want the install to rebuild client.js from src/client/, add the key pnpm prints (e.g. dsh-composer-markdown: true) under allowBuilds in the profile's pnpm-workspace.yaml and re-run the add — treat it as permission to run the package's code on your machine at install time.

Or through the DSH plugin marketplace (设置 → DSH插件市场) — tag the repo with the dsh-plugin topic and it is indexed automatically.

After installing, restart the dsh web service and refresh the browser page. With a link: install, edit the sources under src/client/, run node scripts/build.mjs to regenerate client.js — in dev mode HMR hot-reloads the regenerated artifact (no refresh needed; refresh once if HMR is not running).

Toggle and uninstall

  • Toggle: Settings → Plugins (设置 → 插件) → composer-markdown; disabling restores all native behavior (effective after a refresh);
  • Uninstall: dsh plugin --profile web remove dsh-composer-markdown.

Disable/uninstall auto-cleans: the document listeners, the injected style tag, and every code-style format bit in the editor (the draft text content is unchanged).

Usage

Every editing gesture rides on Shift+Enter; Enter = native submit, never intercepted; a Shift+Enter that matches no scenario = the native soft break. Tab / Shift+Tab are claimed only when the caret's line is a list item (the level ladder) — everywhere else they stay native (focus traversal and the like). Plain Backspace / Delete / arrow keys are claimed only when they hit exactly the boundaries below — everything else keeps the native per-character behavior.

Lists

OperationTriggerBehavior
Bullet/ordered continuationType content after a line-head - / * / N. (any indent depth), press Shift+EnterThe new line below is auto-seeded with the {indent}- / {n+1}. prefix (nesting level preserved), caret right after it; works on any visual line — paragraph heads, soft-broken lines, and multi-line-pasted lines alike
Split at caretShift+Enter with the caret mid-content of a list itemThe line is cut at the caret: everything after it moves down and becomes the new item's content, spliced after the new prefix (byte-faithful); a caret at the line start / inside the marker / at the line end keeps the append-below behavior
Mid-insert shift-downShift+Enter inserting a new item mid-groupThe new line takes n+1 and every member below in the same group shifts +1 (1. 2. 3. inserting between 1 and 2 → 1. 2. 3. 4.); the group stays continuous and duplicate-free; insert and shift share one undo step
Renumbering (the invariant)After any change (delete/insert a line, empty-item exit, paste, undo/redo)Every ordered group (consecutive ordered lines, one indent, outside fences) is always numbered 1. … n. from its first member: a deleted line closes the gap below; a group split by a plain/blank/bullet line restarts its tail at 1.; deleting the splitting line merges the two runs into one continuous count; deeper indents merely suspend the outer group (a nested sublist never interrupts the outer run — its count resumes when the outer indent returns), nested groups normalize independently. Deliberate trade: a group cannot hold a non-1 start or a manual gap — hand-typed digits snap back (continuity IS the contract)
Empty-item exitShift+Enter on an item that is only a prefixThe prefix is removed and the empty paragraph kept (an empty line on soft lines) — press twice to cleanly leave the list; the caret stays on the emptied line (v0.1.2) wherever the item sat — trailing, mid-paragraph between items, or the block's first line — never rolled onto the previous item's end nor clamped onto the next line's head
Level ladder — sinkTab with the collapsed caret anywhere on a list item's line (mid-content included)The item and every deeper line below it (nested sublists, deeper-continuation text — the whole subtree, recursively) each gain 2 spaces of indent; same-level siblings and everything after them are untouched; the caret rides its in-line position; the whole subtree moves in one keystroke, one undo step; the reshuffled ordered groups normalize on the invariant right after (a 2. nested under a parent renumbers to that nested run's 1.). Sinking is capped (v2.9): an item may sit at most ONE level below its parent — the nearest item line above (plain lines skipped, blanks/fences ending the search). An item already one level below its parent, and a list's FIRST item (nothing above to nest under), get a claimed-but-inert no-op on Tab (focus never jumps away mid-list-editing) — the ladder cannot descend without bound
Level ladder — lift / unlistSame positions, Shift+Tab — or Backspace with the collapsed caret right after the list atom (at the content head)A nested item (indent ≥ 1 space) → the item and its whole subtree each lose min(2, indent) spaces (odd hand-typed indents floor at 0); a top-level item → unlisted: the whole atom dies, the content stays as a plain-text line (the caret parks on that same line, v0.1.2), the subtree still rises one level. The plain line an unlist leaves behind splits an ordered group — the same-indent members below restart at 1. per the invariant. Holding Tab sinks once and then holds at the cap; holding Shift+Tab climbs one rung per repeat — focus never escapes an item line on either key
Atomic atom deleteDelete with the collapsed caret right at a list item's line head (before the whole atom)The indent + marker atom goes in one stroke (␣␣1. — all of it), the line becomes top-level plain text, its own undo step; the indent can never be eaten character by character
Atomic arrow hop over atomsPlain ← / → with the collapsed caret at any list-atom boundary→ at the line head jumps clear over the whole ␣␣1. / ␣␣- (indent included) to the content's first character; ← right after the atom jumps back to the line head; a caret already inside the atom (including inside the indent spaces — a click, a vertical move) exits to the far edge — the keyboard never walks the atom interior. Shift+arrows stay native (a selection can still cover exactly the digit characters for manual renumbering)
Differentiated marker renderingAny visual line headed by - / * / N. (outside fences, empty prefixes included)Ordered digits switch to the code font; the bullet dash/star renders as • (dot + space as one marker unit); the marker bytes are kept verbatim in the draft/copy/send text; a selection genuinely covering a marker character reveals the raw character
No resting inside an atomA collapsed caret landing inside a list atom (indent spaces included) via any path (↑/↓, mouse click)The caret is homed to the nearest atom edge (ties snap to the line head); the edges themselves (line head / content head) are legal rest points; selections are unaffected

Inline code

  • When a line holds a paired `non-empty` whose content stays on one line and whose inner-edge characters are non-blank (full-width space / NBSP included): the inner text gets the inline-code look (code font, light background, rounded corners — reusing the DSH theme tokens);
  • The paired backticks hide the instant the pair closes (invisible, zero width, still kept byte-for-byte in the draft and the sent text); they stay hidden while the caret travels inside the pair or brushes either side;
  • Only a selection genuinely covering one of the backtick characters (Shift+arrows, select-all) temporarily reveals the pair (select-what-you-see); it hides again once the selection collapses; a cross-paragraph selection (Ctrl+A) projects per paragraph — no misses at paragraph boundaries;
  • Non-matching pairs are fully inert (their backticks stay visible and never affect later pairing); ``-style multi-backtick delimiters are not recognized (naive left-to-right pairing).

Code blocks

OperationTriggerBehavior
Skeleton closingThe caret's line is exactly ``` (optionally with a language id ```ts) and the caret sits inside/after the marker run, Shift+EnterInserts the three-line skeleton ``` / empty / ```, caret at the head of the empty line; works when typed at the head of any line (soft-broken and pasted lines included); a caret before the marker run (bare ``` line, offset 0) is plain text and keeps the native soft break
Content-preserving flowType ``` at the head of a line that already has content (caret still right after it), Shift+EnterSplits after the ```: the marker line keeps ```, an empty line + closing skeleton is inserted, and everything after ``` on the original line moves below the closing marker (outside the block, byte-faithful); the language-id flow is unaffected (```ts at line end still yields the badged skeleton)
Paired renderingA paired ```…``` region in the draftThe whole region renders as one code block: code background + code font inside (the same theme tokens and geometry as the message-side CodeBlock); the first marker line is zero-height, the language id shows as a floating badge at the block's top-right; the ``` markers are fully imperceptible (invisible, never host the caret); an unclosed ``` is plain text, always — typing ``` mid-draft never swallows the content below into a block
One-keystroke unwrapBackspace / Delete at a block boundary (← at the first body line's head / the paragraph after the block; → at the paragraph before the block / the last body line's end)One keystroke removes the pair of ``` marker lines: the block un-renders, the body survives verbatim as plain paragraphs, the caret stays put — its own undo step
Leaving the block↓ at the last body line's end (an empty paragraph grows below when the block ends the draft)The caret drops to the new paragraph below the block to continue typing plain text; ↑/↓ skip the marker lines at block boundaries; the marker-line eviction lands a text-anchored caret (v0.1.3), so ↑/↓ stay armed from a body edge the caret was homed onto — the box top/bottom is never a keyboard trap
New paragraph inside a fenceShift+Enter inside a fence (the closing line included)Inserts a new paragraph (not a soft break), split at the caret; plain Enter inside a fence still submits

Coexistence with native behavior

  • Enter: keeps DSH's native "submit" and is never intercepted (on list lines and inside fences alike);
  • Ctrl/Cmd+Enter: keeps the native "accelerated submit";
  • Shift+Enter (no scenario matched): the native soft break (<br>);
  • Tab / Shift+Tab (not a list-item line, inside a fence, a range selection, or a modifier chord): fully native (browser focus traversal etc.), never intercepted; likewise yielded while a trigger menu is open; held repeats on a list-item line re-plan like any press (they never fall through to focus traversal);
  • While a / or @ trigger menu is open: the Enter family is fully yielded;
  • IME (e.g. Chinese input): keys during a composition trigger nothing (three-signal guard: isComposing / keyCode 229 / a 10 ms window after compositionend — the same guard the DSH input machine itself uses);
  • Slash-command claims, @ reference chips, image drag-drop/paste, busy/locked states, and Q&A/approval cards are unaffected;
  • Coexisting with other composer plugins: capture-phase first-come-first-served — a key already preventDefault-ed is yielded to; keys this plugin consumes get stopImmediatePropagation.

Architecture

The two halves

A DSH plugin has a host (node) half and a browser half; this plugin is the extreme pure-client shape:

┌─ host half (node) ────────────────────────────────────────────┐
│ index.js          a no-op carrier: an importable entry row for │
│                   the bundle, so the plugin shows up in        │
│                   "Settings → Plugins" and is toggleable there │
└────────────────────────────────────────────────────────────────┘
┌─ browser half (web) ──────────────────────────────────────────┐
│ client.js         the single-file load artifact (the DSH       │
│                   loader only accepts single-file bundles),    │
│                   generated from the 15 src/client/ modules by │
│                   scripts/build.mjs and committed; registers   │
│                   via window.__ModuleLoader__.load             │
│                   ({id, factory}), with an in-bundle           │
│                   CommonJS-style module registry               │
└────────────────────────────────────────────────────────────────┘

Source layering (src/client/, one-way dependencies top-down)

constants.js     constants + line-grammar regexes + CSS class names
                 (the stylesheet composes its selectors from them — no drift)
editor.js        the host-contract seam: the ONLY module touching the
                 host's underscore internals
                 (__lexicalEditor/_nodes/_nodeMap/_selection/_compositionKey)
grammar.js       the line-grammar substrate: visual lines, fence intervals
                 + committed coverage, the shared caret-line resolution,
                 the selection-covers reveal rule — derived once per read
doc.js           the document read: block/leaf flat geometry + caret/selection
                 mapping (committed/live flavors) + the husk-block query
fence-plan.js    pure fence projections and decisions (line/block roles,
                 soft-line promotion, fence pairs, orphan cleanup,
                 atomic keys, caret homing)
code-plan.js     the pure inline-code kernels + per-block span/delimiter
                 projections
enter-plan.js    pure Shift+Enter arbitration (one decision tree: fences
                 first, then lists)
list-plan.js     pure list planning (the state-driven renumber walk, marker
                 glyphs, atomic delete/hop/homing, join/detach, shift-down,
                 re-anchor)
analysis.js      one analyzeDraft(texts): the model + every domain projection
edits.js         the edit algebra: the ONLY home of every live-node mutation
                 (flat-range erase / soft-line promotion / paragraph split /
                 digit rewrite / format shapes)
style-sheet.js   the CSS text (composed from the class constants) + the
                 style-tag lifecycle
present.js       the presentation layer: block marks (fence classes / badge)
                 + the ONE glyph-mark engine ({at, class, reveal} table)
                 + stripDom
gestures.js      the key surface: guards + the gesture policy table
                 (prepare/apply/history steps) + the one claim path
restyle.js       the convergent engine: the ordered STAGES table (normalize →
                 DOM marks → repairs → caret home → code/marker shapes),
                 one analysis per pass, at most one structural commit
                 + cross-pass memory + loop guard; instance state, never
                 module globals
index.js         the composition root: lifecycle + __internals assembly

Five cross-cutting designs

Features are not individually plumbed pipelines — they are rows over one set of orthogonal abstractions:

  1. One line grammar: visual-line splitting (each paragraph split on \n; a soft break's \n and a pasted \n are the same thing), fence pairing with committed coverage, and caret-line resolution are derived once in grammar.js; lists, inline code, fences, and Enter arbitration all consume the same model — they cannot drift apart by construction.
  2. One draft analysis: analyzeDraft(texts) assembles the fence roles, soft-line promotion, inline-code spans/delimiters, list marker glyphs, and the renumber invariant into a single object — every projection sees the same lines and the same fence coverage.
  3. One edit algebra: plan (pure data, unit-tested) and apply (live-node operations, edits.js only) are strictly separated; neither the gesture policies nor the convergence engine touch nodes directly.
  4. One presentation engine: edit-state visuals come in exactly two shapes — block marks (fence paragraph classes / badge) and glyph marks (a single-char leaf + class + reveal rule); hidden backticks and list-marker styling share one engine and one reveal predicate — adding a glyph kind is a table row, not a pass.
  5. One table-driven dual driver: the keyboard side is a policy table (one prepare/apply/history per key family), the convergence side is a stage table (one pure plan function per stage); new behavior is a row in a table, not a new pipeline.

Key technical details