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
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Transcript export: turn the in-app message list into a markdown document.
3
+ *
4
+ * Pure and free of Harness imports, like everything under `./tui/`, so it can
5
+ * be tested without a profile.
6
+ * @module
7
+ */
8
+ import { messageText } from "./state.js";
9
+ /**
10
+ * Render a transcript as markdown.
11
+ *
12
+ * User turns become `## >`-quoted sections and assistant turns `##` sections.
13
+ * An assistant turn is written in the order it happened — each tool call as a
14
+ * checklist line between the prose it came between — because a document that
15
+ * collects the calls at the end tells you what the agent did but not when, and
16
+ * the reason it said the next thing is usually what the call returned.
17
+ */
18
+ export function transcriptMarkdown(messages, title) {
19
+ const lines = [`# ${title === '' ? 'dsh transcript' : title}`, ''];
20
+ for (const message of messages) {
21
+ if (message.role === 'user') {
22
+ lines.push('## >', '', ...indented(messageText(message)), '');
23
+ continue;
24
+ }
25
+ const label = message.command === undefined ? '' : ` (${message.command.ok ? 'ok' : 'failed'})`;
26
+ lines.push(`## assistant${label}`, '');
27
+ if (message.reasoning !== undefined && message.reasoning !== '') {
28
+ lines.push('<details><summary>thinking</summary>', '', '```', ...message.reasoning.split('\n'), '```', '', '</details>', '');
29
+ }
30
+ for (const segment of message.segments) {
31
+ if (segment.kind === 'text') {
32
+ if (segment.text.trim() !== '')
33
+ lines.push(...segment.text.trim().split('\n'), '');
34
+ continue;
35
+ }
36
+ const tool = segment.tool;
37
+ const mark = tool.status === 'ok' ? 'x' : tool.status === 'error' ? ' ' : '~';
38
+ lines.push(`- [${mark}] \`${tool.name}\`${tool.detail === undefined ? '' : ` — ${tool.detail}`}`, '');
39
+ }
40
+ }
41
+ return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`;
42
+ }
43
+ /** Quote a user turn the way an email client quotes a reply. */
44
+ function indented(text) {
45
+ if (text.trim() === '')
46
+ return [];
47
+ return text.split('\n').map((line) => (line === '' ? '>' : `> ${line}`));
48
+ }
@@ -0,0 +1,346 @@
1
+ /**
2
+ * The fleet overview: sessions across every device, in one list.
3
+ *
4
+ * This module is pure. It knows nothing about filesystems, SSH, or the
5
+ * Harness — it takes presence records that someone else collected and turns
6
+ * them into ordered, rendered rows. That is what keeps the interesting part
7
+ * (staleness, ranking, what a row says) testable without a second machine.
8
+ *
9
+ * The transport is deliberately outside this file: see `src/fleet-sources.ts`.
10
+ * @module
11
+ */
12
+ import { displayWidth, padEnd, truncate } from "./text.js";
13
+ import { colGold, colGreen, colMuted, colText, muted, ok, selected, style, warn, } from "./theme.js";
14
+ /** The record version this build writes and accepts. */
15
+ export const PRESENCE_VERSION = 1;
16
+ /**
17
+ * How long a record may go unrefreshed before its device is presumed gone.
18
+ *
19
+ * Generous relative to the heartbeat: a laptop that sleeps mid-turn should
20
+ * read as `stale` rather than flapping, and a slow SSH round trip must not
21
+ * make a healthy device look dead.
22
+ */
23
+ export const DEFAULT_STALE_AFTER_MS = 30_000;
24
+ /** Rank for sorting: what is running matters most, what is gone matters least. */
25
+ const STATUS_RANK = {
26
+ running: 0,
27
+ ready: 1,
28
+ idle: 2,
29
+ stale: 3,
30
+ };
31
+ /** Whether a record is well-formed enough to display. */
32
+ export function isPresenceRecord(value) {
33
+ if (value === null || typeof value !== 'object')
34
+ return false;
35
+ const record = value;
36
+ return (record.v === PRESENCE_VERSION &&
37
+ typeof record.host === 'string' &&
38
+ record.host !== '' &&
39
+ typeof record.sessionId === 'string' &&
40
+ record.sessionId !== '' &&
41
+ typeof record.updatedAt === 'number' &&
42
+ Number.isFinite(record.updatedAt) &&
43
+ (record.status === 'running' || record.status === 'ready' || record.status === 'idle'));
44
+ }
45
+ /**
46
+ * Merge every device's records into one ordered list.
47
+ *
48
+ * A record older than `staleAfterMs` is reported `stale` whatever it claimed:
49
+ * a device that stopped heartbeating mid-turn would otherwise sit in the
50
+ * overview claiming to be running forever.
51
+ */
52
+ export function mergeFleet(sources, now, staleAfterMs = DEFAULT_STALE_AFTER_MS) {
53
+ const rows = [];
54
+ const seen = new Set();
55
+ for (const source of sources) {
56
+ for (const record of source.records) {
57
+ if (!isPresenceRecord(record))
58
+ continue;
59
+ // One session is owned by one device; a duplicate id is a stale copy.
60
+ const key = `${record.host}\u0000${record.sessionId}`;
61
+ if (seen.has(key))
62
+ continue;
63
+ seen.add(key);
64
+ const age = Math.max(now - record.updatedAt, 0);
65
+ rows.push({
66
+ host: record.host,
67
+ sessionId: record.sessionId,
68
+ title: record.title,
69
+ status: age > staleAfterMs ? 'stale' : record.status,
70
+ model: record.model,
71
+ cwd: record.cwd,
72
+ updatedAt: record.updatedAt,
73
+ local: source.local,
74
+ ageSeconds: Math.floor(age / 1000),
75
+ });
76
+ }
77
+ }
78
+ // Grouped by device, because "which machine is this on" is the question the
79
+ // overview exists to answer, and the renderer prints a heading per device.
80
+ // Inside a device the most urgent comes first. The global "what needs me"
81
+ // question is answered by fleetSummary, not by interleaving hosts here.
82
+ rows.sort((left, right) => {
83
+ if (left.local !== right.local)
84
+ return left.local ? -1 : 1;
85
+ const byHost = left.host.localeCompare(right.host);
86
+ if (byHost !== 0)
87
+ return byHost;
88
+ const byStatus = (STATUS_RANK[left.status] ?? 9) - (STATUS_RANK[right.status] ?? 9);
89
+ if (byStatus !== 0)
90
+ return byStatus;
91
+ return right.updatedAt - left.updatedAt;
92
+ });
93
+ return rows;
94
+ }
95
+ /** A compact age: 8s, 4m, 2h, 3d. */
96
+ export function formatAge(seconds) {
97
+ if (seconds < 60)
98
+ return `${String(seconds)}s`;
99
+ if (seconds < 3600)
100
+ return `${String(Math.floor(seconds / 60))}m`;
101
+ if (seconds < 86_400)
102
+ return `${String(Math.floor(seconds / 3600))}h`;
103
+ return `${String(Math.floor(seconds / 86_400))}d`;
104
+ }
105
+ /** The status mark, matching the marks the session bar already uses. */
106
+ function statusMark(status, spinner) {
107
+ switch (status) {
108
+ case 'running':
109
+ return style(spinner, { fg: colGreen });
110
+ case 'ready':
111
+ return style('●', { fg: colGold });
112
+ case 'idle':
113
+ return muted('·');
114
+ case 'stale':
115
+ return muted('✗');
116
+ }
117
+ }
118
+ /**
119
+ * The command that opens a session on the device that owns it.
120
+ *
121
+ * A remote session is reached the same way the device itself is: over SSH,
122
+ * with a TTY, resuming by id. Nothing new is exposed to do it.
123
+ */
124
+ export function jumpCommand(session, profile = 'tui') {
125
+ const resume = `dsh --profile ${profile} --resume ${session.sessionId}`;
126
+ return session.local ? resume : `ssh -t ${session.host} '${resume}'`;
127
+ }
128
+ /**
129
+ * Quote one argument for a POSIX shell.
130
+ *
131
+ * A prompt is arbitrary text and is about to travel through `ssh`, which hands
132
+ * it to the remote shell — so it is single-quoted with the one escape a single
133
+ * quoted string has. Nothing here trusts the caller.
134
+ */
135
+ export function shellQuote(text) {
136
+ return `'${text.replaceAll("'", "'\\''")}'`;
137
+ }
138
+ /**
139
+ * The argv for dispatching a task to a peer's headless profile.
140
+ *
141
+ * The prompt is quoted for the remote shell; the profile name and host are
142
+ * passed as separate argv words so the local shell never interprets them.
143
+ */
144
+ export function dispatchArgv(host, profile, prompt) {
145
+ return ['-o', 'BatchMode=yes', host, `dsh --profile ${profile} ${shellQuote(prompt)}`];
146
+ }
147
+ /**
148
+ * Render the overview to styled lines.
149
+ *
150
+ * Grouped by device, because "which machine is this on" is the question the
151
+ * overview exists to answer; an unreachable device is named rather than
152
+ * silently contributing nothing.
153
+ */
154
+ export function renderFleet(sessions, options) {
155
+ const width = Math.max(options.width, 20);
156
+ // Not the same glyph as `ready`: a caller that forgets to pass an animation
157
+ // frame must not make a running session look like a finished one.
158
+ const spinner = options.spinner ?? '⠋';
159
+ const selectedIndex = options.selectedIndex ?? -1;
160
+ const out = [];
161
+ if (sessions.length === 0) {
162
+ out.push(muted(' no sessions on any device'));
163
+ }
164
+ let host = '';
165
+ sessions.forEach((session, index) => {
166
+ if (session.host !== host) {
167
+ host = session.host;
168
+ if (out.length > 0)
169
+ out.push('');
170
+ const label = session.local ? `${host} (this device)` : host;
171
+ out.push(style(label, { fg: colText, bold: true }));
172
+ }
173
+ const mark = statusMark(session.status, spinner);
174
+ const age = muted(formatAge(session.ageSeconds));
175
+ const model = session.model === undefined ? '' : muted(` ${session.model}`);
176
+ const title = session.title === '' ? 'new session' : session.title;
177
+ const head = ` ${mark} ${truncate(title, Math.max(width - 22, 8))}${model}`;
178
+ const pad = Math.max(width - displayWidth(head) - displayWidth(age) - 1, 1);
179
+ const row = `${head}${' '.repeat(pad)}${age}`;
180
+ out.push(index === selectedIndex ? selected(padEnd(row, width)) : row);
181
+ });
182
+ for (const source of options.sources ?? []) {
183
+ if (source.error === undefined)
184
+ continue;
185
+ out.push(warn(` ${source.host}: ${truncate(source.error, Math.max(width - 4, 8))}`));
186
+ }
187
+ return out;
188
+ }
189
+ /** A one-line summary for the status bar: how much is running where. */
190
+ export function fleetSummary(sessions) {
191
+ const running = sessions.filter((session) => session.status === 'running').length;
192
+ const ready = sessions.filter((session) => session.status === 'ready').length;
193
+ const hosts = new Set(sessions.map((session) => session.host)).size;
194
+ const parts = [];
195
+ if (running > 0)
196
+ parts.push(`${String(running)} running`);
197
+ if (ready > 0)
198
+ parts.push(`${String(ready)} ready`);
199
+ if (parts.length === 0)
200
+ parts.push(`${String(sessions.length)} idle`);
201
+ return `${parts.join(', ')} across ${String(hosts)} device${hosts === 1 ? '' : 's'}`;
202
+ }
203
+ /** Colors re-exported so a caller can match the overview's palette. */
204
+ export const FLEET_COLORS = { colGreen, colGold, colMuted, colText, ok };
205
+ /**
206
+ * Which rendered line carries row `index`.
207
+ *
208
+ * {@link renderFleet} inserts a heading per device and a blank line between
209
+ * groups, so the selected row's index is not its line. The pane needs the
210
+ * line to scroll, and duplicating the rule here rather than returning it from
211
+ * the renderer keeps the renderer a plain function of its inputs. The two must
212
+ * agree, which is what the smoke test pins.
213
+ */
214
+ export function fleetLineOf(sessions, index) {
215
+ let line = 0;
216
+ let host = '';
217
+ for (const [position, session] of sessions.entries()) {
218
+ if (session.host !== host) {
219
+ host = session.host;
220
+ // Mirrors renderFleet: a separator before every group but the first.
221
+ if (line > 0)
222
+ line += 1;
223
+ line += 1;
224
+ }
225
+ if (position === index)
226
+ return line;
227
+ line += 1;
228
+ }
229
+ return 0;
230
+ }
231
+ /**
232
+ * Whether a string is safe and sensible to use as a peer.
233
+ *
234
+ * The value ends up on an `ssh` command line, so this is a gate rather than a
235
+ * tidy-up: anything a shell would treat as more than one word, or that `ssh`
236
+ * would read as an option, is refused outright instead of being escaped and
237
+ * hoped for. What remains is the shape of a host, an alias, or `user@host`.
238
+ */
239
+ export function isValidPeer(host) {
240
+ const trimmed = host.trim();
241
+ if (trimmed === '' || trimmed.length > 255)
242
+ return false;
243
+ // A leading dash would be parsed by ssh as a flag, not a destination.
244
+ if (trimmed.startsWith('-'))
245
+ return false;
246
+ return /^[A-Za-z0-9_.@:[\]-]+$/.test(trimmed);
247
+ }
248
+ /**
249
+ * The overview's interaction state: what was collected, and where the cursor is.
250
+ *
251
+ * Kept beside the renderer because it is the same concern and equally pure —
252
+ * it never reads a file or a socket. The app owns collection and hands the
253
+ * result here.
254
+ */
255
+ export class FleetView {
256
+ open = false;
257
+ /** True while a collection round is in flight, so the pane can say so. */
258
+ loading = false;
259
+ sessions = [];
260
+ sources = [];
261
+ selected = 0;
262
+ /**
263
+ * Set while the pane is asking for a device to add.
264
+ *
265
+ * Adding a peer belongs here rather than only on the command line, because
266
+ * the list is exactly where you notice a device is missing from it.
267
+ */
268
+ adding = false;
269
+ /** What has been typed into that prompt so far. */
270
+ draft = '';
271
+ show() {
272
+ this.open = true;
273
+ this.loading = true;
274
+ }
275
+ hide() {
276
+ this.open = false;
277
+ this.loading = false;
278
+ }
279
+ /**
280
+ * Install a freshly collected round.
281
+ *
282
+ * The cursor follows the session it was on rather than the position it was
283
+ * at: rows reorder as work starts and finishes, and a refresh that moved the
284
+ * selection onto a different machine would be a way to open the wrong thing.
285
+ */
286
+ setResult(sessions, sources) {
287
+ const anchor = this.current()?.sessionId;
288
+ this.loading = false;
289
+ this.sessions = [...sessions];
290
+ this.sources = [...sources];
291
+ if (anchor !== undefined) {
292
+ const found = this.sessions.findIndex((session) => session.sessionId === anchor);
293
+ this.selected = found === -1 ? 0 : found;
294
+ }
295
+ this.clamp();
296
+ }
297
+ move(delta) {
298
+ if (this.sessions.length === 0)
299
+ return;
300
+ this.selected += delta;
301
+ this.clamp();
302
+ }
303
+ current() {
304
+ return this.sessions[this.selected];
305
+ }
306
+ /** Start asking for a device to add. */
307
+ beginAdd() {
308
+ this.adding = true;
309
+ this.draft = '';
310
+ }
311
+ /** Abandon the prompt, leaving the list as it was. */
312
+ cancelAdd() {
313
+ this.adding = false;
314
+ this.draft = '';
315
+ }
316
+ typeAdd(text) {
317
+ if (!this.adding)
318
+ return;
319
+ this.draft += text;
320
+ }
321
+ backspaceAdd() {
322
+ if (!this.adding)
323
+ return;
324
+ this.draft = this.draft.slice(0, -1);
325
+ }
326
+ /**
327
+ * Finish the prompt, returning the host to add.
328
+ *
329
+ * A rejected name leaves the prompt open with the text intact, so a typo is
330
+ * corrected rather than retyped.
331
+ */
332
+ commitAdd() {
333
+ if (!this.adding)
334
+ return undefined;
335
+ const host = this.draft.trim();
336
+ if (!isValidPeer(host))
337
+ return undefined;
338
+ this.adding = false;
339
+ this.draft = '';
340
+ return host;
341
+ }
342
+ clamp() {
343
+ const last = this.sessions.length - 1;
344
+ this.selected = last < 0 ? 0 : Math.min(Math.max(this.selected, 0), last);
345
+ }
346
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Interface language: one catalog, looked up by key.
3
+ *
4
+ * The scope is deliberate: the chrome a reader reads — the welcome, the key
5
+ * reference, the trust panels, and the footer hints. Operational status lines
6
+ * (what a command just did) stay English because they are diagnostics, not
7
+ * interface, and translating a moving target is how a UI ends up half in each
8
+ * language.
9
+ *
10
+ * A missing key falls back to English and then to the key itself, so a new
11
+ * string can never render as blank.
12
+ * @module
13
+ */
14
+ /** Every language, in menu order, with the label to show in a picker. */
15
+ export const LANGS = [
16
+ { id: 'en', label: 'English' },
17
+ { id: 'zh-CN', label: '简体中文' },
18
+ ];
19
+ /** Whether an untrusted string names a language this build speaks. */
20
+ export function isLang(value) {
21
+ return LANGS.some((entry) => entry.id === value);
22
+ }
23
+ const EN = {
24
+ 'welcome.title': '◆ DeepSeek Harness',
25
+ 'welcome.connected': 'connected to {host} · {model}',
26
+ 'welcome.harness': 'sessions, compaction and tools live in the harness',
27
+ 'welcome.type': 'type a message, or ',
28
+ 'welcome.forCommands': ' for commands',
29
+ 'footer.hint': '/ commands · ? help · ctrl+c menu',
30
+ 'footer.scrolled': '↑ {lines} line{s} · ctrl+g newest',
31
+ 'footer.recording': '{spinner} ● recording · ctrl+v stop · esc cancel',
32
+ 'footer.transcribing': '{spinner} transcribing…',
33
+ 'approval.title': 'Allow {tool}?',
34
+ 'approval.allow': 'Allow once',
35
+ 'approval.allowDetail': 'run this one call',
36
+ 'approval.deny': 'Deny',
37
+ 'approval.denyDetail': 'the agent is told no',
38
+ 'approval.hint': '↑↓ move · enter choose · 1 allow · 2 deny · esc denies',
39
+ 'questions.title': 'Question',
40
+ 'questions.of': 'Question {n} of {total}',
41
+ 'questions.answer': 'Answer',
42
+ 'questions.answerPlaceholder': '(type an answer)',
43
+ 'questions.hint': '↑↓ move · enter choose · tab type an answer · esc back',
44
+ 'questions.hintMulti': '↑↓ move · space toggle · enter next · tab type an answer · esc back',
45
+ 'questions.hintPlan': '↑↓ move · enter decide · type feedback to keep planning · esc back',
46
+ 'questions.cancelSuffix': ' · esc cancels',
47
+ 'help.body': [
48
+ '**Keys**',
49
+ '',
50
+ '- `enter` — send · steers into a running reply · `ctrl+j` — newline',
51
+ '- `↑` / `↓` on the first / last row — recall earlier prompts',
52
+ '- `/` — command palette · `tab` accept · `esc` dismiss',
53
+ '- `@` — file completion · `tab`/`enter` accept · `esc` dismiss',
54
+ '- `esc` — clear a search, else interrupt a reply while it is streaming',
55
+ '- `tab` while streaming queues · `/unqueue` discards · `/interrupt` runs them',
56
+ '- `ctrl+n` — new session · `ctrl+r` — resume · `ctrl+t` — toggle thinking',
57
+ '- `pgup` / `pgdn` — page · `shift+↑` / `shift+↓` — one line · `ctrl+g` — newest',
58
+ '- `ctrl+↑` / `ctrl+↓` — half page · `ctrl+u` — clear the composer',
59
+ '- `alt+e` — edit draft · `alt+↑`/`alt+↓` select a turn · `alt+c` — copy it',
60
+ '- `ctrl+o` — tool calls · `ctrl+x` — compact · `ctrl+b` — background agents',
61
+ '- `ctrl+y` — copy the last reply · `ctrl+f` — every device · `ctrl+v` — push to talk',
62
+ '- `?` — open this help on an empty composer',
63
+ '',
64
+ '**Sessions**',
65
+ '',
66
+ '- `ctrl+n` — new session · `alt+1`…`alt+9` jump · `tab` on empty cycles · `/sessions` picks',
67
+ '- `/close` · `/rename <t>` · `/rewind` redo · `/fork` twin · `/tree` lineage · `/jobs`',
68
+ '- `ctrl+a` / `ctrl+e` — start / end of line · `ctrl+w` — delete word · `ctrl+d` — delete forward',
69
+ '',
70
+ '**Fleet** (`ctrl+f`, `/fleet`) — every device running this app, by machine',
71
+ '',
72
+ '- `↑`/`↓` move · `r` refresh · `enter` open · `p` preview · `d` dispatch · `esc` back',
73
+ '- a session elsewhere copies the `ssh` that reaches it; `--peer <host>` adds one',
74
+ '',
75
+ '**Plugins** (`/plugins`) — the packages this profile composes',
76
+ '',
77
+ '- `enter` — enable or disable the selected package · restart to apply',
78
+ '- `/plugins add|remove <pkg>` — both confirm; install scripts run as you',
79
+ '',
80
+ '**Searching, palettes and lists**',
81
+ '',
82
+ '- `/find <text>` — search · `n` / `N` on an empty composer — next / previous',
83
+ '- `/find --sessions <text>` — search every stored session on this machine',
84
+ '- `/theme` — pick a palette · `mono` is greyscale · in a list: type to filter',
85
+ '',
86
+ '**Decisions** — when the agent stops to ask, the panel owns the keyboard',
87
+ '',
88
+ '- approval: `1` allow once · `2` / `esc` deny · questions: `↑`/`↓`, `space`, `enter`, `tab`',
89
+ '- plan review: `enter` approves or keeps planning · typing is feedback',
90
+ '',
91
+ '**Commands**',
92
+ '',
93
+ 'Type `/` for every command the harness has registered, its own plugins too.',
94
+ '',
95
+ '- `ctrl+c` — sessions menu · again within 1.5s — quit',
96
+ ].join('\n'),
97
+ };
98
+ const ZH = {
99
+ 'welcome.title': '◆ DeepSeek Harness',
100
+ 'welcome.connected': '已连接 {host} · {model}',
101
+ 'welcome.harness': '会话、压缩与工具都在 harness 中',
102
+ 'welcome.type': '输入消息,或用 ',
103
+ 'welcome.forCommands': ' 打开命令',
104
+ 'footer.hint': '/ 命令 · ? 帮助 · ctrl+c 菜单',
105
+ 'footer.scrolled': '↑ {lines} 行 · ctrl+g 回到最新',
106
+ 'footer.recording': '{spinner} ● 正在录音 · ctrl+v 停止 · esc 取消',
107
+ 'footer.transcribing': '{spinner} 正在转写…',
108
+ 'approval.title': '允许 {tool}?',
109
+ 'approval.allow': '允许一次',
110
+ 'approval.allowDetail': '只运行这一次调用',
111
+ 'approval.deny': '拒绝',
112
+ 'approval.denyDetail': '告诉 agent 不行',
113
+ 'approval.hint': '↑↓ 移动 · enter 选择 · 1 允许 · 2 拒绝 · esc 拒绝',
114
+ 'questions.title': '提问',
115
+ 'questions.of': '第 {n} / {total} 个问题',
116
+ 'questions.answer': '回答',
117
+ 'questions.answerPlaceholder': '(输入回答)',
118
+ 'questions.hint': '↑↓ 移动 · enter 选择 · tab 输入回答 · esc 返回',
119
+ 'questions.hintMulti': '↑↓ 移动 · space 多选 · enter 下一个 · tab 输入回答 · esc 返回',
120
+ 'questions.hintPlan': '↑↓ 移动 · enter 决定 · 直接输入即为反馈 · esc 返回',
121
+ 'questions.cancelSuffix': ' · esc 取消',
122
+ 'help.body': [
123
+ '**按键**',
124
+ '',
125
+ '- `enter` — 发送 · 回复中则插入运行中的回合 · `ctrl+j` — 换行',
126
+ '- 首行 / 末行的 `↑` / `↓` — 调出历史提示词',
127
+ '- `/` — 命令面板 · `tab` 接受 · `esc` 关闭',
128
+ '- `@` — 文件补全 · `tab`/`enter` 接受 · `esc` 关闭',
129
+ '- `esc` — 先清搜索,否则打断正在流式的回复',
130
+ '- 流式中 `tab` 排队 · `/unqueue` 丢弃 · `/interrupt` 立即执行',
131
+ '- `ctrl+n` — 新会话 · `ctrl+r` — 恢复 · `ctrl+t` — 思考显示',
132
+ '- `pgup` / `pgdn` — 翻页 · `shift+↑` / `shift+↓` — 一行 · `ctrl+g` — 最新',
133
+ '- `ctrl+↑` / `ctrl+↓` — 半页 · `ctrl+u` — 清空输入框',
134
+ '- `alt+e` — 外部编辑器 · `alt+↑`/`alt+↓` 选中回合 · `alt+c` — 复制',
135
+ '- `ctrl+o` — 工具调用 · `ctrl+x` — 压缩 · `ctrl+b` — 后台 agent',
136
+ '- `ctrl+y` — 复制上一条回复 · `ctrl+f` — 所有设备 · `ctrl+v` — 语音输入',
137
+ '- 输入框为空时 `?` — 打开本帮助',
138
+ '',
139
+ '**会话**',
140
+ '',
141
+ '- `ctrl+n` — 新会话 · `alt+1`…`alt+9` 跳转 · 空输入框 `tab` 循环 · `/sessions` 选择',
142
+ '- `/close` · `/rename <标题>` · `/rewind` 重来 · `/fork` 分身 · `/tree` 谱系 · `/jobs`',
143
+ '- `ctrl+a` / `ctrl+e` — 行首 / 行尾 · `ctrl+w` — 删词 · `ctrl+d` — 向后删除',
144
+ '',
145
+ '**舰队**(`ctrl+f`、`/fleet`)— 每台运行本应用的机器',
146
+ '',
147
+ '- `↑`/`↓` 移动 · `r` 刷新 · `enter` 打开 · `p` 预览 · `d` 下发任务 · `esc` 返回',
148
+ '- 其他设备上的会话会复制出可达的 `ssh` 命令;`--peer <主机>` 添加设备',
149
+ '',
150
+ '**插件**(`/plugins`)— 该 profile 组合的软件包',
151
+ '',
152
+ '- `enter` — 启用或停用所选包 · 重启后生效',
153
+ '- `/plugins add|remove <包>` — 都会确认;安装脚本以你的身份运行',
154
+ '',
155
+ '**搜索、配色与列表**',
156
+ '',
157
+ '- `/find <文本>` — 搜索 · 空输入框 `n` / `N` — 下一个 / 上一个',
158
+ '- `/find --sessions <文本>` — 搜索本机所有已存储会话',
159
+ '- `/theme` — 选择配色 · `mono` 为灰度 · 列表中输入即筛选',
160
+ '',
161
+ '**需要决定时** — 面板接管键盘',
162
+ '',
163
+ '- 审批:`1` 允许一次 · `2` / `esc` 拒绝 · 提问:`↑`/`↓`、`space`、`enter`、`tab`',
164
+ '- 计划审核:`enter` 批准或继续规划 · 直接输入即为反馈',
165
+ '',
166
+ '**命令**',
167
+ '',
168
+ '输入 `/` 查看 harness 注册的全部命令,包括其插件。',
169
+ '',
170
+ '- `ctrl+c` — 会话菜单 · 1.5 秒内再次按下 — 退出',
171
+ ].join('\n'),
172
+ };
173
+ const CATALOGS = { en: EN, 'zh-CN': ZH };
174
+ /** Fill `{name}` placeholders; a missing value leaves the placeholder alone. */
175
+ export function fill(template, params) {
176
+ if (params === undefined)
177
+ return template;
178
+ return template.replace(/\{(\w+)\}/g, (whole, key) => Object.prototype.hasOwnProperty.call(params, key) ? String(params[key]) : whole);
179
+ }
180
+ /** Look a string up in one explicit language. */
181
+ export function translate(lang, key, params) {
182
+ const template = CATALOGS[lang]?.[key] ?? EN[key] ?? key;
183
+ return fill(template, params);
184
+ }
185
+ let active = 'en';
186
+ /** The language the interface is drawing in. */
187
+ export function currentLanguage() {
188
+ return active;
189
+ }
190
+ /** Switch the interface language; the caller persists the choice. */
191
+ export function setLanguage(lang) {
192
+ active = lang;
193
+ }
194
+ /** Look a string up in the active language. */
195
+ export function t(key, params) {
196
+ return translate(active, key, params);
197
+ }
198
+ /** The full key reference in the active language. */
199
+ export function helpText() {
200
+ return t('help.body');
201
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `/jobs` formatting: a background job list rendered as markdown.
3
+ *
4
+ * Pure, like the rest of `tui/`: the app layer hands over snapshots and owns
5
+ * the registry calls.
6
+ * @module
7
+ */
8
+ /** A compact "3s" / "4m" / "2h" duration. */
9
+ export function formatDuration(seconds) {
10
+ if (seconds < 60)
11
+ return `${String(Math.max(Math.floor(seconds), 0))}s`;
12
+ if (seconds < 3600)
13
+ return `${String(Math.floor(seconds / 60))}m`;
14
+ if (seconds < 86400)
15
+ return `${String(Math.floor(seconds / 3600))}h`;
16
+ return `${String(Math.floor(seconds / 86400))}d`;
17
+ }
18
+ /** The mark a job's state earns in the list. */
19
+ export function jobMark(status) {
20
+ if (status === 'running')
21
+ return '⠹';
22
+ if (status === 'stopping')
23
+ return '⠴';
24
+ if (status === 'completed')
25
+ return '✓';
26
+ if (status === 'killed')
27
+ return '·';
28
+ return '✗';
29
+ }
30
+ /**
31
+ * The `/jobs` overlay body.
32
+ *
33
+ * Running jobs first, then the settled ones newest-first, because the list
34
+ * exists to answer "what is still going" before "what happened".
35
+ */
36
+ export function renderJobs(jobs, now = Date.now()) {
37
+ if (jobs.length === 0) {
38
+ return ['**Background jobs**', '', 'Nothing has run in the background in this session.'].join('\n');
39
+ }
40
+ const running = jobs.filter((job) => job.status === 'running' || job.status === 'stopping');
41
+ const settled = jobs
42
+ .filter((job) => job.status !== 'running' && job.status !== 'stopping')
43
+ .sort((a, b) => (b.finishedAt ?? b.startedAt) - (a.finishedAt ?? a.startedAt));
44
+ const lines = ['**Background jobs**', ''];
45
+ const row = (job) => {
46
+ const end = job.finishedAt ?? now;
47
+ const duration = formatDuration((end - job.startedAt) / 1000);
48
+ const detail = job.detail === undefined || job.detail === '' ? '' : ` — ${job.detail}`;
49
+ return `- ${jobMark(job.status)} \`${job.id}\` ${job.kind}: ${job.label}${detail} · ${job.status} ${duration}`;
50
+ };
51
+ if (running.length > 0) {
52
+ lines.push(`Running (${String(running.length)})`, '');
53
+ for (const job of running)
54
+ lines.push(row(job));
55
+ lines.push('');
56
+ }
57
+ if (settled.length > 0) {
58
+ lines.push(`Finished (${String(settled.length)})`, '');
59
+ for (const job of settled)
60
+ lines.push(row(job));
61
+ lines.push('');
62
+ }
63
+ lines.push('`/jobs kill <id>` stops one · esc closes');
64
+ return lines.join('\n');
65
+ }