@illuminis/comprism 0.1.4 → 0.1.5

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.
@@ -127,6 +127,13 @@ function takeTerminal() {
127
127
  completer: complete,
128
128
  });
129
129
  keyboard.bind(reader);
130
+ // Enter has already moved the cursor onto the first line of the live list,
131
+ // so clearing from here down removes it before anything else prints.
132
+ reader.on('line', () => {
133
+ if (popupShown)
134
+ process.stdout.write(`${CSI}J`);
135
+ popupShown = 0;
136
+ });
130
137
  reader.on('line', onLine);
131
138
  reader.on('SIGINT', () => interrupt());
132
139
  }
@@ -136,6 +143,7 @@ function takeTerminal() {
136
143
  * Anywhere else it offers nothing, so it never rewrites a sentence.
137
144
  */
138
145
  function complete(line, done) {
146
+ clearPopup();
139
147
  const slash = /^\/(\S*)$/.exec(line);
140
148
  if (!slash) {
141
149
  done(null, attach.completeAt(line, () => {
@@ -172,6 +180,103 @@ function complete(line, done) {
172
180
  edit.cursor = edit.line.length;
173
181
  reader.prompt(true);
174
182
  }
183
+ const CSI = `${String.fromCharCode(27)}[`;
184
+ const SAVE = `${String.fromCharCode(27)}7`;
185
+ const RESTORE = `${String.fromCharCode(27)}8`;
186
+ /** How many lines the live list under the prompt holds now, 0 when none. */
187
+ let popupShown = 0;
188
+ const POPUP_ROWS = 8;
189
+ /** What the list under the prompt holds, and which of it is marked (-1: none). */
190
+ let popupHits = [];
191
+ let popupAt = -1;
192
+ /**
193
+ * Up and Down move through the list under the prompt; Enter runs the marked
194
+ * command, Tab fills it in to add to. With nothing marked, or no list showing,
195
+ * every key does what it always did (Up and Down recall earlier requests).
196
+ */
197
+ keyboard.onMenuKey = (key) => {
198
+ if (!popupShown || !reader || popupHits.length === 0)
199
+ return false;
200
+ const edit = reader;
201
+ if (key === 'up' || key === 'down') {
202
+ popupAt = key === 'down' ? Math.min(popupHits.length - 1, popupAt + 1) : Math.max(-1, popupAt - 1);
203
+ updatePopup();
204
+ return true;
205
+ }
206
+ if (popupAt < 0)
207
+ return false;
208
+ const chosen = `/${popupHits[popupAt].name}`;
209
+ if (key === 'tab') {
210
+ edit.line = `${chosen} `;
211
+ edit.cursor = edit.line.length;
212
+ edit._refreshLine();
213
+ clearPopup();
214
+ return true;
215
+ }
216
+ // Enter: the marked command replaces what was typed, then Enter goes on.
217
+ edit.line = chosen;
218
+ edit.cursor = chosen.length;
219
+ popupAt = -1;
220
+ return false;
221
+ };
222
+ /** Takes the live list away, leaving the prompt and the cursor where they are. */
223
+ function clearPopup() {
224
+ if (!popupShown || !process.stdout.isTTY)
225
+ return;
226
+ // Each row the list used, blanked; never a clear to the end of the screen.
227
+ process.stdout.write(`${SAVE}${`${CSI}1E${CSI}2K`.repeat(popupShown)}${RESTORE}`);
228
+ popupShown = 0;
229
+ }
230
+ /**
231
+ * The commands and skills that match what is typed, shown under the prompt
232
+ * while it is typed (manual 6.4). The same list Tab and `help` offer, from the
233
+ * service, so a skill added on the server appears here with no new build.
234
+ * Drawn only while the line is `/` and a word; anything else clears it.
235
+ */
236
+ function updatePopup() {
237
+ if (!reader || !process.stdout.isTTY)
238
+ return;
239
+ const line = reader.line ?? '';
240
+ const slash = /^\/(\S*)$/.exec(line);
241
+ const typed = slash ? slash[1].toLowerCase() : '';
242
+ const hits = slash ? listedItems().filter((i) => i.name.startsWith(typed)) : [];
243
+ // A different set of matches starts with nothing marked, so Enter still
244
+ // sends what was typed until Down is pressed.
245
+ if (hits.map((h) => h.name).join() !== popupHits.map((h) => h.name).join())
246
+ popupAt = -1;
247
+ popupHits = hits;
248
+ // Shown for a whole word too, so the list stays in sight while it is chosen.
249
+ if (!hits.length) {
250
+ clearPopup();
251
+ return;
252
+ }
253
+ const first = Math.max(0, Math.min(popupAt - POPUP_ROWS + 1, hits.length - POPUP_ROWS));
254
+ const shown = hits.slice(first, first + POPUP_ROWS);
255
+ const width = Math.min(18, Math.max(...shown.map((i) => i.name.length)) + 1);
256
+ const room = Math.max(20, (process.stdout.columns || 100) - width - 8);
257
+ const rows = shown.map((i, k) => {
258
+ const from = i.source === 'session' ? '' : ` ${i.source}`;
259
+ const words = `${i.description}${from}`;
260
+ const name = `/${i.name}`.padEnd(width + 1);
261
+ const marked = first + k === popupAt;
262
+ return `${marked ? ui.c.blue('\u25B8 ') : ' '}${marked ? ui.c.bold(ui.c.blue(name)) : ui.c.bold(name)} `
263
+ + ui.c.dim(words.length > room ? `${words.slice(0, room - 3)}...` : words);
264
+ });
265
+ if (hits.length > shown.length)
266
+ rows.push(ui.c.dim(` ${hits.length - shown.length} more, Down to see them`));
267
+ const col = reader.getCursorPos().cols;
268
+ // A shorter list than last time blanks the rows it no longer uses, rather
269
+ // than clearing to the end of the screen: on the bottom row there is no line
270
+ // below to move to, and that clear wiped the list's own last row.
271
+ const height = rows.length;
272
+ while (rows.length < popupShown)
273
+ rows.push('');
274
+ // Room is made first, so a prompt on the bottom row scrolls up rather than
275
+ // the list running off the screen, and the cursor comes back to its column.
276
+ process.stdout.write(`${'\n'.repeat(rows.length)}${CSI}${rows.length}A${CSI}${col + 1}G${SAVE}`
277
+ + rows.map((r) => `${CSI}1E${CSI}2K${r}`).join('') + RESTORE);
278
+ popupShown = Math.max(height, 1);
279
+ }
175
280
  /** The service's list for this session, once fetched (manual 6.4). */
176
281
  let listed = null;
177
282
  /** The session words this tool runs. Sent to the service, which lists only
@@ -214,9 +319,10 @@ let interrupt = () => leaveSession();
214
319
  /** Words that are commands. Anything else typed is work. */
215
320
  const COMMANDS = new Set([
216
321
  'new', 'sessions', 'branch', 'remote', 'model', 'providers', 'keys', 'status',
217
- 'voice', 'help', 'exit', 'quit', 'clear', 'login', 'logout', 'signout',
322
+ 'voice', 'help', 'exit', 'quit', 'clear', 'login', 'logout', 'signout', 'history',
218
323
  'disconnect', 'cost', 'spend', 'attached', 'paste', 'plan', 'todo', 'mode',
219
324
  'queue', 'transcript', 'edit', 'context', 'compact', 'why', 'unpin', 'pin', 'effort',
325
+ 'optimize',
220
326
  ]);
221
327
  /** `processes` and `stop pN` (manual 5.13): what this session started, and
222
328
  * stopping one by its number. Works while a job runs, too. True if handled. */
@@ -308,9 +414,9 @@ async function withTerminal(run) {
308
414
  * request. Returns nothing when the workspace cannot be reached: a list is not
309
415
  * worth stopping a session over.
310
416
  */
311
- async function earlierSessions(run) {
417
+ async function earlierSessions(run, limit = 10) {
312
418
  const { sessions } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
313
- const reply = await sessions('list', { limit: 10 });
419
+ const reply = await sessions('list', { limit });
314
420
  if (!reply.ok || !reply.sessions?.sessions)
315
421
  return [];
316
422
  const rows = reply.sessions.sessions;
@@ -324,7 +430,7 @@ async function earlierSessions(run) {
324
430
  here.id = r.number;
325
431
  return;
326
432
  }
327
- found.push({ number: r.number, line: lines[i] ?? r.number });
433
+ found.push({ number: r.number, line: lines[i] ?? r.number, card: r.card });
328
434
  });
329
435
  return found;
330
436
  }
@@ -416,8 +522,100 @@ async function opening(how) {
416
522
  for (const line of reply.sessions.lines)
417
523
  out(line);
418
524
  out();
525
+ // What the session was about, before the next question goes in.
526
+ await showConversation(reply.sessions.session_id, false);
419
527
  return reopened(reply.sessions);
420
528
  }
529
+ /**
530
+ * A session's earlier questions and answers, read back (manual 8.4).
531
+ *
532
+ * On reopening, the last three exchanges, each answer cut to its first lines,
533
+ * so the screen says what the session was about without filling it. `history`
534
+ * shows every turn in full. Answers are drawn the way a new answer is: tables
535
+ * as tables, emphasis as emphasis.
536
+ */
537
+ async function showConversation(key, full) {
538
+ const { sessions } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
539
+ const { plain } = await Promise.resolve().then(() => __importStar(require('../agent/render')));
540
+ const reply = await sessions('read', { key });
541
+ let turns = (reply.sessions?.turns) ?? [];
542
+ // A service released before it sent the turns still hands over the whole
543
+ // session on export, every request and answer in full. Read from there, so
544
+ // the answers are drawn with their headings and tables rather than as the
545
+ // one line summaries an older service lists.
546
+ if (reply.ok && turns.length === 0)
547
+ turns = await exportedTurns(key);
548
+ if (!reply.ok || turns.length === 0) {
549
+ if (full) {
550
+ out(ui.c.dim(' Nothing earlier in this session was kept to show.'));
551
+ out();
552
+ }
553
+ return;
554
+ }
555
+ const exchanges = turns.filter((t) => t.role === 'you').length;
556
+ let shown = turns;
557
+ if (!full) {
558
+ // From the third question from the end, so what is shown is whole exchanges.
559
+ let asked = 0;
560
+ let from = turns.length;
561
+ for (let i = turns.length - 1; i >= 0; i--) {
562
+ if (turns[i].role === 'you' && ++asked === 3) {
563
+ from = i;
564
+ break;
565
+ }
566
+ if (i === 0)
567
+ from = 0;
568
+ }
569
+ shown = turns.slice(from);
570
+ }
571
+ out(ui.c.dim(full ? ` The whole session, ${exchanges} question(s):`
572
+ : ` Earlier in this session${exchanges > 3 ? `, the last 3 of ${exchanges} questions` : ''}:`));
573
+ out();
574
+ for (const t of shown) {
575
+ if (t.role === 'you') {
576
+ out(` ${ui.c.bold(ui.c.blue('you'))} ${ui.c.bold(t.text.trim())}`);
577
+ out();
578
+ continue;
579
+ }
580
+ // Drawn exactly as a new answer is, rail and all, never indented further.
581
+ const lines = plain(t.text.trim()).split('\n');
582
+ const keep = full ? lines : lines.slice(0, openingCut(lines));
583
+ for (const l of keep)
584
+ out(l);
585
+ if (keep.length < lines.length)
586
+ out(ui.c.dim(` ... ${lines.length - keep.length} more lines. Type /history to read it all.`));
587
+ out();
588
+ }
589
+ }
590
+ /**
591
+ * Where to cut an earlier answer on reopening: the last paragraph break in its
592
+ * first dozen lines, so a table or a list is never shown half drawn. An answer
593
+ * with no break that early is cut at eight lines, as before.
594
+ */
595
+ function openingCut(lines) {
596
+ if (lines.length <= 12)
597
+ return lines.length;
598
+ // A break is the rail on its own; a blank row inside a table has more rules.
599
+ const isBreak = (l) => /^│?$/.test(l.replace(/\x1b\[[0-9;]*m/g, '').trim());
600
+ for (let i = 12; i >= 3; i--)
601
+ if (isBreak(lines[i]))
602
+ return i;
603
+ return 8;
604
+ }
605
+ /** A session's questions and answers in full, from the service's export. */
606
+ async function exportedTurns(key) {
607
+ const { sessions } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
608
+ const reply = await sessions('export', { key, format: 'json' });
609
+ const doc = reply.sessions?.document;
610
+ const turns = [];
611
+ for (const job of doc?.work ?? []) {
612
+ if (job.request)
613
+ turns.push({ role: 'you', text: job.request });
614
+ if (job.answer)
615
+ turns.push({ role: 'agent', text: job.answer });
616
+ }
617
+ return turns;
618
+ }
421
619
  /** The command and skill list, as the service sent it (manual 6.4). */
422
620
  function printHelp() {
423
621
  out();
@@ -457,14 +655,18 @@ async function sessionFolder() {
457
655
  return currentWorkspace().path;
458
656
  }
459
657
  /** The line this session begins with, as the service wrote it (manual 2.2). */
460
- async function showProjectLine() {
658
+ async function projectLine() {
461
659
  const { resolveProject, asWire } = await Promise.resolve().then(() => __importStar(require('../lib/project')));
462
660
  const { ask } = await Promise.resolve().then(() => __importStar(require('../lib/gateway')));
463
661
  const facts = resolveProject(await sessionFolder(), place.added);
464
662
  const reply = await ask({ intent: 'project', prompt: '', project: asWire(facts, 'approve_writes') });
465
663
  // Drawn as sent. When the service cannot answer, the folder alone, so the
466
664
  // person still knows where they are.
467
- out(ui.c.bold(reply.ok && reply.project_line ? reply.project_line : ` ${facts.root}`));
665
+ return reply.ok && reply.project_line ? reply.project_line : ` ${facts.root}`;
666
+ }
667
+ /** `asked` is the same line already on its way, when startup asked early. */
668
+ async function showProjectLine(asked) {
669
+ out(ui.c.bold(await (asked ?? projectLine())));
468
670
  out();
469
671
  }
470
672
  /** Ctrl+O's choice, kept for the next job too, and notes left over from the
@@ -758,10 +960,48 @@ async function runRepl(how = {}) {
758
960
  const finished = new Promise((resolve) => { done = resolve; });
759
961
  const conn = (0, connection_1.readConnection)();
760
962
  if (!conn?.url || !conn.workspaceKey) {
761
- await (0, welcome_1.showWelcome)();
963
+ await (0, welcome_1.showWelcome)(undefined, process.stdin.isTTY === true && !how.noSignIn);
964
+ // Signed out: sign in right here rather than naming another command to
965
+ // type. The last person is offered, so it is usually Enter and a password.
966
+ if (process.stdin.isTTY === true && !how.noSignIn) {
967
+ const { cmdLogin } = await Promise.resolve().then(() => __importStar(require('./login')));
968
+ await cmdLogin(['--into-session']);
969
+ const { closePrompt } = await Promise.resolve().then(() => __importStar(require('../lib/prompt')));
970
+ closePrompt();
971
+ if ((0, connection_1.readConnection)()?.workspaceKey)
972
+ return runRepl({ ...how, noSignIn: true, noMark: true });
973
+ }
762
974
  return;
763
975
  }
764
- await (0, welcome_1.showWelcome)();
976
+ // Everything the opening screen needs is asked for AT ONCE, then drawn in
977
+ // order as it arrives. Asked one after another it was five round trips end
978
+ // to end, about fifteen seconds on a laptop before the prompt appeared.
979
+ //
980
+ // And what the service said LAST time is drawn at once, so the prompt is
981
+ // ready in well under a second. The same questions are still asked; their
982
+ // answers are kept for next time, and a change that matters is said as soon
983
+ // as it arrives. A first start, or another workspace, waits as before.
984
+ const { readStart, saveStart } = await Promise.resolve().then(() => __importStar(require('../lib/startcache')));
985
+ const { session: whoAmI } = await Promise.resolve().then(() => __importStar(require('./welcome')));
986
+ const known = readStart(conn.url);
987
+ const here = await sessionFolder();
988
+ const meAsked = whoAmI().then((me) => { if (me)
989
+ saveStart(conn.url, { me }); return me; });
990
+ const stateAsked = (0, keys_1.keyState)().then((s) => {
991
+ if (s.reached)
992
+ saveStart(conn.url, { hasKey: s.hasKey });
993
+ return s;
994
+ });
995
+ const lineAsked = projectLine()
996
+ .then((l) => { saveStart(conn.url, { lines: { [here]: l } }); return l; })
997
+ .catch(() => '');
998
+ const listAsked = refreshList()
999
+ .then(() => { if (listed?.ok)
1000
+ saveStart(conn.url, { list: listed }); })
1001
+ .catch(() => undefined);
1002
+ if (known.list?.ok && !listed)
1003
+ listed = known.list;
1004
+ await (0, welcome_1.showWelcome)(known.me ? known.me : await meAsked, false, !how.noMark);
765
1005
  // A key, before anything else. With none there is nothing to bill and nothing
766
1006
  // to measure, so a prompt accepted here could only ever be refused later.
767
1007
  //
@@ -769,7 +1009,18 @@ async function runRepl(how = {}) {
769
1009
  // could not ask your account" is the whole point: on 17 September a person
770
1010
  // whose workspace held four working keys was shown the first message and sent
771
1011
  // to a form, because an unanswered question was read as an answer of none.
772
- const state = await (0, keys_1.keyState)();
1012
+ const state = known.hasKey === true ? { reached: true, hasKey: true } : await stateAsked;
1013
+ if (known.hasKey === true) {
1014
+ // Drawn from the last answer; the fresh one says so if the key has gone.
1015
+ void stateAsked.then((fresh) => {
1016
+ if (fresh.reached && !fresh.hasKey) {
1017
+ out();
1018
+ out(ui.c.amber(' Your account no longer has a provider key stored, so requests will be refused.'));
1019
+ out(` ${ui.c.dim('run')} ${ui.c.bold('keys')} ${ui.c.dim('to add one')}`);
1020
+ reader?.prompt(true);
1021
+ }
1022
+ });
1023
+ }
773
1024
  if (!state.reached) {
774
1025
  out(ui.fail(`Your workspace could not be reached, so ${ui.c.bold('nothing is known')} about your keys.`));
775
1026
  out(` ${ui.c.dim(state.why)}`);
@@ -821,7 +1072,8 @@ async function runRepl(how = {}) {
821
1072
  session = found;
822
1073
  }
823
1074
  else {
824
- await numbered(session);
1075
+ // The session's number arrives while the person is already typing.
1076
+ void numbered(session);
825
1077
  }
826
1078
  history.push(session);
827
1079
  // Ctrl-C leaves, and says what the session cost on the way out. A product
@@ -909,6 +1161,52 @@ async function runRepl(how = {}) {
909
1161
  * the person saw their sentence twice and could edit only the second copy.
910
1162
  */
911
1163
  const speak = async () => {
1164
+ // On a Mac, the words appear as they are spoken, the way the web app shows
1165
+ // them, from the Mac's own recogniser. Anywhere it cannot listen (no
1166
+ // helper, or permission not given) this falls through to recording and
1167
+ // having the server write it down when Enter is pressed.
1168
+ if (process.stdout.isTTY) {
1169
+ const live = voice.listenLive();
1170
+ if (live) {
1171
+ await new Promise((r) => setTimeout(r, 400));
1172
+ if (live.problem() && !live.words()) {
1173
+ await live.stop();
1174
+ out(ui.c.dim(` ${live.problem()}`));
1175
+ }
1176
+ else {
1177
+ out();
1178
+ out(ui.c.green(' listening...') + ui.c.dim(' say it, then press enter'));
1179
+ const draw = () => {
1180
+ const bars = Math.round(live.level() * 12);
1181
+ const meter = ui.c.green('█'.repeat(bars)) + ui.c.dim('░'.repeat(12 - bars));
1182
+ const room = Math.max(20, (process.stdout.columns || 100) - 20);
1183
+ const heard = live.words();
1184
+ const shown = heard.length > room ? `...${heard.slice(-(room - 3))}` : heard;
1185
+ process.stdout.write(`\r${CSI}2K ${meter} ${shown}`);
1186
+ };
1187
+ const ticking = setInterval(draw, 80);
1188
+ await nextLine();
1189
+ clearInterval(ticking);
1190
+ process.stdout.write(`${CSI}1A\r${CSI}2K`);
1191
+ const said = await live.stop();
1192
+ if (said === null) {
1193
+ out(ui.c.dim(` ${live.problem() || 'dictation stopped'}`));
1194
+ out();
1195
+ return prompt();
1196
+ }
1197
+ if (!said) {
1198
+ out(ui.c.dim(' no words in that one'));
1199
+ out();
1200
+ return prompt();
1201
+ }
1202
+ // Into the prompt, not sent: the person reads it, fixes what was
1203
+ // misheard, and presses Enter. Nothing is asked or charged until then.
1204
+ prompt();
1205
+ reader?.write(said);
1206
+ return;
1207
+ }
1208
+ }
1209
+ }
912
1210
  const short = voice.missing();
913
1211
  if (short) {
914
1212
  out();
@@ -927,7 +1225,45 @@ async function runRepl(how = {}) {
927
1225
  }
928
1226
  out();
929
1227
  out(ui.c.green(' listening...') + ui.c.dim(' say it, then press enter'));
1228
+ // What it hears, while it hears it: a meter that moves with the voice at
1229
+ // once, and the words so far, refreshed every few seconds. Without these
1230
+ // a person talks into a blank line with no idea whether anything is
1231
+ // arriving. The words so far are a preview; the whole recording is
1232
+ // written down again when they press Enter, and that is what they get.
1233
+ const tty = process.stdout.isTTY === true;
1234
+ let heard = '';
1235
+ let asking = false;
1236
+ let lastAsked = 0;
1237
+ const draw = () => {
1238
+ const bars = Math.round(voice.level(live.file) * 12);
1239
+ const meter = ui.c.green('█'.repeat(bars)) + ui.c.dim('░'.repeat(12 - bars));
1240
+ const room = Math.max(20, (process.stdout.columns || 100) - 20);
1241
+ const words = heard.length > room ? `...${heard.slice(-(room - 3))}` : heard;
1242
+ process.stdout.write(`\r${CSI}2K ${meter} ${words}`);
1243
+ };
1244
+ const preview = async () => {
1245
+ if (asking || Date.now() - lastAsked < 2500)
1246
+ return;
1247
+ const part = voice.soFar(live.file);
1248
+ if (!part)
1249
+ return;
1250
+ asking = true;
1251
+ lastAsked = Date.now();
1252
+ try {
1253
+ const got = await voice.transcribe(conn.url, conn.workspaceKey, part);
1254
+ if (got.text)
1255
+ heard = got.text.trim();
1256
+ }
1257
+ catch { /* a preview that fails leaves the last one showing */ }
1258
+ asking = false;
1259
+ };
1260
+ const ticking = tty ? setInterval(() => { draw(); void preview(); }, 120) : null;
930
1261
  await nextLine();
1262
+ if (ticking)
1263
+ clearInterval(ticking);
1264
+ // The line the typed Enter left behind is the meter's; clear it.
1265
+ if (tty)
1266
+ process.stdout.write(`${CSI}1A\r${CSI}2K`);
931
1267
  const { file, bytes } = await live.stop();
932
1268
  try {
933
1269
  if (bytes < 2000) {
@@ -1168,17 +1504,20 @@ async function runRepl(how = {}) {
1168
1504
  reader.setPrompt(`${ui.c.bold(ui.c.blue('comprism'))} ${ui.c.dim('>')} `);
1169
1505
  reader.prompt();
1170
1506
  };
1171
- await showProjectLine();
1507
+ const lastLine = known.lines?.[here];
1508
+ await showProjectLine(lastLine ? Promise.resolve(lastLine) : lineAsked);
1172
1509
  // The command and skill list, and whether this company lets typed requests
1173
1510
  // be kept on this machine (manual 6.4, 6.5). Unknown keeps them for this
1174
- // session only and leaves any file alone.
1175
- await refreshList();
1511
+ // session only and leaves any file alone. The last list serves until the
1512
+ // fresh one lands.
1513
+ if (!known.list?.ok)
1514
+ await listAsked;
1176
1515
  // `config set history off` keeps nothing at all, not even for this session.
1177
1516
  const historyOff = (0, machine_1.machineSettings)().history === 'off';
1178
1517
  earlier.start(historyOff ? false : listed?.ok ? listed.keepHistory : null);
1179
1518
  if (historyOff)
1180
1519
  earlier.disable();
1181
- out(ui.c.dim(' Type what you want done, or help. Ctrl+C to leave.'));
1520
+ out(ui.c.dim(' Type what you want done. / lists commands and skills, voice dictates, Ctrl+C leaves.'));
1182
1521
  out();
1183
1522
  // One line at a time, in the order they arrived.
1184
1523
  //
@@ -1290,6 +1629,13 @@ async function runRepl(how = {}) {
1290
1629
  // Shift+Tab moves to the next permission mode (manual 4.5). Only at the
1291
1630
  // prompt: during a job the change would apply from the next job anyway, and
1292
1631
  // redrawing over a running job's output is unreadable.
1632
+ // The live list of commands and skills, redrawn after each key at the prompt,
1633
+ // once the reader has applied the key to the line.
1634
+ keys().on('keypress', (_s, key) => {
1635
+ if (working || answering || leaving || key?.name === 'return' || key?.name === 'tab')
1636
+ return;
1637
+ setImmediate(updatePopup);
1638
+ });
1293
1639
  keys().on('keypress', (_s, key) => {
1294
1640
  if (!key || key.name !== 'tab' || !key.shift || working || answering || leaving)
1295
1641
  return;
@@ -1367,6 +1713,27 @@ async function runRepl(how = {}) {
1367
1713
  out();
1368
1714
  return prompt();
1369
1715
  }
1716
+ // `optimize on` and `optimize off <model>` (manual 4.17): the same switch
1717
+ // as beside the model in the browser and VS Code. Off is the session pin.
1718
+ const optimizeTo = /^optimize\s+(on|off)(?:\s+(\S+))?$/i.exec(word);
1719
+ if (optimizeTo) {
1720
+ const on = optimizeTo[1].toLowerCase() === 'on';
1721
+ const hold = optimizeTo[2] ?? steering_.pin;
1722
+ if (on) {
1723
+ if (await steer({ pin: '' }))
1724
+ out(ui.c.dim(' Optimize is on: CompletionPrism picks the model for each step'));
1725
+ }
1726
+ else if (!hold) {
1727
+ out(ui.fail('Name the model to keep, such as `optimize off sonnet`.'));
1728
+ }
1729
+ else {
1730
+ const said = await steer({ pin: hold });
1731
+ if (said)
1732
+ out(ui.c.dim(` Optimize is off: ${ui.c.bold(said.pin_label ?? hold)} answers every request. Work is not optimized. \`optimize on\` hands the choice back.`));
1733
+ }
1734
+ out();
1735
+ return prompt();
1736
+ }
1370
1737
  const effortTo = /^effort\s+(\S+)$/.exec(word);
1371
1738
  if (effortTo) {
1372
1739
  const level = effortTo[1];
@@ -1544,6 +1911,14 @@ async function runRepl(how = {}) {
1544
1911
  out();
1545
1912
  break;
1546
1913
  }
1914
+ case 'optimize': {
1915
+ const said = await steer({});
1916
+ out(said?.pin
1917
+ ? ` ${ui.c.dim('Optimize is off:')} ${ui.c.bold(said.pin_label ?? said.pin)} ${ui.c.dim('answers every request. `optimize on` turns it back on.')}`
1918
+ : ui.c.dim(' Optimize is on: CompletionPrism picks the model for each step. `optimize off <model>` keeps one.'));
1919
+ out();
1920
+ break;
1921
+ }
1547
1922
  case 'unpin':
1548
1923
  if (await steer({ pin: '' }))
1549
1924
  out(ui.c.dim(' unpinned: the service chooses the model for each step again'));
@@ -1649,6 +2024,46 @@ async function runRepl(how = {}) {
1649
2024
  }
1650
2025
  case 'sessions': {
1651
2026
  out();
2027
+ // What this account did before this window was opened, from the
2028
+ // service's record of sessions (manual 8.5). Kept numbered
2029
+ // separately so a number never means two different things.
2030
+ // Every session the service keeps a record of for this person, up to
2031
+ // its limit, when they can be moved through and picked by number; a
2032
+ // typed list stays short enough to read.
2033
+ const onTerminal = process.stdin.isTTY === true && process.stdout.isTTY === true;
2034
+ recent = await earlierSessions(history, onTerminal ? 200 : 10);
2035
+ // On a terminal, the list is picked from with the arrow keys; the
2036
+ // typed forms below still work everywhere, and in a pipe are the
2037
+ // only way.
2038
+ if (onTerminal && history.length + recent.length > 1) {
2039
+ // Names, dates and cost only. The ids are for typing a session
2040
+ // back, and nobody types in a list they move through with arrows.
2041
+ const noId = (line) => line.trimStart().replace(/^(?:srv|s)_[0-9a-f]+\s+/i, '');
2042
+ const rows = [
2043
+ ...history.map((s) => [
2044
+ `${s.title ?? 'this session'}${s === session ? ' (now)' : ''}`,
2045
+ `this window \u00b7 ${s.jobs.length} job(s) \u00b7 ${money(s.spentUsd)}`,
2046
+ ]),
2047
+ // The service's two lines where it sent them; its one line otherwise.
2048
+ ...recent.map((r) => (r.card?.length ? r.card : [noId(r.line)])),
2049
+ ];
2050
+ const { pick } = await Promise.resolve().then(() => __importStar(require('../lib/picker')));
2051
+ let choice = null;
2052
+ out(ui.c.dim(' choose a session:'));
2053
+ await withTerminal(async () => {
2054
+ choice = await pick(rows, 'Up and Down to choose, or type its number, Enter to open, Esc to stay here', history.indexOf(session), true);
2055
+ });
2056
+ out();
2057
+ if (choice === null)
2058
+ return prompt();
2059
+ const n = choice;
2060
+ if (n < history.length) {
2061
+ if (history[n] === session)
2062
+ return prompt();
2063
+ return handle(`sessions ${n + 1}`);
2064
+ }
2065
+ return handle(`sessions w${n - history.length + 1}`);
2066
+ }
1652
2067
  history.forEach((s, i) => {
1653
2068
  const here = s === session ? ui.c.green(' now') : ' ';
1654
2069
  // The NAME where our server has made one, and the id beside it
@@ -1658,10 +2073,6 @@ async function runRepl(how = {}) {
1658
2073
  out(`${here} ${String(i + 1).padStart(2)}. ${ui.c.bold(s.id)}`
1659
2074
  + ` ${s.jobs.length} job(s) ${money(s.spentUsd)}${named}`);
1660
2075
  });
1661
- // And what this account did before this window was opened, from
1662
- // the service's record of sessions (manual 8.5). Kept numbered
1663
- // separately so a number never means two different things.
1664
- recent = await earlierSessions(history);
1665
2076
  if (recent.length > 0) {
1666
2077
  out();
1667
2078
  out(ui.c.dim(' earlier work on this account:'));
@@ -1718,6 +2129,33 @@ async function runRepl(how = {}) {
1718
2129
  // Signing out ends the session, because everything after it would be
1719
2130
  // refused anyway. It used to answer "not available inside a session",
1720
2131
  // which left quitting and running a second command as the only way.
2132
+ case 'history':
2133
+ // Every earlier question and answer in the session being stood in.
2134
+ out();
2135
+ await showConversation(session.serverId, true);
2136
+ break;
2137
+ case 'login': {
2138
+ // Signing in again, or as somebody else, from inside a session. The
2139
+ // session was opened with the old sign in, so it ends and the next
2140
+ // `comprism` opens with the new one.
2141
+ const { cmdLogin } = await Promise.resolve().then(() => __importStar(require('./login')));
2142
+ const before = (0, connection_1.readConnection)()?.token;
2143
+ await withTerminal(async () => {
2144
+ // The Enter that sent `/login` is still in the terminal's input.
2145
+ // Given a moment it goes to the session, not to the email question.
2146
+ await new Promise((r) => setTimeout(r, 150));
2147
+ // Anything already typed but not yet read is thrown away, so the
2148
+ // first question waits for a real answer.
2149
+ while (process.stdin.read() !== null) { /* discard */ }
2150
+ await cmdLogin([]);
2151
+ });
2152
+ // Canceled or refused: nothing changed, so the session carries on.
2153
+ if ((0, connection_1.readConnection)()?.token === before)
2154
+ break;
2155
+ out(ui.c.dim(` type ${ui.c.bold('comprism')} to start a session with this sign in`));
2156
+ out();
2157
+ return leave();
2158
+ }
1721
2159
  case 'logout':
1722
2160
  case 'signout':
1723
2161
  case 'disconnect': {
@@ -1 +1,25 @@
1
- export declare function showWelcome(): Promise<void>;
1
+ import type { SessionOut } from '../lib/gateway';
2
+ /**
3
+ * Who this machine is signed in as, and everything else the header prints.
4
+ *
5
+ * ── One call, to the one service ──────────────────────────────────────────
6
+ *
7
+ * This used to call the platform's own `whoami` for the identity, and the
8
+ * header then had no way at all to say how many vendor keys the account held.
9
+ * Two questions, two addresses, and the header could show a person facts that
10
+ * disagreed with each other. On 17 September 2026 it did exactly that: "User
11
+ * unknown" beside a company name, and a session that then announced no provider
12
+ * key was stored while four were stored and active.
13
+ *
14
+ * Every line below now comes from one request to the single service, so the
15
+ * header either shows a consistent picture or says plainly that it could not
16
+ * reach the workspace. There is no third state in which half of it is true.
17
+ *
18
+ * Null rather than an error when it cannot be reached. A header is the wrong
19
+ * place to fail: somebody on a plane still gets the local facts, and the lines
20
+ * needing the server say so.
21
+ */
22
+ export declare function session(): Promise<SessionOut | null>;
23
+ /** `known` is who this machine is, when the caller already has it (the last
24
+ * answer, drawn at once while a fresh one is asked for). */
25
+ export declare function showWelcome(known?: SessionOut | null, signingIn?: boolean, withMark?: boolean): Promise<void>;