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/lib/presence.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publishing what this device is doing, for the fleet overview.
|
|
3
|
+
*
|
|
4
|
+
* Each open session gets one small JSON file under `$DSH_HOME/tui-presence`,
|
|
5
|
+
* refreshed on a heartbeat and deleted on exit. The file's age is the liveness
|
|
6
|
+
* signal — `session.lock` in the session store is not, because it is an empty
|
|
7
|
+
* flock target that outlives the process that made it.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here listens on a port. The records are ordinary files, read by
|
|
10
|
+
* another device over SSH, so the fleet overview adds no network surface and
|
|
11
|
+
* no credentials of its own.
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
import { hostname } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
|
|
17
|
+
import { isPresenceRecord, PRESENCE_VERSION, } from "./tui/fleet.js";
|
|
18
|
+
/** Directory holding this device's presence records. */
|
|
19
|
+
export function presenceDir(dshHome) {
|
|
20
|
+
return join(dshHome, 'tui-presence');
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Writes and refreshes this device's presence records.
|
|
24
|
+
*
|
|
25
|
+
* The publisher is deliberately forgiving: a read-only or missing home must
|
|
26
|
+
* degrade to publishing nothing, never to failing a turn. The overview is a
|
|
27
|
+
* convenience, and a device that cannot publish simply does not appear.
|
|
28
|
+
*/
|
|
29
|
+
export class PresencePublisher {
|
|
30
|
+
dir;
|
|
31
|
+
host;
|
|
32
|
+
owned = new Set();
|
|
33
|
+
timer;
|
|
34
|
+
snapshot = [];
|
|
35
|
+
disabled = false;
|
|
36
|
+
constructor(dshHome, host = hostname()) {
|
|
37
|
+
this.dir = presenceDir(dshHome);
|
|
38
|
+
this.host = host;
|
|
39
|
+
}
|
|
40
|
+
/** Begin heartbeating. `intervalMs` must be well under the stale threshold. */
|
|
41
|
+
start(intervalMs = 5000) {
|
|
42
|
+
if (this.timer !== undefined)
|
|
43
|
+
return;
|
|
44
|
+
this.timer = setInterval(() => {
|
|
45
|
+
this.publish(this.snapshot);
|
|
46
|
+
}, intervalMs);
|
|
47
|
+
// A heartbeat must not be the reason the process stays alive.
|
|
48
|
+
this.timer.unref?.();
|
|
49
|
+
}
|
|
50
|
+
/** Publish the current set of sessions, replacing whatever was there. */
|
|
51
|
+
publish(sessions) {
|
|
52
|
+
if (this.disabled)
|
|
53
|
+
return;
|
|
54
|
+
this.snapshot = sessions;
|
|
55
|
+
try {
|
|
56
|
+
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// A home that cannot be written is a device that does not appear.
|
|
60
|
+
this.disabled = true;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
const live = new Set();
|
|
65
|
+
for (const session of sessions) {
|
|
66
|
+
const record = {
|
|
67
|
+
v: PRESENCE_VERSION,
|
|
68
|
+
host: this.host,
|
|
69
|
+
pid: process.pid,
|
|
70
|
+
sessionId: session.sessionId,
|
|
71
|
+
title: session.title,
|
|
72
|
+
status: session.status,
|
|
73
|
+
model: session.model,
|
|
74
|
+
cwd: session.cwd,
|
|
75
|
+
updatedAt: now,
|
|
76
|
+
};
|
|
77
|
+
const path = join(this.dir, `${safeName(session.sessionId)}.json`);
|
|
78
|
+
try {
|
|
79
|
+
writeFileSync(path, `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
|
80
|
+
live.add(path);
|
|
81
|
+
this.owned.add(path);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Skip this record; the rest of the fleet view still works.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Drop records for sessions this process has closed.
|
|
88
|
+
for (const path of [...this.owned]) {
|
|
89
|
+
if (live.has(path))
|
|
90
|
+
continue;
|
|
91
|
+
this.owned.delete(path);
|
|
92
|
+
try {
|
|
93
|
+
rmSync(path, { force: true });
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Best effort: a leftover record ages out as stale.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Remove every record this process published. Safe to call repeatedly. */
|
|
101
|
+
stop() {
|
|
102
|
+
if (this.timer !== undefined) {
|
|
103
|
+
clearInterval(this.timer);
|
|
104
|
+
this.timer = undefined;
|
|
105
|
+
}
|
|
106
|
+
for (const path of this.owned) {
|
|
107
|
+
try {
|
|
108
|
+
rmSync(path, { force: true });
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Nothing to do; the record ages out.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
this.owned.clear();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Read every presence record in a directory, skipping anything malformed. */
|
|
118
|
+
export function readPresenceDir(dir) {
|
|
119
|
+
let names;
|
|
120
|
+
try {
|
|
121
|
+
names = readdirSync(dir);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
const records = [];
|
|
127
|
+
for (const name of names) {
|
|
128
|
+
if (!name.endsWith('.json'))
|
|
129
|
+
continue;
|
|
130
|
+
try {
|
|
131
|
+
const parsed = JSON.parse(readFileSync(join(dir, name), 'utf8'));
|
|
132
|
+
if (isPresenceRecord(parsed))
|
|
133
|
+
records.push(parsed);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// A half-written or foreign file is not a reason to lose the rest.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return records;
|
|
140
|
+
}
|
|
141
|
+
/** Keep a session id usable as a filename. */
|
|
142
|
+
function safeName(sessionId) {
|
|
143
|
+
return sessionId.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
144
|
+
}
|
package/lib/rename.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/rename` decided before any service is touched.
|
|
3
|
+
*
|
|
4
|
+
* The command has two outcomes — pin a user title or regenerate the automatic
|
|
5
|
+
* one — and which one applies depends only on the text after the command
|
|
6
|
+
* name. Deciding here keeps `index.ts` down to wiring, so the suite can cover
|
|
7
|
+
* every branch without a Harness behind it.
|
|
8
|
+
*
|
|
9
|
+
* @module moqi-tui/rename
|
|
10
|
+
*/
|
|
11
|
+
/** The tab-label budget: the automatic first-prompt title uses the same cap. */
|
|
12
|
+
const TITLE_LIMIT = 60;
|
|
13
|
+
/**
|
|
14
|
+
* Classify one `/rename` request. Whitespace folds to single spaces — a
|
|
15
|
+
* session name is one line — surrounding space is dropped, and a request that
|
|
16
|
+
* says nothing asks for the automatic title to be regenerated (the service's
|
|
17
|
+
* documented unpin) rather than erroring on a stray space.
|
|
18
|
+
*/
|
|
19
|
+
export function planRename(request) {
|
|
20
|
+
const title = request.trim().replace(/\s+/g, ' ');
|
|
21
|
+
if (title === '')
|
|
22
|
+
return { kind: 'refresh' };
|
|
23
|
+
return { kind: 'pin', title: title.slice(0, TITLE_LIMIT) };
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Read the title a `session/title` snapshot carries, defensively: the value
|
|
27
|
+
* crosses the plugin boundary from a service this build may shade, so nothing
|
|
28
|
+
* is assumed beyond "an object with a non-empty string `title`".
|
|
29
|
+
*
|
|
30
|
+
* @returns the snapshot's title, or `undefined` when it carries none.
|
|
31
|
+
*/
|
|
32
|
+
export function snapshotTitle(snapshot) {
|
|
33
|
+
const title = snapshot?.title;
|
|
34
|
+
return typeof title === 'string' && title.trim() !== '' ? title : undefined;
|
|
35
|
+
}
|
package/lib/rewind.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rewind and fork arithmetic over a session log.
|
|
3
|
+
*
|
|
4
|
+
* A fork must be a *balanced completed-turn prefix*: contiguous from seq 0,
|
|
5
|
+
* ending between turns, with no open turn, step, or dangling tool call. This
|
|
6
|
+
* module finds those boundaries from the event types alone, so the rules are
|
|
7
|
+
* testable without a live session.
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
/** Text of one model-visible message event, or `''` when it carries none. */
|
|
11
|
+
function textOf(event) {
|
|
12
|
+
const blocks = event.data?.message?.content;
|
|
13
|
+
if (!Array.isArray(blocks))
|
|
14
|
+
return '';
|
|
15
|
+
return blocks
|
|
16
|
+
.filter((block) => {
|
|
17
|
+
const candidate = block;
|
|
18
|
+
return candidate.type === 'text' && typeof candidate.text === 'string';
|
|
19
|
+
})
|
|
20
|
+
.map((block) => block.text)
|
|
21
|
+
.join('');
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Every human prompt in log order, each remembering the turn that carried it.
|
|
25
|
+
*
|
|
26
|
+
* A `user/message` outside any turn (a resumed or repaired log) keeps
|
|
27
|
+
* `turnStartSeq: undefined`, which makes it unrewindable rather than guessed.
|
|
28
|
+
*/
|
|
29
|
+
export function projectUserTurns(events) {
|
|
30
|
+
const turns = [];
|
|
31
|
+
let currentTurnStart;
|
|
32
|
+
for (const event of events) {
|
|
33
|
+
if (event.type === 'turn/start')
|
|
34
|
+
currentTurnStart = event.seq;
|
|
35
|
+
else if (event.type === 'user/message') {
|
|
36
|
+
const text = textOf(event);
|
|
37
|
+
if (text.trim() !== '')
|
|
38
|
+
turns.push({ seq: event.seq, text, turnStartSeq: currentTurnStart });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return turns;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Where to cut the log to rewind to a chosen prompt.
|
|
45
|
+
*
|
|
46
|
+
* Rewinding means "take me back to just before this prompt was sent", so the
|
|
47
|
+
* cut is the start of the turn that contains it — everything before that turn
|
|
48
|
+
* is the seed, and the prompt itself returns to the composer.
|
|
49
|
+
*
|
|
50
|
+
* @returns the exclusive cut offset and the prompt text, or `undefined` when
|
|
51
|
+
* the rewind is impossible: no turn boundary, or the boundary is the very
|
|
52
|
+
* start of the log (rewinding past the first message leaves nothing).
|
|
53
|
+
*/
|
|
54
|
+
export function rewindTarget(turns, chosenIndex) {
|
|
55
|
+
const turn = turns[chosenIndex];
|
|
56
|
+
if (turn === undefined || turn.turnStartSeq === undefined)
|
|
57
|
+
return undefined;
|
|
58
|
+
if (turn.turnStartSeq <= 0)
|
|
59
|
+
return undefined;
|
|
60
|
+
return { cutSeq: turn.turnStartSeq, text: turn.text };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The exclusive cut offset for a full fork: after the last completed turn.
|
|
64
|
+
*
|
|
65
|
+
* A fork of an idle session keeps every completed turn; a log with an open
|
|
66
|
+
* turn at the end is cut back to the last `turn/end`, because a fork may not
|
|
67
|
+
* inherit a half-finished turn.
|
|
68
|
+
*
|
|
69
|
+
* @returns the exclusive offset, or 0 when there is no completed turn yet.
|
|
70
|
+
*/
|
|
71
|
+
export function forkCut(events, endSeq) {
|
|
72
|
+
for (let index = Math.min(endSeq, events.length) - 1; index >= 0; index -= 1) {
|
|
73
|
+
if (events[index]?.type === 'turn/end')
|
|
74
|
+
return index + 1;
|
|
75
|
+
}
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The lineage of a session id inside a set of known sessions, oldest ancestor
|
|
80
|
+
* first, for `/tree`.
|
|
81
|
+
*/
|
|
82
|
+
export function lineage(sessions, id) {
|
|
83
|
+
const byId = new Map(sessions.map((session) => [session.id, session]));
|
|
84
|
+
const path = [];
|
|
85
|
+
let current = byId.get(id);
|
|
86
|
+
const seen = new Set();
|
|
87
|
+
while (current !== undefined && !seen.has(current.id)) {
|
|
88
|
+
seen.add(current.id);
|
|
89
|
+
path.unshift({ id: current.id, title: current.title });
|
|
90
|
+
const parent = current.parentSession;
|
|
91
|
+
current = parent === undefined ? undefined : byId.get(parent);
|
|
92
|
+
}
|
|
93
|
+
return path;
|
|
94
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the JSONL session store keeps sessions on disk, and how to remove one.
|
|
3
|
+
*
|
|
4
|
+
* The harness has no public delete API: storage is append-only by design and
|
|
5
|
+
* the query index reconciles against the filesystem, dropping entries whose
|
|
6
|
+
* session directory has gone. Deleting the directory is therefore the whole
|
|
7
|
+
* operation — and the reason a session stays stored until the user asks.
|
|
8
|
+
*
|
|
9
|
+
* The path encoding here mirrors `dsh-session-persistence-jsonl` exactly:
|
|
10
|
+
* `$DSH_HOME/sessions/<projectKey(cwd)>/<encodeSegment(id)>/`.
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
import { readdir, rm } from 'node:fs/promises';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
/** The sessions root: `$DSH_HOME/sessions`, defaulting to `~/.dsh/sessions`. */
|
|
17
|
+
export function sessionsRoot() {
|
|
18
|
+
const home = process.env['DSH_HOME'] ?? join(homedir(), '.dsh');
|
|
19
|
+
return join(home, 'sessions');
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Encode one path segment: safe characters pass through, everything else
|
|
23
|
+
* becomes `~XXXX` with the code unit in upper-case hex. `.`
|
|
24
|
+
* and `..` are always escaped so a session id can never traverse.
|
|
25
|
+
*/
|
|
26
|
+
export function encodeSegment(raw) {
|
|
27
|
+
if (raw.length === 0)
|
|
28
|
+
throw new Error('cannot encode an empty path segment');
|
|
29
|
+
if (raw === '.')
|
|
30
|
+
return '~002E';
|
|
31
|
+
if (raw === '..')
|
|
32
|
+
return '~002E~002E';
|
|
33
|
+
let out = '';
|
|
34
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
35
|
+
const code = raw.charCodeAt(index);
|
|
36
|
+
const char = String.fromCharCode(code);
|
|
37
|
+
if (char !== '~' && /^[A-Za-z0-9._-]$/.test(char))
|
|
38
|
+
out += char;
|
|
39
|
+
else
|
|
40
|
+
out += `~${code.toString(16).toUpperCase().padStart(4, '0')}`;
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The readable directory key for a project path. Separators fold to `-`, the
|
|
46
|
+
* result is bounded to a filesystem component, and the name always carries the
|
|
47
|
+
* leading `--`/trailing `--` fence so a project directory is recognizable.
|
|
48
|
+
*/
|
|
49
|
+
export function projectKey(cwd) {
|
|
50
|
+
if (cwd.length === 0)
|
|
51
|
+
throw new Error('cannot encode an empty project path');
|
|
52
|
+
let readable = '';
|
|
53
|
+
let separatorRun = false;
|
|
54
|
+
for (let index = 0; index < cwd.length; index += 1) {
|
|
55
|
+
const code = cwd.charCodeAt(index);
|
|
56
|
+
const char = String.fromCharCode(code);
|
|
57
|
+
if (char === '/' || char === '\\' || char === ':') {
|
|
58
|
+
if (!separatorRun)
|
|
59
|
+
readable += '-';
|
|
60
|
+
separatorRun = true;
|
|
61
|
+
}
|
|
62
|
+
else if (char !== '~' && /^[A-Za-z0-9._-]$/.test(char)) {
|
|
63
|
+
readable += char;
|
|
64
|
+
separatorRun = false;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
readable += `~${code.toString(16).toUpperCase().padStart(4, '0')}`;
|
|
68
|
+
separatorRun = false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return `--${(readable.replace(/^-+/, '') || 'root').slice(0, 251)}--`;
|
|
72
|
+
}
|
|
73
|
+
/** The directory one stored session owns, given the cwd it was created with. */
|
|
74
|
+
export function storedSessionDir(cwd, id) {
|
|
75
|
+
return join(sessionsRoot(), projectKey(cwd), encodeSegment(id));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Find a session's directory by id alone, scanning the project directories.
|
|
79
|
+
*
|
|
80
|
+
* The picker knows an id but not always the cwd it was created under, and the
|
|
81
|
+
* encoded id segment is unique across projects, so a scan is both correct and
|
|
82
|
+
* cheap: one `readdir` of the root plus one per project directory.
|
|
83
|
+
*/
|
|
84
|
+
export async function findStoredSessionDir(id) {
|
|
85
|
+
const segment = encodeSegment(id);
|
|
86
|
+
const root = sessionsRoot();
|
|
87
|
+
let projects;
|
|
88
|
+
try {
|
|
89
|
+
projects = await readdir(root, { withFileTypes: true });
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return undefined; // no sessions root: nothing is stored
|
|
93
|
+
}
|
|
94
|
+
for (const entry of projects) {
|
|
95
|
+
if (!entry.isDirectory())
|
|
96
|
+
continue;
|
|
97
|
+
const candidate = join(root, entry.name, segment);
|
|
98
|
+
try {
|
|
99
|
+
const contents = await readdir(candidate);
|
|
100
|
+
if (contents.length > 0)
|
|
101
|
+
return candidate;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Not in this project directory; keep scanning.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Delete a stored session from disk. The query index notices on its next
|
|
111
|
+
* reconciliation pass, so the session disappears from `/resume` too.
|
|
112
|
+
*
|
|
113
|
+
* @returns `true` when something was removed, `false` when no such session
|
|
114
|
+
* was stored.
|
|
115
|
+
*/
|
|
116
|
+
export async function deleteStoredSession(cwd, id) {
|
|
117
|
+
return removeDir(storedSessionDir(cwd, id));
|
|
118
|
+
}
|
|
119
|
+
/** Delete a session found by id through {@link findStoredSessionDir}. */
|
|
120
|
+
export async function deleteStoredSessionDir(dir) {
|
|
121
|
+
return removeDir(dir);
|
|
122
|
+
}
|
|
123
|
+
async function removeDir(dir) {
|
|
124
|
+
try {
|
|
125
|
+
await rm(dir, { recursive: true });
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
const code = error.code;
|
|
130
|
+
if (code === 'ENOENT')
|
|
131
|
+
return false;
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
package/lib/startup.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal app's command-line provider.
|
|
3
|
+
*
|
|
4
|
+
* It parses this app's own flags out of the shared immutable cmdline snapshot
|
|
5
|
+
* and publishes them as a service, so the app row can consume them lazily —
|
|
6
|
+
* the same shape the shipped headless bundle uses.
|
|
7
|
+
* @module moqi-tui/startup
|
|
8
|
+
*/
|
|
9
|
+
import { Command } from 'commander';
|
|
10
|
+
import { parseCmdline } from '@deepseek-ai/dsh-cmdline';
|
|
11
|
+
import { VERSION } from "./version.js";
|
|
12
|
+
/** Stable Cordis plugin name. */
|
|
13
|
+
export const name = 'tui-startup';
|
|
14
|
+
/** Services required before the flags can be resolved. */
|
|
15
|
+
export const inject = ['cmdlineArgs'];
|
|
16
|
+
/** Service key provided by this plugin and injected by the app row. */
|
|
17
|
+
export const TUI_STARTUP_SERVICE = 'tuiStartup';
|
|
18
|
+
/** This app's command grammar, help text, and examples. */
|
|
19
|
+
function tuiCommand() {
|
|
20
|
+
return new Command()
|
|
21
|
+
.name('dsh --profile tui')
|
|
22
|
+
.description('An interactive terminal client for the Harness.')
|
|
23
|
+
.version(VERSION, '--version', 'print the app version and exit')
|
|
24
|
+
.helpOption('-h, --help', 'show this help')
|
|
25
|
+
.option('--resume <id>', 'open the persisted session with this id instead of a new one')
|
|
26
|
+
.option('--model <name>', 'model to select for this run')
|
|
27
|
+
.option('--thinking', 'start with reasoning output visible')
|
|
28
|
+
.option('--context-limit <tokens>', "context budget override; default is the model's own capacity")
|
|
29
|
+
.option('--mouse', 'report mouse events so the wheel scrolls (disables terminal text selection)')
|
|
30
|
+
.option('--no-bell', 'stay silent when a session finishes instead of ringing the terminal bell')
|
|
31
|
+
.option('--no-restore', 'start with one empty session instead of reopening the last ones')
|
|
32
|
+
.option('--peer <host>', 'device to include in the fleet overview; repeatable, anything ssh accepts', (value, previous) => [...previous, value], [])
|
|
33
|
+
.option('--voice-model <path>', 'whisper.cpp weights for push-to-talk dictation (ctrl+v)')
|
|
34
|
+
.option('--voice-bin <path>', 'whisper.cpp executable to transcribe with; default searches PATH')
|
|
35
|
+
.option('--dispatch-profile <name>', 'profile /dispatch boots on a peer; headless by default')
|
|
36
|
+
.addHelpText('after', `
|
|
37
|
+
Examples:
|
|
38
|
+
dsh --profile tui start a new session
|
|
39
|
+
dsh --profile tui --resume session-... reopen an existing session
|
|
40
|
+
dsh --profile tui --thinking show the reasoner's chain of thought
|
|
41
|
+
dsh --profile tui --peer laptop include another device in ctrl+f
|
|
42
|
+
dsh --profile tui --no-restore start clean instead of reopening tabs
|
|
43
|
+
|
|
44
|
+
Inside the app, type / for the command palette; press ctrl+c for the sessions
|
|
45
|
+
menu and ctrl+c again within 1.5s to quit.
|
|
46
|
+
`);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Parse this app's flags and provide them as an ordinary Cordis service.
|
|
50
|
+
* @param ctx - plugin context carrying the command line.
|
|
51
|
+
*/
|
|
52
|
+
export function apply(ctx) {
|
|
53
|
+
const program = tuiCommand();
|
|
54
|
+
program.action(() => {
|
|
55
|
+
const options = program.opts();
|
|
56
|
+
if (options.resume !== undefined && options.resume.trim() === '') {
|
|
57
|
+
program.error('error: --resume requires a non-empty session id');
|
|
58
|
+
}
|
|
59
|
+
// Left undefined, the app asks the provider for the model's real capacity.
|
|
60
|
+
let contextLimit;
|
|
61
|
+
const fromEnvironment = process.env['MOQI_CONTEXT_LIMIT'];
|
|
62
|
+
if (fromEnvironment !== undefined && /^\d+$/.test(fromEnvironment)) {
|
|
63
|
+
contextLimit = Number.parseInt(fromEnvironment, 10);
|
|
64
|
+
}
|
|
65
|
+
if (options.contextLimit !== undefined) {
|
|
66
|
+
if (!/^\d+$/.test(options.contextLimit)) {
|
|
67
|
+
program.error('error: --context-limit requires a positive integer');
|
|
68
|
+
}
|
|
69
|
+
contextLimit = Number.parseInt(options.contextLimit, 10);
|
|
70
|
+
}
|
|
71
|
+
if (contextLimit !== undefined && contextLimit <= 0) {
|
|
72
|
+
program.error('error: --context-limit must be greater than zero');
|
|
73
|
+
}
|
|
74
|
+
ctx.provide(TUI_STARTUP_SERVICE, {
|
|
75
|
+
resumeSessionId: options.resume,
|
|
76
|
+
model: options.model,
|
|
77
|
+
thinking: options.thinking === true,
|
|
78
|
+
contextLimit,
|
|
79
|
+
mouse: options.mouse === true,
|
|
80
|
+
// commander maps --no-bell to bell: false and leaves it true otherwise.
|
|
81
|
+
bell: options.bell !== false,
|
|
82
|
+
restore: options.restore !== false,
|
|
83
|
+
peers: options.peer ?? [],
|
|
84
|
+
// Left undefined the app reads MOQI_WHISPER_MODEL / _BIN, then falls
|
|
85
|
+
// back to its own search, so passing nothing here is the normal case.
|
|
86
|
+
voiceModel: options.voiceModel,
|
|
87
|
+
voiceBin: options.voiceBin,
|
|
88
|
+
dispatchProfile: options.dispatchProfile,
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
parseCmdline(ctx, program);
|
|
92
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@` file completion: token detection, fuzzy filtering, and the inline menu.
|
|
3
|
+
*
|
|
4
|
+
* Like the rest of `tui/`, this module knows nothing about the terminal or the
|
|
5
|
+
* Harness — it turns composer text plus a candidate list into a menu state, so
|
|
6
|
+
* the interaction rules stay testable on their own.
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
import { fuzzyMatch } from "./state.js";
|
|
10
|
+
/** Raster formats the Harness attachment service admits. */
|
|
11
|
+
const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp', '.gif'];
|
|
12
|
+
/** Whether a path names an image the composer can stage as an attachment. */
|
|
13
|
+
export function isImagePath(path) {
|
|
14
|
+
const dot = path.lastIndexOf('.');
|
|
15
|
+
if (dot === -1)
|
|
16
|
+
return false;
|
|
17
|
+
const extension = path.slice(dot).toLowerCase();
|
|
18
|
+
return IMAGE_EXTENSIONS.includes(extension);
|
|
19
|
+
}
|
|
20
|
+
/** Directories a workspace walk never descends into. */
|
|
21
|
+
export const SKIP_DIRECTORIES = new Set([
|
|
22
|
+
'node_modules',
|
|
23
|
+
'.git',
|
|
24
|
+
'.hg',
|
|
25
|
+
'.svn',
|
|
26
|
+
'dist',
|
|
27
|
+
'lib',
|
|
28
|
+
'build',
|
|
29
|
+
'out',
|
|
30
|
+
'coverage',
|
|
31
|
+
'.cache',
|
|
32
|
+
'.venv',
|
|
33
|
+
'__pycache__',
|
|
34
|
+
'.DS_Store',
|
|
35
|
+
]);
|
|
36
|
+
/**
|
|
37
|
+
* The `@` token being typed at the cursor, if any.
|
|
38
|
+
*
|
|
39
|
+
* A token counts only when the `@` sits at a token boundary — the start of
|
|
40
|
+
* the text or right after whitespace — so an email address in prose
|
|
41
|
+
* (`user@host`) or a social handle never opens the menu. The query is what
|
|
42
|
+
* follows the `@` up to the cursor; a query containing `/` is path-shaped and
|
|
43
|
+
* lists one directory rather than fuzzy-matching the whole workspace.
|
|
44
|
+
*/
|
|
45
|
+
export function activeAtToken(text, cursor) {
|
|
46
|
+
// Walk back over the token the cursor sits in.
|
|
47
|
+
let start = cursor;
|
|
48
|
+
while (start > 0 && !/[\s]/.test(text[start - 1] ?? ''))
|
|
49
|
+
start -= 1;
|
|
50
|
+
if (text[start] !== '@')
|
|
51
|
+
return undefined;
|
|
52
|
+
const query = text.slice(start + 1, cursor);
|
|
53
|
+
// A space has been typed after the token: the user has moved on.
|
|
54
|
+
if (/[\n]/.test(query))
|
|
55
|
+
return undefined;
|
|
56
|
+
return { query, start };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Whether a query names a directory rather than fuzzy-matching the workspace:
|
|
60
|
+
* anything containing a separator (`src/`, `../lib`, `~/notes`).
|
|
61
|
+
*/
|
|
62
|
+
export function isPathShaped(query) {
|
|
63
|
+
return query.includes('/');
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Rank candidates for a plain (non-path-shaped) query.
|
|
67
|
+
*
|
|
68
|
+
* Every path must contain the query as a subsequence, the same filter the
|
|
69
|
+
* model picker uses; shallower and shorter paths win so `state` finds
|
|
70
|
+
* `src/tui/state.ts` before `tests/theme-state-fixture.ts`.
|
|
71
|
+
*/
|
|
72
|
+
export function filterFiles(query, paths, limit = 200) {
|
|
73
|
+
const normalized = query.toLowerCase();
|
|
74
|
+
const scored = [];
|
|
75
|
+
for (const path of paths) {
|
|
76
|
+
if (!fuzzyMatch(normalized, path.toLowerCase()))
|
|
77
|
+
continue;
|
|
78
|
+
const depth = path.split('/').length;
|
|
79
|
+
scored.push({ path, score: depth * 1000 + path.length });
|
|
80
|
+
}
|
|
81
|
+
scored.sort((a, b) => a.score - b.score);
|
|
82
|
+
return scored.slice(0, limit).map(({ path }) => ({ path, directory: false }));
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The inline completion menu over the active `@` token.
|
|
86
|
+
*
|
|
87
|
+
* It follows the composer rather than owning the keyboard: typing keeps
|
|
88
|
+
* filtering, `↑`/`↓` (or ctrl+p/n) move, `tab`/`enter` accept, `esc` dismisses
|
|
89
|
+
* — and only the menu closes, not anything layered beneath it.
|
|
90
|
+
*/
|
|
91
|
+
export class AtMenu {
|
|
92
|
+
open = false;
|
|
93
|
+
matches = [];
|
|
94
|
+
selected = 0;
|
|
95
|
+
/** The token the menu is showing matches for; a change reopens it. */
|
|
96
|
+
query = '';
|
|
97
|
+
/** Recompute from the active token. `dismissed` is the token the user pressed esc on. */
|
|
98
|
+
update(token, candidates, dismissed) {
|
|
99
|
+
if (token === undefined || token.query === dismissed) {
|
|
100
|
+
this.close();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
this.query = token.query;
|
|
104
|
+
this.matches = [...candidates];
|
|
105
|
+
this.selected = 0;
|
|
106
|
+
this.open = this.matches.length > 0;
|
|
107
|
+
}
|
|
108
|
+
move(delta) {
|
|
109
|
+
if (!this.open || this.matches.length === 0)
|
|
110
|
+
return;
|
|
111
|
+
this.selected = Math.min(Math.max(this.selected + delta, 0), this.matches.length - 1);
|
|
112
|
+
}
|
|
113
|
+
current() {
|
|
114
|
+
if (!this.open)
|
|
115
|
+
return undefined;
|
|
116
|
+
return this.matches[this.selected];
|
|
117
|
+
}
|
|
118
|
+
close() {
|
|
119
|
+
this.open = false;
|
|
120
|
+
this.matches = [];
|
|
121
|
+
this.selected = 0;
|
|
122
|
+
this.query = '';
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Replace the token a menu pick stands for with the accepted path.
|
|
127
|
+
*
|
|
128
|
+
* Returns the new composer text and cursor: the `@` and everything typed
|
|
129
|
+
* after it up to the cursor give way to the path plus a trailing space, so
|
|
130
|
+
* the next word starts cleanly. Whatever followed the cursor stays.
|
|
131
|
+
*/
|
|
132
|
+
export function acceptToken(text, cursor, token, path) {
|
|
133
|
+
const before = text.slice(0, token.start);
|
|
134
|
+
const after = text.slice(cursor);
|
|
135
|
+
const inserted = `${path} `;
|
|
136
|
+
return { text: before + inserted + after, cursor: before.length + inserted.length };
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Extract `[Image #N name]` tokens from a draft.
|
|
140
|
+
*
|
|
141
|
+
* Returns the text with the tokens stripped and the numbers in order, so the
|
|
142
|
+
* sender can pair them with staged attachment references. A token the user
|
|
143
|
+
* deleted leaves no trace: unmatched staged images are dropped at send.
|
|
144
|
+
*/
|
|
145
|
+
export function extractImageTokens(text) {
|
|
146
|
+
const numbers = [];
|
|
147
|
+
const stripped = text.replace(/\[Image #(\d+)[^\]]*\]/g, (whole, digits) => {
|
|
148
|
+
numbers.push(Number.parseInt(digits, 10));
|
|
149
|
+
return '';
|
|
150
|
+
});
|
|
151
|
+
// Tidy the doubled spaces removal can leave behind.
|
|
152
|
+
const cleaned = stripped.replace(/ {2,}/g, ' ').replace(/^[ \t]+|[ \t]+$/gm, '');
|
|
153
|
+
return { text: cleaned, numbers };
|
|
154
|
+
}
|