DSH Plugin Store
Back to home

PerryLink

dsh-composer-history

Terminal-style input history for the DeepSeek Harness web composer - edge-first arrow keys, draft stashing with exact restore, Esc recovery. A dsh-plugin.

Stars
2
Language
TypeScript
Created
Aug 13, 2026
Updated
Aug 14, 2026
Other
GitHub repo

Introduction

⌨️ dsh-composer-history

Terminal-style input history for the DeepSeek Harness Web GUI composer.

English | 简体中文 | 日本語 | 한국어 | Русский

topic: dsh topic: dsh-plugin topic: deepseek-harness
license version node tests harness client

Press like you're in a terminal — but keep your half-typed prompt safe. dsh-composer-history brings Claude Code's edge-first arrow-key model to the dsh web composer, and goes one step further: when you walk back to the newest entry (or hit Esc), your stashed draft and caret position are restored exactly — not cleared. On top of that: sent messages are persisted browser-locally so history survives reloads and reaches across sessions, Ctrl+R opens a reverse search, and every key and tunable is configurable.

Pure UI behavior: no session events, no agent-loop changes, no model requests. Recalled text only enters the ordinary composer draft; it reaches the model only if you press Enter. The persisted history is browser-local text (see Privacy).

✨ Highlights

  • 🎯 Edge-first arrows — bare ↑/↓ move the caret first. History recall only triggers when the caret hits the first/last line of the draft.
  • 💾 Draft stashing — the first recall stashes {draft, caret}; reaching the newest entry again (or Esc) restores both precisely. Claude Code clears here — we restore.
  • 🛡️ Divergence guard — edit a recalled entry and browsing ends instantly; your edit becomes the new draft.
  • 🔄 Live history — re-extracted from the session snapshot on every keypress: kind === 'user' messages, text blocks joined, blanks skipped, adjacent duplicates merged, newest last. Newly sent messages join automatically.
  • 💿 Persisted history — every sent message is appended to a bounded browser-local store (dsh.composer-history.v1), so recall works after a page reload and across sessions. Opt out with persistHistory: false.
  • 🗂️ Workspace scopehistoryScope: 'workspace' prepends other listed sessions' messages before the current session's.
  • 🔍 Reverse searchCtrl+R (configurable) opens a query panel under the composer: type to filter, ↑/↓ to pick, Enter to fill, Esc to cancel.
  • 🎛️ Every key is configurableupKey/downKey/escapeKey/searchKeys live in the Config schema, not in code.
  • ⚙️ Settings integration — the host half registers the composer-history settings namespace (cordis.yml config becomes the composition base); user overrides from the settings document reach the browser. Without a settings service the plugin keeps working exactly as composed.
  • 🚦 Full gating — intercepts only in the plain input phase; yields to the slash menu, command popups, IME composition, text selections, and alt/meta/shift combos. Pass-through paths have zero side effects.
  • 📐 Two edge modeslogical (newline-based, default) or visual (a hidden mirror div measures real wrapped lines).

🎬 How it feels

$ you type a half-finished prompt and press ↑
        └─ draft is stashed, newest history entry fills the composer
$ ↑ ↑ … walk to older entries        $ ↓ ↓ … walk back to the newest
        └─ at the oldest: hold (no-op)         └─ one more ↓: your draft is back,
                                                  caret exactly where it was
$ press Esc at any time → instant restore, browsing ends
$ press Ctrl+R → type a fragment → ↑/↓ → Enter → the match fills the composer

🚀 Quick start

cd Project/Plugins/dsh-composer-history
pnpm install
pnpm run typecheck && pnpm run build && pnpm run test   # all green: 168/168
pnpm run test:coverage                                  # per-module coverage report
pnpm run check:readmes && pnpm run verify:pack          # doc consistency + pack surface

Then register it in a profile (see Installation) and launch dsh --profile <your-profile> --port 3080.

📦 Installation

Build before launching — the client-package check refuses to boot against an unbuilt bundle.

