aegiscode 6.0.0 → 6.1.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.
@@ -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
+ };
@@ -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
+ };