moqi-tui 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +782 -0
  3. package/bin/moqi.mjs +40 -0
  4. package/cordis.patch.yml +41 -0
  5. package/lib/cross-find.js +217 -0
  6. package/lib/file-index.js +121 -0
  7. package/lib/fleet-sources.js +114 -0
  8. package/lib/index.js +3999 -0
  9. package/lib/persist.js +194 -0
  10. package/lib/plugins.js +371 -0
  11. package/lib/presence.js +144 -0
  12. package/lib/rename.js +35 -0
  13. package/lib/rewind.js +94 -0
  14. package/lib/sessions-store.js +134 -0
  15. package/lib/startup.js +92 -0
  16. package/lib/tui/atfile.js +154 -0
  17. package/lib/tui/export.js +48 -0
  18. package/lib/tui/fleet.js +346 -0
  19. package/lib/tui/i18n.js +201 -0
  20. package/lib/tui/jobs.js +65 -0
  21. package/lib/tui/keys.js +205 -0
  22. package/lib/tui/markdown.js +368 -0
  23. package/lib/tui/mcp.js +95 -0
  24. package/lib/tui/panels.js +231 -0
  25. package/lib/tui/screen.js +156 -0
  26. package/lib/tui/state.js +502 -0
  27. package/lib/tui/stream.js +109 -0
  28. package/lib/tui/text.js +173 -0
  29. package/lib/tui/theme.js +183 -0
  30. package/lib/tui/themes.js +153 -0
  31. package/lib/tui/tooldetail.js +140 -0
  32. package/lib/tui/view.js +830 -0
  33. package/lib/tui/vim.js +222 -0
  34. package/lib/tui-host-core.js +141 -0
  35. package/lib/tui-host.js +48 -0
  36. package/lib/types/cross-find.d.ts +66 -0
  37. package/lib/types/file-index.d.ts +34 -0
  38. package/lib/types/fleet-sources.d.ts +34 -0
  39. package/lib/types/index.d.ts +51 -0
  40. package/lib/types/persist.d.ts +116 -0
  41. package/lib/types/plugins.d.ts +218 -0
  42. package/lib/types/presence.d.ts +48 -0
  43. package/lib/types/rename.d.ts +32 -0
  44. package/lib/types/rewind.d.ts +75 -0
  45. package/lib/types/sessions-store.d.ts +46 -0
  46. package/lib/types/startup.d.ts +45 -0
  47. package/lib/types/tui/atfile.d.ts +90 -0
  48. package/lib/types/tui/export.d.ts +18 -0
  49. package/lib/types/tui/fleet.d.ts +209 -0
  50. package/lib/types/tui/i18n.d.ts +34 -0
  51. package/lib/types/tui/jobs.d.ts +28 -0
  52. package/lib/types/tui/keys.d.ts +52 -0
  53. package/lib/types/tui/markdown.d.ts +14 -0
  54. package/lib/types/tui/mcp.d.ts +34 -0
  55. package/lib/types/tui/panels.d.ts +125 -0
  56. package/lib/types/tui/screen.d.ts +79 -0
  57. package/lib/types/tui/state.d.ts +323 -0
  58. package/lib/types/tui/stream.d.ts +78 -0
  59. package/lib/types/tui/text.d.ts +28 -0
  60. package/lib/types/tui/theme.d.ts +87 -0
  61. package/lib/types/tui/themes.d.ts +70 -0
  62. package/lib/types/tui/tooldetail.d.ts +45 -0
  63. package/lib/types/tui/view.d.ts +163 -0
  64. package/lib/types/tui/vim.d.ts +64 -0
  65. package/lib/types/tui-host-core.d.ts +62 -0
  66. package/lib/types/tui-host.d.ts +42 -0
  67. package/lib/types/version.d.ts +8 -0
  68. package/lib/types/voice.d.ts +227 -0
  69. package/lib/version.js +32 -0
  70. package/lib/voice.js +405 -0
  71. package/package.json +119 -0
  72. package/scripts/harness-root.mjs +88 -0
  73. package/scripts/install-profile.mjs +133 -0