From npm: pnpm add dsh-composer-history (or npm/yarn) ships the built bundles — skip steps 1 and 5.

  1. Build the plugin (above).

  2. Register the row in $DSH_HOME/profiles/<your-profile>/cordis.patch.yml. Use the bare package name as the row name: the browser graph row id is that string, and the client bundle stamps the same id at build time.

    # appended after the bundle layers
    - insert:
        - id: composer-history
          name: dsh-composer-history
          config:
            recallWithDraft: save     # 'save' | 'gate'
            restoreOnEscape: true
            edgeMode: logical         # 'logical' | 'visual'
            enableCtrlAlias: true
            restoreCaret: true
            upKey: ArrowUp
            downKey: ArrowDown
            escapeKey: Escape
            maxHistory: 500           # 0 = unlimited
            includeKinds: [user]      # optionally add 'steering'
            historyScope: session     # 'session' | 'workspace'
            persistHistory: true
            maxPersisted: 200         # 0 = unlimited
            enableSearch: true
            searchKeys: [Ctrl+R]
            searchCaseSensitive: false
    

    The config: block is validated by the host Loader against the same schema, and (when the settings service is present) flows into the browser as the settings base layer — so these values actually reach the browser half, not just the validator.

  3. Bare rows must also appear in the profile's resolver manifest — the host Loader resolves them from the profile directory's node_modules:

    $DSH_HOME/profiles/<your-profile>/package.json:

    {
      "name": "dsh-profile-<your-profile>",
      "private": true,
      "dependencies": {
        "dsh-composer-history": "file:D:/deepseek-harness/Project/Plugins/dsh-composer-history"
      },
      "dsh": {
        "profile": {
          "bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]
        }
      }
    }
    
  4. pnpm install inside the profile directory, then:

    dsh --profile <your-profile> --port 3080
    
  5. For a tarball install (any package manager): pnpm pack in this directory, then reference the tarball in the profile's package.json. pnpm run verify:pack checks the pack surface before you ship it.

⚙️ Config

Every tunable lives in a Schemastery Config schema (no hardcoded knobs). Invalid enum values fail the whole dsh boot loudly — the host Loader validates your cordis.yml block against the same schema, and the settings section is re-validated in the browser before use.

FieldTypeDefaultMeaning
recallWithDraft'save' | 'gate''save'save: a non-empty draft is stashed before recall; gate: only an empty draft recalls (Claude/Codex-style gating)
restoreOnEscapebooleantrueEsc while browsing restores the stashed draft
edgeMode'logical' | 'visual''logical'edge detection by \n lines or by measured wrapped lines
enableCtrlAliasbooleantrueCtrl+↑/↓ behaves like the bare arrows
restoreCaretbooleantruebottom-out / Esc also restores the stashed caret
upKeystring'ArrowUp'KeyboardEvent.key that recalls upward; '' disables
downKeystring'ArrowDown'KeyboardEvent.key that walks newer / restores; '' disables
escapeKeystring'Escape'KeyboardEvent.key that escapes browsing; '' disables
maxHistorynumber500maximum recalled entries (newest kept); 0 = unlimited
includeKindsstring[]['user']conversation node kinds admitted into the history (add 'steering' to include steer messages)
historyScope'session' | 'workspace''session''workspace' prepends other listed sessions' user messages before the current session's
persistHistorybooleantrueappend sent messages to the browser-local store (see Privacy)
maxPersistednumber200maximum stored entries; 0 = unlimited
enableSearchbooleanfalseenable the Ctrl+R reverse-search overlay
searchKeysstring[]['Ctrl+R']chord specs opening the search (modifiers Ctrl/Alt/Meta/Shift + a key name); a malformed spec fails the browser fiber loudly
searchCaseSensitivebooleanfalsewhether search matching distinguishes letter case

🎹 Keybindings

