moqi-tui 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +782 -0
  3. package/bin/moqi.mjs +40 -0
  4. package/cordis.patch.yml +41 -0
  5. package/lib/cross-find.js +217 -0
  6. package/lib/file-index.js +121 -0
  7. package/lib/fleet-sources.js +114 -0
  8. package/lib/index.js +3999 -0
  9. package/lib/persist.js +194 -0
  10. package/lib/plugins.js +371 -0
  11. package/lib/presence.js +144 -0
  12. package/lib/rename.js +35 -0
  13. package/lib/rewind.js +94 -0
  14. package/lib/sessions-store.js +134 -0
  15. package/lib/startup.js +92 -0
  16. package/lib/tui/atfile.js +154 -0
  17. package/lib/tui/export.js +48 -0
  18. package/lib/tui/fleet.js +346 -0
  19. package/lib/tui/i18n.js +201 -0
  20. package/lib/tui/jobs.js +65 -0
  21. package/lib/tui/keys.js +205 -0
  22. package/lib/tui/markdown.js +368 -0
  23. package/lib/tui/mcp.js +95 -0
  24. package/lib/tui/panels.js +231 -0
  25. package/lib/tui/screen.js +156 -0
  26. package/lib/tui/state.js +502 -0
  27. package/lib/tui/stream.js +109 -0
  28. package/lib/tui/text.js +173 -0
  29. package/lib/tui/theme.js +183 -0
  30. package/lib/tui/themes.js +153 -0
  31. package/lib/tui/tooldetail.js +140 -0
  32. package/lib/tui/view.js +830 -0
  33. package/lib/tui/vim.js +222 -0
  34. package/lib/tui-host-core.js +141 -0
  35. package/lib/tui-host.js +48 -0
  36. package/lib/types/cross-find.d.ts +66 -0
  37. package/lib/types/file-index.d.ts +34 -0
  38. package/lib/types/fleet-sources.d.ts +34 -0
  39. package/lib/types/index.d.ts +51 -0
  40. package/lib/types/persist.d.ts +116 -0
  41. package/lib/types/plugins.d.ts +218 -0
  42. package/lib/types/presence.d.ts +48 -0
  43. package/lib/types/rename.d.ts +32 -0
  44. package/lib/types/rewind.d.ts +75 -0
  45. package/lib/types/sessions-store.d.ts +46 -0
  46. package/lib/types/startup.d.ts +45 -0
  47. package/lib/types/tui/atfile.d.ts +90 -0
  48. package/lib/types/tui/export.d.ts +18 -0
  49. package/lib/types/tui/fleet.d.ts +209 -0
  50. package/lib/types/tui/i18n.d.ts +34 -0
  51. package/lib/types/tui/jobs.d.ts +28 -0
  52. package/lib/types/tui/keys.d.ts +52 -0
  53. package/lib/types/tui/markdown.d.ts +14 -0
  54. package/lib/types/tui/mcp.d.ts +34 -0
  55. package/lib/types/tui/panels.d.ts +125 -0
  56. package/lib/types/tui/screen.d.ts +79 -0
  57. package/lib/types/tui/state.d.ts +323 -0
  58. package/lib/types/tui/stream.d.ts +78 -0
  59. package/lib/types/tui/text.d.ts +28 -0
  60. package/lib/types/tui/theme.d.ts +87 -0
  61. package/lib/types/tui/themes.d.ts +70 -0
  62. package/lib/types/tui/tooldetail.d.ts +45 -0
  63. package/lib/types/tui/view.d.ts +163 -0
  64. package/lib/types/tui/vim.d.ts +64 -0
  65. package/lib/types/tui-host-core.d.ts +62 -0
  66. package/lib/types/tui-host.d.ts +42 -0
  67. package/lib/types/version.d.ts +8 -0
  68. package/lib/types/voice.d.ts +227 -0
  69. package/lib/version.js +32 -0
  70. package/lib/voice.js +405 -0
  71. package/package.json +119 -0
  72. package/scripts/harness-root.mjs +88 -0
  73. package/scripts/install-profile.mjs +133 -0
@@ -0,0 +1,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
+ }
@@ -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
+ }