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.
- package/LICENSE +21 -0
- package/README.md +782 -0
- package/bin/moqi.mjs +40 -0
- package/cordis.patch.yml +41 -0
- package/lib/cross-find.js +217 -0
- package/lib/file-index.js +121 -0
- package/lib/fleet-sources.js +114 -0
- package/lib/index.js +3999 -0
- package/lib/persist.js +194 -0
- package/lib/plugins.js +371 -0
- package/lib/presence.js +144 -0
- package/lib/rename.js +35 -0
- package/lib/rewind.js +94 -0
- package/lib/sessions-store.js +134 -0
- package/lib/startup.js +92 -0
- package/lib/tui/atfile.js +154 -0
- package/lib/tui/export.js +48 -0
- package/lib/tui/fleet.js +346 -0
- package/lib/tui/i18n.js +201 -0
- package/lib/tui/jobs.js +65 -0
- package/lib/tui/keys.js +205 -0
- package/lib/tui/markdown.js +368 -0
- package/lib/tui/mcp.js +95 -0
- package/lib/tui/panels.js +231 -0
- package/lib/tui/screen.js +156 -0
- package/lib/tui/state.js +502 -0
- package/lib/tui/stream.js +109 -0
- package/lib/tui/text.js +173 -0
- package/lib/tui/theme.js +183 -0
- package/lib/tui/themes.js +153 -0
- package/lib/tui/tooldetail.js +140 -0
- package/lib/tui/view.js +830 -0
- package/lib/tui/vim.js +222 -0
- package/lib/tui-host-core.js +141 -0
- package/lib/tui-host.js +48 -0
- package/lib/types/cross-find.d.ts +66 -0
- package/lib/types/file-index.d.ts +34 -0
- package/lib/types/fleet-sources.d.ts +34 -0
- package/lib/types/index.d.ts +51 -0
- package/lib/types/persist.d.ts +116 -0
- package/lib/types/plugins.d.ts +218 -0
- package/lib/types/presence.d.ts +48 -0
- package/lib/types/rename.d.ts +32 -0
- package/lib/types/rewind.d.ts +75 -0
- package/lib/types/sessions-store.d.ts +46 -0
- package/lib/types/startup.d.ts +45 -0
- package/lib/types/tui/atfile.d.ts +90 -0
- package/lib/types/tui/export.d.ts +18 -0
- package/lib/types/tui/fleet.d.ts +209 -0
- package/lib/types/tui/i18n.d.ts +34 -0
- package/lib/types/tui/jobs.d.ts +28 -0
- package/lib/types/tui/keys.d.ts +52 -0
- package/lib/types/tui/markdown.d.ts +14 -0
- package/lib/types/tui/mcp.d.ts +34 -0
- package/lib/types/tui/panels.d.ts +125 -0
- package/lib/types/tui/screen.d.ts +79 -0
- package/lib/types/tui/state.d.ts +323 -0
- package/lib/types/tui/stream.d.ts +78 -0
- package/lib/types/tui/text.d.ts +28 -0
- package/lib/types/tui/theme.d.ts +87 -0
- package/lib/types/tui/themes.d.ts +70 -0
- package/lib/types/tui/tooldetail.d.ts +45 -0
- package/lib/types/tui/view.d.ts +163 -0
- package/lib/types/tui/vim.d.ts +64 -0
- package/lib/types/tui-host-core.d.ts +62 -0
- package/lib/types/tui-host.d.ts +42 -0
- package/lib/types/version.d.ts +8 -0
- package/lib/types/voice.d.ts +227 -0
- package/lib/version.js +32 -0
- package/lib/voice.js +405 -0
- package/package.json +119 -0
- package/scripts/harness-root.mjs +88 -0
- 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.
|