KeyStateBehavior
IDLE, caret on first linestash {draft, caret}, fill newest entry, caret to end (no history → pass)
BROWSING, caret on first lineolder entry; hold at the oldest (intercept, no mutation)
caret not on first linefully released (browser moves the caret)
IDLEalways released (plain caret movement)
BROWSING, caret on last linenewer entry; at newest → restore savedDraft + savedCaret → IDLE
caret not on last linefully released
EscBROWSING (restoreOnEscape: true)restore savedDraft + savedCaret → IDLE, intercepted
Escotherwisereleased (menu/popup Escape semantics untouched)
Ctrl+↑/↓enableCtrlAlias: truesame as bare arrows
searchKeys chordcomposer focused, plain phase, no menu/selection/IMEopen reverse search; browsing ends, the shown text becomes the draft
Shift/Alt/Meta+arrows, IME, selectionanyalways released

upKey/downKey/escapeKey/searchKeys rename the keys above; the modifier policy (and the search chord's exact-modifier match) is unchanged. Inside the search overlay: ↑/↓ move the match selection, Enter fills, Esc cancels, a click picks, a press outside cancels.

🔍 Reverse search

  • Open: the searchKeys chord while the composer is focused and the input is plain (a Ctrl+R here also stops the browser's page reload — the key is consumed only inside the composer).
  • Filter: substring match over the merged history (current session + persisted + workspace entries); case sensitivity per searchCaseSensitive.
  • Pick: Enter fills the draft and moves the caret to the end — the same single setDraft write path as ordinary recall. Recalled text reaches the model only if you press Enter afterwards.
  • Cancel: Esc or a press outside the panel; the draft is untouched.

🔒 Privacy

persistHistory: true (default) writes sent messages to this browser's localStorage under dsh.composer-history.v1, bounded by maxPersisted, never uploaded anywhere, and readable only by pages of the same origin. Disable it with persistHistory: false — recall then uses only the live session projection (and workspace scope), like the v1 behavior. Corrupt or foreign payloads are silently reset.

✅ Verification

  1. Open the web UI and confirm window.__DSH_BOOT__ contains this plugin's row (id: "dsh-composer-history", url: "/plugins/dsh-composer-history/client.js?rev=…").
  2. Request /plugins/dsh-composer-history/client.js — expect 200 (text/javascript).
  3. Manual checklist:
    • Empty composer: ↑ recalls the last message; more ↑ walks older; ↓↓ back to newest; one more ↓ returns to empty.
    • Half-typed draft: ↑ stashes and recalls; ↓↓ to the bottom restores the draft including caret; Esc restores instantly.
    • Multiline draft: mid-line ↑/↓ only move the caret; recall triggers only from the first/last line.
    • Recalling a /xxx entry then pressing Enter adjudicates the command normally (expected).
    • With the slash menu open, ↑/↓ highlight menu items only.
    • During model generation (phase ≠ plain) arrows never recall.
    • Shift+↑/↓ selection, IME composition, Ctrl+Z/Y undo/redo are all unaffected.
    • Ctrl+R opens the search panel; typing filters; ↑/↓ + Enter fills; Esc leaves the draft untouched.
    • After a page reload, ↑ recalls messages sent before the reload (with persistHistory on).
    • With historyScope: 'workspace', entries from other listed sessions precede the current session's.
  4. Gates: pnpm run typecheck, pnpm run build, pnpm run test — all green, including a smoke test that executes the built bundle in jsdom through the real __ModuleLoader__ handshake; plus pnpm run test:coverage, pnpm run check:readmes, pnpm run verify:pack.

🔬 Compatibility baseline (measured on this machine, 2026-08-14)

  • Types: devDependencies pin the published client packages 0.1.0-rc.6 from npm (dsh-client-runtime, dsh-client-ui-conversation, dsh-client-ui-input-trigger, dsh-client-ui-settings, dsh-settings, dsh-api-remotes); typecheck no longer depends on a local checkout. Runtime smoke runs against a checkout whose client packages are 0.1.0-rc.5; @deepseek-ai/cordis 4.0.1; @deepseek-ai/schemastery 3.18.1.
  • InputState phases (read from packages/client/ui-conversation/src/client/input/contract.ts): 'plain' | 'adjudicating' | 'claimed' | 'submitting', plus draft/draftRev. The single public draft write path is ctx.conversation.input.for(actx).setDraft(text); the editRange-aware ComposerKeyboard face is InputBar-private (see docs/upstream-proposals.md C1).
  • Client plugin metadata is the nested dsh.client field (packages/client/modules resolveMeta reads pkg.dsh.client): putting it in the wrong place silently drops the package from the boot graph — no error.
  • Vendored cordis is renamed @deepseek-ai/cordis: type-only imports from it; the built lib/client.js has zero runtime cordis imports (no require( calls at all).
  • The browser boot passes no config to plugins (the boot graph carries {id, url, rev, inject, immediately}): the browser half resolves the schema defaults, and — the new path — the settings scope delivers the host-resolved section (cordis.yml base + user overrides) once the transport is ready. Without a settings service the plugin falls back to schema defaults, exactly as before. Measured: recallWithDraft: bogus aborts the whole boot with failed to apply loader entry composer-history … $.recallWithDraft expected "save" | "gate" but got "bogus".
  • Settings transport is loopback-only: a remote browser cannot read the host document; its scope reports memory/unavailable mode and the plugin falls back to defaults (the history store is browser-local regardless).
  • Rebuild before relaunching: the startup check reads lib/client.js; unbuilt packages are rejected, and the browser only ever fetches build artifacts.
  • Exported symbols only: cross-package reads use types/services exported from @deepseek-ai/dsh-client-* /client entries; asserting the inputTriggers service instance to InputTriggerService is the sanctioned community pattern (done here as a type-only import + ctx.get('inputTriggers') as InputTriggerService | undefined, with a comment explaining why).
  • Client bundle contract: a CJS factory wrapped in window.__ModuleLoader__.load({ id, factory }), platform modules (react, cordis, slots, … plus the @deepseek-ai/dsh-client-runtime/client exemption) external, everything else inlined. This plugin needs no runtime externals; the tsdown config declares the list defensively.
  • Profile files must be UTF-8 without BOM (readProfileManifest runs plain JSON.parse; a BOM aborts boot with Unexpected token '\uFEFF' — measured).

⚠️ Known limitations

  • Logical vs visual lines: default logical keys off \n (a long auto-wrapped message counts as one line); visual measures real wraps via the mirror (a hidden node, O(lines·log n) binary search per edge check, memoized per draft/width). The mirror measurement itself needs a real layout engine — the pure span math is unit-tested instead (see tests/visual-mirror.spec.ts).
  • Persisted history is per-browser: the store lives in localStorage of one origin; it never syncs between browsers or machines. Corrupt payloads reset silently.
  • Undo stack includes recall transactions: every fill/restore is one setDraft transaction in the input machine's undo log; Ctrl+Z can step back through recalls. The plugin never modifies undo/redo semantics; the precision fix needs the upstream edit-range exposure (see docs/upstream-proposals.md C1).
  • Recalling a /xxx entry then Enter follows the normal command claim/adjudication path (expected, and Enter is never intercepted).
  • Menus/popups and non-plain phases always win; a committed send (programmatic draft clear) and session switches both reset to IDLE.
  • Reference chips (U+FFFC placeholders) ride along with recalled/restored draft text.
  • historyScope: 'workspace' reads the live assemblies of other listed sessions; sessions whose assembly has not materialized simply contribute nothing yet.
  • The search overlay is plain DOM (no React dependency); it renders all matches up to the maxHistory bound.

🗺️ Upstream proposals

Three extension-point proposals for deepseek-ai/deepseek-harness are written up in docs/upstream-proposals.md: a public edit-range write on the input face (C1), exporting the trigger-detection pure function (C2), and a documented composer keyboard arbitration chain (C3).

🏷️ Topics & ecosystem

This project is part of the DeepSeek Harness plugin ecosystem. Suggested GitHub topics (set them in the repository settings):

deepseek-harness · dsh · dsh-plugin · web-gui · input-history · keyboard-shortcuts · typescript

Useful links: github.com/topics/dsh-plugin · github.com/topics/deepseek-harness · deepseek-ai/deepseek-harness

📄 License

Apache License 2.0