cawdev-cli 1.0.3-beta → 1.0.5-beta
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/lib/agents.mjs +27 -0
- package/lib/ansi.mjs +2 -0
- package/lib/codex.mjs +214 -0
- package/lib/harness-prompt.mjs +26 -7
- package/lib/runner-config.mjs +3 -2
- package/lib/stage-tools.mjs +33 -2
- package/lib/tool-line.mjs +19 -8
- package/mcp/README.md +1 -0
- package/mcp/server.mjs +150 -0
- package/package.json +2 -5
- package/runner/README.md +357 -6
- package/runner/attach.mjs +1269 -24
- package/runner/auto-workspaces.mjs +106 -0
- package/runner/bootstrap.mjs +37 -5
- package/runner/cawdev.mjs +49 -13
- package/runner/changed-lines.mjs +87 -0
- package/runner/configure.mjs +77 -3
- package/runner/runner.mjs +1529 -101
- package/runner/select.mjs +31 -4
- package/runner/split.mjs +830 -0
- package/runner/macbook-laptop.json +0 -48
package/runner/split.mjs
ADDED
|
@@ -0,0 +1,830 @@
|
|
|
1
|
+
// Advanced mode — the attached terminal split into one window per session,
|
|
2
|
+
// emacs-style, with the client's own prompt at the bottom.
|
|
3
|
+
//
|
|
4
|
+
// **R81 decided against a pane manager, and this does not reopen that.** The
|
|
5
|
+
// reason there was that a full-screen repaint EATS THE SCROLLBACK: the wheel,
|
|
6
|
+
// search and copy stop working and the transcript above the fold is gone. So
|
|
7
|
+
// the default is still `scrollback.mjs`, untouched. This is a mode somebody
|
|
8
|
+
// ASKS for with `/advanced-mode`, and it is drawn on the terminal's ALTERNATE
|
|
9
|
+
// screen — the buffer vim and emacs use — which leaves the normal screen and
|
|
10
|
+
// its scrollback exactly as they were. Leaving the mode puts them back, and
|
|
11
|
+
// what was said on the watched session meanwhile is laid into the scrollback
|
|
12
|
+
// then, so nothing is lost by having looked at it this way.
|
|
13
|
+
//
|
|
14
|
+
// Everything that decides a picture is a pure function — the layout, one
|
|
15
|
+
// window, the whole frame — so it can be rendered at four sizes and READ,
|
|
16
|
+
// which is how scrollback.mjs's own layout bugs were found. The class at the
|
|
17
|
+
// bottom only enters, leaves, and writes the rows that changed.
|
|
18
|
+
|
|
19
|
+
import { clip, padVisible, visibleWidth, wrap } from '../lib/ansi.mjs';
|
|
20
|
+
|
|
21
|
+
const ESC = '\x1b';
|
|
22
|
+
const ALT_ON = `${ESC}[?1049h`;
|
|
23
|
+
const ALT_OFF = `${ESC}[?1049l`;
|
|
24
|
+
// What nano and emacs set as well as the alternate screen, and what makes the
|
|
25
|
+
// screen hold still: no AUTO-WRAP, so a row a little wider than we measured it
|
|
26
|
+
// (an emoji in an agent's output is two columns and one character) is cut at
|
|
27
|
+
// the edge instead of wrapping — a wrap on the last row scrolls the whole
|
|
28
|
+
// screen, and iTerm keeps what scrolled off where the wheel reaches it; and
|
|
29
|
+
// ALTERNATE SCROLL, so the wheel sends arrow keys to the program instead of
|
|
30
|
+
// scrolling the terminal. Both are put back on the way out.
|
|
31
|
+
const HOLD_STILL = `${ESC}[?7l${ESC}[?1007h`;
|
|
32
|
+
const LET_GO = `${ESC}[?1007l${ESC}[?7h`;
|
|
33
|
+
const HIDE_CURSOR = `${ESC}[?25l`;
|
|
34
|
+
const SHOW_CURSOR = `${ESC}[?25h`;
|
|
35
|
+
const RESET = `${ESC}[0m`;
|
|
36
|
+
|
|
37
|
+
/** Narrower than this a window cannot show a title and a line of output. */
|
|
38
|
+
export const MIN_ZONE_WIDTH = 28;
|
|
39
|
+
/** Border, title, two lines of output, an action line. */
|
|
40
|
+
export const MIN_ZONE_HEIGHT = 5;
|
|
41
|
+
/** The prompt's band when nothing asks for more: border, messages, input, keys. */
|
|
42
|
+
export const PROMPT_ROWS = 8;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Where everything goes, for `count` sessions on a `width`×`height` screen.
|
|
46
|
+
*
|
|
47
|
+
* The prompt is ALWAYS the band at the bottom, the way a shell's is — the
|
|
48
|
+
* sessions fill the screen above it, in one row or, when there is room, two.
|
|
49
|
+
* Sessions that do not fit at `MIN_ZONE_WIDTH` are not drawn and are counted
|
|
50
|
+
* in `hidden`, so the prompt can say how many are off screen rather than
|
|
51
|
+
* squeezing every window into something unreadable.
|
|
52
|
+
*
|
|
53
|
+
* @param promptRows how tall the prompt wants to be — taller while a picker
|
|
54
|
+
* is open, so the list is not cut off.
|
|
55
|
+
* @returns `{ zones: [{ top, left, width, height }], prompt: {…}, hidden }`,
|
|
56
|
+
* rows and columns 0-based.
|
|
57
|
+
*/
|
|
58
|
+
export function splitLayout(width, height, count, promptRows = PROMPT_ROWS) {
|
|
59
|
+
const promptHeight = Math.max(4, Math.min(height, promptRows));
|
|
60
|
+
const room = height - promptHeight;
|
|
61
|
+
if (count <= 0 || room < MIN_ZONE_HEIGHT) {
|
|
62
|
+
return { zones: [], prompt: { top: 0, left: 0, width, height }, hidden: Math.max(0, count) };
|
|
63
|
+
}
|
|
64
|
+
const perRow = Math.max(1, Math.floor(width / MIN_ZONE_WIDTH));
|
|
65
|
+
const twoRows = count > perRow || (count > 2 && Math.floor(room / 2) >= MIN_ZONE_HEIGHT * 2);
|
|
66
|
+
const rows = twoRows && Math.floor(room / 2) >= MIN_ZONE_HEIGHT ? 2 : 1;
|
|
67
|
+
const shown = Math.min(count, perRow * rows);
|
|
68
|
+
const topCount = rows === 2 ? Math.ceil(shown / 2) : shown;
|
|
69
|
+
const bottomCount = shown - topCount;
|
|
70
|
+
const topHeight = rows === 2 ? Math.ceil(room / 2) : room;
|
|
71
|
+
|
|
72
|
+
const row = (n, top, rowHeight) => spread(width, n).map(([left, w]) => ({
|
|
73
|
+
top, left, width: w, height: rowHeight,
|
|
74
|
+
}));
|
|
75
|
+
return {
|
|
76
|
+
zones: [
|
|
77
|
+
...row(topCount, 0, topHeight),
|
|
78
|
+
...(bottomCount ? row(bottomCount, topHeight, room - topHeight) : []),
|
|
79
|
+
],
|
|
80
|
+
prompt: { top: room, left: 0, width, height: promptHeight },
|
|
81
|
+
hidden: count - shown,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `n` columns across `width`, the remainder given to the leftmost ones. */
|
|
86
|
+
function spread(width, n) {
|
|
87
|
+
const base = Math.floor(width / n);
|
|
88
|
+
const extra = width - base * n;
|
|
89
|
+
const out = [];
|
|
90
|
+
let left = 0;
|
|
91
|
+
for (let i = 0; i < n; i++) {
|
|
92
|
+
const w = base + (i < extra ? 1 : 0);
|
|
93
|
+
out.push([left, w]);
|
|
94
|
+
left += w;
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* One window, as exactly `rect.height` strings of exactly `rect.width` columns.
|
|
101
|
+
*
|
|
102
|
+
* The SUBTITLE, when there is one, is a fixed first line inside it.
|
|
103
|
+
* The TITLE is in the top border, clipped from the right so the start of it —
|
|
104
|
+
* the number and the card — survives a narrow window. The BODY is the tail of
|
|
105
|
+
* whatever lines it is handed, wrapped to the inside width: a window shows the
|
|
106
|
+
* latest output, the way a terminal does. The FOOTER, when there is one, is the
|
|
107
|
+
* last line inside the border — what can be done about this session now.
|
|
108
|
+
*
|
|
109
|
+
* The focused window is drawn in heavy lines, so which one the prompt is
|
|
110
|
+
* talking to survives `NO_COLOR`; `tone` colours the frame by the session's
|
|
111
|
+
* state — red while it runs, green once it finished, amber if it stopped any
|
|
112
|
+
* other way.
|
|
113
|
+
*/
|
|
114
|
+
export function windowLines(rect, {
|
|
115
|
+
title = '', subtitle = '', body = [], footer = '', focused = false, tone = null, dotted = false,
|
|
116
|
+
}, ink) {
|
|
117
|
+
const { width, height } = rect;
|
|
118
|
+
const inner = Math.max(0, width - 2);
|
|
119
|
+
// Heavy and solid is the window the prompt talks to; a session window that
|
|
120
|
+
// is NOT focused is dotted, so which one is active reads at a glance — the
|
|
121
|
+
// heavy line alone was too close to the thin one on most terminal fonts.
|
|
122
|
+
const [h, v, tl, tr, bl, br] = focused
|
|
123
|
+
? ['━', '┃', '┏', '┓', '┗', '┛']
|
|
124
|
+
: dotted
|
|
125
|
+
? ['┄', '┆', '┌', '┐', '└', '┘']
|
|
126
|
+
: ['─', '│', '┌', '┐', '└', '┘'];
|
|
127
|
+
// The frame's COLOUR is the session's state (see `toneFor`); which window is
|
|
128
|
+
// focused is the heavy line, so the two never compete for one channel.
|
|
129
|
+
const byTone = { live: ink.danger, done: ink.success, stopped: ink.warn };
|
|
130
|
+
const paint = byTone[tone] ?? ((text) => (focused ? ink.accent(text) : ink.muted(text)));
|
|
131
|
+
|
|
132
|
+
const label = title ? ` ${clip(title, Math.max(0, inner - 3))} ` : '';
|
|
133
|
+
const top = paint(tl + h) + label + paint(h.repeat(Math.max(0, inner - 1 - visibleWidth(label))) + tr);
|
|
134
|
+
|
|
135
|
+
const footerRows = footer ? 1 : 0;
|
|
136
|
+
const subtitleRows = subtitle ? 1 : 0;
|
|
137
|
+
const rows = Math.max(0, height - 2 - footerRows - subtitleRows);
|
|
138
|
+
const tail = tailOf(body, inner, rows);
|
|
139
|
+
const lines = [top];
|
|
140
|
+
if (subtitle) {
|
|
141
|
+
// A fixed line under the title — the project — so a long title never
|
|
142
|
+
// pushes it out of sight and the output scrolling never takes it.
|
|
143
|
+
lines.push(paint(v) + padVisible(clip(subtitle, inner), inner) + RESET + paint(v));
|
|
144
|
+
}
|
|
145
|
+
for (let i = 0; i < rows; i++) {
|
|
146
|
+
lines.push(paint(v) + padVisible(clip(tail[i] ?? '', inner), inner) + RESET + paint(v));
|
|
147
|
+
}
|
|
148
|
+
if (footer) {
|
|
149
|
+
lines.push(paint(v) + padVisible(clip(footer, inner), inner) + RESET + paint(v));
|
|
150
|
+
}
|
|
151
|
+
lines.push(paint(bl + h.repeat(inner) + br));
|
|
152
|
+
return lines.slice(0, height);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The last `rows` screen lines of `lines`, each wrapped to `width` — walked
|
|
157
|
+
* from the end, so a session with four thousand lines of history costs what
|
|
158
|
+
* the window shows and not what the session said.
|
|
159
|
+
*/
|
|
160
|
+
export function tailOf(lines, width, rows) {
|
|
161
|
+
const out = [];
|
|
162
|
+
for (let i = lines.length - 1; i >= 0 && out.length < rows; i--) {
|
|
163
|
+
const wrapped = wrap(String(lines[i] ?? ''), Math.max(1, width));
|
|
164
|
+
for (let j = wrapped.length - 1; j >= 0 && out.length < rows; j--) {
|
|
165
|
+
out.push(wrapped[j]);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return out.reverse();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The whole screen, from placed pieces: `height` rows, each the pieces that
|
|
173
|
+
* cross it laid left to right, and blank where nothing is.
|
|
174
|
+
*
|
|
175
|
+
* @param pieces `[{ rect, lines }]`, `lines` being what `windowLines` returned.
|
|
176
|
+
*/
|
|
177
|
+
export function composeFrame(width, height, pieces) {
|
|
178
|
+
const rows = [];
|
|
179
|
+
for (let y = 0; y < height; y++) {
|
|
180
|
+
const crossing = pieces
|
|
181
|
+
.filter(({ rect }) => y >= rect.top && y < rect.top + rect.height)
|
|
182
|
+
.sort((a, b) => a.rect.left - b.rect.left);
|
|
183
|
+
let row = '';
|
|
184
|
+
let at = 0;
|
|
185
|
+
for (const { rect, lines } of crossing) {
|
|
186
|
+
if (rect.left > at) {
|
|
187
|
+
row += ' '.repeat(rect.left - at);
|
|
188
|
+
at = rect.left;
|
|
189
|
+
}
|
|
190
|
+
row += padVisible(lines[y - rect.top] ?? '', rect.width) + RESET;
|
|
191
|
+
at += rect.width;
|
|
192
|
+
}
|
|
193
|
+
rows.push(at < width ? row + ' '.repeat(width - at) : row);
|
|
194
|
+
}
|
|
195
|
+
return rows;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The alternate screen, and the rows on it.
|
|
200
|
+
*
|
|
201
|
+
* Writes only the rows that changed since the last frame: a busy session
|
|
202
|
+
* prints hundreds of lines a second into one window, and repainting the four
|
|
203
|
+
* others and the prompt for each of them is how a split screen flickers.
|
|
204
|
+
*/
|
|
205
|
+
export class SplitScreen {
|
|
206
|
+
constructor(out = process.stdout) {
|
|
207
|
+
this.out = out;
|
|
208
|
+
this.last = [];
|
|
209
|
+
this.on = false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
get width() {
|
|
213
|
+
return this.out.columns ?? 80;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
get height() {
|
|
217
|
+
return this.out.rows ?? 24;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
enter() {
|
|
221
|
+
if (this.on) return;
|
|
222
|
+
this.on = true;
|
|
223
|
+
this.last = [];
|
|
224
|
+
this.out.write(`${ALT_ON}${HOLD_STILL}${HIDE_CURSOR}${ESC}[2J${ESC}[H`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Back to the normal screen, its scrollback exactly as it was left. */
|
|
228
|
+
leave() {
|
|
229
|
+
if (!this.on) return;
|
|
230
|
+
this.on = false;
|
|
231
|
+
this.last = [];
|
|
232
|
+
this.out.write(`${RESET}${LET_GO}${ALT_OFF}${HIDE_CURSOR}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The window changed shape: every row is repainted on the next draw. */
|
|
236
|
+
resize() {
|
|
237
|
+
this.last = [];
|
|
238
|
+
if (this.on) this.out.write(`${ESC}[2J`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
draw(rows) {
|
|
242
|
+
if (!this.on) return;
|
|
243
|
+
let out = '';
|
|
244
|
+
for (let y = 0; y < rows.length; y++) {
|
|
245
|
+
if (rows[y] !== this.last[y]) {
|
|
246
|
+
out += `${ESC}[${y + 1};1H${rows[y]}${RESET}${ESC}[K`;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
this.last = rows.slice();
|
|
250
|
+
if (out) this.out.write(out);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Leaving for good — a crash or a quit must not strand somebody on the alternate screen. */
|
|
254
|
+
release() {
|
|
255
|
+
if (this.on) {
|
|
256
|
+
this.out.write(`${RESET}${LET_GO}${ALT_OFF}${SHOW_CURSOR}`);
|
|
257
|
+
this.on = false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// --- what a window says about its session -------------------------------------
|
|
263
|
+
|
|
264
|
+
/** A profile as the word a person uses for it — "dev", not CODE. */
|
|
265
|
+
export const TYPE_WORDS = {
|
|
266
|
+
PLAN: 'plan', CODE: 'dev', REVIEW: 'review', ASK: 'ask', AUDIT: 'audit', SCOPE: 'scope',
|
|
267
|
+
INTERVIEW: 'interview', MERGE: 'merge', RELEASE: 'release', ROADMAP: 'roadmap',
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
/** A pull request URL, and not the host's offer to open one (`/compare/`). */
|
|
271
|
+
export function isPullRequest(url) {
|
|
272
|
+
return typeof url === 'string' && /^https?:\/\//.test(url) && !url.includes('/compare/');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The window's title: what kind of session it is, where it stands, the card
|
|
277
|
+
* and its title. The project is on a line of its own (`windowLines`'s
|
|
278
|
+
* subtitle).
|
|
279
|
+
*
|
|
280
|
+
* @param meta what this terminal knows of a session — the daemon's snapshot
|
|
281
|
+
* (`projectSlug`, `label`, `profile`, `state`) and, once read, the platform's
|
|
282
|
+
* `view` of the run, which is where the card and the final state come from.
|
|
283
|
+
* @param waiting a word when the session is stopped on a person, else null.
|
|
284
|
+
*/
|
|
285
|
+
export function titleFor(meta, waiting = null) {
|
|
286
|
+
const view = meta.view ?? {};
|
|
287
|
+
const profile = view.profile ?? meta.profile ?? null;
|
|
288
|
+
const type = TYPE_WORDS[profile] ?? (profile ? String(profile).toLowerCase() : 'session');
|
|
289
|
+
const what = view.entryRef
|
|
290
|
+
? `${view.entryRef} ${view.entryTitle ?? view.label ?? meta.label ?? ''}`.trim()
|
|
291
|
+
: (view.label ?? meta.label ?? 'a session');
|
|
292
|
+
const state = meta.ended
|
|
293
|
+
? String(view.state ?? 'ended').toLowerCase().replace(/_/g, ' ')
|
|
294
|
+
: meta.state ?? 'running';
|
|
295
|
+
// What it is and where it stands FIRST: a narrow window clips from the
|
|
296
|
+
// right, and "plan · finished" is what decides what to do next. The project
|
|
297
|
+
// is the window's subtitle, a line of its own, and not in here.
|
|
298
|
+
return [type, waiting ?? state, what].filter(Boolean).join(' · ');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* What a person calls a model: `claude-opus-5-5` → `Opus 5.5`, `sonnet` →
|
|
303
|
+
* `Sonnet`, `claude-haiku-4-5-20251001` → `Haiku 4.5`, `gpt-5-codex` →
|
|
304
|
+
* `GPT-5-codex`. Anything else is shown as it came — a label must never hide
|
|
305
|
+
* which model actually ran.
|
|
306
|
+
*/
|
|
307
|
+
export function modelLabel(model) {
|
|
308
|
+
if (!model) return null;
|
|
309
|
+
const raw = String(model).trim().replace(/\[.*\]$/, '');
|
|
310
|
+
const claude = /^(?:claude-)?(opus|sonnet|haiku|fable)(?:-(\d+))?(?:-(\d{1,2}))?(?:-\d{8})?$/i.exec(raw);
|
|
311
|
+
if (claude) {
|
|
312
|
+
const [, family, major, minor] = claude;
|
|
313
|
+
const version = major ? ` ${major}${minor ? `.${minor}` : ''}` : '';
|
|
314
|
+
return `${family[0].toUpperCase()}${family.slice(1).toLowerCase()}${version}`;
|
|
315
|
+
}
|
|
316
|
+
if (/^gpt/i.test(raw)) return `GPT${raw.slice(3)}`;
|
|
317
|
+
return raw;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Which CLI, as a person says it. */
|
|
321
|
+
const AGENT_NAMES = { claude: 'Claude', codex: 'Codex', agy: 'Antigravity' };
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* `Claude · Opus 5.5` — the agent a session runs on and its model, for the
|
|
325
|
+
* window's second line. The daemon's word for a live session (what it
|
|
326
|
+
* actually spawned, per stage), the platform's for an ended one. With no
|
|
327
|
+
* model named, the CLI's own default is what ran, and it says so.
|
|
328
|
+
*/
|
|
329
|
+
export function agentModelFor(meta) {
|
|
330
|
+
const agent = meta.agent ?? meta.view?.agent ?? null;
|
|
331
|
+
const model = meta.model ?? meta.view?.model ?? null;
|
|
332
|
+
if (!agent && !model) return '';
|
|
333
|
+
const who = AGENT_NAMES[agent] ?? (agent ? String(agent) : 'Claude');
|
|
334
|
+
return `${who} · ${modelLabel(model) ?? 'default model'}`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* A window's second line: the project, and for a dev session the ABSOLUTE
|
|
339
|
+
* path of the checkout its changes are being made in — on a machine with
|
|
340
|
+
* `saasng`, `saasng-auto-1` and `saasng-auto-2`, the label alone does not say
|
|
341
|
+
* which directory to open. The daemon's list says where a live session is;
|
|
342
|
+
* the platform's view says where an ended one was.
|
|
343
|
+
*
|
|
344
|
+
* Too long for `width`, the path gives way FROM THE LEFT: `…/saasng-auto-2`
|
|
345
|
+
* tells two checkouts apart, `/Users/ben/dev/…` tells nothing.
|
|
346
|
+
*/
|
|
347
|
+
export function subtitleFor(meta, width) {
|
|
348
|
+
const project = `▸ ${meta.projectSlug ?? ''}`;
|
|
349
|
+
const profile = meta.view?.profile ?? meta.profile ?? null;
|
|
350
|
+
const path = meta.workspace ?? meta.view?.workspace ?? null;
|
|
351
|
+
if (profile !== 'CODE' || !path) {
|
|
352
|
+
return project;
|
|
353
|
+
}
|
|
354
|
+
const room = width - project.length - 3;
|
|
355
|
+
if (room < 8) {
|
|
356
|
+
return project;
|
|
357
|
+
}
|
|
358
|
+
const shown = path.length <= room ? path : `…${path.slice(path.length - room + 1)}`;
|
|
359
|
+
return `${project} · ${shown}`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* A window frame's colour, from where its session stands: `live` (red) while
|
|
364
|
+
* it runs or waits, `done` (green) once it FINISHED — a plan planned, a dev
|
|
365
|
+
* developed — and `stopped` (amber) for any other ending. Null until an ended
|
|
366
|
+
* session's outcome has been read, so a frame never claims a success it has
|
|
367
|
+
* not seen.
|
|
368
|
+
*/
|
|
369
|
+
export function toneFor(meta) {
|
|
370
|
+
if (!meta.ended) {
|
|
371
|
+
return 'live';
|
|
372
|
+
}
|
|
373
|
+
const state = meta.view?.state;
|
|
374
|
+
if (!state || meta.view?.live) {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
return state === 'FINISHED' ? 'done' : 'stopped';
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* What can be done about a session now — the window's last line. Empty while
|
|
382
|
+
* it runs: the prompt, the answer and the permission keys are the prompt's.
|
|
383
|
+
*
|
|
384
|
+
* A finished PLAN on a card offers its development. A finished session that
|
|
385
|
+
* wrote a branch offers the pull request until one is known, then shows it.
|
|
386
|
+
* Every ended window can be closed.
|
|
387
|
+
*/
|
|
388
|
+
export function actionsFor(meta) {
|
|
389
|
+
if (!meta.ended) {
|
|
390
|
+
return '';
|
|
391
|
+
}
|
|
392
|
+
const view = meta.view;
|
|
393
|
+
const parts = [];
|
|
394
|
+
if (view?.canCarryOn && !meta.carryAsked) {
|
|
395
|
+
parts.push('/continue picks it back up here');
|
|
396
|
+
} else if (meta.carryAsked) {
|
|
397
|
+
parts.push('continue asked for — back when this machine takes it');
|
|
398
|
+
}
|
|
399
|
+
if (view?.state === 'FINISHED' && view.profile === 'PLAN' && view.entryRef) {
|
|
400
|
+
parts.push(`/develop starts ${view.entryRef}`);
|
|
401
|
+
}
|
|
402
|
+
if (isPullRequest(view?.prUrl)) {
|
|
403
|
+
parts.push(`PR ${view.prUrl} · /open`);
|
|
404
|
+
} else if (meta.prAsked) {
|
|
405
|
+
parts.push(meta.prAsked === true ? 'pull request asked for…' : meta.prAsked);
|
|
406
|
+
} else if (view?.branch && (view.profile ?? 'CODE') === 'CODE' && view.state === 'FINISHED') {
|
|
407
|
+
parts.push('/pr opens the pull request');
|
|
408
|
+
}
|
|
409
|
+
if (meta.dropped) {
|
|
410
|
+
parts.push('checkout deleted');
|
|
411
|
+
} else if (isAutoCheckout(view?.workspace) && view?.state !== 'RUNNING' && !view?.live) {
|
|
412
|
+
parts.push('/drop deletes its checkout');
|
|
413
|
+
}
|
|
414
|
+
parts.push('/close');
|
|
415
|
+
return parts.join(' · ');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Whether a run the platform listed is one THIS terminal can carry on, on
|
|
420
|
+
* THIS machine — the console's Carry on, from here. Three conditions, each
|
|
421
|
+
* the API's own refusal read in advance: the platform says it can be carried
|
|
422
|
+
* on at all (`canCarryOn`: stopped or failed, a session or a hand-off to go
|
|
423
|
+
* on from, not discarded); it is the signed-in person's, because carry-on is
|
|
424
|
+
* the starter's alone; and its checkout is here or nowhere, because a run
|
|
425
|
+
* holding one resumes on the machine that holds it.
|
|
426
|
+
*/
|
|
427
|
+
export function carriesOnHere(view, runnerId, email) {
|
|
428
|
+
return Boolean(view?.canCarryOn)
|
|
429
|
+
&& Boolean(email) && view.startedByEmail === email
|
|
430
|
+
&& (!view.runnerId || view.runnerId === runnerId);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Whether a checkout is one the daemon made on demand — `<first>-auto-<n>`
|
|
435
|
+
* (auto-workspaces.mjs). Only those are ever offered to `/drop`: the
|
|
436
|
+
* checkouts in the config are somebody's, and the project always keeps them.
|
|
437
|
+
*/
|
|
438
|
+
export function isAutoCheckout(path) {
|
|
439
|
+
return typeof path === 'string' && /-auto-\d+$/.test(path.replace(/\/+$/, ''));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* `/new plan R3`, `/new dev R3`, `/new ask what does the importer do?` — and a
|
|
444
|
+
* plan or a dev from a sentence, with no card: `/new plan add a CSV export`,
|
|
445
|
+
* `/new dev fix the sort on the client list`. `@slug` anywhere names the
|
|
446
|
+
* project; without it the caller asks which.
|
|
447
|
+
*
|
|
448
|
+
* One card reference and nothing else is that card; anything else is the task
|
|
449
|
+
* in words. A plan from words writes its card first and plans what it wrote
|
|
450
|
+
* (R150); a dev from words is a manual session, on a branch of its own.
|
|
451
|
+
*
|
|
452
|
+
* @param words what followed `/new`, split on spaces
|
|
453
|
+
* @param projects the slugs this machine serves
|
|
454
|
+
* @returns `{ kind, slug, ref }` for a card, `{ kind, slug, text }` for words,
|
|
455
|
+
* `{ needsProject, projects }` when no `@project` was written — the project
|
|
456
|
+
* is ALWAYS asked, never guessed — or `{ error }` in words.
|
|
457
|
+
*/
|
|
458
|
+
export function parseNew(words, { projects = [] } = {}) {
|
|
459
|
+
const named = words.find((word) => word.startsWith('@'));
|
|
460
|
+
const rest = words.filter((word) => word !== named && word !== '');
|
|
461
|
+
const [kindWord, ...args] = rest;
|
|
462
|
+
const kind = { plan: 'plan', dev: 'dev', code: 'dev', ask: 'ask' }[String(kindWord ?? '').toLowerCase()];
|
|
463
|
+
const usage = 'use /new plan R3, /new dev R3, /new plan|dev <the task in words> or /new ask <question>'
|
|
464
|
+
+ ' (and @project to pick one)';
|
|
465
|
+
if (!kind) {
|
|
466
|
+
return { error: kindWord ? `no such session kind: ${kindWord} — ${usage}` : usage };
|
|
467
|
+
}
|
|
468
|
+
const slug = named ? named.slice(1) : null;
|
|
469
|
+
if (slug && projects.length && !projects.includes(slug)) {
|
|
470
|
+
return { error: `this machine does not serve ${slug} — it serves ${projects.join(', ')}` };
|
|
471
|
+
}
|
|
472
|
+
const text = args.join(' ').trim();
|
|
473
|
+
if (!text) {
|
|
474
|
+
return { error: kind === 'ask' ? 'ask what? /new ask <question>'
|
|
475
|
+
: `${kind} what? /new ${kind} R3, or /new ${kind} <the task in words>` };
|
|
476
|
+
}
|
|
477
|
+
let what;
|
|
478
|
+
if (kind !== 'ask' && args.length === 1 && /^[RrIi]?\d+$/.test(args[0])) {
|
|
479
|
+
// A card is written R3 and an issue i3 — EntryRef's spelling; a bare
|
|
480
|
+
// number is a roadmap card, as it is everywhere else.
|
|
481
|
+
const digits = args[0].replace(/^[RrIi]/, '');
|
|
482
|
+
what = { kind, ref: /^[Ii]/.test(args[0]) ? `i${digits}` : `R${digits}` };
|
|
483
|
+
} else {
|
|
484
|
+
what = { kind, text };
|
|
485
|
+
}
|
|
486
|
+
// Never guessed — not from the focused window, not from a machine serving
|
|
487
|
+
// one project: a session started on the wrong project is a card planned or
|
|
488
|
+
// a branch cut in the wrong repository, and asking costs one key.
|
|
489
|
+
if (!slug) {
|
|
490
|
+
return projects.length
|
|
491
|
+
? { needsProject: true, projects: [...projects], error: `which project? ${projects.join(', ')}` }
|
|
492
|
+
: { error: 'this machine serves no project yet — c adds one' };
|
|
493
|
+
}
|
|
494
|
+
return { ...what, slug };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* The branch a dev from words works on: `task-` and the first words of the
|
|
499
|
+
* task, accents folded, and a short suffix so two tasks worded alike do not
|
|
500
|
+
* share one. Never the default branch — a manual session given no branch
|
|
501
|
+
* works on the project's default, and a task typed into a prompt must not
|
|
502
|
+
* commit straight onto `main`.
|
|
503
|
+
*/
|
|
504
|
+
/**
|
|
505
|
+
* The title of the card `/new dev <the task in words>` writes before it
|
|
506
|
+
* starts: the sentence itself, its first line, cut between words near a
|
|
507
|
+
* hundred characters with `…` — the whole text goes in the card's body, so
|
|
508
|
+
* nothing typed is lost to the cut.
|
|
509
|
+
*/
|
|
510
|
+
export function cardTitleFor(text, max = 100) {
|
|
511
|
+
const first = String(text).trim().split('\n')[0].trim();
|
|
512
|
+
if (first.length <= max) return first;
|
|
513
|
+
const cut = first.slice(0, max - 1);
|
|
514
|
+
const at = cut.lastIndexOf(' ');
|
|
515
|
+
return `${(at > max / 2 ? cut.slice(0, at) : cut).trimEnd()}…`;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function branchForTask(text, suffix = Date.now().toString(36).slice(-4)) {
|
|
519
|
+
const words = String(text).normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
|
520
|
+
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
521
|
+
let cut = words.slice(0, 40);
|
|
522
|
+
if (words.length > 40 && cut.includes('-')) {
|
|
523
|
+
cut = cut.slice(0, cut.lastIndexOf('-'));
|
|
524
|
+
}
|
|
525
|
+
return ['task', cut || 'session', suffix].filter(Boolean).join('-');
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// --- the roadmap, across the top ------------------------------------------------
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* A card's title in `rows` lines of at most `width`, broken between words — a
|
|
532
|
+
* card read at a glance should not say "mob / ile" — with `…` where it had to
|
|
533
|
+
* stop. A word longer than a line is cut, since it cannot be broken anywhere
|
|
534
|
+
* better.
|
|
535
|
+
*/
|
|
536
|
+
export function wordsIn(text, width, rows) {
|
|
537
|
+
const lines = [];
|
|
538
|
+
let line = '';
|
|
539
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
540
|
+
for (let i = 0; i < words.length; i++) {
|
|
541
|
+
const word = words[i].length > width ? `${words[i].slice(0, width - 1)}…` : words[i];
|
|
542
|
+
const next = line ? `${line} ${word}` : word;
|
|
543
|
+
if (next.length <= width) {
|
|
544
|
+
line = next;
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
lines.push(line);
|
|
548
|
+
line = word;
|
|
549
|
+
if (lines.length === rows) {
|
|
550
|
+
line = '';
|
|
551
|
+
const last = lines[rows - 1];
|
|
552
|
+
lines[rows - 1] = last.length < width ? `${last}…` : `${last.slice(0, width - 1)}…`;
|
|
553
|
+
return lines;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (line) lines.push(line);
|
|
557
|
+
return lines.slice(0, rows);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Plain text in lines of at most `width`, broken between words; a word longer
|
|
562
|
+
* than a line is cut, since it cannot be broken anywhere better. What a
|
|
563
|
+
* question needs in a narrow prompt: `wordsIn` stops after N rows, and
|
|
564
|
+
* ansi.mjs's `wrap` breaks mid-word ("fiscal ye" / "ar?").
|
|
565
|
+
*/
|
|
566
|
+
export function wordWrap(text, width) {
|
|
567
|
+
const w = Math.max(1, width);
|
|
568
|
+
const lines = [];
|
|
569
|
+
for (const paragraph of String(text).split('\n')) {
|
|
570
|
+
let line = '';
|
|
571
|
+
for (let word of paragraph.split(/\s+/).filter(Boolean)) {
|
|
572
|
+
while (word.length > w) {
|
|
573
|
+
if (line) { lines.push(line); line = ''; }
|
|
574
|
+
lines.push(word.slice(0, w));
|
|
575
|
+
word = word.slice(w);
|
|
576
|
+
}
|
|
577
|
+
if (!word) continue;
|
|
578
|
+
const next = line ? `${line} ${word}` : word;
|
|
579
|
+
if (next.length <= w) {
|
|
580
|
+
line = next;
|
|
581
|
+
} else {
|
|
582
|
+
lines.push(line);
|
|
583
|
+
line = word;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
lines.push(line);
|
|
587
|
+
}
|
|
588
|
+
return lines;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* The board's columns, in the board's order, and the words the console uses
|
|
593
|
+
* for them. SHIPPED and DECLINED are not columns: they only grow, and the
|
|
594
|
+
* console's own board folds them to a count (R68) for the same reason — so
|
|
595
|
+
* here they are counts beside the project's name.
|
|
596
|
+
*/
|
|
597
|
+
export const BOARD_COLUMNS = [
|
|
598
|
+
['CONSIDERING', 'Considering'],
|
|
599
|
+
['PLANNED', 'Planned'],
|
|
600
|
+
['IN_DEVELOPMENT', 'In development'],
|
|
601
|
+
['MERGED', 'Merged'],
|
|
602
|
+
];
|
|
603
|
+
const FOLDED = [['SHIPPED', 'shipped'], ['DECLINED', 'declined']];
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Which column gives way first when the width runs out: the finished work,
|
|
607
|
+
* then the ideas, then what is ready, and what is being built last of all —
|
|
608
|
+
* the order of how much a person at this terminal is about to act on it.
|
|
609
|
+
*/
|
|
610
|
+
const GIVES_WAY = ['MERGED', 'CONSIDERING', 'PLANNED', 'IN_DEVELOPMENT'];
|
|
611
|
+
|
|
612
|
+
/** Narrower than this, a column's card lines say nothing. */
|
|
613
|
+
export const MIN_COLUMN = 18;
|
|
614
|
+
|
|
615
|
+
/** Header rows above the cards: the projects, then the columns. */
|
|
616
|
+
const STRIP_HEAD = 2;
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* The roadmap across the top of advanced mode: every project this machine
|
|
620
|
+
* serves, each as a small board — its columns side by side, one line per
|
|
621
|
+
* card, a `●` on a card with a session running now.
|
|
622
|
+
*
|
|
623
|
+
* THE ROOM RUNS OUT TWO WAYS, and each is said rather than cut:
|
|
624
|
+
* - **Too many cards for the rows** — a column ends in `+N more` on its last
|
|
625
|
+
* row, never a card cut in half, and the header keeps the true count.
|
|
626
|
+
* - **Too many columns for the width** — empty columns go first (the header
|
|
627
|
+
* line of their project still names nothing lost), then whole columns in
|
|
628
|
+
* `GIVES_WAY` order, each named in the project's header as `merged 3
|
|
629
|
+
* hidden`; then whole projects, counted as `+N projects`. A project always
|
|
630
|
+
* keeps one column.
|
|
631
|
+
*
|
|
632
|
+
* Between `STRIP_HEAD + 1` and `maxRows` lines — as many as the fullest
|
|
633
|
+
* column needs, no more — of exactly `width` columns. One line when there is
|
|
634
|
+
* no project at all.
|
|
635
|
+
*
|
|
636
|
+
* @param cards `[{ ref, title, slug, status, live? }]`
|
|
637
|
+
* @param projects the slugs to show, in order; defaults to those the cards
|
|
638
|
+
* come from
|
|
639
|
+
*/
|
|
640
|
+
export function roadmapStrip(width, maxRows, cards, ink, projects = null) {
|
|
641
|
+
const slugs = projects?.length ? [...projects] : [...new Set(cards.map((card) => card.slug))];
|
|
642
|
+
if (!slugs.length) {
|
|
643
|
+
return [padVisible(ink.muted(' no project here yet — the roadmap shows here'), width)];
|
|
644
|
+
}
|
|
645
|
+
const boards = slugs.map((slug) => {
|
|
646
|
+
const mine = cards.filter((card) => card.slug === slug);
|
|
647
|
+
return {
|
|
648
|
+
slug,
|
|
649
|
+
folded: FOLDED.map(([status, word]) => [word, mine.filter((c) => c.status === status).length]),
|
|
650
|
+
columns: BOARD_COLUMNS.map(([status, label]) => ({
|
|
651
|
+
status, label, cards: mine.filter((card) => card.status === status),
|
|
652
|
+
})),
|
|
653
|
+
hidden: [],
|
|
654
|
+
};
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
// What fits: every column at MIN_COLUMN, a space between two columns and a
|
|
658
|
+
// rule between two projects.
|
|
659
|
+
const need = (list) => list.reduce((sum, board) => sum + board.columns.length * (MIN_COLUMN + 1), 0)
|
|
660
|
+
- 1 + (list.length - 1) * 2;
|
|
661
|
+
const drop = (board, column) => {
|
|
662
|
+
board.columns = board.columns.filter((each) => each !== column);
|
|
663
|
+
if (column.cards.length) board.hidden.push(column);
|
|
664
|
+
};
|
|
665
|
+
if (need(boards) > width) {
|
|
666
|
+
for (const board of boards) {
|
|
667
|
+
for (const column of [...board.columns]) {
|
|
668
|
+
if (!column.cards.length && board.columns.length > 1) drop(board, column);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
for (const status of GIVES_WAY) {
|
|
673
|
+
if (need(boards) <= width) break;
|
|
674
|
+
for (const board of boards) {
|
|
675
|
+
const column = board.columns.find((each) => each.status === status);
|
|
676
|
+
if (column && board.columns.length > 1) drop(board, column);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
const shown = [...boards];
|
|
680
|
+
while (shown.length > 1 && need(shown) > width) shown.pop();
|
|
681
|
+
const hiddenProjects = boards.length - shown.length;
|
|
682
|
+
|
|
683
|
+
// Widths: what is left after the gaps, shared by column, the remainder
|
|
684
|
+
// going to the first columns so the rule lines never move by one.
|
|
685
|
+
const columnCount = shown.reduce((sum, board) => sum + board.columns.length, 0);
|
|
686
|
+
const gaps = shown.reduce((sum, board) => sum + board.columns.length - 1, 0) + (shown.length - 1) * 3;
|
|
687
|
+
const base = Math.max(1, Math.floor((width - gaps) / columnCount));
|
|
688
|
+
let extra = Math.max(0, width - gaps - base * columnCount);
|
|
689
|
+
for (const board of shown) {
|
|
690
|
+
for (const column of board.columns) {
|
|
691
|
+
column.width = base + (extra > 0 ? 1 : 0);
|
|
692
|
+
if (extra > 0) extra -= 1;
|
|
693
|
+
}
|
|
694
|
+
board.width = board.columns.reduce((sum, column) => sum + column.width, 0) + board.columns.length - 1;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Rows: as many as the fullest column needs, within what the screen gave.
|
|
698
|
+
const fullest = Math.max(1, ...shown.flatMap((board) => board.columns.map((column) => column.cards.length)));
|
|
699
|
+
const rows = Math.max(STRIP_HEAD + 1, Math.min(maxRows, STRIP_HEAD + fullest));
|
|
700
|
+
const cardRows = rows - STRIP_HEAD;
|
|
701
|
+
|
|
702
|
+
const lineOf = (pick) => shown.map((board, b) => {
|
|
703
|
+
const segment = pick(board, b);
|
|
704
|
+
return padVisible(ellipsed(segment, board.width), board.width);
|
|
705
|
+
}).join(ink.muted(' │ '));
|
|
706
|
+
|
|
707
|
+
const lines = [];
|
|
708
|
+
lines.push(lineOf((board, b) => {
|
|
709
|
+
const counts = board.folded.filter(([, n]) => n).map(([word, n]) => `${n} ${word}`);
|
|
710
|
+
const lost = board.hidden.map((column) => `${column.label.toLowerCase()} ${column.cards.length} hidden`);
|
|
711
|
+
const more = b === shown.length - 1 && hiddenProjects
|
|
712
|
+
? [`+${hiddenProjects} project${hiddenProjects === 1 ? '' : 's'}`] : [];
|
|
713
|
+
// What is NOT drawn first: a clipped header should lose the tally of
|
|
714
|
+
// shipped work before it loses the fact that a column is missing.
|
|
715
|
+
const tail = [...lost, ...more, ...counts].join(' · ');
|
|
716
|
+
return `${ink.bold(board.slug)}${tail ? ink.muted(` · ${tail}`) : ''}`;
|
|
717
|
+
}));
|
|
718
|
+
lines.push(lineOf((board) => board.columns.map((column) =>
|
|
719
|
+
padVisible(clip(`${hueOf(column.status, ink)(column.label)} ${ink.muted(String(column.cards.length))}`,
|
|
720
|
+
column.width), column.width)).join(' ')));
|
|
721
|
+
for (let row = 0; row < cardRows; row++) {
|
|
722
|
+
lines.push(lineOf((board) => board.columns.map((column) => {
|
|
723
|
+
const overflow = column.cards.length > cardRows;
|
|
724
|
+
const last = row === cardRows - 1;
|
|
725
|
+
let cell = '';
|
|
726
|
+
if (overflow && last) {
|
|
727
|
+
cell = ink.muted(`+${column.cards.length - (cardRows - 1)} more`);
|
|
728
|
+
} else if (column.cards[row]) {
|
|
729
|
+
const card = column.cards[row];
|
|
730
|
+
const mark = card.live ? ink.accent('●') : ' ';
|
|
731
|
+
cell = `${mark}${ink.bold(card.ref)} ${card.title ?? ''}`;
|
|
732
|
+
} else if (row === 0 && !column.cards.length) {
|
|
733
|
+
cell = ink.muted(' —');
|
|
734
|
+
}
|
|
735
|
+
return padVisible(ellipsed(cell, column.width), column.width);
|
|
736
|
+
}).join(' ')));
|
|
737
|
+
}
|
|
738
|
+
return lines.map((line) => padVisible(clip(line, width), width));
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** Clipped to `width`, with `…` where it had to stop — a title cut dead reads as the whole title. */
|
|
742
|
+
function ellipsed(text, width) {
|
|
743
|
+
return visibleWidth(text) <= width ? text : `${clip(text, width - 1)}…`;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** A column's name in the colour of how close to done it is. */
|
|
747
|
+
function hueOf(status, ink) {
|
|
748
|
+
return status === 'IN_DEVELOPMENT' ? ink.accent
|
|
749
|
+
: status === 'MERGED' ? ink.success
|
|
750
|
+
: status === 'PLANNED' ? ink.warn : ink.muted;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// --- more sessions than windows ------------------------------------------------
|
|
754
|
+
|
|
755
|
+
/** No more windows than this: past four, a window is too small to read. */
|
|
756
|
+
export const MAX_WINDOWS = 4;
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Which sessions get a window when there are more than `max`, in the order
|
|
760
|
+
* they are listed (so a window does not jump about as the choice changes).
|
|
761
|
+
*
|
|
762
|
+
* The focused one always — Tab onto a session in the tab line brings it up.
|
|
763
|
+
* Then sessions still running, then ended ones, each most recently focused
|
|
764
|
+
* first: the work in progress is what the screen is for.
|
|
765
|
+
*
|
|
766
|
+
* @param metas every session with a window, listed in their order
|
|
767
|
+
* @param recent session ids, most recently focused first
|
|
768
|
+
*/
|
|
769
|
+
export function visibleSessions(metas, recent, focus, max = MAX_WINDOWS) {
|
|
770
|
+
if (metas.length <= max) return metas;
|
|
771
|
+
const rank = (meta) => {
|
|
772
|
+
const at = recent.indexOf(meta.id);
|
|
773
|
+
return [meta.id === focus ? 0 : 1, meta.ended ? 1 : 0, at === -1 ? Infinity : at];
|
|
774
|
+
};
|
|
775
|
+
const order = [...metas].sort((a, b) => {
|
|
776
|
+
const [x, y] = [rank(a), rank(b)];
|
|
777
|
+
return x[0] - y[0] || x[1] - y[1] || x[2] - y[2] || metas.indexOf(a) - metas.indexOf(b);
|
|
778
|
+
});
|
|
779
|
+
const keep = new Set(order.slice(0, max).map((meta) => meta.id));
|
|
780
|
+
return metas.filter((meta) => keep.has(meta.id));
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* The one line naming the sessions with no window: each one's number (what
|
|
785
|
+
* `1`–`9` and the window titles use), its kind, its card and where it stands,
|
|
786
|
+
* in its state's colour. Clipped to the width, the count of what did not fit
|
|
787
|
+
* at the end.
|
|
788
|
+
*
|
|
789
|
+
* @param hidden `[{ n, meta }]`
|
|
790
|
+
*/
|
|
791
|
+
export function tabsLine(hidden, width, ink) {
|
|
792
|
+
const byTone = { live: ink.danger, done: ink.success, stopped: ink.warn };
|
|
793
|
+
const parts = hidden.map(({ n, meta }) => {
|
|
794
|
+
const view = meta.view ?? {};
|
|
795
|
+
const type = TYPE_WORDS[view.profile ?? meta.profile] ?? 'session';
|
|
796
|
+
const state = meta.ended ? String(view.state ?? 'ended').toLowerCase() : meta.state ?? 'running';
|
|
797
|
+
const what = clip(String(view.entryRef ?? view.label ?? meta.label ?? ''), 24);
|
|
798
|
+
const text = `${n} ${type} · ${state}${what ? ` · ${what}` : ''}`;
|
|
799
|
+
return (byTone[toneFor(meta)] ?? ink.muted)(text);
|
|
800
|
+
});
|
|
801
|
+
// Room is kept for the "+N more" a line that does not hold them all ends in.
|
|
802
|
+
const reserve = 24;
|
|
803
|
+
let line = ink.muted(' also ▸ ');
|
|
804
|
+
for (let i = 0; i < parts.length; i++) {
|
|
805
|
+
const next = line + (i ? ink.muted(' ') : '') + parts[i];
|
|
806
|
+
const last = i === parts.length - 1;
|
|
807
|
+
if (!last && visibleWidth(next) > width - reserve && i > 0) {
|
|
808
|
+
line += ink.muted(` +${parts.length - i} more — tab reaches them`);
|
|
809
|
+
break;
|
|
810
|
+
}
|
|
811
|
+
line = next;
|
|
812
|
+
}
|
|
813
|
+
return padVisible(clip(line, width), width);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* A session named so a person can find its window: its number, its kind, its
|
|
818
|
+
* card (or label) and its project — "2 · plan · R3 Offline mode — saasng".
|
|
819
|
+
* What a question or a permission request in the prompt says it came from.
|
|
820
|
+
*/
|
|
821
|
+
export function sessionTag(n, meta) {
|
|
822
|
+
const view = meta?.view ?? {};
|
|
823
|
+
const profile = view.profile ?? meta?.profile ?? null;
|
|
824
|
+
const type = TYPE_WORDS[profile] ?? (profile ? String(profile).toLowerCase() : 'session');
|
|
825
|
+
// Cut, so the tag leaves room for what it is attached to — the question.
|
|
826
|
+
const what = clip(view.entryRef
|
|
827
|
+
? `${view.entryRef} ${view.entryTitle ?? ''}`.trim()
|
|
828
|
+
: (view.label ?? meta?.label ?? 'a session'), 32);
|
|
829
|
+
return `${n ? `${n} · ` : ''}${type} · ${what}${meta?.projectSlug ? ` — ${meta.projectSlug}` : ''}`;
|
|
830
|
+
}
|