package/README.md ADDED
@@ -0,0 +1,782 @@
1
+ # Moqi
2
+
3
+ *默契 — the unspoken understanding between you and your harness.*
4
+
5
+ A terminal app for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness),
6
+ packaged as a Harness bundle. It is the rebuild of an earlier standalone Go
7
+ client, reimplemented as a first-class `dsh` profile so it drives the real
8
+ Harness agent instead of a private HTTP API.
9
+
10
+ The name is the point of the thing: a good terminal agent is one you stop
11
+ having to explain yourself to. Moqi keeps the conversation in the order it
12
+ happened, puts each tool call where it was made, and gets out of the way.
13
+
14
+ ```
15
+ ◆ moqi tail docker logs local harness
16
+
17
+ ▌ how do i tail the last 50 lines of a container log?
18
+
19
+ ✓ shell docker ps
20
+
21
+ Use docker logs with --tail and -f :
22
+
23
+ sh
24
+ │ docker logs --tail 50 -f webui
25
+
26
+ • --since 10m — only the last ten minutes
27
+ • -t — prefix each line with a timestamp
28
+
29
+ ╭──────────────────────────────────────────────────────────────────────────────╮
30
+ │ Ask the harness… (/ for commands) │
31
+ ╰──────────────────────────────────────────────────────────────────────────────╯
32
+ deepseek-chat · ctx 1.5K/65K 2% · ↑1.2K ↓312 / commands · ctrl+c menu
33
+ ```
34
+
35
+ ## Why a bundle
36
+
37
+ The shipped `dsh` bundles are `dsh-base`, `dsh-web-app`, `dsh-headless`,
38
+ `dsh-sdk-app`, `dsh-sdk-minimal`, and `dsh-acp-app` — there is no terminal app.
39
+ The CLI's own README even refers to `dsh --profile tui` as a hypothetical
40
+ ("assuming the tui profile is installed"). This package is that profile.
41
+
42
+ It mounts over `dsh-base` with no Host, HTTP server, or browser plugin: the
43
+ terminal is the only surface.
44
+
45
+ ## Install
46
+
47
+ Requires a working `dsh` on `PATH` (`npm install -g @deepseek-ai/dsh`) and
48
+ Node 22+.
49
+
50
+ From npm — one command, then the launcher installs the profile and hands the
51
+ terminal to dsh:
52
+
53
+ ```sh
54
+ npm install -g moqi-tui
55
+ moqi
56
+ ```
57
+
58
+ `moqi install` only refreshes the profile, and
59
+ `dsh plugin --profile tui add moqi-tui` works too. `/update`
60
+ inside the app checks npm and upgrades the global install.
61
+
62
+ The installer also links the installed Harness's own `@deepseek-ai` packages
63
+ into the app. This is not optional bookkeeping: the profile links the app from
64
+ wherever it was installed, so Node resolves the app's imports from the app's
65
+ own directory, where those packages do not otherwise exist — and the app would
66
+ crash on boot with `ERR_MODULE_NOT_FOUND`. Linking the harness's copies (rather
67
+ than installing a second set) also guarantees exactly one `@deepseek-ai/cordis`,
68
+ because two copies would be two different `Service` classes.
69
+
70
+ From source — clone, build, and link the profile to the checkout:
71
+
72
+ ```sh
73
+ git clone https://github.com/JWE24-code/moqi ~/Projects/moqi
74
+ cd ~/Projects/moqi
75
+ npm install
76
+ npm run build # emits lib/
77
+ npm run install-profile # creates $DSH_HOME/profiles/tui and links this checkout
78
+ dsh --profile tui
79
+ ```
80
+
81
+ `install-profile` writes the profile directory itself rather than copying a
82
+ template, because the profile's dependency on this package has to be an
83
+ absolute path to wherever the repository actually lives. It creates:
84
+
85
+ ```
86
+ $DSH_HOME/profiles/tui/ # $DSH_HOME defaults to ~/.dsh
87
+ package.json # dsh.profile.bundles + a link: to this checkout
88
+ cordis.patch.yml # your own patch layer, composed last
89
+ pnpm-workspace.yaml # nodeLinker: hoisted, autoInstallPeers: false
90
+ ```
91
+
92
+ with the bundle order the profile composes:
93
+
94
+ ```json
95
+ "dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "moqi-tui"] } }
96
+ ```
97
+
98
+ Pass a name to install under a different profile: `npm run install-profile -- chat`.
99
+
100
+ ### Typechecking against your installed Harness
101
+
102
+ The `@deepseek-ai/*` imports are optional peers: the Harness resolves them from
103
+ its own installation anchor at runtime, so they are deliberately not
104
+ dependencies here. To typecheck against the exact build you will run under:
105
+
106
+ ```sh
107
+ npm run link-types # symlinks the installed dsh's @deepseek-ai packages
108
+ npm run typecheck
109
+ ```
110
+
111
+ ## Flags
112
+
113
+ | Flag | Meaning |
114
+ |---|---|
115
+ | `--resume <id>` | Open a persisted session instead of starting a new one |
116
+ | `--model <name>` | Model to select for this run |
117
+ | `--thinking` | Start with reasoning output visible |
118
+ | `--context-limit <n>` | Override the context budget; the default is the model's own capacity |
119
+ | `--mouse` | Report mouse events so the wheel scrolls (costs terminal text selection) |
120
+ | `--no-bell` | Stay silent when a session finishes |
121
+ | `--peer <host>` | Device to include in the fleet overview; repeatable |
122
+ | `--no-restore` | Start with one empty session instead of reopening the last ones |
123
+ | `--version` | Print the app version — shadowed by the launcher's own `--version`, so use `/about` inside the app |
124
+
125
+ `MOQI_CONTEXT_LIMIT` sets the same budget; `MOQI_THEME=light\|dark`
126
+ overrides background detection; `NO_COLOR` disables styling.
127
+
128
+ ## Keys
129
+
130
+ | Key | Action |
131
+ |---|---|
132
+ | `enter` | Send · steers into a running reply · `ctrl+j` inserts a newline |
133
+ | `↑` / `↓` | On the first / last composer row, recall earlier prompts |
134
+ | `/` | Command palette · `tab` accepts · `esc` dismisses |
135
+ | `@` | File completion over the workspace · `tab`/`enter` accepts · `esc` dismisses |
136
+ | `?` | Open the key reference on an empty composer |
137
+ | `esc` | Interrupt a streaming reply |
138
+ | `esc` (alone) | Recognized after a 50 ms grace, so a lone press is never mistaken for a sequence's first byte |
139
+ | `alt+e` | Edit the draft in `$VISUAL`/`$EDITOR` · non-zero exit keeps it |
140
+ | `alt+↑`/`alt+↓` | Select a transcript turn (gold bar) · `esc` clears |
141
+ | `alt+c` | Copy the selected turn over OSC 52 |
142
+ | `tab` | While a reply streams: queue the prompt for after it |
143
+ | `ctrl+enter` | Interrupt the reply and send now (needs a terminal that reports it) |
144
+ | `ctrl+n` / `ctrl+r` / `ctrl+t` | New session · resume · toggle thinking |
145
+ | `pgup`/`pgdn` | Scroll a page · `ctrl+↑`/`ctrl+↓` half a page |
146
+ | `shift+↑`/`shift+↓` | Scroll one line · `ctrl+g` jumps back to the newest |
147
+ | `ctrl+o` | Show or hide each tool call's outcome, under the call itself |
148
+ | `ctrl+x` | Compact the session |
149
+ | `ctrl+b` | Expand or collapse the background-agent strip |
150
+ | `ctrl+y` | Copy the last reply to the clipboard |
151
+ | `ctrl+f` | Fleet overview: sessions across every device |
152
+ | `n` / `N` | With a search open and an empty composer, next / previous match |
153
+ | `alt+1`…`alt+9` | Jump to a session · `alt+n`/`alt+p` cycle · `tab` cycles on an empty composer |
154
+ | `ctrl+a`/`ctrl+e`/`home`/`end`, `ctrl+w`, `ctrl+k` | Line start/end, delete word, kill to end |
155
+ | `ctrl+u` | Clear the composer line (readline) |
156
+ | `ctrl+d` | Delete forward · `alt+b`/`alt+f`, `ctrl+←`/`ctrl+→` word motion |
157
+ | `ctrl+c` | Sessions menu · press again within 1.5s to quit |
158
+
159
+ In a list (`/model`, `/theme`, `/resume`): type to filter, `enter` selects, `esc` closes;
160
+ `ctrl+n`/`ctrl+p` or the arrows move, `pgup`/`pgdn` move by ten, `home`/`end`
161
+ jump, and `ctrl+u` clears the filter.
162
+
163
+ ## Steering, queueing, and interrupting a running reply
164
+
165
+ A prompt entered while the active session is still replying has three
166
+ destinations, one per key:
167
+
168
+ - **`enter` steers** — the prompt is delivered into the running turn and lands
169
+ at its next step boundary, so the agent changes course mid-answer. Steered
170
+ prompts render dimmed in the transcript so the interleaving reads honestly.
171
+ - **`tab` queues** — the prompt waits under the streaming block, dimmed, with
172
+ the footer counting it (`2 queued — sends when the reply finishes`). The
173
+ moment a turn finishes without an interrupt, the next queued prompt sends
174
+ itself, in order, into the same session — even if you have switched tabs in
175
+ between.
176
+ - **`ctrl+enter` interrupts and sends** — the running reply stops and the
177
+ prompt goes in immediately (the same thing `/interrupt` does to a queue).
178
+
179
+ Interrupting with `esc` keeps the queue; it flushes the next time a turn
180
+ completes cleanly, `/interrupt` stops the reply and flushes it now, and
181
+ `/unqueue` discards it.
182
+
183
+ ## `@` file completion
184
+
185
+ Type `@` at the start of a word for a fuzzy picker over the workspace — the
186
+ same subsequence filter the model picker uses, shallower paths first. A query
187
+ containing `/` (`@src/tu`) lists that one directory instead; picking a
188
+ directory descends into it. `enter` or `tab` accepts, `esc` dismisses only the
189
+ menu. An `@` in prose (`user@host`) never triggers it.
190
+
191
+ Picking an image (png/jpeg/webp/gif) stages it as a durable attachment through
192
+ the Harness attachment service and inserts an `[Image #N path]` token; on send
193
+ the token leaves the text and the image goes along as a content block, with a
194
+ `🖼 name WxH` line in the transcript. Without the attachment service the path
195
+ is inserted as plain text instead.
196
+
197
+ ## Tool calls, where they happened
198
+
199
+ An agent turn is a sequence: it says something, runs a tool, says something
200
+ about what came back. The transcript is written that way — each call is one line
201
+ in the place it was made, between the prose on either side of it:
202
+
203
+ ```
204
+ Let me check what is running.
205
+
206
+ ✓ bash docker ps
207
+
208
+ Only webui is up, so its log is the one to read.
209
+
210
+ ⠹ bash docker logs --tail 50 webui 8s
211
+ ```
212
+
213
+ The call in flight carries the spinner and its own elapsed time; a settled call
214
+ carries `✓`, or `✗` with its error. `ctrl+o` adds each call's outcome
215
+ underneath the call that produced it:
216
+
217
+ ```
218
+ ✓ bash docker ps
219
+ ↳ webui postgres
220
+ ```
221
+
222
+ A turn whose calls returned something to show says so once, at the end, rather
223
+ than advertising an expansion that would reveal nothing:
224
+
225
+ ```
226
+ ctrl+o for detail
227
+ ```
228
+
229
+ This replaced an earlier design that held a turn's prose as one string and its
230
+ calls as a separate list, then drew all the calls above all the text. That threw
231
+ away the one thing a reader needs — which call the next sentence is about — and
232
+ because the prose fragments were concatenated with nothing between them, two
233
+ paragraphs from either side of a call arrived as one run of text. Order is now
234
+ part of the model (`Segment` in `src/tui/state.ts`), not something the renderer
235
+ tries to reconstruct.
236
+
237
+ ## Scrolling
238
+
239
+ The app is keyboard-first, so the wheel is **off** by default: terminals
240
+ suppress their own text selection while mouse reporting is on, which is a poor
241
+ trade for a scroll you can do with `pgup`. Pass `--mouse` if you want it.
242
+ Scrolling away from the newest output is announced in the status bar with the
243
+ way back (`ctrl+g`).
244
+
245
+ ## Searching the transcript
246
+
247
+ `/find <text>` searches the whole conversation (case-insensitive) and scrolls
248
+ the first match into view, with a `match 1/12` counter in the status bar. On
249
+ an empty composer, `n` jumps to the next match and `N` to the previous one —
250
+ the same letters a pager uses — and `esc` clears the search so `n` types an
251
+ `n` again (press `esc` a second time to interrupt a streaming reply; the
252
+ cheapest thing open closes first). Matches are recomputed each jump, so a
253
+ reply still streaming in simply adds lines to search rather than going stale.
254
+
255
+ ## Copying an answer
256
+
257
+ `/copy` (or `ctrl+y`) yanks the last reply to the system clipboard, and
258
+ `alt+↑`/`alt+↓` then `alt+c` copies any turn you select. Very long answers are
259
+ truncated to what the terminal is willing to accept.
260
+
261
+ Two channels are used, because neither is sufficient alone. The OSC 52 escape
262
+ is the one a terminal owns: no dependency, no external process, and it is what
263
+ survives SSH and tmux, where nothing running locally can reach the clipboard
264
+ you are actually looking at. But a Wayland compositor grants clipboard
265
+ ownership only against an input-focus serial, so a terminal can accept a
266
+ perfectly well-formed escape and still leave the selection untouched — the copy
267
+ reports success and nothing is on the clipboard. So when a local helper is
268
+ present (`wl-copy`, `xclip`, `xsel`, `pbcopy`) the text goes there too, and
269
+ that is the one that lands on a desktop. A missing helper is not an error; it
270
+ just leaves OSC 52 to do the job it is good at.
271
+
272
+ ## Dictating with your voice
273
+
274
+ ```sh
275
+ npm run setup-voice
276
+ ```
277
+
278
+ Then press `ctrl+v` in the app, speak, and press it again. The transcript is
279
+ placed in the composer for you to read and edit — it is never sent for you,
280
+ because a misheard prompt that sends itself is worse than no dictation at all.
281
+
282
+ Everything happens on your machine: audio is recorded by `arecord` or `sox` to
283
+ a temporary 16 kHz mono wav and transcribed by a local
284
+ [whisper.cpp](https://github.com/ggerganov/whisper.cpp) binary. No audio leaves
285
+ the machine and there is no API key. It follows that dictation only works where
286
+ the microphone is — over SSH there isn't one.
287
+
288
+ `setup-voice` is the whole story: it installs a recorder and whisper.cpp with
289
+ your system package manager (asking for your password once), downloads the
290
+ `base.en` weights to `~/.cache/whisper/`, and re-runs safely, skipping whatever
291
+ is already in place. `--print-only` shows what it would do without doing it.
292
+ The one platform it cannot finish is Debian and Ubuntu, which package the
293
+ Python implementation rather than whisper.cpp; it says so rather than guessing.
294
+
295
+ These are deliberately not npm dependencies — the executable is native and the
296
+ weights are a 140MB download — which is why they are a setup step rather than
297
+ part of `npm install`.
298
+
299
+ To point at your own build or weights, `--voice-bin` and `--voice-model` win,
300
+ then `MOQI_WHISPER_BIN` and `MOQI_WHISPER_MODEL`, then a search of PATH
301
+ and of `~/.cache/whisper`, `~/.local/share/whisper` and the two
302
+ `share/whisper.cpp` directories. `--voice-lang` or `MOQI_WHISPER_LANG` sets
303
+ the language; without one, whisper decides.
304
+
305
+ When a piece is missing the footer names which one and the command that fixes
306
+ it, rather than reporting that voice is unavailable.
307
+
308
+ ## What persists
309
+
310
+ Sent prompts, the thinking preference, and the chosen color palette are saved
311
+ to `$DSH_HOME/tui-state.json` (`$DSH_HOME` defaults to `~/.dsh`) and restored on
312
+ the next launch. The model choice is saved through the Harness's own
313
+ `saveSelection`, not this file. Writing is atomic and best-effort: a read-only
314
+ home means the app runs exactly as before, just without recall across
315
+ restarts.
316
+ Sent prompts, the thinking preference, and the sessions you had open are saved
317
+ to `$DSH_HOME/tui-state.json` (`$DSH_HOME` defaults to `~/.dsh`) and restored
318
+ on the next launch. The profile-wide model default is saved through the
319
+ Harness's own `saveSelection`, not this file; the per-session model a `/model`
320
+ switch chose is part of the tab and comes back with it.
321
+
322
+ Reopening is deliberately timid. Only the session ids are remembered — every
323
+ transcript is re-read from the Harness's own session store — and an id that
324
+ store no longer holds is skipped without a word, because `/delete` and
325
+ anything else that touches `$DSH_HOME` can prune it between two runs. If
326
+ nothing at all comes back you get a fresh session, exactly as before. Pass
327
+ `--no-restore` to always start clean, and `--resume <id>` to name one session,
328
+ which wins over both.
329
+
330
+ Writing is atomic and best-effort: a read-only home means the app runs exactly
331
+ as before, just without recall across restarts.
332
+
333
+ ## One list of every device
334
+
335
+ ```sh
336
+ dsh --profile tui --peer laptop --peer workstation
337
+ ```
338
+
339
+ `ctrl+f` (or `/fleet`) shows every dsh session across every device, grouped by
340
+ machine, most urgent first, with a status mark and the age of each heartbeat.
341
+
342
+ ```
343
+ Fleet
344
+ 2 running, 1 ready across 2 devices
345
+
346
+ workstation (this device)
347
+ ⠹ rebuild the search index deepseek-chat 3s
348
+ · draft the release notes deepseek-chat 12m
349
+
350
+ laptop
351
+ ● summarise yesterday glm-4.7 8s
352
+
353
+ ↑↓ move · enter open · r refresh · esc back 3 sessions
354
+ ```
355
+
356
+ Each device writes one small JSON record per open session under
357
+ `$DSH_HOME/tui-presence/`, refreshed on a heartbeat and deleted on exit. Peers
358
+ are read with a single non-interactive `ssh` command, so **nothing new listens
359
+ on a port and no credential is added** — SSH is already the boundary. A record
360
+ that stops being refreshed reads as `stale` rather than claiming forever that
361
+ it is running.
362
+
363
+ `enter` opens the session when this app already owns it. It cannot open
364
+ anything else — another process has no terminal here — so instead it copies the
365
+ command that does reach it. `p` goes one better for reading: it fetches the
366
+ peer's session log over the same SSH channel and shows the last turns as a
367
+ read-only preview, decoded here. Nothing on the peer is written, nothing new
368
+ listens, and the path is built with the store's own segment encoder, so a
369
+ hostile presence record cannot reach outside its own session directory.
370
+
371
+ `d` dispatches instead of reading: the composer's text is sent to that peer's
372
+ `headless` profile (`--dispatch-profile` changes it), which answers one task and
373
+ exits. The prompt is quoted for the remote shell and SSH runs in `BatchMode`, so
374
+ a password prompt can never swallow the terminal; the peer's answer comes back
375
+ as an overlay, and the session it left behind stays the peer's to resume.
376
+
377
+ The copied command is:
378
+
379
+ ```sh
380
+ ssh -t laptop 'dsh --profile tui --resume session-…'
381
+ ```
382
+
383
+ See [docs/fleet-overview.md](docs/fleet-overview.md) for why presence files
384
+ rather than the session store.
385
+
386
+ ## Reaching it from another device
387
+
388
+ ```sh
389
+ ./scripts/serve-tailnet.sh on # web UI on your tailnet, over TLS
390
+ ./scripts/serve-tailnet.sh status
391
+ ./scripts/serve-tailnet.sh off
392
+ ```
393
+
394
+ `dsh web` stays bound to `127.0.0.1` — upstream states `--host 0.0.0.0` is
395
+ unsupported, and it would mean every interface, not just the tailnet — while
396
+ `tailscale serve` terminates TLS in front and the machine's tailnet name is
397
+ passed to `--trusted-host` so the browser-trust fence accepts it.
398
+
399
+ It is authenticated: a request from the tailnet without credentials answers
400
+ `401`, and a forged `Host` answers `403`. It also runs shell commands as you,
401
+ so the script prints a warning every time. See
402
+ [docs/remote-access.md](docs/remote-access.md).
403
+
404
+ ## Several sessions at once
405
+
406
+ `ctrl+n` opens another session beside the current one rather than replacing it,
407
+ so a long-running turn keeps going while you start something else. With more
408
+ than one open, a bar appears under the header:
409
+
410
+ ```
411
+ · 1 tail docker logs │ ⠹ 2 vlan plan │ ● 3 skills question
412
+ ```
413
+
414
+ `⠹` is a turn in flight, `●` is a finished answer you have not read, `·` is
415
+ seen. `alt+1`…`alt+9` jump straight to a session, `alt+n`/`alt+p` cycle,
416
+ `/sessions` opens a picker — which also carries a **+ Ask the harness in a new
417
+ session** entry, so starting one does not depend on already knowing `ctrl+n` —
418
+ and `/close` closes the current one.
419
+
420
+ **The bell.** When a session's turn finishes, the terminal bell rings — that is
421
+ the point of running several: you start one, go and do something else, and get
422
+ told when it is done. A session you are already looking at is marked seen
423
+ rather than nagged about. `--no-bell` turns the sound off.
424
+
425
+ ## Background agents
426
+
427
+ The transcript only ever shows the foreground agent, so a turn that delegates
428
+ to subagents would otherwise look idle while the machine is busy. Live agents
429
+ other than the current one appear in a strip above the composer:
430
+
431
+ ```
432
+ ⠹ 2 agents 1 running · research, verify ctrl+b
433
+ ```
434
+
435
+ `ctrl+b` expands it into a list with each agent's depth and age. It is fed by
436
+ the `agent/created`, `agent/status` and `agent/disposed` lifecycle events, and
437
+ the strip is the first chrome to collapse when the window is too short — the
438
+ transcript always wins.
439
+
440
+ ## Choosing a model
441
+
442
+ `/model` opens a picker over everything the mounted adapters can serve,
443
+ grouped by provider, with the model in use marked:
444
+
445
+ ```
446
+ Models
447
+ › g53
448
+
449
+ z.ai (GLM coding plan)
450
+ glm-5.3 GLM-5.3
451
+ glm-5.3-flash GLM-5.3-Flash
452
+
453
+ ↑↓ move · enter select · esc back 2/7
454
+ ```
455
+
456
+ The filter is a subsequence match, so `g53` finds `glm-5.3`. `/model <id>`
457
+ skips the picker; use `provider/model` when two routes serve the same id.
458
+
459
+ Switching re-resolves the agent against the **same session**, so the
460
+ conversation survives the change, and the choice is saved as the default for
461
+ new sessions. The model is **per session**: switching in one conversation
462
+ leaves every other tab on the model it was already using, and the footer's
463
+ model and context bar always describe the session on screen. A new session
464
+ starts from the model of the session it was opened from, then diverges
465
+ independently.
466
+
467
+ ## Color palettes
468
+
469
+ `/theme` opens a picker over the palettes the app ships with; `/theme <name>`
470
+ switches straight away.
471
+
472
+ | Theme | |
473
+ |---|---|
474
+ | `rose-pine` | the default — muted purples on a soft ink background |
475
+ | `gruvbox` | warm retro earth tones, medium contrast |
476
+ | `nord` | cool arctic blues, low saturation |
477
+ | `solarized` | Schoonover's balanced pairing |
478
+ | `mono` | greyscale, maximum contrast, no color coding at all |
479
+
480
+ Every palette defines both a light and a dark variant, because *which* palette
481
+ is in force and *which background* it is drawn against are separate questions.
482
+ `MOQI_THEME=light|dark` still forces the variant (the `COLORFGBG`
483
+ convention decides otherwise, and dark is the fallback), and `NO_COLOR` or
484
+ `TERM=dumb` still turns color off entirely — under those the theme has nothing
485
+ to do and picking one changes nothing.
486
+
487
+ `mono` is the accessibility option: it drops the hues rather than trying to
488
+ keep them, so success and failure no longer differ by color. Nothing in the
489
+ app relies on color alone — a failed tool call prints `✗` and its error text
490
+ either way — so what is left is legible where a hue-based palette is not.
491
+
492
+ The choice is saved with the rest of the durable state and applied before the
493
+ first frame, so it survives a restart. Switching repaints the whole screen at
494
+ once, since a palette change moves the color of nearly every cell.
495
+
496
+ ## Vim mode
497
+
498
+ `/vim` turns the composer modal. It starts in INSERT — enabling vim never
499
+ changes what typing does — and `esc` switches to NORMAL, where the footer shows
500
+ the mode and bare keys follow vim:
501
+
502
+ | NORMAL key | |
503
+ |---|---|
504
+ | `h` / `l` / `0` / `^` / `$` / `w` / `b` | motion, with vim's word-start `w` rather than readline's end-of-word |
505
+ | `i` / `I` / `a` / `A` / `o` / `O` | enter INSERT at, before, after, or on a new line |
506
+ | `x` / `X` / `dd` / `d$` / `d0` / `dw` | delete a character, a line, to the end, to the start, a word |
507
+ | `u` | undo the last vim edit (100 deep) |
508
+ | anything unbound | swallowed, so a stray `j` cannot type |
509
+
510
+ While vim mode is on, `esc` belongs to the editor: `ctrl+c` is the interrupt,
511
+ which is also what the footer's mode badge is there to remind you of. The mode
512
+ is session-scoped and not persisted, and the vim layer only ever touches the
513
+ composer — a panel, picker, or fleet screen owns the keyboard when it is open.
514
+
515
+ ## Interface language
516
+
517
+ `/lang` switches the interface between English and Simplified Chinese and
518
+ remembers the choice across restarts. The translated surface is the chrome you
519
+ read: the welcome, the full key reference, the trust panels, and the footer
520
+ hints. Operational status lines stay English on purpose — they are diagnostics
521
+ that change with every release, and a half-translated diagnostic is worse than
522
+ an English one. A missing key falls back to English and then to its own name,
523
+ so nothing ever renders blank.
524
+
525
+ ## MCP servers
526
+
527
+ `/mcp` shows which MCP servers' tools are mounted here, grouped by server, by
528
+ reading the tool registry for bridge-prefixed names (`mcp__server__tool`,
529
+ `server/tool`). Servers are declared by composition, not at runtime, so the
530
+ pane says where to add one instead of pretending to manage them live — and it
531
+ recognizes MCP patterns narrowly enough that a path like `src/tui/state.ts` is
532
+ never mistaken for a server.
533
+
534
+ ## Extending the terminal
535
+
536
+ The app provides `ctx.tuiHost`, a service other plugins extend it with:
537
+
538
+ ```ts
539
+ const dispose = ctx.tuiHost.registerShortcut({
540
+ combo: 'ctrl+shift+g', label: 'git status', handler: () => { /* … */ },
541
+ })
542
+ ctx.tuiHost.setStatusLine('2 agents spinning')
543
+ ```
544
+
545
+ A shortcut must carry `ctrl` or `alt`; a combination the app already uses is
546
+ refused rather than ordered, so a plugin can never swallow the quit
547
+ confirmation or a scroll key. `registerShortcut` and `setStatusLine` both
548
+ return disposers, and the status line is one row — replaced, not stacked, last
549
+ registration wins — that the layout surrenders first when the window is short.
550
+
551
+ Searching across sessions is built in: `/find --sessions <text>` reads the
552
+ stored session logs (plain or zstd) under `$DSH_HOME/sessions`, shows every
553
+ matching line with its project and speaker, and opens the session on `enter`.
554
+ The store is read-only here; a compressed log on a Node too old to decode it is
555
+ reported as skipped, never as a wrong answer.
556
+
557
+ ## Rate, cache, and background jobs
558
+
559
+ The footer carries what the provider reports: prompt and completion tokens, the
560
+ context bar, output tokens per second for the last settled turn, and the share
561
+ of the prompt that came from the provider's cache. Each is displayed only when
562
+ it is real — an unmeasurable rate or a cache hit on an empty prompt is omitted
563
+ rather than faked.
564
+
565
+ `/jobs` lists what ran or is still running in the background for this session —
566
+ state, elapsed time, and the producer's own detail line — with running jobs
567
+ first and finished ones newest first. `/jobs kill <id>` stops one. A profile
568
+ with no job registry says so instead of showing an empty list.
569
+
570
+ ## Rewinding and forking
571
+
572
+ `/rewind` lists every prompt in the conversation; picking one forks the session
573
+ at the start of that prompt's turn, restores the prompt into the composer, and
574
+ opens the fork beside the original. The original is untouched, so trying a
575
+ different wording costs nothing — and the first prompt cannot be rewound past,
576
+ because there would be nothing left to inherit.
577
+
578
+ `/fork` copies the whole conversation into a resumable twin, cut at the last
579
+ completed turn so the seed is always a balanced prefix. `/tree` shows the
580
+ family: the lineage of forks this session belongs to, oldest ancestor first.
581
+
582
+ Forks are real Harness sessions (`parentSession` plus a seeded prefix), so they
583
+ appear in `/resume`, survive restarts, and can themselves be rewound or forked.
584
+
585
+ ## When the agent stops to ask
586
+
587
+ Three moments hand the keyboard to a panel in place of the transcript, and all
588
+ three answer through the Harness's own seams — the `approval/request` and
589
+ `user-questions/request` waterfalls — so no answer is faked and a headless
590
+ mount fails closed rather than swallowing a prompt it cannot show.
591
+
592
+ **Tool approval.** When the permission layer needs a decision, the panel shows
593
+ the tool, the exact command from the tool call already in the transcript, and
594
+ the asker's reason: `1` allows once, `2` or `esc` denies. The protocol has no
595
+ persistent grant, so nothing offers one.
596
+
597
+ **`ask_user_question`.** Options navigate with `↑`/`↓`, `space` toggles a
598
+ multi-select, `enter` answers and advances, `tab` moves to the free-text line,
599
+ and `esc` steps back a question before it cancels the set (`ASK_CANCELLED`).
600
+ Typing on an option row answers with that option plus your text, the way a
601
+ form does.
602
+
603
+ **Answering by voice.** With push-to-talk configured, `ctrl+v` while an
604
+ approval panel is open records a take and reads it: an unambiguous "allow" or
605
+ "deny" (or 允许 / 拒绝) decides the request, while anything ambiguous leaves the
606
+ panel waiting — the microphone can never grant a tool call on a misheard
607
+ sentence, and a denial wins when both words appear. The same precedence applies
608
+ to `esc`: it cancels a live recording before it denies anything.
609
+
610
+ **Plan review.** `exit_plan_mode` renders the plan as markdown with its own
611
+ Approve / Keep-planning options. Approving never carries feedback — the
612
+ protocol reads feedback as "keep planning" — so typing while feedback is not a
613
+ decision is kept as feedback only on a declining answer.
614
+
615
+ ## Commands
616
+
617
+ The palette merges two sources, so it shows whatever the Harness has actually
618
+ registered — `/compact` from `command-compact`, plus anything a plugin adds —
619
+ alongside the app's own:
620
+
621
+ | Command | Owner |
622
+ |---|---|
623
+ | `/compact`, and any other plugin command | `ctx.commands` (the Harness registry) |
624
+ | `/new`, `/sessions`, `/close`, `/resume`, `/delete`, `/rename`, `/model`, `/theme`, `/thinking`, `/tools`, `/export`, `/find`, `/unqueue`, `/interrupt`, `/copy`, `/rewind`, `/fork`, `/tree`, `/jobs`, `/about`, `/update`, `/help`, `/exit` (`/quit`) | this app |
625
+
626
+ Unknown commands are dispatched to `ctx.commands.execute()` and only reported
627
+ as unknown if the registry also rejects them.
628
+
629
+ ## How it maps onto the Harness
630
+
631
+ | Feature | Service |
632
+ |---|---|
633
+ | Streaming text, reasoning, tool activity, token usage | `agent/assistant-stream` frames |
634
+ | Sending a turn | `agent.followup(createUserMessage(...))` then `agent.whenIdle()` |
635
+ | New / resumed sessions | `ctx.agents.create()` / `ctx.agents.resume()` |
636
+ | Session list for `/resume` | `ctx.sessionQuery` (optional; the picker degrades if absent) |
637
+ | Persistence | `ctx.sessions.flush()` (optional) |
638
+ | Slash commands | `ctx.commands` |
639
+ | Transcript on resume | the session log, projected from `user/message` and `assistant/message` events |
640
+
641
+ Optional services are probed rather than injected, so a profile without
642
+ persistence or session query still runs — the affected command just reports
643
+ that the service is missing.
644
+
645
+ ## Layout
646
+
647
+ ```
648
+ src/
649
+ index.ts the app plugin: Harness wiring, key dispatch, commands
650
+ startup.ts the cmdline provider (--resume/--model/--thinking/...)
651
+ persist.ts durable history, preferences, and open sessions under $DSH_HOME
652
+ sessions-store.ts session storage paths and deletion under $DSH_HOME
653
+ version.ts reads the package version for --version and /about
654
+ tui/
655
+ screen.ts raw mode, alternate screen, per-line diffed painting
656
+ keys.ts escape-sequence decoding, chunk-tolerant
657
+ view.ts frame composition and layout arithmetic
658
+ state.ts composer, palette, picker, history, token formatting
659
+ stream.ts projects assistant-stream chunks onto the transcript
660
+ export.ts transcript to markdown for /export
661
+ markdown.ts markdown to ANSI plus a small syntax highlighter
662
+ text.ts ANSI-aware width, wrap, truncate
663
+ theme.ts adaptive palette and SGR styling
664
+ themes.ts the named palettes /theme chooses between
665
+ ```
666
+
667
+ `src/tui/` imports nothing from the Harness and nothing from npm, which is why
668
+ it can be tested without a profile. `src/tui-host.ts` is the one module that
669
+ does import Cordis, because it *is* the seam (exported as
670
+ `moqi-tui/tui-host`); the shortcut registry and status line it
671
+ delegates to are plain classes in `src/tui-host-core.ts`, which the suites
672
+ import instead, so both are tested without a context — and re-exported from the
673
+ seam, so a plugin still needs the one import.
674
+
675
+ That split is load-bearing rather than tidy: `npm test` runs from a bare
676
+ `npm ci`, where `@deepseek-ai/*` does not resolve at all, so a suite that
677
+ reaches the Harness cannot even load. `tests/offline-imports-smoke.ts` walks
678
+ the import graph of every suite and fails if one does, because a development
679
+ checkout has run `npm run link-types` and would otherwise never notice.
680
+
681
+ ## Tests
682
+
683
+ ```sh
684
+ npm test # render, queue, persist, stream, export, sessions, fleet, theme, patch, pty
685
+ npm test # render + queue + persist + stream + pty (370 + 8 + 41 + 20 + 13)
686
+ npm run test:pty # just the pty round trip, for a quick loop (needs script(1))
687
+ node --experimental-strip-types tests/preview.ts [normal|palette|picker|stream|think]
688
+ ```
689
+
690
+ The smoke test renders real frames at sizes from 20x8 to 200x60 and asserts
691
+ the invariants the screen driver depends on: the frame never exceeds the
692
+ window, no line exceeds the width, and the cursor always lands inside the
693
+ composer. It also exercises the input-history recall and transcript-search
694
+ matching; sibling scripts cover queue rendering, the persistence round-trip
695
+ against a temporary `$DSH_HOME`, the stream projection (a synthetic model
696
+ reply replayed through `tui/stream.ts`), and the color palettes — every theme
697
+ is checked for ten well-formed colors in both variants, `/theme` is checked to
698
+ actually change the bytes `style()` emits and to restore the default exactly,
699
+ and a child process re-runs the suite under `NO_COLOR` to prove it still
700
+ suppresses everything. The pty harness drives the real
701
+ `Screen`, key decoding, and frame renderer through an actual pseudo-terminal —
702
+ raw mode, the alternate screen, split escape sequences, and the two-step
703
+ ctrl+c — so the terminal layer is proven by a round trip, not types alone.
704
+ `preview.ts` prints a frame so a layout change can be eyeballed. CI runs the
705
+ whole suite on Node 22 and 24 plus a typecheck against the real Harness
706
+ packages.
707
+
708
+ ## Performance
709
+
710
+ Rendering is a per-line diff over a zero-dependency renderer, and each settled
711
+ transcript turn's lines are cached by identity, width, and the two view toggles
712
+ that change them (`ctrl+o` and `/thinking`), so a frame re-renders only what
713
+ changed. Measured with `npm run bench` (Node 26, 200x60 window, five segments
714
+ per assistant turn):
715
+
716
+ | Transcript | Before the message cache | Now |
717
+ |---|---|---|
718
+ | 400 messages | 25.3 ms/frame | **0.20 ms/frame** |
719
+
720
+ A full scroll or a spinner tick therefore costs a fraction of the 80 ms it has
721
+ between paints, which is what makes a long session stay smooth. The benchmark
722
+ prints numbers instead of asserting them; the test suite only asserts an
723
+ order-of-magnitude bound, so machine noise cannot fail a build while a cache
724
+ regression still would.
725
+
726
+ ## Status and caveats
727
+
728
+ - **Verified against real `dsh` installs** (0.1.5-rc.3 and 0.1.7-rc.2; CI
729
+ typechecks `latest` and `next`):
730
+ - `npm run link-types && npm run typecheck` passes clean against both lines.
731
+ - `dsh --profile tui --dump-config` composes the tree, showing `dsh-base`
732
+ patched by this bundle and both `tui-startup` and `tui-app` mounted.
733
+ - `dsh --profile tui --help` prints this app's own flags, so the startup
734
+ provider parses the real command line.
735
+ - `dsh --profile tui </dev/null` boots the bundle and exits on the non-TTY
736
+ guard.
737
+ - **29 suites, 1460 assertions**, covering rendering (including a pty round
738
+ trip through the real screen, decoder, and frame renderer), streaming
739
+ projection, queueing, steering, persistence, session storage, cross-session
740
+ search, the panels, the plugin seam, i18n, the fleet, and the render cache.
741
+ - **The boot-to-model turn is now automated, on demand.** `npm run test:live`
742
+ (`MOQI_LIVE=1`) boots `dsh --profile tui` under `script(1)`, types a
743
+ prompt, and asserts that the model's answer reaches a painted frame before
744
+ quitting with the two-step ctrl+c. It needs credentials and costs a model
745
+ call, so it is deliberately not part of `npm test`; a manual GitHub workflow
746
+ runs it when a key is configured. The offline pty suite additionally drives
747
+ the real `Screen`, key decoder, and renderer through the `@` menu, an
748
+ approval panel, a questionnaire, and a language switch — which is how a
749
+ space that never matched the panel's toggle was caught.
750
+ - **`ctx.sessionQuery` listing is probed.** The service is documented as
751
+ offering "filtered lists" without a stable method name, so `/resume` and
752
+ `/tree` try `listSessions`, `list`, then `querySessions` and report cleanly
753
+ when none exist.
754
+ - **Interrupt is best-effort.** `esc` aborts the app's wait and calls
755
+ `interrupt()`/`abort()` on the agent if either exists; whatever streamed is
756
+ still committed.
757
+ - **`/mcp` reads, it does not manage.** The MCP client is configured by
758
+ composition, so the pane reports the bridge-prefixed tools that are actually
759
+ mounted and where to declare a server — there is no runtime add/remove.
760
+ - **Two release steps remain manual**: publishing to npm (needs the account's
761
+ credentials) and listing on dshfind.
762
+
763
+ ## Release steps
764
+
765
+ ```sh
766
+ npm test # 29 suites, including the pty round trip
767
+ npm run test:live # a real model turn through the TUI (needs credentials)
768
+ npm run test:package # packs, installs into a clean prefix + DSH_HOME, boots
769
+ npm publish # prepublishOnly re-runs build + typecheck + npm test
770
+ ```
771
+
772
+ `prepublishOnly` runs all four gates, so publishing needs `dsh` on `PATH` — a
773
+ broken artifact must fail the publish rather than reach the registry.
774
+
775
+ `test:package` exists because the suites all run from the source checkout,
776
+ where `link-types` has already made the Harness resolvable — which is exactly
777
+ how a tarball that could not resolve `@deepseek-ai/*` once passed every test
778
+ and still crashed on boot. It now fails the release instead.
779
+
780
+ ## License
781
+
782
+ MIT.