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
Introduction
⌨️ dsh-composer-history
Terminal-style input history for the DeepSeek Harness Web GUI composer.
English | 简体中文 | 日本語 | 한국어 | Русский
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 (orEsc) 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 withpersistHistory: false. - 🗂️ Workspace scope —
historyScope: 'workspace'prepends other listed sessions' messages before the current session's. - 🔍 Reverse search —
Ctrl+R(configurable) opens a query panel under the composer: type to filter, ↑/↓ to pick, Enter to fill, Esc to cancel. - 🎛️ Every key is configurable —
upKey/downKey/escapeKey/searchKeyslive in the Config schema, not in code. - ⚙️ Settings integration — the host half registers the
composer-historysettings namespace (cordis.yml config becomes the compositionbase); 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
plaininput 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 modes —
logical(newline-based, default) orvisual(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.
-
Build the plugin (above).
-
Register the row in
$DSH_HOME/profiles/<your-profile>/cordis.patch.yml. Use the bare package name as the rowname: 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: falseThe
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 settingsbaselayer — so these values actually reach the browser half, not just the validator. -
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"] } } } -
pnpm installinside the profile directory, then:dsh --profile <your-profile> --port 3080 -
For a tarball install (any package manager):
pnpm packin this directory, then reference the tarball in the profile'spackage.json.pnpm run verify:packchecks 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.
| Field | Type | Default | Meaning |
|---|---|---|---|
recallWithDraft | 'save' | 'gate' | 'save' | save: a non-empty draft is stashed before recall; gate: only an empty draft recalls (Claude/Codex-style gating) |
restoreOnEscape | boolean | true | Esc while browsing restores the stashed draft |
edgeMode | 'logical' | 'visual' | 'logical' | edge detection by \n lines or by measured wrapped lines |
enableCtrlAlias | boolean | true | Ctrl+↑/↓ behaves like the bare arrows |
restoreCaret | boolean | true | bottom-out / Esc also restores the stashed caret |
upKey | string | 'ArrowUp' | KeyboardEvent.key that recalls upward; '' disables |
downKey | string | 'ArrowDown' | KeyboardEvent.key that walks newer / restores; '' disables |
escapeKey | string | 'Escape' | KeyboardEvent.key that escapes browsing; '' disables |
maxHistory | number | 500 | maximum recalled entries (newest kept); 0 = unlimited |
includeKinds | string[] | ['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 |
persistHistory | boolean | true | append sent messages to the browser-local store (see Privacy) |
maxPersisted | number | 200 | maximum stored entries; 0 = unlimited |
enableSearch | boolean | false | enable the Ctrl+R reverse-search overlay |
searchKeys | string[] | ['Ctrl+R'] | chord specs opening the search (modifiers Ctrl/Alt/Meta/Shift + a key name); a malformed spec fails the browser fiber loudly |
searchCaseSensitive | boolean | false | whether search matching distinguishes letter case |
🎹 Keybindings
| Key | State | Behavior |
|---|---|---|
| ↑ | IDLE, caret on first line | stash {draft, caret}, fill newest entry, caret to end (no history → pass) |
| ↑ | BROWSING, caret on first line | older entry; hold at the oldest (intercept, no mutation) |
| ↑ | caret not on first line | fully released (browser moves the caret) |
| ↓ | IDLE | always released (plain caret movement) |
| ↓ | BROWSING, caret on last line | newer entry; at newest → restore savedDraft + savedCaret → IDLE |
| ↓ | caret not on last line | fully released |
| Esc | BROWSING (restoreOnEscape: true) | restore savedDraft + savedCaret → IDLE, intercepted |
| Esc | otherwise | released (menu/popup Escape semantics untouched) |
| Ctrl+↑/↓ | enableCtrlAlias: true | same as bare arrows |
searchKeys chord | composer focused, plain phase, no menu/selection/IME | open reverse search; browsing ends, the shown text becomes the draft |
| Shift/Alt/Meta+arrows, IME, selection | any | always 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
searchKeyschord while the composer is focused and the input isplain(aCtrl+Rhere 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
setDraftwrite 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
- 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=…"). - Request
/plugins/dsh-composer-history/client.js— expect200(text/javascript). - 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;
Escrestores instantly. - Multiline draft: mid-line ↑/↓ only move the caret; recall triggers only from the first/last line.
- Recalling a
/xxxentry 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+Ropens the search panel; typing filters; ↑/↓ + Enter fills; Esc leaves the draft untouched.- After a page reload, ↑ recalls messages sent before the reload (with
persistHistoryon). - With
historyScope: 'workspace', entries from other listed sessions precede the current session's.
- 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; pluspnpm 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);typecheckno longer depends on a local checkout. Runtime smoke runs against a checkout whose client packages are 0.1.0-rc.5;@deepseek-ai/cordis4.0.1;@deepseek-ai/schemastery3.18.1. InputStatephases (read frompackages/client/ui-conversation/src/client/input/contract.ts):'plain' | 'adjudicating' | 'claimed' | 'submitting', plusdraft/draftRev. The single public draft write path isctx.conversation.input.for(actx).setDraft(text); theeditRange-awareComposerKeyboardface is InputBar-private (seedocs/upstream-proposals.mdC1).- Client plugin metadata is the nested
dsh.clientfield (packages/client/modulesresolveMetareadspkg.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 builtlib/client.jshas zero runtime cordis imports (norequire(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.ymlbase+ user overrides) once the transport is ready. Without a settings service the plugin falls back to schema defaults, exactly as before. Measured:recallWithDraft: bogusaborts the whole boot withfailed 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-*/cliententries; asserting theinputTriggersservice instance toInputTriggerServiceis 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/clientexemption) 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 (
readProfileManifestruns plainJSON.parse; a BOM aborts boot withUnexpected token '\uFEFF'— measured).
⚠️ Known limitations
- Logical vs visual lines: default
logicalkeys off\n(a long auto-wrapped message counts as one line);visualmeasures 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 (seetests/visual-mirror.spec.ts). - Persisted history is per-browser: the store lives in
localStorageof one origin; it never syncs between browsers or machines. Corrupt payloads reset silently. - Undo stack includes recall transactions: every fill/restore is one
setDrafttransaction 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 (seedocs/upstream-proposals.mdC1). - Recalling a
/xxxentry then Enter follows the normal command claim/adjudication path (expected, and Enter is never intercepted). - Menus/popups and non-
plainphases 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
maxHistorybound.
🗺️ 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