Terminal-style input history for the web composer: edge-first arrow-key recall with exact draft and caret restore, browser-local persisted history, Ctrl+R reverse search, and sliding-context awareness (compaction summaries join recall and search).
Install
# from npm (prebuilt)
dsh plugin --profile web add dsh-composer-history
# from GitHub (first run asks for allowBuilds approval — follow the hint, retry)
dsh plugin --profile web add github:PerryLink/dsh-composer-history
Any plugin you install runs third-party code with your own permissions — it can read your files, use your credentials, and reach the network, and tool approvals don’t sandbox it. GitHub-sourced plugins also run build scripts at install time. Only install sources you trust, and pin a commit (github:owner/repo#sha).
README
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. And when the harness's sliding context compacts a long conversation (the same auto-compact workflow as Claude Code and Codex), the plugin keeps that history usable: checkpoint summaries join recall and search, and a transient notice announces each compaction with a one-click /compact fill.
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. - 🧭 Sliding-context aware — when the harness auto-compacts (Claude Code / Codex-style), checkpoint summaries join ↑ recall and
Ctrl+Rsearch as[compacted] …entries, and a transient notice (with a one-click "Fill/compact" action) announces each compaction. See Sliding context. - ⚙️ 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: 200/200
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. The package also declares adsh.bundlemanifest (rootcordis.patch.yml), sodsh plugin add-style installers can register the row automatically.
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: false includeCompactionSummaries: true # summaries join recall/search showCompactionNotice: true # transient notice on compaction compactCommandText: /compact # filled by "Compact now"; '' hides itThe
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 3080For 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 |
true |
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 |
includeCompactionSummaries |
boolean |
true |
admit [compacted] … checkpoint summaries into recall and search |
showCompactionNotice |
boolean |
true |
show a transient notice when a compaction checkpoint lands |
compactCommandText |
string |
'/compact' |
slash command the notice's "Compact now" action fills into the composer; '' hides the action |
🎹 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 (the selected row scrolls into view), Enter fills, Esc cancels, a click picks, a press outside cancels; matched substrings are highlighted in every row.
🔍 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; matched substrings are highlighted in each row. - 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.
🧭 Sliding context
The harness core gives every dsh session a sliding context window, the same workflow Claude Code and Codex ship: when a conversation approaches the model's context limit (or the provider reports an overflow), the harness auto-compacts — older turns are summarized behind a compaction checkpoint marker that stays visible in the transcript, the model keeps only the summary plus the recent tail, and the session continues. /compact triggers the same compaction on demand, and the marker renders as an expandable "Context compacted" row.
dsh-composer-history plugs the composer into that workflow so the window slide never costs you your typing history:
- Recall survives compaction — shadowed turns stay in the session snapshot, so ↑ still walks every message you sent before and after a checkpoint.
- Summaries join the history — each checkpoint's summary text enters ↑ recall and
Ctrl+Rsearch as a[compacted] …entry (toggle:includeCompactionSummaries), so context the model no longer sees verbatim stays one keystroke away. - Compaction notice — when a checkpoint lands while the page is open, a transient snackbar announces it (the Claude Code "Auto-compacting conversation…" moment) with the summary snippet and a one-click Fill
/compactaction (showCompactionNotice,compactCommandText); the fill lands in the ordinary draft, and only your Enter sends it. - Search counts — the
Ctrl+Rpanel now shows a liveN entries/N matchesstatus line, and long entries are clamped to two lines.
Compaction itself (thresholds, summary model,
/compact) is owned by the harness core's compaction plugins — this plugin only observes the checkpoint markers the client snapshot already exposes, so it works without any agent-loop or model-request changes.
🔒 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. To erase everything already stored, run localStorage.removeItem('dsh.composer-history.v1') in the page's devtools console.
✅ 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. - After a compaction lands (auto or
/compact), ↑ walks into the[compacted] …summary entry;Ctrl+Rfinds it by its text. - A compaction notice appears near the bottom, auto-dismisses, and its button fills
/compactinto the composer.
- 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. - Compaction markers are client-visible in rc.6:
ConversationNodeincludesCompactionSummaryNode(kind: 'compaction',summary/shadowedItemCount/shadowedTokenCount), and the transcript stays intact above each marker — shadowed turns are never removed from the snapshot. The sliding-context features read only that published face. 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. - Compaction awareness is observational: checkpoints that landed before the plugin install (or before a session switch) never trigger a notice; only markers landing while the page is open do. A checkpoint whose summary event fell outside the loaded window contributes no
[compacted] …entry (summary: null). - The notice's "Compact now" action only fills the configured command text into the draft — sending (and
/compact's own admission) remains the user's Enter.
🗺️ 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 · compaction · sliding-context · typescript
Useful links: github.com/topics/dsh-plugin · github.com/topics/deepseek-harness · deepseek-ai/deepseek-harness
🙌 Contributors
Thanks to everyone who has contributed to this plugin:
- PerryLink — creator and maintainer: every release from v0.1.0 to v0.4.0 (edge-first arrow recall, persisted history, reverse search, sliding-context awareness, search overlay polish,
dsh.bundleanddshWorkshopmanifests).
Open a PR to join this list.
📄 License
Apache License 2.0 · release history in CHANGELOG.md
Links
More in this category
zhu1090093659/dsh-web-ui#packages/dsh-web-ui-all★ 2263
Plugin and skin collection for the DSH Web UI: task board, Git graph, right-side panel, remote mobile UI, pet, live token stats, and a skin center.
ccch1mneyyy/dsh-TUI★ 1051
Claude Code-style full-screen terminal UI: pixel-whale header, live status line, and streaming thought expansion.
omdsh-dev/DSH-better-sidebar★ 920
Full sidebar workbench with file rendering and editing, terminal, Git, and subagents; third-party plugins can register new tabs.
omdsh-dev/dsh-at-file★ 171
Codex-style `@file` mentions: search workspace files in the composer and attach their contents to prompts.
huiliyi37/dsh-tianshu-tui★ 143
A terminal UI (TUI) for DeepSeek Harness.
Nagi-ovo/dsh-visualize★ 90
In-conversation generative UI: the model renders interactive HTML cards into the chat stream, with streaming preview and sandboxed rendering.