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/tui/keys.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key decoding for raw-mode stdin.
|
|
3
|
+
*
|
|
4
|
+
* Node hands the app raw bytes, so escape sequences have to be turned back
|
|
5
|
+
* into key names. The decoder is chunk-tolerant: a sequence split across two
|
|
6
|
+
* reads is held until it completes rather than being reported as a stray
|
|
7
|
+
* escape.
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
const ESC = '';
|
|
11
|
+
/** CSI final bytes mapped to key names. */
|
|
12
|
+
const CSI_NAMES = {
|
|
13
|
+
A: 'up',
|
|
14
|
+
B: 'down',
|
|
15
|
+
C: 'right',
|
|
16
|
+
D: 'left',
|
|
17
|
+
H: 'home',
|
|
18
|
+
F: 'end',
|
|
19
|
+
};
|
|
20
|
+
/** `ESC [ n ~` tilde codes mapped to key names. */
|
|
21
|
+
const TILDE_NAMES = {
|
|
22
|
+
'1': 'home',
|
|
23
|
+
'2': 'insert',
|
|
24
|
+
'3': 'delete',
|
|
25
|
+
'4': 'end',
|
|
26
|
+
'5': 'pageup',
|
|
27
|
+
'6': 'pagedown',
|
|
28
|
+
'7': 'home',
|
|
29
|
+
'8': 'end',
|
|
30
|
+
// Enter as a tilde code, so terminals speaking CSI-u can report
|
|
31
|
+
// ctrl+enter (`ESC [ 13;5 ~`) for interrupt-and-send.
|
|
32
|
+
'13': 'enter',
|
|
33
|
+
};
|
|
34
|
+
/** Modifier bitmask from a CSI parameter, per the xterm convention. */
|
|
35
|
+
function modifiers(parameter) {
|
|
36
|
+
if (parameter === undefined)
|
|
37
|
+
return '';
|
|
38
|
+
const value = Number.parseInt(parameter, 10);
|
|
39
|
+
if (Number.isNaN(value))
|
|
40
|
+
return '';
|
|
41
|
+
const bits = value - 1;
|
|
42
|
+
let prefix = '';
|
|
43
|
+
if ((bits & 1) !== 0)
|
|
44
|
+
prefix += 'shift+';
|
|
45
|
+
if ((bits & 2) !== 0)
|
|
46
|
+
prefix += 'alt+';
|
|
47
|
+
if ((bits & 4) !== 0)
|
|
48
|
+
prefix += 'ctrl+';
|
|
49
|
+
return prefix;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Decode a buffer into keys, returning the keys and any trailing bytes that
|
|
53
|
+
* form an incomplete sequence.
|
|
54
|
+
*/
|
|
55
|
+
export function decode(input) {
|
|
56
|
+
const keys = [];
|
|
57
|
+
let index = 0;
|
|
58
|
+
while (index < input.length) {
|
|
59
|
+
const char = input[index] ?? '';
|
|
60
|
+
if (char === ESC) {
|
|
61
|
+
const rest = input.slice(index);
|
|
62
|
+
// A lone ESC at the very end may be the start of a longer sequence.
|
|
63
|
+
if (rest.length === 1)
|
|
64
|
+
return { keys, rest };
|
|
65
|
+
// SGR mouse report: ESC [ < button ; column ; row (M press | m release).
|
|
66
|
+
// Only the wheel is acted on; other buttons are swallowed so a click
|
|
67
|
+
// cannot leak into the composer as stray text.
|
|
68
|
+
if (rest.startsWith(`${ESC}[<`)) {
|
|
69
|
+
const mouse = /^\[<(\d+);(\d+);(\d+)([Mm])/.exec(rest);
|
|
70
|
+
if (mouse === null) {
|
|
71
|
+
if (rest.length < 24)
|
|
72
|
+
return { keys, rest };
|
|
73
|
+
index += 1;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const button = Number.parseInt(mouse[1] ?? '0', 10);
|
|
77
|
+
if (mouse[4] === 'M') {
|
|
78
|
+
if (button === 64)
|
|
79
|
+
keys.push({ name: 'wheelup', text: '' });
|
|
80
|
+
else if (button === 65)
|
|
81
|
+
keys.push({ name: 'wheeldown', text: '' });
|
|
82
|
+
}
|
|
83
|
+
index += mouse[0].length;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (rest[1] === '[' || rest[1] === 'O') {
|
|
87
|
+
const match = /^[[O]([0-9;]*)([A-Za-z~])/.exec(rest);
|
|
88
|
+
if (match === null) {
|
|
89
|
+
// Incomplete CSI: keep it for the next chunk, unless it is clearly junk.
|
|
90
|
+
if (rest.length < 16)
|
|
91
|
+
return { keys, rest };
|
|
92
|
+
index += 1;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const parameters = (match[1] ?? '').split(';');
|
|
96
|
+
const final = match[2] ?? '';
|
|
97
|
+
if (final === '~') {
|
|
98
|
+
const name = TILDE_NAMES[parameters[0] ?? ''];
|
|
99
|
+
if (name !== undefined)
|
|
100
|
+
keys.push({ name: modifiers(parameters[1]) + name, text: '' });
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
const name = CSI_NAMES[final];
|
|
104
|
+
if (name !== undefined)
|
|
105
|
+
keys.push({ name: modifiers(parameters[1]) + name, text: '' });
|
|
106
|
+
}
|
|
107
|
+
index += match[0].length;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
// alt+<char>
|
|
111
|
+
const next = rest[1] ?? '';
|
|
112
|
+
if (next >= ' ' && next <= '~') {
|
|
113
|
+
keys.push({ name: `alt+${next.toLowerCase()}`, text: '' });
|
|
114
|
+
index += 2;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
keys.push({ name: 'esc', text: '' });
|
|
118
|
+
index += 1;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const code = char.codePointAt(0) ?? 0;
|
|
122
|
+
if (char === '\r' || char === '\n') {
|
|
123
|
+
keys.push({ name: 'enter', text: '' });
|
|
124
|
+
index += 1;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (char === '\t') {
|
|
128
|
+
keys.push({ name: 'tab', text: '' });
|
|
129
|
+
index += 1;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (code === 127 || code === 8) {
|
|
133
|
+
keys.push({ name: 'backspace', text: '' });
|
|
134
|
+
index += 1;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
// Control characters map to ctrl+<letter>; ctrl+a is 0x01.
|
|
138
|
+
if (code < 32) {
|
|
139
|
+
const letter = String.fromCharCode(code + 96);
|
|
140
|
+
keys.push({ name: `ctrl+${letter}`, text: '' });
|
|
141
|
+
index += 1;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const point = String.fromCodePoint(code);
|
|
145
|
+
keys.push({ name: point, text: point });
|
|
146
|
+
index += point.length;
|
|
147
|
+
}
|
|
148
|
+
return { keys, rest: '' };
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* How long a lone escape waits for the rest of a sequence before it is read as
|
|
152
|
+
* the escape key.
|
|
153
|
+
*
|
|
154
|
+
* A terminal sends the same byte for "the user pressed Escape" and for the
|
|
155
|
+
* first byte of `ESC [ A`; only time tells them apart. Without this, a lone
|
|
156
|
+
* Escape produced no key at all and the *next* keystroke was misread as an
|
|
157
|
+
* `alt+` chord, so every documented `esc` — interrupt, close an overlay,
|
|
158
|
+
* dismiss a menu — was dead. Vim's own `ttimeoutlen` sits in this range.
|
|
159
|
+
*/
|
|
160
|
+
export const ESCAPE_DELAY_MS = 50;
|
|
161
|
+
/**
|
|
162
|
+
* A stateful decoder that carries an incomplete sequence between chunks.
|
|
163
|
+
*
|
|
164
|
+
* @param emit - receives a key that arrives asynchronously (a flushed lone
|
|
165
|
+
* escape), because no further chunk will carry it.
|
|
166
|
+
* @param escapeDelayMs - how long a lone escape waits; see {@link ESCAPE_DELAY_MS}.
|
|
167
|
+
*/
|
|
168
|
+
export function createDecoder(emit, escapeDelayMs = ESCAPE_DELAY_MS) {
|
|
169
|
+
let pending = '';
|
|
170
|
+
let timer;
|
|
171
|
+
const cancel = () => {
|
|
172
|
+
if (timer !== undefined) {
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
timer = undefined;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const decoder = ((chunk) => {
|
|
178
|
+
cancel();
|
|
179
|
+
const { keys, rest } = decode(pending + chunk);
|
|
180
|
+
pending = rest;
|
|
181
|
+
// A lone escape is ambiguous until time passes: it is either the key
|
|
182
|
+
// itself or the head of a sequence whose rest has not arrived.
|
|
183
|
+
if (pending === ESC && emit !== undefined) {
|
|
184
|
+
timer = setTimeout(() => {
|
|
185
|
+
timer = undefined;
|
|
186
|
+
if (pending === ESC) {
|
|
187
|
+
pending = '';
|
|
188
|
+
emit({ name: 'esc', text: '' });
|
|
189
|
+
}
|
|
190
|
+
}, escapeDelayMs);
|
|
191
|
+
// Never hold the process open for it.
|
|
192
|
+
timer.unref?.();
|
|
193
|
+
}
|
|
194
|
+
return keys;
|
|
195
|
+
});
|
|
196
|
+
decoder.flush = () => {
|
|
197
|
+
cancel();
|
|
198
|
+
if (pending === ESC) {
|
|
199
|
+
pending = '';
|
|
200
|
+
emit?.({ name: 'esc', text: '' });
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
decoder.dispose = cancel;
|
|
204
|
+
return decoder;
|
|
205
|
+
}
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown to ANSI rendering, plus a small syntax highlighter for fenced code.
|
|
3
|
+
*
|
|
4
|
+
* The original Go client leaned on glamour and chroma. This is the same job
|
|
5
|
+
* done with no dependency: the transcript is re-rendered on every frame and
|
|
6
|
+
* while a reply streams in, so the renderer must tolerate a half-written
|
|
7
|
+
* document (an unterminated fence, a dangling emphasis run) without throwing
|
|
8
|
+
* or swallowing text.
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
import { colAccent, colGold, colGreen, colMuted, colRose, colText, style, } from "./theme.js";
|
|
12
|
+
import { displayWidth, wrap } from "./text.js";
|
|
13
|
+
/** Languages the highlighter knows keywords for. */
|
|
14
|
+
const KEYWORDS = {
|
|
15
|
+
js: ['const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'class', 'new', 'await', 'async', 'import', 'export', 'from', 'try', 'catch', 'finally', 'throw', 'typeof', 'instanceof', 'this', 'null', 'undefined', 'true', 'false', 'switch', 'case', 'break', 'continue', 'default', 'extends', 'static', 'yield', 'delete', 'in', 'of'],
|
|
16
|
+
ts: ['const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'class', 'new', 'await', 'async', 'import', 'export', 'from', 'try', 'catch', 'finally', 'throw', 'typeof', 'instanceof', 'this', 'null', 'undefined', 'true', 'false', 'switch', 'case', 'break', 'continue', 'default', 'extends', 'implements', 'interface', 'type', 'enum', 'readonly', 'private', 'public', 'protected', 'static', 'satisfies', 'as', 'is', 'keyof', 'declare', 'namespace', 'yield', 'in', 'of'],
|
|
17
|
+
go: ['package', 'import', 'func', 'return', 'if', 'else', 'for', 'range', 'var', 'const', 'type', 'struct', 'interface', 'map', 'chan', 'go', 'defer', 'switch', 'case', 'default', 'break', 'continue', 'nil', 'true', 'false', 'select', 'fallthrough', 'goto'],
|
|
18
|
+
python: ['def', 'return', 'if', 'elif', 'else', 'for', 'while', 'import', 'from', 'as', 'class', 'try', 'except', 'finally', 'raise', 'with', 'lambda', 'None', 'True', 'False', 'and', 'or', 'not', 'in', 'is', 'pass', 'yield', 'global', 'nonlocal', 'assert', 'async', 'await', 'del'],
|
|
19
|
+
bash: ['if', 'then', 'else', 'elif', 'fi', 'for', 'in', 'do', 'done', 'while', 'case', 'esac', 'function', 'return', 'export', 'local', 'readonly', 'set', 'unset', 'echo', 'cd', 'exit', 'source'],
|
|
20
|
+
yaml: ['true', 'false', 'null', 'yes', 'no', 'on', 'off'],
|
|
21
|
+
json: ['true', 'false', 'null'],
|
|
22
|
+
rust: ['fn', 'let', 'mut', 'const', 'struct', 'enum', 'impl', 'trait', 'pub', 'use', 'mod', 'match', 'if', 'else', 'for', 'while', 'loop', 'return', 'self', 'Self', 'where', 'async', 'await', 'move', 'ref', 'dyn', 'true', 'false', 'crate', 'super', 'type', 'unsafe'],
|
|
23
|
+
};
|
|
24
|
+
/** Map a fence's info string onto a keyword set. */
|
|
25
|
+
function languageOf(info) {
|
|
26
|
+
const name = info.trim().toLowerCase().split(/\s+/)[0] ?? '';
|
|
27
|
+
switch (name) {
|
|
28
|
+
case 'js':
|
|
29
|
+
case 'javascript':
|
|
30
|
+
case 'mjs':
|
|
31
|
+
case 'cjs':
|
|
32
|
+
case 'jsx':
|
|
33
|
+
return KEYWORDS['js'];
|
|
34
|
+
case 'ts':
|
|
35
|
+
case 'typescript':
|
|
36
|
+
case 'tsx':
|
|
37
|
+
return KEYWORDS['ts'];
|
|
38
|
+
case 'go':
|
|
39
|
+
case 'golang':
|
|
40
|
+
return KEYWORDS['go'];
|
|
41
|
+
case 'py':
|
|
42
|
+
case 'python':
|
|
43
|
+
return KEYWORDS['python'];
|
|
44
|
+
case 'sh':
|
|
45
|
+
case 'bash':
|
|
46
|
+
case 'zsh':
|
|
47
|
+
case 'shell':
|
|
48
|
+
case 'console':
|
|
49
|
+
return KEYWORDS['bash'];
|
|
50
|
+
case 'yaml':
|
|
51
|
+
case 'yml':
|
|
52
|
+
return KEYWORDS['yaml'];
|
|
53
|
+
case 'json':
|
|
54
|
+
return KEYWORDS['json'];
|
|
55
|
+
case 'rs':
|
|
56
|
+
case 'rust':
|
|
57
|
+
return KEYWORDS['rust'];
|
|
58
|
+
default:
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const COMMENT_PREFIX = {
|
|
63
|
+
python: '#',
|
|
64
|
+
bash: '#',
|
|
65
|
+
yaml: '#',
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Highlight one line of code. A deliberately small tokenizer: strings, line
|
|
69
|
+
* comments, numbers, and keywords. It never fails, so a language it does not
|
|
70
|
+
* know simply renders in the plain code color.
|
|
71
|
+
*/
|
|
72
|
+
function highlight(line, keywords, info) {
|
|
73
|
+
const name = info.trim().toLowerCase().split(/\s+/)[0] ?? '';
|
|
74
|
+
const hashComment = COMMENT_PREFIX[name] !== undefined;
|
|
75
|
+
let out = '';
|
|
76
|
+
let index = 0;
|
|
77
|
+
while (index < line.length) {
|
|
78
|
+
const rest = line.slice(index);
|
|
79
|
+
// Line comments run to the end of the line.
|
|
80
|
+
if ((hashComment && rest.startsWith('#')) || (!hashComment && rest.startsWith('//'))) {
|
|
81
|
+
out += style(rest, { fg: colMuted, italic: true });
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
// String literals, single or double quoted, with backslash escapes.
|
|
85
|
+
const quote = rest[0];
|
|
86
|
+
if (quote === '"' || quote === "'" || quote === '`') {
|
|
87
|
+
let end = 1;
|
|
88
|
+
while (end < rest.length) {
|
|
89
|
+
if (rest[end] === '\\') {
|
|
90
|
+
end += 2;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (rest[end] === quote) {
|
|
94
|
+
end += 1;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
end += 1;
|
|
98
|
+
}
|
|
99
|
+
out += style(rest.slice(0, end), { fg: colGold });
|
|
100
|
+
index += end;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// Identifiers and keywords.
|
|
104
|
+
const word = /^[A-Za-z_$][A-Za-z0-9_$]*/.exec(rest);
|
|
105
|
+
if (word !== null) {
|
|
106
|
+
const text = word[0];
|
|
107
|
+
out += keywords !== undefined && keywords.includes(text)
|
|
108
|
+
? style(text, { fg: colAccent, bold: true })
|
|
109
|
+
: style(text, { fg: colText });
|
|
110
|
+
index += text.length;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
// Numbers.
|
|
114
|
+
const number = /^\d[\d_.]*/.exec(rest);
|
|
115
|
+
if (number !== null) {
|
|
116
|
+
out += style(number[0], { fg: colRose });
|
|
117
|
+
index += number[0].length;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
out += style(rest[0] ?? '', { fg: colMuted });
|
|
121
|
+
index += 1;
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
/** Render inline spans: code, bold, italic, strikethrough, and links. */
|
|
126
|
+
export function renderInline(text) {
|
|
127
|
+
let out = '';
|
|
128
|
+
let index = 0;
|
|
129
|
+
while (index < text.length) {
|
|
130
|
+
const rest = text.slice(index);
|
|
131
|
+
const code = /^`([^`]+)`/.exec(rest);
|
|
132
|
+
if (code !== null) {
|
|
133
|
+
out += style(` ${code[1] ?? ''} `, { fg: colGold });
|
|
134
|
+
index += code[0].length;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const strong = /^\*\*([^*]+)\*\*/.exec(rest) ?? /^__([^_]+)__/.exec(rest);
|
|
138
|
+
if (strong !== null) {
|
|
139
|
+
out += style(strong[1] ?? '', { fg: colText, bold: true });
|
|
140
|
+
index += strong[0].length;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const strike = /^~~([^~]+)~~/.exec(rest);
|
|
144
|
+
if (strike !== null) {
|
|
145
|
+
out += style(strike[1] ?? '', { fg: colMuted, strike: true });
|
|
146
|
+
index += strike[0].length;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const emphasis = /^\*([^*]+)\*/.exec(rest) ?? /^_([^_]+)_/.exec(rest);
|
|
150
|
+
if (emphasis !== null) {
|
|
151
|
+
out += style(emphasis[1] ?? '', { fg: colText, italic: true });
|
|
152
|
+
index += emphasis[0].length;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const link = /^\[([^\]]*)\]\(([^)]+)\)/.exec(rest);
|
|
156
|
+
if (link !== null) {
|
|
157
|
+
out += style(link[1] ?? '', { fg: colOKish, underline: true });
|
|
158
|
+
out += style(` (${link[2] ?? ''})`, { fg: colMuted });
|
|
159
|
+
index += link[0].length;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
out += text[index] ?? '';
|
|
163
|
+
index += 1;
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
// Declared after use above for readability of the inline renderer.
|
|
168
|
+
const colOKish = colGreen;
|
|
169
|
+
/** Split a document into blocks, tolerating an unterminated code fence. */
|
|
170
|
+
function parse(source) {
|
|
171
|
+
const blocks = [];
|
|
172
|
+
const rows = source.replace(/\r\n/g, '\n').split('\n');
|
|
173
|
+
let index = 0;
|
|
174
|
+
while (index < rows.length) {
|
|
175
|
+
const line = rows[index] ?? '';
|
|
176
|
+
const fence = /^\s*(`{3,}|~{3,})(.*)$/.exec(line);
|
|
177
|
+
if (fence !== null) {
|
|
178
|
+
const marker = (fence[1] ?? '```').slice(0, 3);
|
|
179
|
+
const info = fence[2] ?? '';
|
|
180
|
+
const body = [];
|
|
181
|
+
index += 1;
|
|
182
|
+
while (index < rows.length) {
|
|
183
|
+
const candidate = rows[index] ?? '';
|
|
184
|
+
if (candidate.trimStart().startsWith(marker)) {
|
|
185
|
+
index += 1;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
body.push(candidate);
|
|
189
|
+
index += 1;
|
|
190
|
+
}
|
|
191
|
+
blocks.push({ kind: 'code', info, lines: body });
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (/^\s*$/.test(line)) {
|
|
195
|
+
index += 1;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (/^\s*(?:---+|\*\*\*+|___+)\s*$/.test(line)) {
|
|
199
|
+
blocks.push({ kind: 'rule' });
|
|
200
|
+
index += 1;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
204
|
+
if (heading !== null) {
|
|
205
|
+
blocks.push({ kind: 'heading', level: (heading[1] ?? '#').length, text: heading[2] ?? '' });
|
|
206
|
+
index += 1;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (/^\s*\|.*\|\s*$/.test(line)) {
|
|
210
|
+
const tableRows = [];
|
|
211
|
+
while (index < rows.length && /^\s*\|.*\|\s*$/.test(rows[index] ?? '')) {
|
|
212
|
+
const raw = (rows[index] ?? '').trim();
|
|
213
|
+
const cells = raw.slice(1, -1).split('|').map((cell) => cell.trim());
|
|
214
|
+
// Skip the alignment row that separates head from body.
|
|
215
|
+
if (!cells.every((cell) => /^:?-{2,}:?$/.test(cell)))
|
|
216
|
+
tableRows.push(cells);
|
|
217
|
+
index += 1;
|
|
218
|
+
}
|
|
219
|
+
blocks.push({ kind: 'table', rows: tableRows });
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const quote = /^\s*>\s?(.*)$/.exec(line);
|
|
223
|
+
if (quote !== null) {
|
|
224
|
+
const parts = [quote[1] ?? ''];
|
|
225
|
+
index += 1;
|
|
226
|
+
while (index < rows.length) {
|
|
227
|
+
const next = /^\s*>\s?(.*)$/.exec(rows[index] ?? '');
|
|
228
|
+
if (next === null)
|
|
229
|
+
break;
|
|
230
|
+
parts.push(next[1] ?? '');
|
|
231
|
+
index += 1;
|
|
232
|
+
}
|
|
233
|
+
blocks.push({ kind: 'quote', text: parts.join('\n') });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const bullet = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line);
|
|
237
|
+
if (bullet !== null) {
|
|
238
|
+
const items = [];
|
|
239
|
+
while (index < rows.length) {
|
|
240
|
+
const match = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(rows[index] ?? '');
|
|
241
|
+
if (match === null) {
|
|
242
|
+
// A plain indented line continues the previous item.
|
|
243
|
+
const continuation = /^\s{2,}(\S.*)$/.exec(rows[index] ?? '');
|
|
244
|
+
const last = items[items.length - 1];
|
|
245
|
+
if (continuation !== null && last !== undefined) {
|
|
246
|
+
last.text += ` ${continuation[1] ?? ''}`;
|
|
247
|
+
index += 1;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
const indent = (match[1] ?? '').length;
|
|
253
|
+
const raw = match[2] ?? '-';
|
|
254
|
+
items.push({
|
|
255
|
+
marker: /^\d/.test(raw) ? raw : '•',
|
|
256
|
+
text: match[3] ?? '',
|
|
257
|
+
depth: Math.floor(indent / 2),
|
|
258
|
+
});
|
|
259
|
+
index += 1;
|
|
260
|
+
}
|
|
261
|
+
blocks.push({ kind: 'list', items });
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const paragraph = [line];
|
|
265
|
+
index += 1;
|
|
266
|
+
while (index < rows.length) {
|
|
267
|
+
const next = rows[index] ?? '';
|
|
268
|
+
if (/^\s*$/.test(next) ||
|
|
269
|
+
/^\s*(`{3,}|~{3,})/.test(next) ||
|
|
270
|
+
/^#{1,6}\s/.test(next) ||
|
|
271
|
+
/^\s*([-*+]|\d+[.)])\s/.test(next) ||
|
|
272
|
+
/^\s*>/.test(next) ||
|
|
273
|
+
/^\s*\|.*\|\s*$/.test(next))
|
|
274
|
+
break;
|
|
275
|
+
paragraph.push(next);
|
|
276
|
+
index += 1;
|
|
277
|
+
}
|
|
278
|
+
blocks.push({ kind: 'paragraph', text: paragraph.join(' ') });
|
|
279
|
+
}
|
|
280
|
+
return blocks;
|
|
281
|
+
}
|
|
282
|
+
/** Render a parsed block to styled lines at `width` columns. */
|
|
283
|
+
function renderBlock(block, width) {
|
|
284
|
+
switch (block.kind) {
|
|
285
|
+
case 'heading': {
|
|
286
|
+
const prefix = block.level <= 1 ? '' : `${'#'.repeat(block.level)} `;
|
|
287
|
+
const text = `${prefix}${block.text}`;
|
|
288
|
+
const rendered = wrap(text, width).map((line) => style(line, { fg: block.level <= 2 ? colAccent : colText, bold: true }));
|
|
289
|
+
return block.level <= 2 ? [...rendered, style('─'.repeat(Math.min(width, displayWidth(text))), { fg: colBorderish })] : rendered;
|
|
290
|
+
}
|
|
291
|
+
case 'rule':
|
|
292
|
+
return [style('─'.repeat(width), { fg: colBorderish })];
|
|
293
|
+
case 'code': {
|
|
294
|
+
const keywords = languageOf(block.info);
|
|
295
|
+
const label = block.info.trim();
|
|
296
|
+
const head = label === ''
|
|
297
|
+
? []
|
|
298
|
+
: [style(` ${label}`, { fg: colMuted, italic: true })];
|
|
299
|
+
const body = block.lines.map((line) => {
|
|
300
|
+
const clipped = line.length > width - 4 ? line.slice(0, Math.max(width - 4, 0)) : line;
|
|
301
|
+
return ` ${style('│', { fg: colBorderish })} ${highlight(clipped, keywords, block.info)}`;
|
|
302
|
+
});
|
|
303
|
+
return [...head, ...body];
|
|
304
|
+
}
|
|
305
|
+
case 'quote':
|
|
306
|
+
return block.text
|
|
307
|
+
.split('\n')
|
|
308
|
+
.flatMap((line) => wrap(line, Math.max(width - 2, 8)))
|
|
309
|
+
.map((line) => `${style('┃', { fg: colMuted })} ${style(renderInline(line), { fg: colMuted, italic: true })}`);
|
|
310
|
+
case 'list':
|
|
311
|
+
return block.items.flatMap((item) => {
|
|
312
|
+
const indent = ' '.repeat(item.depth);
|
|
313
|
+
const bullet = style(item.marker, { fg: colAccent });
|
|
314
|
+
const body = wrap(renderInlineForWrap(item.text), Math.max(width - indent.length - 2, 8));
|
|
315
|
+
return body.map((line, position) => position === 0
|
|
316
|
+
? `${indent}${bullet} ${renderInline(line)}`
|
|
317
|
+
: `${indent} ${renderInline(line)}`);
|
|
318
|
+
});
|
|
319
|
+
case 'table': {
|
|
320
|
+
const columns = Math.max(...block.rows.map((row) => row.length), 0);
|
|
321
|
+
if (columns === 0)
|
|
322
|
+
return [];
|
|
323
|
+
const widths = [];
|
|
324
|
+
for (let column = 0; column < columns; column += 1) {
|
|
325
|
+
widths.push(Math.max(...block.rows.map((row) => displayWidth(row[column] ?? '')), 3));
|
|
326
|
+
}
|
|
327
|
+
// Shrink proportionally when the table is wider than the viewport.
|
|
328
|
+
const total = widths.reduce((sum, value) => sum + value + 3, 1);
|
|
329
|
+
if (total > width) {
|
|
330
|
+
const scale = (width - columns * 3 - 1) / (total - columns * 3 - 1);
|
|
331
|
+
for (let column = 0; column < columns; column += 1) {
|
|
332
|
+
widths[column] = Math.max(Math.floor((widths[column] ?? 3) * scale), 3);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return block.rows.map((row, position) => {
|
|
336
|
+
const cells = widths.map((cellWidth, column) => {
|
|
337
|
+
const value = row[column] ?? '';
|
|
338
|
+
const clipped = displayWidth(value) > cellWidth ? `${value.slice(0, Math.max(cellWidth - 1, 0))}…` : value;
|
|
339
|
+
return clipped + ' '.repeat(Math.max(cellWidth - displayWidth(clipped), 0));
|
|
340
|
+
});
|
|
341
|
+
const line = cells.join(style(' │ ', { fg: colBorderish }));
|
|
342
|
+
return position === 0 ? style(line, { fg: colText, bold: true }) : renderInline(line);
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
case 'paragraph':
|
|
346
|
+
return wrap(renderInlineForWrap(block.text), width).map((line) => renderInline(line));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Wrapping happens on the unstyled text, because escape codes would corrupt
|
|
351
|
+
* the width arithmetic; the inline renderer then runs per output line.
|
|
352
|
+
*/
|
|
353
|
+
function renderInlineForWrap(text) {
|
|
354
|
+
return text;
|
|
355
|
+
}
|
|
356
|
+
const colBorderish = colMuted;
|
|
357
|
+
/** Render a markdown document to styled lines at `width` columns. */
|
|
358
|
+
export function renderMarkdown(source, width) {
|
|
359
|
+
const safeWidth = Math.max(width, 4);
|
|
360
|
+
const blocks = parse(source);
|
|
361
|
+
const out = [];
|
|
362
|
+
blocks.forEach((block, index) => {
|
|
363
|
+
if (index > 0)
|
|
364
|
+
out.push('');
|
|
365
|
+
out.push(...renderBlock(block, safeWidth));
|
|
366
|
+
});
|
|
367
|
+
return out.join('\n');
|
|
368
|
+
}
|
package/lib/tui/mcp.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/mcp` formatting: which MCP servers' tools are actually mounted.
|
|
3
|
+
*
|
|
4
|
+
* The MCP client is a composition-level plugin — servers are declared in the
|
|
5
|
+
* profile, not registered through a queryable runtime API — so the honest view
|
|
6
|
+
* is the tool registry filtered to MCP-provided names. This module does the
|
|
7
|
+
* naming and grouping; the app layer does the probing.
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The separator conventions MCP bridges use in tool names.
|
|
12
|
+
*
|
|
13
|
+
* `mcp__server__tool` is the Claude-style double-underscore form; `server/tool`
|
|
14
|
+
* and `server:tool` appear in some bridges. Parsing all three means a server
|
|
15
|
+
* shows up whichever bridge mounted it.
|
|
16
|
+
*/
|
|
17
|
+
export function parseMcpToolName(name) {
|
|
18
|
+
if (name.startsWith('mcp__')) {
|
|
19
|
+
const rest = name.slice('mcp__'.length);
|
|
20
|
+
const at = rest.indexOf('__');
|
|
21
|
+
if (at <= 0 || at === rest.length - 2)
|
|
22
|
+
return undefined;
|
|
23
|
+
return { server: rest.slice(0, at), tool: rest.slice(at + 2) };
|
|
24
|
+
}
|
|
25
|
+
if (name.startsWith('mcp_')) {
|
|
26
|
+
const rest = name.slice('mcp_'.length);
|
|
27
|
+
const at = rest.indexOf('_');
|
|
28
|
+
if (at <= 0 || at === rest.length - 1)
|
|
29
|
+
return undefined;
|
|
30
|
+
return { server: rest.slice(0, at), tool: rest.slice(at + 1) };
|
|
31
|
+
}
|
|
32
|
+
for (const separator of ['/', ':']) {
|
|
33
|
+
const parts = name.split(separator);
|
|
34
|
+
// Exactly two segments, neither a path or a filename: `files/read` is a
|
|
35
|
+
// bridge, `src/tui/state.ts` is a path and must not become a server.
|
|
36
|
+
if (parts.length !== 2)
|
|
37
|
+
continue;
|
|
38
|
+
const [head, tail] = parts;
|
|
39
|
+
if (head.includes('.') || tail.includes('.'))
|
|
40
|
+
continue;
|
|
41
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/i.test(head))
|
|
42
|
+
continue;
|
|
43
|
+
if (tail === '')
|
|
44
|
+
continue;
|
|
45
|
+
return { server: head, tool: tail };
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
/** Group tool names into their MCP servers, alphabetically by server then tool. */
|
|
50
|
+
export function groupMcpTools(toolNames) {
|
|
51
|
+
const servers = new Map();
|
|
52
|
+
for (const name of toolNames) {
|
|
53
|
+
const parsed = parseMcpToolName(name);
|
|
54
|
+
if (parsed === undefined)
|
|
55
|
+
continue;
|
|
56
|
+
const list = servers.get(parsed.server) ?? [];
|
|
57
|
+
list.push(parsed.tool);
|
|
58
|
+
servers.set(parsed.server, list);
|
|
59
|
+
}
|
|
60
|
+
return [...servers.entries()]
|
|
61
|
+
.map(([name, tools]) => ({ name, tools: [...tools].sort() }))
|
|
62
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The `/mcp` overlay body.
|
|
66
|
+
*
|
|
67
|
+
* @param servers - grouped MCP tools, from {@link groupMcpTools}.
|
|
68
|
+
* @param totalTools - every tool the registry offers, for the empty case.
|
|
69
|
+
*/
|
|
70
|
+
export function renderMcp(servers, totalTools) {
|
|
71
|
+
if (servers.length === 0) {
|
|
72
|
+
return [
|
|
73
|
+
'**MCP servers**',
|
|
74
|
+
'',
|
|
75
|
+
totalTools === 0
|
|
76
|
+
? 'This profile exposes no tool registry to the app.'
|
|
77
|
+
: `No MCP-provided tools are mounted (${String(totalTools)} tools in total).`,
|
|
78
|
+
'',
|
|
79
|
+
'Servers are declared in the profile, not added at runtime:',
|
|
80
|
+
'',
|
|
81
|
+
'- `dsh plugin --profile tui add <mcp-bridge-package>`',
|
|
82
|
+
'- or an `mcp-client` entry in `$DSH_HOME/profiles/tui/cordis.patch.yml`',
|
|
83
|
+
].join('\n');
|
|
84
|
+
}
|
|
85
|
+
const lines = ['**MCP servers**', ''];
|
|
86
|
+
const toolCount = servers.reduce((sum, server) => sum + server.tools.length, 0);
|
|
87
|
+
lines.push(`${String(servers.length)} server${servers.length === 1 ? '' : 's'}, ${String(toolCount)} tools`, '');
|
|
88
|
+
for (const server of servers) {
|
|
89
|
+
lines.push(`- **${server.name}** (${String(server.tools.length)})`);
|
|
90
|
+
for (const tool of server.tools)
|
|
91
|
+
lines.push(` - \`${tool}\``);
|
|
92
|
+
}
|
|
93
|
+
lines.push('', 'Tools are granted by composition; nothing is added at runtime.');
|
|
94
|
+
return lines.join('\n');
|
|
95
|
+
}
|