aegiscode 6.0.0 → 6.1.1
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/README.md +69 -45
- package/package.json +2 -2
- package/scripts/demo.mjs +1 -1
- package/src/app.js +100 -24
- package/src/art.js +219 -29
- package/src/commands.js +150 -50
- package/src/fuzzy.js +116 -0
- package/src/markdown.js +303 -0
- package/src/overlays.js +286 -0
- package/src/render.js +235 -120
- package/src/screen.js +196 -21
- package/src/theme.js +102 -66
package/src/markdown.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Minimal markdown → styled span lines, in the spirit of Claude Code's
|
|
4
|
+
// transcript rendering (monokai-flavored code, bold text, inline code).
|
|
5
|
+
//
|
|
6
|
+
// Ported from aegiscodex-dev/src/markdown.js (ESM → CommonJS). The one
|
|
7
|
+
// structural change: instead of importing `span`/`w` from screen.js — which a
|
|
8
|
+
// concurrent workstream is rewriting — this module carries a local cell-width
|
|
9
|
+
// function and a local `span()` that produce the *same* shape the rest of the
|
|
10
|
+
// design system consumes: { t: text, s: style-prefix, w: cell-width }. That
|
|
11
|
+
// shape is a contract with whoever renders these lines.
|
|
12
|
+
//
|
|
13
|
+
// Colours come from the theme tokens (via themeOf(ctx)) rather than a bare
|
|
14
|
+
// palette, so light/dark both work.
|
|
15
|
+
|
|
16
|
+
const {
|
|
17
|
+
BOLD,
|
|
18
|
+
BOLD_OFF,
|
|
19
|
+
DIM,
|
|
20
|
+
ITALIC,
|
|
21
|
+
ITALIC_OFF,
|
|
22
|
+
RESET_BG,
|
|
23
|
+
RESET_FG,
|
|
24
|
+
themeOf,
|
|
25
|
+
} = require('./theme.js');
|
|
26
|
+
|
|
27
|
+
// Real terminal cell width, mirroring aegiscodex-dev/src/screen.js's w():
|
|
28
|
+
// combining marks / variation selectors are zero-width, CJK fullwidth forms and
|
|
29
|
+
// emoji are two cells, everything else one. A plain codepoint count misaligns
|
|
30
|
+
// every width decision the moment a line contains CJK or emoji.
|
|
31
|
+
const RE_ZERO = /[\u0300-\u036F\u1AB0-\u1AFF\u1DC0-\u1DFF\u20D0-\u20FF\uFE00-\uFE0F\uFE20-\uFE2F]/u;
|
|
32
|
+
const RE_WIDE = /[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uA000-\uA4CF\uA960-\uA97F\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F900}-\u{1F9FF}\u{1FA70}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u;
|
|
33
|
+
function w(t) {
|
|
34
|
+
let n = 0;
|
|
35
|
+
for (const ch of String(t == null ? '' : t)) {
|
|
36
|
+
if (RE_ZERO.test(ch)) continue;
|
|
37
|
+
n += RE_WIDE.test(ch) ? 2 : 1;
|
|
38
|
+
}
|
|
39
|
+
return n;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A styled text run. Shape { t, s, w } is shared with the render model. */
|
|
43
|
+
function span(style, t) {
|
|
44
|
+
return { t, s: style, w: w(t) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Turn an fg colour token into its background twin (38;2;R;G;B → 48;2;R;G;B),
|
|
48
|
+
// so a code block sits on the theme's own code-block tint without typing a
|
|
49
|
+
// colour inline — it stays theme-aware for free.
|
|
50
|
+
function asBg(fgSgr) {
|
|
51
|
+
return fgSgr.replace('[38;2;', '[48;2;');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Rendering rules verified against a live capture of Claude Code 2.1.211:
|
|
55
|
+
// - code lines: two-space indent, no fence label; a theme-tinted background
|
|
56
|
+
// so the block reads as a distinct surface
|
|
57
|
+
// - "- item" → " - item" (hyphen kept, two-space indent)
|
|
58
|
+
// - "> quote" → " ▎ quote" (dim ▎, italic text)
|
|
59
|
+
function renderMarkdown(text, width, ctx) {
|
|
60
|
+
const t = themeOf(ctx);
|
|
61
|
+
const codeStyle = asBg(t.black) + t.white; // code-block surface
|
|
62
|
+
const lines = String(text == null ? '' : text).split('\n');
|
|
63
|
+
const out = [];
|
|
64
|
+
let inCode = false;
|
|
65
|
+
for (const raw of lines) {
|
|
66
|
+
const line = raw.replace(/\r$/, '');
|
|
67
|
+
if (line.startsWith('```')) {
|
|
68
|
+
inCode = !inCode;
|
|
69
|
+
continue; // fences are not rendered
|
|
70
|
+
}
|
|
71
|
+
if (inCode) {
|
|
72
|
+
out.push([span(codeStyle, ' ' + (line || ''))]);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
out.push(...inlineLine(line, width, t));
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Render one plain line with inline **bold** / `code` / "- " bullets / quotes.
|
|
81
|
+
function inlineLine(text, width, t) {
|
|
82
|
+
const ts = text.trimStart();
|
|
83
|
+
let body = text;
|
|
84
|
+
let prefix = null;
|
|
85
|
+
let isQuote = false;
|
|
86
|
+
// "- item" → " - item": keep the hyphen marker with a two-space indent.
|
|
87
|
+
if (ts.startsWith('- ')) {
|
|
88
|
+
prefix = span(t.white, ' - ');
|
|
89
|
+
body = ts.slice(2);
|
|
90
|
+
} else if (ts.startsWith(' - ')) {
|
|
91
|
+
prefix = span(t.white, ' - ');
|
|
92
|
+
body = ts.slice(4);
|
|
93
|
+
} else if (ts.startsWith('> ')) {
|
|
94
|
+
// "> quote" → " ▎ quote" (dim bar, italic body).
|
|
95
|
+
prefix = span(DIM, ' ▎ ');
|
|
96
|
+
body = ts.slice(2);
|
|
97
|
+
isQuote = true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const spans = [];
|
|
101
|
+
const re = /(\*\*[^*]+\*\*|`[^`]+`)/g;
|
|
102
|
+
let last = 0;
|
|
103
|
+
let m;
|
|
104
|
+
while ((m = re.exec(body))) {
|
|
105
|
+
if (m.index > last) pushText(spans, body.slice(last, m.index), t);
|
|
106
|
+
const tok = m[0];
|
|
107
|
+
if (tok.startsWith('**')) pushText(spans, tok.slice(2, -2), t, BOLD);
|
|
108
|
+
else pushText(spans, tok.slice(1, -1), t, t.green);
|
|
109
|
+
last = m.index + tok.length;
|
|
110
|
+
}
|
|
111
|
+
if (last < body.length) pushText(spans, body.slice(last), t);
|
|
112
|
+
if (isQuote) {
|
|
113
|
+
// italicize the body of a quote line
|
|
114
|
+
for (const sp of spans) sp.s = ITALIC + sp.s + ITALIC_OFF;
|
|
115
|
+
}
|
|
116
|
+
return wrapInline(spans, width, prefix);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function pushText(spans, text, t, style) {
|
|
120
|
+
// Emit only non-space words; wrapInline adds separators. This avoids
|
|
121
|
+
// double-spacing when text arrives in chunks.
|
|
122
|
+
const s = style == null ? t.white : style;
|
|
123
|
+
const parts = text.split(/[ \t]+/).filter(Boolean);
|
|
124
|
+
for (const p of parts) {
|
|
125
|
+
spans.push(span(s, p));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Wrap the word spans, prepending `prefix` verbatim to the first line only
|
|
130
|
+
// (so " - " / " ▎ " markers keep their exact spacing).
|
|
131
|
+
function wrapInline(line, width, prefix) {
|
|
132
|
+
const res = [];
|
|
133
|
+
let cur = prefix ? [prefix] : [];
|
|
134
|
+
let curW = prefix ? prefix.w : 0;
|
|
135
|
+
// Does the accumulator's last character end in whitespace? This mirrors the
|
|
136
|
+
// old /\s$/.test(cur.map(join)) check but tracks incrementally — the join
|
|
137
|
+
// version was O(line) PER WORD, making a paragraph render O(n²) and freezing
|
|
138
|
+
// the UI on long streaming answers.
|
|
139
|
+
let endsSpace = prefix ? /\s$/.test(prefix.t) : false;
|
|
140
|
+
const flush = () => {
|
|
141
|
+
if (cur.length) {
|
|
142
|
+
res.push(cur);
|
|
143
|
+
cur = [];
|
|
144
|
+
curW = 0;
|
|
145
|
+
endsSpace = false;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
for (const sp of line) {
|
|
149
|
+
const words = sp.t.split(' ');
|
|
150
|
+
for (let i = 0; i < words.length; i++) {
|
|
151
|
+
const word = words[i];
|
|
152
|
+
const ww = w(word);
|
|
153
|
+
// Only insert a separator between words when nothing before already
|
|
154
|
+
// ends in whitespace (prevents "· " + word becoming "· word").
|
|
155
|
+
const needSep = curW > 0 && i === 0 && /^\S/.test(word) && !endsSpace;
|
|
156
|
+
if (curW + ww + (needSep ? 1 : 0) > width && curW > 0) {
|
|
157
|
+
flush();
|
|
158
|
+
}
|
|
159
|
+
if (ww > width) {
|
|
160
|
+
// An unbreakable token wider than the terminal: emit one width-sized
|
|
161
|
+
// row per piece. Slice once into code-point arrays (O(n)) rather than
|
|
162
|
+
// re-slicing the remainder each piece (O(n²)), and cap the rows so a
|
|
163
|
+
// pathological token can't balloon the transcript either.
|
|
164
|
+
const chars = [...word];
|
|
165
|
+
const cws = chars.map((c) => w(c)); // cell width per code point, once
|
|
166
|
+
let total = 0;
|
|
167
|
+
for (const cw of cws) total += cw;
|
|
168
|
+
const MAX_PIECES = 400;
|
|
169
|
+
let j = 0;
|
|
170
|
+
let piece = 0;
|
|
171
|
+
while (total > width) {
|
|
172
|
+
// Take whole code points until the row is full — a codepoint slice
|
|
173
|
+
// (width chars) would split 2-cell CJK/emoji mid-glyph.
|
|
174
|
+
let take = 0;
|
|
175
|
+
let hw = 0;
|
|
176
|
+
while (j + take < chars.length && hw + cws[j + take] <= width) {
|
|
177
|
+
hw += cws[j + take];
|
|
178
|
+
take++;
|
|
179
|
+
}
|
|
180
|
+
if (!take) take = 1; // single glyph wider than the terminal
|
|
181
|
+
const head = chars.slice(j, j + take).join('');
|
|
182
|
+
if (curW) {
|
|
183
|
+
cur.push(span('', ' '));
|
|
184
|
+
curW++;
|
|
185
|
+
endsSpace = true;
|
|
186
|
+
}
|
|
187
|
+
cur.push(span(sp.s, head));
|
|
188
|
+
curW += hw;
|
|
189
|
+
flush();
|
|
190
|
+
j += take;
|
|
191
|
+
total -= hw;
|
|
192
|
+
if (++piece >= MAX_PIECES) {
|
|
193
|
+
if (curW) {
|
|
194
|
+
cur.push(span('', ' '));
|
|
195
|
+
curW++;
|
|
196
|
+
}
|
|
197
|
+
cur.push(span(sp.s, '…'));
|
|
198
|
+
flush();
|
|
199
|
+
return res.length ? res : [[span('', '')]];
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const rest = chars.slice(j).join('');
|
|
203
|
+
if (rest) {
|
|
204
|
+
cur.push(span(sp.s, rest));
|
|
205
|
+
curW += w(rest);
|
|
206
|
+
endsSpace = /\s$/.test(rest);
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
if (curW > 0 && needSep) {
|
|
210
|
+
cur.push(span('', ' '));
|
|
211
|
+
curW++;
|
|
212
|
+
endsSpace = true;
|
|
213
|
+
}
|
|
214
|
+
cur.push(span(sp.s, word));
|
|
215
|
+
curW += ww;
|
|
216
|
+
endsSpace = /\s$/.test(word);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
flush();
|
|
221
|
+
return res.length ? res : [[span('', '')]];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// The Monokai Extended diff preview shown on the theme picker (verbatim from
|
|
225
|
+
// the reference capture; the literals are that theme's colours, not this one's).
|
|
226
|
+
function renderDiffPreview(width, ctx) {
|
|
227
|
+
const t = themeOf(ctx);
|
|
228
|
+
const div = '╌'.repeat(Math.max(10, width - 4));
|
|
229
|
+
const num = (s) => span(t.gray + '\x1b[2m', s);
|
|
230
|
+
const plain = (s) => span(t.white, s);
|
|
231
|
+
const kw = (s) => span(RGB_LEGACY(102, 217, 239), s); // monokai: function
|
|
232
|
+
const fn = (s) => span(RGB_LEGACY(166, 226, 46), s);
|
|
233
|
+
const str = (s) => span(RGB_LEGACY(230, 219, 116), s);
|
|
234
|
+
const del = (s) => span('\x1b[38;2;220;90;90m\x1b[48;2;61;1;0m', s);
|
|
235
|
+
const delHi = (s) => span('\x1b[38;2;248;248;242m\x1b[48;2;92;2;0m', s);
|
|
236
|
+
const add = (s) => span('\x1b[38;2;80;200;80m\x1b[48;2;2;40;0m', s);
|
|
237
|
+
const addHi = (s) => span('\x1b[38;2;255;255;255m\x1b[48;2;4;71;0m', s);
|
|
238
|
+
|
|
239
|
+
const W = Math.max(28, width - 8);
|
|
240
|
+
const padBg = (spans, bg) => {
|
|
241
|
+
let cur = 0;
|
|
242
|
+
const out = [...spans];
|
|
243
|
+
for (const sp of out) cur += sp.w;
|
|
244
|
+
if (cur < W) out.push(span(bg + ' ', ' '.repeat(W - cur)));
|
|
245
|
+
out.push(span(RESET_BG, ''));
|
|
246
|
+
return out;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const lines = [];
|
|
250
|
+
lines.push([span(t.dim, div)]);
|
|
251
|
+
lines.push([num(' 1 '), kw('function'), plain(' '), fn('greet'), plain('()'), span(t.white, '{')]);
|
|
252
|
+
lines.push(
|
|
253
|
+
padBg(
|
|
254
|
+
[
|
|
255
|
+
num(' 2 '),
|
|
256
|
+
del('-'),
|
|
257
|
+
plain(' '),
|
|
258
|
+
del('console'),
|
|
259
|
+
span('\x1b[38;2;166;226;46m\x1b[48;2;61;1;0m', '.log'),
|
|
260
|
+
del('('),
|
|
261
|
+
str('"Hello, '),
|
|
262
|
+
delHi('World'),
|
|
263
|
+
del('!");'),
|
|
264
|
+
],
|
|
265
|
+
'\x1b[48;2;61;1;0m'
|
|
266
|
+
)
|
|
267
|
+
);
|
|
268
|
+
lines.push(
|
|
269
|
+
padBg(
|
|
270
|
+
[
|
|
271
|
+
num(' 2 '),
|
|
272
|
+
add('+'),
|
|
273
|
+
plain(' '),
|
|
274
|
+
add('console'),
|
|
275
|
+
span('\x1b[38;2;166;226;46m\x1b[48;2;2;40;0m', '.log'),
|
|
276
|
+
add('('),
|
|
277
|
+
str('"Hello, '),
|
|
278
|
+
addHi('Claude'),
|
|
279
|
+
add('!");'),
|
|
280
|
+
],
|
|
281
|
+
'\x1b[48;2;2;40;0m'
|
|
282
|
+
)
|
|
283
|
+
);
|
|
284
|
+
lines.push([num(' 3 '), plain('}')]);
|
|
285
|
+
lines.push([span(t.dim, div)]);
|
|
286
|
+
return lines;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function RGB_LEGACY(r, g, b) {
|
|
290
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
module.exports = {
|
|
294
|
+
renderMarkdown,
|
|
295
|
+
renderDiffPreview,
|
|
296
|
+
// Re-exported style tokens the reference exposed for legacy consumers.
|
|
297
|
+
RESET_FG,
|
|
298
|
+
BOLD,
|
|
299
|
+
BOLD_OFF,
|
|
300
|
+
// Also expose the local model helpers so consumers/tests can reuse them.
|
|
301
|
+
span,
|
|
302
|
+
w,
|
|
303
|
+
};
|
package/src/overlays.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Overlay renderers — the "/" command palette, the alt+p model picker, the
|
|
5
|
+
* /effort picker and the /resume session list. Ported from
|
|
6
|
+
* `aegiscodex-dev/src/commands.js` (which mirrors live captures of Claude Code
|
|
7
|
+
* 2.1.228: a gray `─` rule, a lavender `❯` cursor, a blue selected row, a
|
|
8
|
+
* `⎿ Usage: /x hint` hook under the focused command and a `·`-separated footer).
|
|
9
|
+
*
|
|
10
|
+
* Every renderer here is PURE. It receives the data it draws — a command list, a
|
|
11
|
+
* model list, an item list, a selection index, a width and a height — and
|
|
12
|
+
* returns an array of span lines (`{ t, s, w }`, see screen.js). It imports no
|
|
13
|
+
* registry, no session state and no key handling; the session loop owns those.
|
|
14
|
+
* The palette's command list is the array the concurrent workstream owns:
|
|
15
|
+
* `{ name, aliases?, help|desc, args?, category?, hint?, tool?|local?|unavailable? }`.
|
|
16
|
+
*
|
|
17
|
+
* Filtering reuses `./fuzzy.js` (fuzzyRankWithAliases + fuzzyMatchPositions).
|
|
18
|
+
* The require is guarded: if that module is absent the palette falls back to a
|
|
19
|
+
* substring filter so this file still loads and `node --check` still passes.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const { span, padLine } = require('./screen.js');
|
|
23
|
+
const { C, BOLD, BOLD_OFF, GLYPH } = require('./theme.js');
|
|
24
|
+
|
|
25
|
+
// ── fuzzy.js (guarded require — a concurrent workstream owns it) ─────────────
|
|
26
|
+
let fuzzy = null;
|
|
27
|
+
try {
|
|
28
|
+
// eslint-disable-next-line global-require
|
|
29
|
+
fuzzy = require('./fuzzy.js');
|
|
30
|
+
} catch {
|
|
31
|
+
fuzzy = null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Rank palette commands by query (aliases count, like the reference). */
|
|
35
|
+
function rankCommands(query, commands) {
|
|
36
|
+
if (fuzzy && typeof fuzzy.fuzzyRankWithAliases === 'function') {
|
|
37
|
+
return fuzzy.fuzzyRankWithAliases(query, commands, (c) => c.name, (c) => c.aliases || []);
|
|
38
|
+
}
|
|
39
|
+
// Fallback: plain case-insensitive substring across name + aliases.
|
|
40
|
+
const q = String(query || '').toLowerCase();
|
|
41
|
+
if (!q) return commands;
|
|
42
|
+
return commands.filter((c) =>
|
|
43
|
+
`${c.name} ${(c.aliases || []).join(' ')}`.toLowerCase().includes(q)
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The short description a registry entry carries (help or desc). */
|
|
48
|
+
const descOf = (c) => c.help != null ? c.help : (c.desc != null ? c.desc : '');
|
|
49
|
+
/** The usage hint shown on the selected row's `⎿` hook (hint, else args). */
|
|
50
|
+
const hintOf = (c) => (c.hint != null ? c.hint : (c.args != null ? c.args : ''));
|
|
51
|
+
|
|
52
|
+
/** Pad (and, if needed, truncate) every line to `width` cells so an overlay can
|
|
53
|
+
* never paint wider than the terminal. */
|
|
54
|
+
function fit(lines, width) {
|
|
55
|
+
return lines.map((l) => padLine(l, width));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A full-width blank row. */
|
|
59
|
+
const blank = (width) => [span('', ' '.repeat(Math.max(0, width)))];
|
|
60
|
+
|
|
61
|
+
// Phase 10b (2.1.228 port): split a command name into spans that bold the
|
|
62
|
+
// characters which matched the query; the selected row reads blue.
|
|
63
|
+
function boldMatched(query, name, colour) {
|
|
64
|
+
const pos = fuzzy && typeof fuzzy.fuzzyMatchPositions === 'function'
|
|
65
|
+
? fuzzy.fuzzyMatchPositions(query, name)
|
|
66
|
+
: [];
|
|
67
|
+
if (!pos || !pos.length) return [span(colour, name)];
|
|
68
|
+
const segs = [];
|
|
69
|
+
let last = 0;
|
|
70
|
+
for (const p of pos) {
|
|
71
|
+
if (p > last) segs.push(span(colour, name.slice(last, p)));
|
|
72
|
+
segs.push(span(colour + BOLD, name[p]));
|
|
73
|
+
last = p + 1;
|
|
74
|
+
}
|
|
75
|
+
if (last < name.length) segs.push(span(colour, name.slice(last)));
|
|
76
|
+
return segs;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Palette overlay (/) ──────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {Array|{commands:Array, query?:string, sel?:number}} commands
|
|
83
|
+
* the registry command list (or a state object carrying it as `.commands`)
|
|
84
|
+
* @param {{query?:string, sel?:number}} [state]
|
|
85
|
+
* @param {number} [width]
|
|
86
|
+
* @param {number} [height]
|
|
87
|
+
*/
|
|
88
|
+
function renderPalette(commands, state = {}, width = 80, height = 24) {
|
|
89
|
+
// Accept either `renderPalette(list, {query, sel}, w, h)` or the reference's
|
|
90
|
+
// `renderPalette({commands, query, sel}, w, h)`.
|
|
91
|
+
if (!Array.isArray(commands) && commands && typeof commands === 'object') {
|
|
92
|
+
state = commands;
|
|
93
|
+
commands = Array.isArray(commands.commands) ? commands.commands : [];
|
|
94
|
+
}
|
|
95
|
+
const list = rankCommands(state.query || '', commands || []);
|
|
96
|
+
const sel = Number.isInteger(state.sel) ? state.sel : 0;
|
|
97
|
+
const total = list.length;
|
|
98
|
+
const per = Math.max(1, Math.min(12, height - 8));
|
|
99
|
+
const start = Math.max(0, Math.min(sel - Math.floor(per / 2), Math.max(0, total - per)));
|
|
100
|
+
const visible = list.slice(start, start + per);
|
|
101
|
+
|
|
102
|
+
const lines = [];
|
|
103
|
+
lines.push(blank(width));
|
|
104
|
+
lines.push([span(C.gray, ' ' + '─'.repeat(Math.max(10, width - 4)))]);
|
|
105
|
+
for (let i = 0; i < visible.length; i++) {
|
|
106
|
+
const c = visible[i];
|
|
107
|
+
const idx = start + i;
|
|
108
|
+
const active = idx === sel;
|
|
109
|
+
const left = active ? span(C.lavender, GLYPH.cursor) : span('', ' ');
|
|
110
|
+
const row = active ? C.blue : C.white;
|
|
111
|
+
const name = [span(row, '/'), ...boldMatched(state.query || '', c.name, row)];
|
|
112
|
+
const pad = ' '.repeat(Math.max(1, 22 - [...c.name].length));
|
|
113
|
+
lines.push([
|
|
114
|
+
span('', ' '),
|
|
115
|
+
left,
|
|
116
|
+
span('', ' '),
|
|
117
|
+
...name,
|
|
118
|
+
span(c.unavailable ? C.dim : C.gray, pad + descOf(c)),
|
|
119
|
+
]);
|
|
120
|
+
if (active && hintOf(c)) {
|
|
121
|
+
lines.push([span('', ' '), span(C.gray, `${GLYPH.hook} Usage: /${c.name} ${hintOf(c)}`)]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
while (lines.length < 4 + per * 2) lines.push(blank(width));
|
|
125
|
+
lines.push([span(C.gray, ' ' + '─'.repeat(Math.max(10, width - 4)))]);
|
|
126
|
+
lines.push([
|
|
127
|
+
span(
|
|
128
|
+
C.gray,
|
|
129
|
+
` type to filter ${GLYPH.bullet} enter to run ${GLYPH.bullet} esc to close`
|
|
130
|
+
),
|
|
131
|
+
]);
|
|
132
|
+
return fit(lines, width);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Model picker overlay (alt+p) ─────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {Array} models entries ({id, label?, name?, note?, model?})
|
|
139
|
+
* @param {number} sel selected index
|
|
140
|
+
* @param {number} width
|
|
141
|
+
* @param {number} height
|
|
142
|
+
* @param {string|null} current the currently-pinned model id
|
|
143
|
+
*/
|
|
144
|
+
function renderModelPicker(models, sel = 0, width = 80, height = 24, current = null) {
|
|
145
|
+
const lines = [];
|
|
146
|
+
lines.push(blank(width));
|
|
147
|
+
lines.push([span('', ' '), span(C.white + BOLD, 'Select model'), span(BOLD_OFF, '')]);
|
|
148
|
+
lines.push([span('', ' '), span(C.gray, 'Switch between models. Your pick becomes the default for new sessions.')]);
|
|
149
|
+
lines.push([span('', ' '), span(C.gray, 'Manage the list with /model add|remove — /model <id> switches directly.')]);
|
|
150
|
+
lines.push(blank(width));
|
|
151
|
+
for (let i = 0; i < models.length; i++) {
|
|
152
|
+
const m = models[i] || {};
|
|
153
|
+
const label = m.label || m.name || m.id || '';
|
|
154
|
+
const active = i === sel;
|
|
155
|
+
const cur = current != null && m.id === current;
|
|
156
|
+
const left = active ? span(C.lavender, GLYPH.cursor) : span('', ' ');
|
|
157
|
+
const num = span(C.gray, `${i + 1}.`);
|
|
158
|
+
const nameSpan = active ? span(C.lavender, label) : span(C.white, label);
|
|
159
|
+
const mark = cur ? span(C.green, ' ' + GLYPH.check) : span('', '');
|
|
160
|
+
const pad = ' '.repeat(Math.max(1, 22 - [...label].length));
|
|
161
|
+
lines.push([
|
|
162
|
+
span('', ' '),
|
|
163
|
+
left,
|
|
164
|
+
span('', ' '),
|
|
165
|
+
num,
|
|
166
|
+
span('', ' '),
|
|
167
|
+
nameSpan,
|
|
168
|
+
mark,
|
|
169
|
+
span(C.gray, pad + (m.note || m.model || '')),
|
|
170
|
+
]);
|
|
171
|
+
}
|
|
172
|
+
lines.push(blank(width));
|
|
173
|
+
lines.push([span('', ' '), span(C.gray, `arrow keys to navigate ${GLYPH.bullet} enter to select ${GLYPH.bullet} esc to cancel`)]);
|
|
174
|
+
return fit(lines, width);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Effort picker overlay (/effort) ──────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
const EFFORT_LEVELS = [
|
|
180
|
+
{ label: 'Low', note: 'Fastest, most efficient' },
|
|
181
|
+
{ label: 'Medium', note: 'Balanced' },
|
|
182
|
+
{ label: 'High', note: 'Highest quality, slowest' },
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @param {number} sel selected index
|
|
187
|
+
* @param {number} width
|
|
188
|
+
* @param {string|null} current the currently-selected effort level
|
|
189
|
+
* @param {Array} [levels] override the level table
|
|
190
|
+
*/
|
|
191
|
+
function renderEffortPicker(sel = 0, width = 80, current = null, levels = EFFORT_LEVELS) {
|
|
192
|
+
const lines = [];
|
|
193
|
+
lines.push(blank(width));
|
|
194
|
+
lines.push([span('', ' '), span(C.white + BOLD, 'Select effort'), span(BOLD_OFF, '')]);
|
|
195
|
+
lines.push([span('', ' '), span(C.gray, 'Controls how much reasoning the model puts into each turn.')]);
|
|
196
|
+
lines.push(blank(width));
|
|
197
|
+
for (let i = 0; i < levels.length; i++) {
|
|
198
|
+
const lv = levels[i];
|
|
199
|
+
const active = i === sel;
|
|
200
|
+
const cur = current != null && String(current).toLowerCase() === String(lv.label).toLowerCase();
|
|
201
|
+
const left = active ? span(C.lavender, GLYPH.cursor) : span('', ' ');
|
|
202
|
+
const nameSpan = active ? span(C.lavender, lv.label) : span(C.white, lv.label);
|
|
203
|
+
const mark = cur ? span(C.green, ' ' + GLYPH.check) : span('', '');
|
|
204
|
+
const pad = ' '.repeat(Math.max(1, 12 - [...lv.label].length));
|
|
205
|
+
lines.push([span('', ' '), left, span('', ' '), nameSpan, mark, span(C.gray, pad + lv.note)]);
|
|
206
|
+
}
|
|
207
|
+
lines.push(blank(width));
|
|
208
|
+
lines.push([span('', ' '), span(C.gray, `arrow keys to navigate ${GLYPH.bullet} enter to select ${GLYPH.bullet} esc to cancel`)]);
|
|
209
|
+
return fit(lines, width);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Resume-list overlay (/resume) ────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* @param {Array} items entries ({own?, summary?, cwd?, time?})
|
|
216
|
+
* @param {number} sel selected index
|
|
217
|
+
* @param {number} width
|
|
218
|
+
* @param {number} height
|
|
219
|
+
*/
|
|
220
|
+
function renderResumeList(items, sel = 0, width = 80, height = 24) {
|
|
221
|
+
const list = Array.isArray(items) ? items : [];
|
|
222
|
+
const lines = [];
|
|
223
|
+
lines.push(blank(width));
|
|
224
|
+
lines.push([span('', ' '), span(C.gray, '─'.repeat(Math.max(10, width - 4)))]);
|
|
225
|
+
lines.push([
|
|
226
|
+
span('', ' '),
|
|
227
|
+
span(C.white + BOLD, 'Resume session'),
|
|
228
|
+
span(C.gray, ` (${sel + 1} of ${Math.max(1, list.length)})`),
|
|
229
|
+
span(BOLD_OFF, ''),
|
|
230
|
+
]);
|
|
231
|
+
// search box
|
|
232
|
+
const boxW = Math.max(10, width - 4);
|
|
233
|
+
lines.push([span('', ' '), span(C.gray, '╭' + '─'.repeat(Math.max(0, boxW - 2)) + '╮')]);
|
|
234
|
+
lines.push([
|
|
235
|
+
span('', ' '),
|
|
236
|
+
span(C.gray, '│'),
|
|
237
|
+
span(C.white, '⌕ Search…'),
|
|
238
|
+
span('', ' '.repeat(Math.max(0, boxW - 9))),
|
|
239
|
+
span(C.gray, '│'),
|
|
240
|
+
]);
|
|
241
|
+
lines.push([span('', ' '), span(C.gray, '╰' + '─'.repeat(Math.max(0, boxW - 2)) + '╯')]);
|
|
242
|
+
lines.push(blank(width));
|
|
243
|
+
const per = Math.max(2, Math.min(6, height - 16));
|
|
244
|
+
const start = Math.max(0, Math.min(sel - Math.floor(per / 2), Math.max(0, list.length - per)));
|
|
245
|
+
const visible = list.slice(start, start + per);
|
|
246
|
+
for (let i = 0; i < visible.length; i++) {
|
|
247
|
+
const it = visible[i] || {};
|
|
248
|
+
const idx = start + i;
|
|
249
|
+
const active = idx === sel;
|
|
250
|
+
const left = active ? span(C.lavender, GLYPH.cursor) : span('', ' ');
|
|
251
|
+
const tag = it.own ? span(C.green, GLYPH.star + ' ') : span(C.gray, GLYPH.bullet + ' ');
|
|
252
|
+
const title = active
|
|
253
|
+
? span(C.white + BOLD, it.summary || '(untitled)')
|
|
254
|
+
: span(C.white, it.summary || '(untitled)');
|
|
255
|
+
lines.push([span('', ' '), left, span('', ' '), tag, title]);
|
|
256
|
+
lines.push([span('', ' '), span(C.gray, `${it.cwd || '~'} ${GLYPH.bullet} ${ago(it.time)}`)]);
|
|
257
|
+
}
|
|
258
|
+
while (lines.length < 6 + per * 2) lines.push(blank(width));
|
|
259
|
+
lines.push([span('', ' '), span(C.gray, '─'.repeat(Math.max(10, width - 4)))]);
|
|
260
|
+
lines.push([
|
|
261
|
+
span('', ' '),
|
|
262
|
+
span(
|
|
263
|
+
C.gray,
|
|
264
|
+
`Ctrl+A all projects ${GLYPH.bullet} Space to preview ${GLYPH.bullet} Type to search ${GLYPH.bullet} Esc to cancel`
|
|
265
|
+
),
|
|
266
|
+
]);
|
|
267
|
+
return fit(lines, width);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function ago(ts) {
|
|
271
|
+
if (!ts) return '';
|
|
272
|
+
const ms = Date.now() - new Date(String(ts)).getTime();
|
|
273
|
+
if (!(ms >= 0) || Number.isNaN(ms)) return '';
|
|
274
|
+
if (ms < 60_000) return `${Math.max(1, Math.round(ms / 1000))}s ago`;
|
|
275
|
+
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m ago`;
|
|
276
|
+
if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h ago`;
|
|
277
|
+
return `${Math.round(ms / 86_400_000)}d ago`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
module.exports = {
|
|
281
|
+
boldMatched,
|
|
282
|
+
renderPalette,
|
|
283
|
+
renderModelPicker,
|
|
284
|
+
renderEffortPicker,
|
|
285
|
+
renderResumeList,
|
|
286
|
+
};
|