omdsh-shortcuts
English | 中文
Bind a chord to anything the DeepSeek Harness can do, from one document, across both surfaces it runs on.
Mounting this plugin makes a menu appear and a set of keys start working; unmounting it takes both away; editing its configuration rebuilds them in place. None of that rebuilds or restarts the desktop shell, and none of it is a harness edit.
What it adds
| Surface | Where it comes from |
|---|---|
| The desktop shell's native menu, and every accelerator on it | The document served over GET /api/desktop/menu, pushed again on GET /api/desktop/menu.events at every revision |
| A native press arriving in the page that is actually in front | POST /api/desktop/menu.invoke from the shell, then GET /api/desktop/shortcut.events?client=<id> down to the client that last reported focus |
| In-page chords on the web, where there is no menu to claim them | The browser half's own key listener, binding this surface's chords from the same document |
The shortcut service, in the runtime and in the page alike | ctx.reflect.provide('shortcut', …) on both halves: register, bindings(), onBindings, chordLabel |
| Twelve of the fifteen UI commands, performed | [src/client/builtins.ts](src/client/builtins.ts), calling layout, sessions, workspaces and sessionModes — the other three belong to the plugins that own them |
| A New Window that starts empty | The browser half spending ?new=1, which omdsh-desktop puts on the address of a window it just opened |
| Every chord named in the tooltip of the button that performs it, the harness's own buttons included | [src/client/hints.ts](src/client/hints.ts): the chord appended to the tooltip ui-primitives already raises, and a plate of this package's own where it raises none |
| A rebinding form in the plugin hub | The omdsh-shortcuts settings namespace: a flat id → chord dictionary and the hint switch, applied live |
The idea
A keystroke has to start where the keyboard is and end where the command lives, and those are three different places:
command.kind | Performed by | Reached how |
|---|---|---|
shell | The Electron main process | Native menu accelerator |
runtime | A host plugin, in the Node runtime | This plugin's switchboard |
browser | A UI plugin, in the page | This plugin's browser half |
A plugin says only what it can do. Which chord reaches it — or whether one does at all on this surface — is configuration, so a person who wants ⌘L to open the side chat edits one document and never goes looking for which plugin hard-coded a key.
Two surfaces, one document
The same runtime serves a desktop window and a browser tab at once — using open-in-browser produces exactly that pair — and the two do not hear keystrokes the same way.
web ⌘K ─→ this page's listener ──────────────────→ handler
desktop ⌘K ─→ Electron menu ─→ runtime ─→ this page ──→ handlerOn the desktop the chord is claimed natively, before the page exists, so a press travels: the shell posts the id to the runtime, and the runtime hands it to the browser client in front. On the web there is no menu and no native claim, so the page hears its own keystroke and runs its own handler without asking anybody.
That difference is not configurable, so bindings are not always transferable either. A browser keeps ⌘N, ⌘T, ⌘W and ⌘Q for itself — the page is not asked and preventDefault has nothing to prevent. CmdOrCtrl+N is a perfectly good native binding for new-window and a key a tab is never handed.
On the desktop that chord opens a window. The shell loads it with ?new=1 because the harness UI otherwise restores the last selected conversation from origin-wide localStorage — the same cell every window of that origin shares. This page spends the parameter on mount: it clears the restored selection before the session list arrives and opens a history window, then drops new from the address so a reload of this window is an ordinary restore. It does not start a conversation; ⌘K is still what does that.
webAccelerator is where the two are allowed to disagree:
webAccelerator | On the web |
|---|---|
| absent | the same chord as accelerator |
| a string | that chord instead |
null | no chord at all; still on the menu, still reachable by mouse |
Writing a chord the browser reserves into webAccelerator is a fault at mount, not a key that quietly does nothing: asking for ⌘W in a tab is a request the page cannot honour, and refusing it is the only honest answer. Leaving a native-only accelerator alone is not a fault — the web surface simply reports that binding as unreachable, which a settings surface can render as "native only".
The routes it holds
Registered through ctx.effect on the webServer service, so unmounting the plugin removes them and the shell falls back to the platform's floor.
| Route | Who reads it |
|---|---|
GET /api/desktop/menu | anyone; the document, read once |
GET /api/desktop/menu.events | the shell; the document on connect and on every revision |
POST /api/desktop/menu.invoke | the shell, handing back an item it does not perform |
GET /api/desktop/shortcut.events?client=<id> | a browser client; bindings, and the presses it could not hear |
POST /api/desktop/shortcut.focus | a browser client, saying it is the one in front |
The shell's stream and the client's are separate on purpose. The shell's payload is a bare document, which is what every shipped desktop build already parses; framing it to carry invocations as well would blank the menu bar of every installed shell.
Which client gets a desktop press
The one that most recently reported focus. There is usually more than one client — several windows, several tabs, or both against the same runtime — and "the surface the person is looking at" is the only answer that is ever right.
Focus is reported rather than deduced because nobody else can see it: the shell knows which window owns the menu that was pressed, but its windows carry no preload and stay sandboxed by design, so it has no channel into the page; and an HTTP request says nothing about where somebody's attention is. So the page says so, and keeps saying so.
The document
{
version: 1,
items: [
{
id: 'new-window',
label: 'New Window',
section: 'file', // app | file | view | window | help
command: { kind: 'shell', name: 'new-window' },
accelerator: 'CmdOrCtrl+N', // native only; a tab never gets ⌘N
},
{
id: 'sidechat.open',
label: 'Ask Here',
section: 'view',
command: { kind: 'browser' },
accelerator: 'CmdOrCtrl+L',
webAccelerator: 'CmdOrCtrl+Alt+L', // the browser keeps ⌘L; the web gets Alt
},
{ id: 'say-hello', label: 'Say Hello', section: 'help', command: { kind: 'runtime' } },
],
}shell names one of a fixed vocabulary the main process performs — new-window, restart-runtime, reveal-log, open-in-browser, toggle-idle-suspend. That list is the one part of the contract a plugin cannot grow, because growing it means shipping a new Electron build. Anything a person can bind at will is runtime or browser.
A checkbox: true item renders as one, but its state belongs to the shell, not to this document: the shell reads its own stored setting when it builds the entry, so a rebuild cannot make the tick drift from what it describes.
Refused at mount rather than served: two items sharing an id, two items claiming one native chord, two items binding one chord in the page, and a webAccelerator that is malformed or that the browser reserves.
Registering a command
In the runtime, for a runtime command. The service is resolved by name rather than through an ambient ctx.shortcut, because both halves of this package compile as one program and only the browser half augments cordis's Context:
shortcut is reached from inside apply, never from a top-level inject: whether this plugin is in the profile is a person's dsh plugin add decision, and cordis's inject wait has no timeout, so a top-level entry naming it sits at pending and both boot audits fail the WHOLE page. A fiber started inside apply is not a loader entry, so waiting forever costs nothing.
export function apply(ctx: Context): void {
ctx.inject(['shortcut'], (sctx) => {
const shortcut = sctx.get('shortcut') as unknown as IShortcut | undefined
// Reachable when the name is provided by a fiber that is not active.
if (shortcut === undefined) return
sctx.effect(() => shortcut.register('say-hello', () => { /* ... */ }))
})
}In the browser, for a browser command:
export function apply(ctx: ClientContext): void {
ctx.inject(['shortcut'], (sctx) => {
if (sctx.get('shortcut') === undefined) return
sctx.effect(() => sctx.shortcut.register('sidechat.open', () => { panel.open() }))
})
}Hang the effects on sctx rather than ctx, so unloading this plugin at runtime withdraws the registrations with it.
Registering claims no key. A command the document never declares registers fine and simply never fires, which is the right outcome for a plugin mounted against a configuration that does not mention it. ctx.shortcut.bindings() reports how each command actually stands on this surface, including the ones with no chord here and why; ctx.shortcut.onBindings(fn) fires after each revision, so a surface that DISPLAYS a chord — a tooltip, a settings row — follows a rebinding without a reload.
Register on a RESTRICTED fiber rather than in the plugin's own inject list, or a composition with no keybinding layer loses the behaviour itself instead of merely losing its chord:
ctx.inject(['shortcut'], (sctx) => {
const shortcut = sctx.get('shortcut') as unknown as IShortcutClient
sctx.effect(() => shortcut.register('panel.files', () => { geometry.toggleRight() }))
})Letting a button teach its chord
Someone who found a feature with the mouse should be able to stop using the mouse for it, so a button names its chord in its tooltip. chordLabel is the whole of what that takes:
// "Show the file panel · ⇧⌘E", or "· ⌥⌘E" in a browser tab
const chord = shortcut.chordLabel('panel.files')
const hint = chord === undefined ? t('files.open') : `${t('files.open')} · ${chord}`Three things it settles, so no surface redoes them:
- The platform's spelling —
⇧⌘Eon a Mac,Ctrl+Shift+Eelsewhere.
CmdOrCtrl+Shift+E is the WIRE spelling, and printing it would teach the configuration format instead of the key.
- The surface's chord — the native
⌘1on the desktop,⌥⌘1in a tab,
because those are the keys each one actually receives.
undefinedwhen no chord reaches it — so the tooltip falls back to the
bare title rather than a separator with nothing after it. That is the ordinary state in a tab for a command whose key the browser kept.
Pair it with onBindings: the document is pushed, so the first read is usually empty, and a rebinding has to reach the tooltip too. omdsh-sidepanel's two panel switches and omdsh-chatmode's and omdsh-codemode's mode segments all do exactly this. omdsh-sidechat's summon icon reaches the same place by the lower road — it reads bindings() itself and formats the claim, because it wants to know WHO holds the chord and not only how to print it.
A UI plugin that already binds its own key hands it over by unbinding — the protocol setSummonChord(null) names — and registers a command instead. Between the two there is no case where two handlers race for one keystroke. Two worked examples ship in this repository:
omdsh-sidepanelhands over its two
panels. It bound no key of its own, so it only registers.
omdsh-sidechathands over a key
it was ALREADY using: on a restricted fiber it calls setSummonChord(null), registers sidechat.open, and then feeds its tooltip from onBindings — giving a key up must not mean it stops being teachable. Unloading the fiber gives the built-in CmdOrCtrl+L back, so removing a keybinding layer does not quietly remove the summon with it.
The harness's own buttons, taught from outside
New Session, the session search, add workspace, Settings and the sidebar fold cannot do any of that for themselves: their components live in packages this repository does not edit, and a keybinding layer that arrives from outside the harness is exactly the thing they were never written to ask. So this plugin hangs the hint on them, and hovering one now reads New session · ⌘K.
Two mechanisms, and which one a hover uses is decided rather than configured:
| The harness renders | What happens |
|---|---|
| its own tooltip | the chord is APPENDED to it — one plate, placed and animated and clamped by the code that owns it, now ending in · ⌘K |
| nothing (the Settings trigger, and New Session while the column is wide) | this plugin raises a plate of its own, styled from the same theme variables |
A hover waits 600ms — past the harness's own 500ms hover delay — before it decides, so a control that has a tooltip is always augmented and never doubled. Only an appended element is ever added, never a change to the text React put there, which is the one direction a foreign write into a React subtree is safe in. Unmounting the plugin takes the appended chords and the plate away with it.
A button is recognized by the name it is already wearing. aria-label, compared against the same dictionary entry the button resolved it through — locale.bind('sidebar')('session.new.label') — so this is asking the harness what it calls its own button rather than scraping a rendered string. It follows a locale switch for free, and a renamed key costs a hint rather than producing a wrong one. The two controls whose name is not a dictionary entry to look up — the Settings trigger, which names itself from a slot, and a settings page row, which names itself from its registration — are addressed the way [src/client/anchors.ts](src/client/anchors.ts) addresses everything else: the Settings trigger through the slot outlet inside it, the settings dialog's Plugins row through the slot ledger's ordering. Class names, visible text and render order are used nowhere.
A composition that adds a command of its own names the button it belongs to, and its selector wins over everything shipped here:
- id: omdsh-shortcuts
config:
items:
- id: deploy.run
label: Deploy
section: view
command: { kind: runtime }
accelerator: CmdOrCtrl+Alt+D
anchor: '[data-slot="conversation.session.header.actions"] button'The whole behaviour is one setting, hints, on by default and applied live. Turning it off leaves every chord bound and every tooltip exactly as the harness wrote it.
The defaults
The shell tier: shell commands
| Item | id | Chord | Section |
|---|---|---|---|
| New Window | new-window | CmdOrCtrl+N | file |
| Restart Harness Runtime | restart-runtime | CmdOrCtrl+Alt+R | view |
| Open in Browser | open-in-browser | CmdOrCtrl+Shift+O | view |
| Reveal Runtime Log | reveal-log | CmdOrCtrl+Shift+L | view |
| Release Memory When Idle | idle-suspend | CmdOrCtrl+Alt+M | app |
The id is what a rebinding names, and idle-suspend is the one that does not read like its command: the item is idle-suspend, the capability it asks the shell for is toggle-idle-suspend, and it is the only checkbox in the set.
All five are shell commands, so all five are desktop-only: there is no Electron in a tab for a chord to reach. The tiers are what keep the map memorable — the bare modifier is the standard window operations, Shift reaches a shell surface or destination, and Alt reaches the runtime process, the tier Electron itself puts the developer tools on. Printable characters are deliberately left alone, because the harness UI inside the window owns every key the menu does not — and the tier below is that UI spending them.
The UI tier: browser commands
| Item | id | Desktop | Web | Performed by |
|---|---|---|---|---|
| New Session | session.new | CmdOrCtrl+K | same | this plugin |
| Fork Session | session.fork | CmdOrCtrl+Shift+K | same | this plugin |
| Archive Session | session.archive | CmdOrCtrl+Shift+W | none | this plugin |
| Add Workspace | workspace.add | CmdOrCtrl+O | CmdOrCtrl+Alt+O | this plugin |
| Remote Connect | remdev.connect | CmdOrCtrl+Shift+C | CmdOrCtrl+Alt+C | omdsh-remdev |
| Search Sessions | session.search | CmdOrCtrl+Shift+F | same | this plugin (DOM) |
| Toggle Sidebar | sidebar.toggle | CmdOrCtrl+Shift+B | CmdOrCtrl+Alt+B | this plugin |
| Toggle File Panel | panel.files | CmdOrCtrl+Shift+E | same | omdsh-sidepanel |
| Toggle Terminal | panel.terminal | `Ctrl+ `` | same | omdsh-sidepanel |
| Toggle Details Panel | details.toggle | CmdOrCtrl+Shift+D | CmdOrCtrl+Alt+D | this plugin |
| Ask Here | sidechat.open | CmdOrCtrl+L | CmdOrCtrl+Alt+L | omdsh-sidechat |
| Chat Mode | mode.chat | CmdOrCtrl+1 | CmdOrCtrl+Alt+1 | this plugin |
| Work Mode | mode.work | CmdOrCtrl+2 | CmdOrCtrl+Alt+2 | this plugin |
| Code Mode | mode.code | CmdOrCtrl+3 | CmdOrCtrl+Alt+3 | this plugin |
| Settings | settings.open | CmdOrCtrl+, | CmdOrCtrl+Alt+, | this plugin (DOM) |
| Plugin Settings | settings.plugins | CmdOrCtrl+Shift+P | CmdOrCtrl+Alt+P | this plugin (DOM) |
The web column follows one rule: swap Shift — or nothing — for Alt. The ones that need it are the chords a browser keeps: ⌘, is Preferences, ⌘O is Open File, ⌘1..3 switch tabs, ⌘⇧B toggles the bookmarks bar, ⌘L focuses the address bar, ⌘⇧D bookmarks every tab. Alt is the tier no mainstream browser spends on window chrome, and [isReservedByBrowser](src/chord.ts) agrees — holding it takes a chord out of the reserved set entirely — so one modifier answers the whole class of collisions without a table of per-browser exceptions.
The ones NOT restated — ⌘⇧F, ⌘⇧E, `Ctrl+ `, ⌘⇧K — reach a page in Chrome, Safari and Firefox alike, and a second spelling would be a second key to remember for no gain. remdev.connect is the reverse case: its ⌘⇧C is the chord Chrome and Safari give inspect-element, so it is restated as ⌥⌘C rather than racing the browser for a key only some tabs would hand over. The one item with no web chord at all is session.archive: every ⌘W spelling belongs to the browser, Alt` included in Safari, so it is honestly native-only rather than dishonestly bound.
The built-in commands
The rows above marked this plugin are handled by this package's own browser half ([src/client/builtins.ts](src/client/builtins.ts)). That is the opposite of the posture the rest of this package takes, and worth the explanation:
most UI actions have a service behind them — ctx.layout for the columns, ctx.sessions and ctx.workspaces for conversations and projects, omdsh-basemode's sessionModes for the switch. Nothing in the harness registers a shortcut for them because the harness has no shortcut service to register with; this one arrives from outside it. So the call has to be made from somewhere, and here is the only place that knows the chord was pressed.
Three are reachable only through the DOM: the settings dialog, the page and tab it is showing, and the **sidebar
…