luzonghao/dsh-input-light

dsh-input-light

Input Light — Tab cycles the DSH permission preset (Read Only ⇄ Workspace Write), ⌘S resumes interrupted work with two-step confirm, and the permission chip is color-coded by preset. Client-only DSH plugin.

待分类client-plugincordisdeepseek-harness

dsh-input-light

English | 中文

Composer-side conveniences for DeepSeek Harness (dsh): cycle the permission preset with Tab, resume interrupted work with two taps of ⌘S, color-code the permission chip by preset, and show the workspace path in the composer placeholder. Client-only plugin, zero config, bilingual (en / zh).


What it does

FeatureTriggerBehaviour
Cycle permission presetsTabRead Only ⇄ Workspace Write — never auto-enters Full access / Auto
Resume interrupted work⌘S ×2, or the ↻ button next to the chip ×2Sends 你的工作中断了,请你继续。
Preset colorsautomaticRead Only = amber, Workspace Write = blue, Full access = red
Workspace path in the placeholderautomaticThe composer placeholder shows the workspace's full path — or the host's own text when no path is available

1. Tab cycles the permission preset

Read Only  ⇄  Workspace Write

Why only two presets? As a command, /permission <preset> executes immediately, without confirmation (only picking through the visible control asks you to acknowledge risk). So the cycle deliberately stays inside the two safe presets — use the UI to reach Full access.

Pressing Tab while in a dangerous preset (Full access / Auto) falls back to Read Only.

2. ⌘S resumes interrupted work

Sends a fixed phrase: 你的工作中断了,请你继续。 ("Your work was interrupted, please continue.")

It submits through the host's own input surface, so it behaves exactly like pressing Enter:

ctx.sessions.binding(sessionId)          // → session binding
  → ctx.uiSession.sourceFor(binding)     // → that session's provide channel
  → .props.inputActions                  // → { setDraft, submit, … }
  → setDraft(RECOVERY_TEXT); submit();

The result is a real user/message in the session log (source.kind === "user"), not a call that resolves while nothing happens.

⚠️ Counter-example, for the record: ctx.remote.session.prompt(...) is an ACP protocol method. It resolves, but it does not produce a user message (0 entries in the session log, measured). Do not switch back to it.

Two-step confirmation (anti mis-tap) — shared by ⌘S and the button:

Step⌘SButtonEffect
Firsttap onceclick onceEnters confirm state: the button turns amber ✓, the tooltip becomes "press again to confirm" — nothing is sent
Second (within 3 s)tap againclick againSends
  • Both paths share one state machine, so "⌘S once, then click the button" also confirms.
  • The confirm window is 3 s; after that it reverts to the grey ↻.
  • The button carries a native tooltip showing the action and its shortcut (⌘ S keycaps).

3. The permission chip is color-coded

PresetColorHost theme variable
Read Onlyamber--dsw-alias-state-warn-primary
Workspace Writeblue--dsw-alias-state-business-primary
Full accessred--dsw-alias-state-error-primary

Theme variables (not hard-coded colors) ⇒ follows light/dark automatically. The chip's original inline styles are saved and fully restored on unload — no residue.

4. The composer placeholder shows the workspace path

The placeholder is replaced by the current workspace's full path:

/Users/you/Develop/my-project
  • The path comes from the host's own session list (sessions.list.getSnapshot().byId[sessionId].cwd — the same field ui-conversation uses for relativizeToCwd). The sidebar DOM carries no full path (the workspace row's title is only a display name), so there is deliberately no DOM fallback.
  • If no path is available (no session selected, session missing from the list, empty cwd), the plugin puts the host's own text back. That text is read from the DOM and never hard-coded, so it still follows the language and the session state (parentOffline / unavailable / …).
  • No styling is touched at all. The host already renders the placeholder as white-space: nowrap; text-overflow: ellipsis, so an over-long path is ellipsised by the host's own rule and the composer keeps its native height.
  • That node belongs to React: the plugin rewrites the existing text node in place. Assigning textContent would swap the node out, stranding React's fiber — the placeholder would then freeze the next time the host changed its text. A fake-DOM test pins this.

Rebinding

Both shortcuts are registered with the host shortcut service, so they appear in the shortcut panel (⌘/):

Command idDefaultAction
input-light.cycleModeTabCycle permission mode
input-light.recover⌘SResume interrupted work (press twice)

Change the keys in that panel — no code changes needed.


Install

plugin_manager action=install_bundle
                target=<path-to>/dsh-input-light

See INSTALL.md. In short: register the bundle in your DSH profile, then restart DSH (a newly added bundle is only picked up at startup; later code edits only need a page refresh).


Implementation constraints

Read index.md for the full engineering log. The four constraints that caused every rewrite along the way:

  1. External client plugins can only render into root-scoped slots. Seats like conversation.input.* are scope: "session": a plugin registration succeeds but never renders (5/5 seats measured mounted: false). This plugin uses shell.overlay (kind: "list", scope: "root").

  2. Shortcuts must go through ctx.shortcuts. Raw DOM keydown never sees Tab or ⌘S — the host's keyboard layer consumes them before the DOM (plain characters arrive; those two never do). Registration also requires regions: ['page', 'editable'], otherwise commands do not fire while the composer is focused.

  3. Cordis services and their sub-namespaces must be injected. Accessing an undeclared one throws (cannot get property "remote.commands" without inject).

  4. Registration must be idempotent. apply runs more than once; re-registering a command id throws Duplicate shortcut command, and when that happens before the second command is registered, neither is registered — silently breaking a shortcut that used to work.


Self-check

node test/logic.test.mjs     # 96 pure-logic cases, no DSH needed

In the browser console:

__dshInputLight.probe()            // { chipFound, preset, sessionId, tooltip }
__dshInputLight.state()            // result of the last cycle / recover
__dshInputLight.confirmStage()     // 'idle' | 'confirm'
__dshInputLight.cycle()            // cycle once, manually
__dshInputLight.confirm()          // run the two-step confirm (same path as ⌘S)
__dshInputLight.send()             // send without confirming (self-check only)
__dshInputLight.placeholderProbe() // { found, path, base, composed } — the two-line placeholder
__dshInputLight.restoreChip()      // restore the chip's original styling

License

MIT © 2026 luzonghao