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/text.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Width-aware text helpers.
|
|
3
|
+
*
|
|
4
|
+
* Every measurement in the app goes through {@link displayWidth}, which ignores
|
|
5
|
+
* SGR escapes and counts East Asian wide characters as two columns. Without
|
|
6
|
+
* that, styled or CJK content silently breaks the column arithmetic the whole
|
|
7
|
+
* layout depends on.
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
const ESC = '';
|
|
11
|
+
const BEL = '';
|
|
12
|
+
/** Matches an ANSI escape sequence (CSI or OSC), which occupies no columns. */
|
|
13
|
+
const ANSI_PATTERN = `${ESC}\\[[0-9;?]*[ -/]*[@-~]|${ESC}\\][^${BEL}${ESC}]*(?:${BEL}|${ESC}\\\\)`;
|
|
14
|
+
function ansiRegex() {
|
|
15
|
+
return new RegExp(ANSI_PATTERN, 'g');
|
|
16
|
+
}
|
|
17
|
+
/** Remove every escape sequence, leaving the printable text. */
|
|
18
|
+
export function stripAnsi(text) {
|
|
19
|
+
return text.replace(ansiRegex(), '');
|
|
20
|
+
}
|
|
21
|
+
/** Whether a code point renders two columns wide. */
|
|
22
|
+
function isWide(code) {
|
|
23
|
+
return ((code >= 0x1100 && code <= 0x115f) ||
|
|
24
|
+
(code >= 0x2e80 && code <= 0x303e) ||
|
|
25
|
+
(code >= 0x3041 && code <= 0x33ff) ||
|
|
26
|
+
(code >= 0x3400 && code <= 0x4dbf) ||
|
|
27
|
+
(code >= 0x4e00 && code <= 0x9fff) ||
|
|
28
|
+
(code >= 0xa000 && code <= 0xa4cf) ||
|
|
29
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
30
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
31
|
+
(code >= 0xfe30 && code <= 0xfe6f) ||
|
|
32
|
+
(code >= 0xff00 && code <= 0xff60) ||
|
|
33
|
+
(code >= 0xffe0 && code <= 0xffe6) ||
|
|
34
|
+
(code >= 0x1f300 && code <= 0x1f64f) ||
|
|
35
|
+
(code >= 0x1f900 && code <= 0x1f9ff) ||
|
|
36
|
+
(code >= 0x20000 && code <= 0x3fffd));
|
|
37
|
+
}
|
|
38
|
+
/** Whether a code point is a zero-width joiner, selector, or combining mark. */
|
|
39
|
+
function isZeroWidth(code) {
|
|
40
|
+
return (code === 0x200b ||
|
|
41
|
+
code === 0x200c ||
|
|
42
|
+
code === 0x200d ||
|
|
43
|
+
code === 0xfe0f ||
|
|
44
|
+
(code >= 0x0300 && code <= 0x036f) ||
|
|
45
|
+
(code >= 0x1ab0 && code <= 0x1aff) ||
|
|
46
|
+
(code >= 0x20d0 && code <= 0x20ff));
|
|
47
|
+
}
|
|
48
|
+
/** Width of one code point in terminal columns. */
|
|
49
|
+
function charWidth(code) {
|
|
50
|
+
if (isZeroWidth(code))
|
|
51
|
+
return 0;
|
|
52
|
+
return isWide(code) ? 2 : 1;
|
|
53
|
+
}
|
|
54
|
+
/** Printable width of a string in terminal columns, ignoring escapes. */
|
|
55
|
+
export function displayWidth(text) {
|
|
56
|
+
let width = 0;
|
|
57
|
+
for (const char of stripAnsi(text)) {
|
|
58
|
+
const code = char.codePointAt(0);
|
|
59
|
+
if (code === undefined)
|
|
60
|
+
continue;
|
|
61
|
+
width += charWidth(code);
|
|
62
|
+
}
|
|
63
|
+
return width;
|
|
64
|
+
}
|
|
65
|
+
/** The escape sequence starting at `index`, when there is one. */
|
|
66
|
+
function escapeAt(text, index) {
|
|
67
|
+
if (text[index] !== ESC)
|
|
68
|
+
return undefined;
|
|
69
|
+
const regex = ansiRegex();
|
|
70
|
+
regex.lastIndex = index;
|
|
71
|
+
const match = regex.exec(text);
|
|
72
|
+
if (match === null || match.index !== index)
|
|
73
|
+
return undefined;
|
|
74
|
+
return match[0];
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Cut a string to `limit` columns, appending an ellipsis when it did not fit.
|
|
78
|
+
* Escape sequences pass through so styling is never severed mid-code, and a
|
|
79
|
+
* reset is appended when the cut text carried any styling.
|
|
80
|
+
*/
|
|
81
|
+
export function truncate(text, limit) {
|
|
82
|
+
if (limit <= 0)
|
|
83
|
+
return '';
|
|
84
|
+
if (displayWidth(text) <= limit)
|
|
85
|
+
return text;
|
|
86
|
+
const budget = limit <= 1 ? limit : limit - 1;
|
|
87
|
+
let out = '';
|
|
88
|
+
let width = 0;
|
|
89
|
+
let index = 0;
|
|
90
|
+
let styled = false;
|
|
91
|
+
while (index < text.length) {
|
|
92
|
+
const escape = escapeAt(text, index);
|
|
93
|
+
if (escape !== undefined) {
|
|
94
|
+
out += escape;
|
|
95
|
+
styled = true;
|
|
96
|
+
index += escape.length;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const code = text.codePointAt(index);
|
|
100
|
+
if (code === undefined)
|
|
101
|
+
break;
|
|
102
|
+
const char = String.fromCodePoint(code);
|
|
103
|
+
const width0 = charWidth(code);
|
|
104
|
+
if (width + width0 > budget)
|
|
105
|
+
break;
|
|
106
|
+
out += char;
|
|
107
|
+
width += width0;
|
|
108
|
+
index += char.length;
|
|
109
|
+
}
|
|
110
|
+
const reset = styled ? `${ESC}[0m` : '';
|
|
111
|
+
const tail = limit <= 1 ? '' : '…';
|
|
112
|
+
return `${out}${reset}${tail}`;
|
|
113
|
+
}
|
|
114
|
+
/** Pad a string on the right to `width` columns. */
|
|
115
|
+
export function padEnd(text, width) {
|
|
116
|
+
const pad = width - displayWidth(text);
|
|
117
|
+
return pad > 0 ? text + ' '.repeat(pad) : text;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Hard-wrap plain text to `width` columns, breaking on spaces where possible
|
|
121
|
+
* and mid-word only when a single word cannot fit on a line of its own.
|
|
122
|
+
*/
|
|
123
|
+
export function wrap(text, width) {
|
|
124
|
+
if (width < 1)
|
|
125
|
+
return text.split('\n');
|
|
126
|
+
const out = [];
|
|
127
|
+
for (const paragraph of text.split('\n')) {
|
|
128
|
+
if (paragraph === '') {
|
|
129
|
+
out.push('');
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
let line = '';
|
|
133
|
+
for (const word of paragraph.split(' ')) {
|
|
134
|
+
const candidate = line === '' ? word : `${line} ${word}`;
|
|
135
|
+
if (displayWidth(candidate) <= width) {
|
|
136
|
+
line = candidate;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (line !== '') {
|
|
140
|
+
out.push(line);
|
|
141
|
+
line = '';
|
|
142
|
+
}
|
|
143
|
+
let rest = word;
|
|
144
|
+
while (displayWidth(rest) > width) {
|
|
145
|
+
const head = cut(rest, width);
|
|
146
|
+
out.push(head);
|
|
147
|
+
rest = rest.slice(head.length);
|
|
148
|
+
}
|
|
149
|
+
line = rest;
|
|
150
|
+
}
|
|
151
|
+
out.push(line);
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
/** Take exactly as many code points as fit in `width` columns, no ellipsis. */
|
|
156
|
+
export function cut(text, width) {
|
|
157
|
+
let out = '';
|
|
158
|
+
let used = 0;
|
|
159
|
+
let index = 0;
|
|
160
|
+
while (index < text.length) {
|
|
161
|
+
const code = text.codePointAt(index);
|
|
162
|
+
if (code === undefined)
|
|
163
|
+
break;
|
|
164
|
+
const char = String.fromCodePoint(code);
|
|
165
|
+
const w = charWidth(code);
|
|
166
|
+
if (used + w > width)
|
|
167
|
+
break;
|
|
168
|
+
out += char;
|
|
169
|
+
used += w;
|
|
170
|
+
index += char.length;
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
package/lib/tui/theme.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Palette and text styling for the terminal app.
|
|
3
|
+
*
|
|
4
|
+
* The colors default to the Rose Pine-ish pair the original Go client used,
|
|
5
|
+
* kept as explicit light/dark variants so the app reads on either terminal
|
|
6
|
+
* background. Everything emits truecolor SGR directly: the app already owns
|
|
7
|
+
* the screen, so there is no styling library between it and the escape codes.
|
|
8
|
+
*
|
|
9
|
+
* Which palette is in force is swappable at runtime — see {@link applyTheme}
|
|
10
|
+
* and the table in `themes.ts`. That is deliberately orthogonal to the
|
|
11
|
+
* light/dark question: a theme supplies both variants, and the terminal's own
|
|
12
|
+
* background still decides which of the two is drawn.
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
import { DEFAULT_PALETTE, DEFAULT_THEME, findTheme, THEMES } from "./themes.js";
|
|
16
|
+
/**
|
|
17
|
+
* Copy a palette entry into a fresh object.
|
|
18
|
+
*
|
|
19
|
+
* The exported constants have to be objects this module owns, not the table's
|
|
20
|
+
* own, because {@link applyTheme} writes through them — sharing them with the
|
|
21
|
+
* table would let one theme switch overwrite the palette it came from.
|
|
22
|
+
*/
|
|
23
|
+
function seed(color) {
|
|
24
|
+
return { light: color.light, dark: color.dark };
|
|
25
|
+
}
|
|
26
|
+
export const colAccent = seed(DEFAULT_PALETTE.accent);
|
|
27
|
+
export const colMuted = seed(DEFAULT_PALETTE.muted);
|
|
28
|
+
export const colBorder = seed(DEFAULT_PALETTE.border);
|
|
29
|
+
export const colText = seed(DEFAULT_PALETTE.text);
|
|
30
|
+
export const colWarn = seed(DEFAULT_PALETTE.warn);
|
|
31
|
+
export const colOK = seed(DEFAULT_PALETTE.ok);
|
|
32
|
+
export const colGreen = seed(DEFAULT_PALETTE.green);
|
|
33
|
+
export const colGold = seed(DEFAULT_PALETTE.gold);
|
|
34
|
+
export const colRose = seed(DEFAULT_PALETTE.rose);
|
|
35
|
+
export const colInvert = seed(DEFAULT_PALETTE.invert);
|
|
36
|
+
/** Which palette {@link applyTheme} last installed. */
|
|
37
|
+
let active = DEFAULT_THEME;
|
|
38
|
+
/**
|
|
39
|
+
* Install a named palette, returning false when there is no such theme.
|
|
40
|
+
*
|
|
41
|
+
* Every other module imported the color constants by name, so the switch has
|
|
42
|
+
* to happen *through* those objects rather than by replacing them: an import
|
|
43
|
+
* binding points at the object that existed when the module was evaluated,
|
|
44
|
+
* and reassigning the constant here would leave every call site drawing with
|
|
45
|
+
* the old palette. Writing the two fields in place is what makes a theme
|
|
46
|
+
* change a one-line operation instead of a rewrite of every view.
|
|
47
|
+
*
|
|
48
|
+
* An unknown name is reported rather than thrown: it arrives from `/theme
|
|
49
|
+
* <name>` or from a state file written by a future version, and neither is a
|
|
50
|
+
* reason to take the app down.
|
|
51
|
+
*/
|
|
52
|
+
export function applyTheme(name) {
|
|
53
|
+
const theme = findTheme(name);
|
|
54
|
+
if (theme === undefined)
|
|
55
|
+
return false;
|
|
56
|
+
const pairs = [
|
|
57
|
+
[colAccent, theme.colors.accent],
|
|
58
|
+
[colMuted, theme.colors.muted],
|
|
59
|
+
[colBorder, theme.colors.border],
|
|
60
|
+
[colText, theme.colors.text],
|
|
61
|
+
[colWarn, theme.colors.warn],
|
|
62
|
+
[colOK, theme.colors.ok],
|
|
63
|
+
[colGreen, theme.colors.green],
|
|
64
|
+
[colGold, theme.colors.gold],
|
|
65
|
+
[colRose, theme.colors.rose],
|
|
66
|
+
[colInvert, theme.colors.invert],
|
|
67
|
+
];
|
|
68
|
+
for (const [target, source] of pairs) {
|
|
69
|
+
target.light = source.light;
|
|
70
|
+
target.dark = source.dark;
|
|
71
|
+
}
|
|
72
|
+
active = theme.name;
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
/** The name of the palette currently installed. */
|
|
76
|
+
export function activeTheme() {
|
|
77
|
+
return active;
|
|
78
|
+
}
|
|
79
|
+
/** Every palette {@link applyTheme} will accept, in the order to list them. */
|
|
80
|
+
export function listThemes() {
|
|
81
|
+
return THEMES;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Whether this terminal is being treated as dark. `MOQI_THEME` wins; the
|
|
85
|
+
* `COLORFGBG` convention decides otherwise; dark is the fallback because it is
|
|
86
|
+
* the common default and the safer miss.
|
|
87
|
+
*/
|
|
88
|
+
function detectDark() {
|
|
89
|
+
const forced = process.env['MOQI_THEME'];
|
|
90
|
+
if (forced === 'light')
|
|
91
|
+
return false;
|
|
92
|
+
if (forced === 'dark')
|
|
93
|
+
return true;
|
|
94
|
+
const fgbg = process.env['COLORFGBG'];
|
|
95
|
+
if (fgbg !== undefined) {
|
|
96
|
+
const background = fgbg.split(';').pop();
|
|
97
|
+
if (background !== undefined && /^\d+$/.test(background)) {
|
|
98
|
+
const value = Number(background);
|
|
99
|
+
// 0-6 and 8 are the dark background slots in the COLORFGBG convention.
|
|
100
|
+
return value <= 6 || value === 8;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
let dark = detectDark();
|
|
106
|
+
/**
|
|
107
|
+
* Re-read the environment, so a change of terminal background applies without
|
|
108
|
+
* a restart. This is about the light/dark variant only — the choice of
|
|
109
|
+
* palette is {@link applyTheme}'s.
|
|
110
|
+
*/
|
|
111
|
+
export function refreshTheme() {
|
|
112
|
+
dark = detectDark();
|
|
113
|
+
}
|
|
114
|
+
/** Whether styling currently targets a dark background. */
|
|
115
|
+
export function isDark() {
|
|
116
|
+
return dark;
|
|
117
|
+
}
|
|
118
|
+
/** Resolve an adaptive color against the active background. */
|
|
119
|
+
export function resolve(color) {
|
|
120
|
+
return dark ? color.dark : color.light;
|
|
121
|
+
}
|
|
122
|
+
/** Whether color should be emitted at all. Honors the NO_COLOR convention. */
|
|
123
|
+
const colorEnabled = process.env['NO_COLOR'] === undefined && process.env['TERM'] !== 'dumb';
|
|
124
|
+
function channels(hex) {
|
|
125
|
+
const value = hex.replace('#', '');
|
|
126
|
+
return [
|
|
127
|
+
Number.parseInt(value.slice(0, 2), 16),
|
|
128
|
+
Number.parseInt(value.slice(2, 4), 16),
|
|
129
|
+
Number.parseInt(value.slice(4, 6), 16),
|
|
130
|
+
];
|
|
131
|
+
}
|
|
132
|
+
const ESC = '';
|
|
133
|
+
/** Clears every attribute set by {@link style}. */
|
|
134
|
+
export const RESET = `${ESC}[0m`;
|
|
135
|
+
/**
|
|
136
|
+
* Build the SGR prefix for a style, or an empty string when the style would
|
|
137
|
+
* emit nothing (color disabled and no text attributes).
|
|
138
|
+
*/
|
|
139
|
+
function prefix(options) {
|
|
140
|
+
const codes = [];
|
|
141
|
+
if (options.bold === true)
|
|
142
|
+
codes.push('1');
|
|
143
|
+
if (options.dim === true)
|
|
144
|
+
codes.push('2');
|
|
145
|
+
if (options.italic === true)
|
|
146
|
+
codes.push('3');
|
|
147
|
+
if (options.underline === true)
|
|
148
|
+
codes.push('4');
|
|
149
|
+
if (options.strike === true)
|
|
150
|
+
codes.push('9');
|
|
151
|
+
if (colorEnabled) {
|
|
152
|
+
if (options.fg !== undefined) {
|
|
153
|
+
const [r, g, b] = channels(resolve(options.fg));
|
|
154
|
+
codes.push(`38;2;${r};${g};${b}`);
|
|
155
|
+
}
|
|
156
|
+
if (options.bg !== undefined) {
|
|
157
|
+
const [r, g, b] = channels(resolve(options.bg));
|
|
158
|
+
codes.push(`48;2;${r};${g};${b}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (codes.length === 0)
|
|
162
|
+
return '';
|
|
163
|
+
return `${ESC}[${codes.join(';')}m`;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Wrap text in a style. Each line is styled independently so a styled block
|
|
167
|
+
* survives being split, padded, or placed beside other cells.
|
|
168
|
+
*/
|
|
169
|
+
export function style(text, options) {
|
|
170
|
+
const open = prefix(options);
|
|
171
|
+
if (open === '')
|
|
172
|
+
return text;
|
|
173
|
+
return text
|
|
174
|
+
.split('\n')
|
|
175
|
+
.map((line) => (line === '' ? line : `${open}${line}${RESET}`))
|
|
176
|
+
.join('\n');
|
|
177
|
+
}
|
|
178
|
+
export const muted = (text) => style(text, { fg: colMuted });
|
|
179
|
+
export const warn = (text) => style(text, { fg: colWarn });
|
|
180
|
+
export const ok = (text) => style(text, { fg: colOK });
|
|
181
|
+
export const bold = (text) => style(text, { fg: colText, bold: true });
|
|
182
|
+
export const accent = (text) => style(text, { fg: colAccent });
|
|
183
|
+
export const selected = (text) => style(text, { fg: colInvert, bg: colAccent, bold: true });
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The palettes `/theme` can choose between.
|
|
3
|
+
*
|
|
4
|
+
* This is a data module on purpose: `theme.ts` owns the colour objects every
|
|
5
|
+
* other module imports by name, and it would grow unreadable if five full
|
|
6
|
+
* palettes sat between the `style()` machinery and the SGR encoder. Keeping
|
|
7
|
+
* the table here means adding a palette is an edit to one list of hex pairs
|
|
8
|
+
* and nothing else.
|
|
9
|
+
*
|
|
10
|
+
* Every palette carries both a light and a dark variant because the variant
|
|
11
|
+
* is picked separately, from the terminal background — a user on a light
|
|
12
|
+
* terminal must get a readable Nord, not a dark one washed out.
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* The palette the app has always shipped, and still starts with.
|
|
17
|
+
*
|
|
18
|
+
* Its values are reproduced here byte for byte from the constants in
|
|
19
|
+
* `theme.ts`, so selecting `rose-pine` after wandering through the others
|
|
20
|
+
* restores exactly the original rendering rather than something close to it.
|
|
21
|
+
*/
|
|
22
|
+
const rosePine = {
|
|
23
|
+
name: 'rose-pine',
|
|
24
|
+
description: 'The default: muted purples on a soft ink background',
|
|
25
|
+
colors: {
|
|
26
|
+
accent: { light: '#7A3E9D', dark: '#C4A7E7' },
|
|
27
|
+
muted: { light: '#6B6B6B', dark: '#6E6A86' },
|
|
28
|
+
border: { light: '#D0CCD8', dark: '#393552' },
|
|
29
|
+
text: { light: '#1F1D2E', dark: '#E0DEF4' },
|
|
30
|
+
warn: { light: '#B4637A', dark: '#EB6F92' },
|
|
31
|
+
ok: { light: '#286983', dark: '#9CCFD8' },
|
|
32
|
+
green: { light: '#56949F', dark: '#3E8FB0' },
|
|
33
|
+
gold: { light: '#EA9D34', dark: '#F6C177' },
|
|
34
|
+
rose: { light: '#D7827E', dark: '#EA9A97' },
|
|
35
|
+
invert: { light: '#FFFFFF', dark: '#191724' },
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Gruvbox, in its medium contrast form.
|
|
40
|
+
*
|
|
41
|
+
* The light variant is the published `gruvbox-light` set rather than the dark
|
|
42
|
+
* one lightened: Gruvbox ships two hand-tuned halves and mixing them gives
|
|
43
|
+
* the washed-out result the palette was designed to avoid.
|
|
44
|
+
*/
|
|
45
|
+
const gruvbox = {
|
|
46
|
+
name: 'gruvbox',
|
|
47
|
+
description: 'Warm retro earth tones, medium contrast',
|
|
48
|
+
colors: {
|
|
49
|
+
accent: { light: '#8F3F71', dark: '#D3869B' },
|
|
50
|
+
muted: { light: '#7C6F64', dark: '#928374' },
|
|
51
|
+
border: { light: '#D5C4A1', dark: '#504945' },
|
|
52
|
+
text: { light: '#3C3836', dark: '#EBDBB2' },
|
|
53
|
+
warn: { light: '#9D0006', dark: '#FB4934' },
|
|
54
|
+
ok: { light: '#427B58', dark: '#8EC07C' },
|
|
55
|
+
green: { light: '#79740E', dark: '#B8BB26' },
|
|
56
|
+
gold: { light: '#B57614', dark: '#FABD2F' },
|
|
57
|
+
rose: { light: '#AF3A03', dark: '#FE8019' },
|
|
58
|
+
invert: { light: '#FBF1C7', dark: '#282828' },
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Nord: Polar Night behind Frost and Aurora.
|
|
63
|
+
*
|
|
64
|
+
* Nord only specifies a dark scheme, so the light variant darkens the Aurora
|
|
65
|
+
* accents until they carry against Snow Storm — the published hues sit far
|
|
66
|
+
* too pale on a white terminal to read as anything but noise.
|
|
67
|
+
*/
|
|
68
|
+
const nord = {
|
|
69
|
+
name: 'nord',
|
|
70
|
+
description: 'Cool arctic blues, low saturation',
|
|
71
|
+
colors: {
|
|
72
|
+
accent: { light: '#5E81AC', dark: '#88C0D0' },
|
|
73
|
+
muted: { light: '#616E88', dark: '#4C566A' },
|
|
74
|
+
border: { light: '#D8DEE9', dark: '#3B4252' },
|
|
75
|
+
text: { light: '#2E3440', dark: '#ECEFF4' },
|
|
76
|
+
warn: { light: '#99414A', dark: '#BF616A' },
|
|
77
|
+
ok: { light: '#3B7C7B', dark: '#8FBCBB' },
|
|
78
|
+
green: { light: '#5A7247', dark: '#A3BE8C' },
|
|
79
|
+
gold: { light: '#9A7B2E', dark: '#EBCB8B' },
|
|
80
|
+
rose: { light: '#A05A3F', dark: '#D08770' },
|
|
81
|
+
invert: { light: '#ECEFF4', dark: '#2E3440' },
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Solarized, both halves.
|
|
86
|
+
*
|
|
87
|
+
* Solarized is the one palette here whose accents are deliberately identical
|
|
88
|
+
* in light and dark — that symmetry is the whole point of its design — so
|
|
89
|
+
* only the greys and the base background differ between the two variants.
|
|
90
|
+
*/
|
|
91
|
+
const solarized = {
|
|
92
|
+
name: 'solarized',
|
|
93
|
+
description: "Schoonover's balanced pairing; both variants share their accents",
|
|
94
|
+
colors: {
|
|
95
|
+
accent: { light: '#268BD2', dark: '#268BD2' },
|
|
96
|
+
muted: { light: '#93A1A1', dark: '#586E75' },
|
|
97
|
+
border: { light: '#EEE8D5', dark: '#073642' },
|
|
98
|
+
text: { light: '#586E75', dark: '#93A1A1' },
|
|
99
|
+
warn: { light: '#DC322F', dark: '#DC322F' },
|
|
100
|
+
ok: { light: '#2AA198', dark: '#2AA198' },
|
|
101
|
+
green: { light: '#859900', dark: '#859900' },
|
|
102
|
+
gold: { light: '#B58900', dark: '#B58900' },
|
|
103
|
+
rose: { light: '#CB4B16', dark: '#CB4B16' },
|
|
104
|
+
invert: { light: '#FDF6E3', dark: '#002B36' },
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* A greyscale, high-contrast palette for anyone the coloured ones fail.
|
|
109
|
+
*
|
|
110
|
+
* It trades the colour coding away rather than trying to keep it: every slot
|
|
111
|
+
* is a grey chosen for contrast against the base, and the semantic roles are
|
|
112
|
+
* separated by lightness alone. That loses the green-tick/red-cross reading
|
|
113
|
+
* at a glance, but the app never relies on colour by itself — a failed tool
|
|
114
|
+
* call still prints its own glyph and its error text — so what is left is
|
|
115
|
+
* legible where a hue-based palette is not.
|
|
116
|
+
*/
|
|
117
|
+
const mono = {
|
|
118
|
+
name: 'mono',
|
|
119
|
+
description: 'Greyscale, maximum contrast; no colour coding at all',
|
|
120
|
+
colors: {
|
|
121
|
+
accent: { light: '#000000', dark: '#FFFFFF' },
|
|
122
|
+
muted: { light: '#5A5A5A', dark: '#9A9A9A' },
|
|
123
|
+
border: { light: '#A6A6A6', dark: '#5F5F5F' },
|
|
124
|
+
text: { light: '#0D0D0D', dark: '#F2F2F2' },
|
|
125
|
+
warn: { light: '#000000', dark: '#FFFFFF' },
|
|
126
|
+
ok: { light: '#333333', dark: '#C8C8C8' },
|
|
127
|
+
green: { light: '#333333', dark: '#C8C8C8' },
|
|
128
|
+
gold: { light: '#1C1C1C', dark: '#E4E4E4' },
|
|
129
|
+
rose: { light: '#4A4A4A', dark: '#B4B4B4' },
|
|
130
|
+
invert: { light: '#FFFFFF', dark: '#000000' },
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
/** The name the app starts with when nothing has been chosen or persisted. */
|
|
134
|
+
export const DEFAULT_THEME = 'rose-pine';
|
|
135
|
+
/**
|
|
136
|
+
* The colours the exported constants in `theme.ts` are seeded with.
|
|
137
|
+
*
|
|
138
|
+
* They are seeded from the table rather than written out a second time so the
|
|
139
|
+
* default and the `rose-pine` entry cannot drift apart: if they did, `/theme
|
|
140
|
+
* rose-pine` would quietly stop being a way back to how the app started.
|
|
141
|
+
*/
|
|
142
|
+
export const DEFAULT_PALETTE = rosePine.colors;
|
|
143
|
+
/**
|
|
144
|
+
* Every palette, in the order `/theme` lists them: the default first, then
|
|
145
|
+
* the rest alphabetically, with the accessibility option last so it reads as
|
|
146
|
+
* the deliberate escape hatch it is.
|
|
147
|
+
*/
|
|
148
|
+
export const THEMES = [rosePine, gruvbox, nord, solarized, mono];
|
|
149
|
+
/** Look a palette up by name, or `undefined` when no such palette exists. */
|
|
150
|
+
export function findTheme(name) {
|
|
151
|
+
const wanted = name.trim().toLowerCase();
|
|
152
|
+
return THEMES.find((theme) => theme.name === wanted);
|
|
153
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a tool call says about itself, and what came back.
|
|
3
|
+
*
|
|
4
|
+
* A tool row used to carry only the tool's name — `bash` four times over said
|
|
5
|
+
* nothing about what the agent was doing. The raw `arguments` JSON is already
|
|
6
|
+
* on the call (and its result already in the session log), so the readable
|
|
7
|
+
* summary is a pair of pure functions over strings: no Harness types, no I/O,
|
|
8
|
+
* replayable in a dependency-free suite next to the stream projection.
|
|
9
|
+
*
|
|
10
|
+
* @module moqi-tui/tui/tooldetail
|
|
11
|
+
*/
|
|
12
|
+
/** Stored detail/results are capped here; the renderer truncates to width. */
|
|
13
|
+
const DETAIL_LIMIT = 200;
|
|
14
|
+
/**
|
|
15
|
+
* Argument keys that name what a call does, most specific first. A tool's own
|
|
16
|
+
* key wins when present; otherwise the first string value speaks for the call.
|
|
17
|
+
*/
|
|
18
|
+
const PREFERRED_KEYS = [
|
|
19
|
+
'command',
|
|
20
|
+
'cmd',
|
|
21
|
+
'script',
|
|
22
|
+
'file_path',
|
|
23
|
+
'path',
|
|
24
|
+
'filePath',
|
|
25
|
+
'pattern',
|
|
26
|
+
'query',
|
|
27
|
+
'url',
|
|
28
|
+
'prompt',
|
|
29
|
+
'description',
|
|
30
|
+
'objective',
|
|
31
|
+
'content',
|
|
32
|
+
'name',
|
|
33
|
+
];
|
|
34
|
+
/** One line, whitespace folded, bounded. */
|
|
35
|
+
function oneline(text) {
|
|
36
|
+
return text.replace(/\s+/g, ' ').trim().slice(0, DETAIL_LIMIT);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Summarize what a tool call does from its raw `arguments` JSON — the exact
|
|
40
|
+
* string the model produced, parsed leniently. `bash` becomes
|
|
41
|
+
* `bash docker ps --format {{.Names}}`; a read becomes its path; anything
|
|
42
|
+
* unrecognized falls back to its first string-valued argument, then to the
|
|
43
|
+
* compact JSON, then to nothing rather than to noise.
|
|
44
|
+
*/
|
|
45
|
+
export function describeToolCall(name, rawArguments) {
|
|
46
|
+
if (rawArguments === undefined)
|
|
47
|
+
return '';
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(rawArguments);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return oneline(rawArguments);
|
|
54
|
+
}
|
|
55
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
56
|
+
return typeof parsed === 'string' ? oneline(parsed) : '';
|
|
57
|
+
}
|
|
58
|
+
const record = parsed;
|
|
59
|
+
for (const key of PREFERRED_KEYS) {
|
|
60
|
+
const value = record[key];
|
|
61
|
+
if (typeof value === 'string' && value.trim() !== '')
|
|
62
|
+
return oneline(value);
|
|
63
|
+
}
|
|
64
|
+
for (const value of Object.values(record)) {
|
|
65
|
+
if (typeof value === 'string' && value.trim() !== '')
|
|
66
|
+
return oneline(value);
|
|
67
|
+
}
|
|
68
|
+
return '';
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The one-line face of a tool result: its text content folded to a single
|
|
72
|
+
* line, or the failure's identity when the result block says the tool erred.
|
|
73
|
+
* `content` is the result block's content array; each text block contributes.
|
|
74
|
+
*
|
|
75
|
+
* @returns the summary, or `undefined` when the result says nothing readable.
|
|
76
|
+
*/
|
|
77
|
+
export function summarizeResult(content, error) {
|
|
78
|
+
const text = content
|
|
79
|
+
.map((block) => block?.text)
|
|
80
|
+
.filter((part) => typeof part === 'string')
|
|
81
|
+
.join(' ');
|
|
82
|
+
const line = oneline(text);
|
|
83
|
+
if (line !== '')
|
|
84
|
+
return line;
|
|
85
|
+
if (error !== undefined) {
|
|
86
|
+
const who = oneline([error.name, error.code].filter(Boolean).join(' '));
|
|
87
|
+
if (who !== '')
|
|
88
|
+
return who;
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Fold one session-log event onto the live tool rows of the turn in flight.
|
|
94
|
+
*
|
|
95
|
+
* `tool/call` fills a row's detail (the deltas name it, the log says what it
|
|
96
|
+
* does); `tool/result` settles the row — `ok` or `error` — and attaches its
|
|
97
|
+
* outcome underneath the very call that produced it, in flow. Rows are keyed
|
|
98
|
+
* by call id and never created here: only stream deltas and block ends, which
|
|
99
|
+
* observe the same calls, add rows, so an event from an earlier turn logged
|
|
100
|
+
* before the sync point finds no row and is ignored.
|
|
101
|
+
*/
|
|
102
|
+
export function applyToolEvent(tools, event) {
|
|
103
|
+
const data = event.data;
|
|
104
|
+
if (data === undefined)
|
|
105
|
+
return;
|
|
106
|
+
if (event.type === 'tool/call') {
|
|
107
|
+
const row = tools.find((tool) => tool.id === String(data['callId']));
|
|
108
|
+
if (row === undefined)
|
|
109
|
+
return;
|
|
110
|
+
const name = typeof data['name'] === 'string' ? data['name'] : undefined;
|
|
111
|
+
if (row.name === 'tool' && name !== undefined)
|
|
112
|
+
row.name = name;
|
|
113
|
+
if (row.detail === undefined || row.detail === '') {
|
|
114
|
+
const detail = describeToolCall(name ?? row.name, str(data['arguments']));
|
|
115
|
+
if (detail !== '')
|
|
116
|
+
row.detail = detail;
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (event.type === 'tool/result') {
|
|
121
|
+
const message = data['message'];
|
|
122
|
+
const block = Array.isArray(message?.content)
|
|
123
|
+
? (message?.content)[0]
|
|
124
|
+
: undefined;
|
|
125
|
+
if (block === undefined)
|
|
126
|
+
return;
|
|
127
|
+
const row = tools.find((tool) => tool.id === String(block.toolCallId));
|
|
128
|
+
if (row === undefined)
|
|
129
|
+
return;
|
|
130
|
+
const error = data['error'];
|
|
131
|
+
row.status = block.isError === true || error !== undefined ? 'error' : 'ok';
|
|
132
|
+
const summary = summarizeResult(Array.isArray(block.content) ? block.content : [], error !== undefined ? error : undefined);
|
|
133
|
+
if (summary !== undefined)
|
|
134
|
+
row.result = summary;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** `unknown` to `string | undefined`, the only coercion event fields need. */
|
|
138
|
+
function str(value) {
|
|
139
|
+
return typeof value === 'string' ? value : undefined;
|
|
140
|
+
}
|