jaitus 0.3.23 → 0.3.25

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/src/tui-ink.js CHANGED
@@ -36,6 +36,7 @@ import { readClipboardText, readClipboardImage, shrinkImageFile } from './tui/cl
36
36
  import { onSettingsChange } from './config.js';
37
37
  import { MASCOT_ROWS, startupTip } from './brand.js';
38
38
  import { badgeText, badgeTop, turnShareText } from './planView.js';
39
+ import { createAdSlots, adLineParts } from './adSlots.js';
39
40
 
40
41
  const h = React.createElement;
41
42
  const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
@@ -646,6 +647,23 @@ function WorkBar({ state, width }) {
646
647
 
647
648
  const truncatePlain = (text, width) => (stringWidth(text) <= width ? text : `${String(text).slice(0, Math.max(1, width - 1))}…`);
648
649
 
650
+ /**
651
+ * The sponsored line, directly above the prompt. Always labelled, always in the quiet
652
+ * chrome colours, never in the transcript: an ad that could be mistaken for the
653
+ * assistant, an error or a notice is a disguised advertisement, which is banned (CCPA
654
+ * Dark Patterns Guidelines, 2023). The domain is underlined and clickable.
655
+ */
656
+ function AdLine({ ad, width }) {
657
+ const p = palette();
658
+ const parts = adLineParts(ad, width - 2);
659
+ return h(Box, { flexDirection: 'row', paddingX: 1, width },
660
+ h(Text, { color: p.faint, backgroundColor: p.raised }, ` ${parts.label} `),
661
+ h(Text, { color: p.muted, wrap: 'truncate-end' }, parts.text ? ` ${parts.text}` : ''),
662
+ h(Text, { color: p.faint }, ` ${g('·', '-')} `),
663
+ h(Text, { color: p.faint, underline: true }, parts.domain),
664
+ );
665
+ }
666
+
649
667
  function Menu({ state, width }) {
650
668
  const p = palette();
651
669
  if (!state.menu.length) return null;
@@ -940,16 +958,38 @@ function StatusBar({ state, width }) {
940
958
  * in order. Guessing it — which is what a constant like `6 + options` was — puts the
941
959
  * overlay a few rows off centre and the error grows with the content.
942
960
  */
943
- function dialogHeight(questionRows, previewRows, optionCount) {
961
+ function dialogHeight(questionRows, previewRows, optionCount, detailRows = 0) {
944
962
  return 2 // border top + bottom
945
963
  + 2 // paddingY
946
964
  + 1 // title
947
965
  + 1 + questionRows
948
966
  + (previewRows ? 1 + previewRows : 0)
949
967
  + 1 + optionCount
968
+ + (detailRows ? 1 + detailRows : 0)
950
969
  + 1 + 1; // gap + hint
951
970
  }
952
971
 
972
+ /**
973
+ * The description area under a question's options: the highlighted option's description,
974
+ * wrapped. Its height is the tallest description's (capped), so the box does not jump as
975
+ * the highlight moves.
976
+ */
977
+ function typedTail(text, room) {
978
+ const chars = [...String(text || '')];
979
+ const fit = Math.max(6, room);
980
+ return chars.length <= fit ? chars.join('') : `${g('…', '~')}${chars.slice(-(fit - 1)).join('')}`;
981
+ }
982
+
983
+ function detailLayout(dialog, inner) {
984
+ const described = dialog.options.filter((o) => o.description);
985
+ if (!described.length) return { rows: 0, lines: [] };
986
+ const rowsFor = (o) => (o.description ? wrapRow([{ t: o.description }], inner).length : 0);
987
+ const rows = Math.min(4, Math.max(...dialog.options.map(rowsFor)));
988
+ const current = dialog.options[dialog.index];
989
+ const lines = current?.description ? wrapRow([{ t: current.description }], inner).slice(0, rows) : [];
990
+ return { rows, lines };
991
+ }
992
+
953
993
  function Dialog({ state, width, height, hits }) {
954
994
  const p = palette();
955
995
  const dialog = state.dialog;
@@ -971,11 +1011,20 @@ function Dialog({ state, width, height, hits }) {
971
1011
  }
972
1012
  // More options than fit: a window that follows the selection (a picker of 20 sessions
973
1013
  // or models no longer has to be cut to the 9 that have a number key).
974
- const roomForOptions = Math.max(3, height - questionRows - previewRows.length - 12);
1014
+ const detail = detailLayout(dialog, boxWidth - 6);
1015
+ const roomForOptions = Math.max(3, height - questionRows - previewRows.length - (detail.rows ? detail.rows + 1 : 0) - 12);
975
1016
  const optionCount = Math.min(dialog.options.length, roomForOptions);
976
1017
  const firstOption = Math.max(0, Math.min(dialog.index - Math.floor(optionCount / 2), dialog.options.length - optionCount));
977
1018
  const visibleOptions = dialog.options.slice(firstOption, firstOption + optionCount);
978
- const rows = dialogHeight(questionRows, previewRows.length, optionCount);
1019
+ const rows = dialogHeight(questionRows, previewRows.length, optionCount, detail.rows);
1020
+ const box = (option) => (dialog.multi && !option.text ? `${dialog.checked?.has(option.value) ? g('☑', '[x]') : g('☐', '[ ]')} ` : '');
1021
+ const hint = dialog.editing
1022
+ ? `enter send · esc back to the options`
1023
+ : dialog.multi
1024
+ ? `${g('↑↓', 'up/dn')} choose · space select · enter confirm · esc close`
1025
+ : `${g('↑↓', 'up/dn')} choose · 1-9 jump · enter confirm · esc ${dialog.kind === 'question' ? 'close' : dialog.hideComment ? 'cancel' : 'deny'}`;
1026
+ // Beside the text field only while it leaves room to type; a narrow box keeps it all.
1027
+ const editHint = boxWidth - 6 - 3 - hint.length - 1 >= 20;
979
1028
  const accent = dialog.tone === 'danger' ? p.red : p.brand;
980
1029
 
981
1030
  const top = Math.max(0, Math.round((height - rows) / 2));
@@ -1024,11 +1073,23 @@ function Dialog({ state, width, height, hits }) {
1024
1073
  : (i === visibleOptions.length - 1 && firstOption + optionCount < dialog.options.length) ? ` ${g('↓', 'v')}${dialog.options.length - firstOption - optionCount} more` : '';
1025
1074
  return h(Box, { key: `${option.value}-${index}`, backgroundColor: selected ? p.raised : undefined, flexDirection: 'row' },
1026
1075
  h(Text, { color: selected ? accent : p.muted, bold: selected, wrap: 'truncate-end' },
1027
- `${selected ? g(' ▸ ', ' > ') : ' '}${index < 9 ? `${index + 1}.` : ' '} ${plain(option.label)}`),
1076
+ `${selected ? g(' ▸ ', ' > ') : ' '}${index < 9 ? `${index + 1}.` : ' '} ${box(option)}${plain(option.label)}`),
1028
1077
  more ? h(Text, { color: p.faint }, more) : null);
1029
1078
  })),
1030
- h(Box, { marginTop: 1 },
1031
- h(Text, { color: p.faint }, `${g('↑↓', 'up/dn')} choose · 1-9 jump · enter confirm · esc ${dialog.hideComment ? 'cancel' : 'deny'}`)),
1079
+ detail.rows
1080
+ ? h(Box, { marginTop: 1, flexDirection: 'column', height: detail.rows },
1081
+ detail.lines.map((row, index) => h(Row, { key: index, spans: row.map((s) => ({ ...s, color: p.muted })) })))
1082
+ : null,
1083
+ dialog.editing
1084
+ // One row whatever the length: the END of what is being typed is what matters,
1085
+ // so a long answer scrolls left instead of growing the box.
1086
+ ? h(Box, { marginTop: 1, flexDirection: 'row' },
1087
+ h(Text, { color: accent, bold: true }, `${g('›', '>')} `),
1088
+ h(Text, { color: p.text }, `${typedTail(dialog.text, boxWidth - 6 - 3 - (editHint ? hint.length + 1 : 0))}${g('▏', '_')}`),
1089
+ h(Spacer),
1090
+ editHint ? h(Text, { color: p.faint }, ` ${hint}`) : null)
1091
+ : h(Box, { marginTop: 1 },
1092
+ h(Text, { color: p.faint }, hint)),
1032
1093
  ),
1033
1094
  ),
1034
1095
  );
@@ -1077,7 +1138,7 @@ function meterRows(sections, inner, p) {
1077
1138
  }
1078
1139
  rows.push([
1079
1140
  { t: String(s.label || '').padEnd(labelW), color: p.text },
1080
- { t: String(s.value ?? ''), color: s.tone === 'good' ? p.green : s.tone === 'warn' ? p.yellow : s.tone === 'bad' ? p.red : p.muted },
1141
+ { t: String(s.value ?? ''), color: s.tone === 'good' ? p.green : s.tone === 'warn' ? p.yellow : s.tone === 'bad' ? p.red : s.tone === 'faint' ? p.faint : p.muted },
1081
1142
  ]);
1082
1143
  }
1083
1144
  return rows;
@@ -1238,11 +1299,16 @@ function App({ controller }) {
1238
1299
  + (state.queued.length ? 2 : 0)
1239
1300
  + (liveAttachments(state).length ? 2 : 0);
1240
1301
  const workRows = state.busy || state.status ? 1 : 0;
1302
+ // The sponsored line: only for the work bar / waiting slots, only with room for it, and
1303
+ // never while a dialog or panel covers the screen (it is then not counted as seen).
1304
+ const adSlot = state.adLine && state.adLine.placement !== 'usage' ? state.adLine : null;
1305
+ const adRows = adSlot && !state.dialog && !state.panel && width >= 50 && height >= 16 ? 1 : 0;
1306
+ state._adDrawn = Boolean(adRows);
1241
1307
  // A single pinned line showing what was last asked, while the transcript is scrolled
1242
1308
  // away from it. Scrolling back through a long answer and losing track of the question
1243
1309
  // it was answering is the specific thing this fixes.
1244
1310
  const stickyRows = state.viewingTranscript ? 1 : 0;
1245
- const transcriptHeight = Math.max(3, height - CHROME.header - CHROME.status - composerRows - workRows - CHROME.gapAboveComposer - stickyRows);
1311
+ const transcriptHeight = Math.max(3, height - CHROME.header - CHROME.status - composerRows - workRows - adRows - CHROME.gapAboveComposer - stickyRows);
1246
1312
  state._transcriptHeight = transcriptHeight;
1247
1313
  state._width = width;
1248
1314
 
@@ -1280,6 +1346,11 @@ function App({ controller }) {
1280
1346
  state._composer = { textTop, textLeft: 6, starts: geo.starts, lens: geo.lens };
1281
1347
  hits.add({ x1: 2, x2: width - 1, y1: textTop, y2: textTop + geo.lines.length - 1, action: { type: 'composer' } });
1282
1348
  }
1349
+ if (adRows) {
1350
+ // The row right above the composer's top border; the domain opens the ad's link.
1351
+ const y = height - CHROME.status - composerRows;
1352
+ hits.add({ x1: 1, x2: width, y1: y, y2: y, action: { type: 'adLink', url: adSlot.ad.url, domain: adSlot.ad.domain } });
1353
+ }
1283
1354
  if (state.menu.length) {
1284
1355
  const menuTop = height - CHROME.status - composerRows + 1; // inside the composer border
1285
1356
  state.menu.forEach((_item, index) => {
@@ -1298,6 +1369,7 @@ function App({ controller }) {
1298
1369
  : h(Transcript, { state, width, height: transcriptHeight, layout }),
1299
1370
  h(Box, { height: CHROME.gapAboveComposer, flexShrink: 0 }),
1300
1371
  fixed(h(WorkBar, { state, width })),
1372
+ adRows ? fixed(h(AdLine, { ad: adSlot.ad, width })) : null,
1301
1373
  h(Box, { paddingX: 1, flexShrink: 0 }, h(Composer, { state, width: width - 2 })),
1302
1374
  fixed(h(StatusBar, { state, width })),
1303
1375
  h(Panel, { state, width, height, hits }),
@@ -1359,7 +1431,7 @@ function registerHeaderHits(state, width, hits) {
1359
1431
 
1360
1432
  // ─── Controller ─────────────────────────────────────────────────────────────────
1361
1433
 
1362
- export function createTui({ slashCommands = [], fileProvider = null, meta = {}, onModeCycle = null, settings = null } = {}) {
1434
+ export function createTui({ slashCommands = [], fileProvider = null, meta = {}, onModeCycle = null, settings = null, ads = null } = {}) {
1363
1435
  publishSettings(settings);
1364
1436
  // Settings changed during the session apply now, not at the next start.
1365
1437
  onSettingsChange((next) => {
@@ -1399,8 +1471,28 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
1399
1471
  plan: null,
1400
1472
  spent: { turns: 0, input: 0, cached: 0, output: 0, credits: 0 },
1401
1473
  meta: { model: 'loading…', mode: 'default', ctx: '0 tok', ctxUsed: 0, ctxMax: 1, intent: 'build', ...meta },
1474
+ // The sponsored line now on screen (adSlots), and whether the last frame drew it.
1475
+ adLine: null,
1476
+ _adDrawn: false,
1402
1477
  };
1403
1478
 
1479
+ // Sponsored lines (adSlots.js). A slot counts as visible only while the last frame drew
1480
+ // it — above the prompt, or inside the /usage panel — with nothing covering it.
1481
+ const adSlots = ads ? createAdSlots({
1482
+ source: ads,
1483
+ isVisible: (slot) => state.active && (slot.placement === 'usage'
1484
+ ? Boolean(state.panel?.adSlot && !state.dialog)
1485
+ : state._adDrawn),
1486
+ onChange: () => {
1487
+ const slot = adSlots?.current() || null;
1488
+ state.adLine = slot;
1489
+ if (slot?.placement === 'usage' && state.panel?.adSlot) {
1490
+ state.panel.meters = [...state.panel.adBase, { gap: true }, { label: 'Sponsored', value: `${slot.ad.text} · ${slot.ad.domain}`, tone: 'faint' }];
1491
+ }
1492
+ notify();
1493
+ },
1494
+ }) : null;
1495
+
1404
1496
  let inkInstance = null;
1405
1497
  let update = () => {};
1406
1498
  let pending = null;
@@ -1560,6 +1652,14 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
1560
1652
  * hidden the conversation you were pasting INTO.
1561
1653
  */
1562
1654
  function handlePaste(text) {
1655
+ // A question's answer field is open: the paste is the answer, not composer text
1656
+ // hidden behind the dialog.
1657
+ if (state.dialog?.editing) {
1658
+ const d = state.dialog;
1659
+ d.text = `${d.text}${String(text ?? '').replace(/[\r\n\t]+/g, ' ').replace(/[\x00-\x1f\x7f]/g, '')}`.slice(0, 2000);
1660
+ notifyNow();
1661
+ return;
1662
+ }
1563
1663
  const value = String(text ?? '').replace(/\r\n?/g, '\n');
1564
1664
  // An empty paste is what a terminal sends for its paste key when the clipboard holds
1565
1665
  // only an image — it has no text to give. The image is right there: fetch it.
@@ -1746,6 +1846,35 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
1746
1846
  return match ? state.cursor + match[0].length : state.input.length;
1747
1847
  }
1748
1848
 
1849
+ /** What a dialog resolves with: the option, or for several picks the ticked values. */
1850
+ function dialogAnswer(d, extra = {}) {
1851
+ const option = d.options[d.index];
1852
+ if (!d.multi) return { ...option, comment: d.comment || '', ...extra };
1853
+ const values = d.options.filter((o) => !o.text && d.checked.has(o.value)).map((o) => o.value);
1854
+ return { value: values[0], values, comment: '', ...extra };
1855
+ }
1856
+
1857
+ function toggleDialogOption(d, index) {
1858
+ const option = d.options[index];
1859
+ if (!option || option.text) return;
1860
+ if (d.checked.has(option.value)) d.checked.delete(option.value); else d.checked.add(option.value);
1861
+ }
1862
+
1863
+ /**
1864
+ * An option was picked (number, click, or Enter when `confirm`). The text option opens
1865
+ * the field; with several picks allowed a number or click ticks a box and Enter
1866
+ * confirms — the highlighted one counts when nothing is ticked yet.
1867
+ */
1868
+ function chooseDialogOption(d, index, { confirm = false } = {}) {
1869
+ const option = d.options[index];
1870
+ if (!option) return undefined;
1871
+ if (option.text) { d.editing = true; return notifyNow(); }
1872
+ if (!d.multi) return resolveDialog(dialogAnswer(d));
1873
+ if (!confirm) { toggleDialogOption(d, index); return notifyNow(); }
1874
+ if (!d.checked.size) d.checked.add(option.value);
1875
+ return resolveDialog(dialogAnswer(d));
1876
+ }
1877
+
1749
1878
  function resolveDialog(value) {
1750
1879
  const dialog = state.dialog;
1751
1880
  if (!dialog) return;
@@ -1760,6 +1889,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
1760
1889
  if (!panel) return;
1761
1890
  state.panel = null;
1762
1891
  refreshMenu();
1892
+ if (panel.adSlot) adSlots?.want(state.busy ? 'thinking' : 'idle');
1763
1893
  panel.resolve?.();
1764
1894
  notifyNow();
1765
1895
  }
@@ -2052,7 +2182,8 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
2052
2182
  const d = state.dialog;
2053
2183
  if (!d) return;
2054
2184
  d.index = clamp(region.action.index, 0, d.options.length - 1);
2055
- return resolveDialog({ ...d.options[d.index], comment: d.comment || '' });
2185
+ if (d.editing) { d.editing = false; return notifyNow(); }
2186
+ return chooseDialogOption(d, d.index);
2056
2187
  }
2057
2188
  case 'paletteRow': {
2058
2189
  const panel = state.panel;
@@ -2078,6 +2209,11 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
2078
2209
  return void state.onCommand?.('/model');
2079
2210
  case 'context':
2080
2211
  return void state.onCommand?.('/context');
2212
+ case 'adLink':
2213
+ // Through Jaitus's own link (counted, then sent on); named by the advertiser's
2214
+ // domain, which is what the line showed.
2215
+ if (openUrl(region.action.url)) flash(`Opened ${region.action.domain}`, 'info', 1600);
2216
+ return;
2081
2217
  case 'link':
2082
2218
  // Only http(s) reaches here — see openUrl. A terminal will happily render
2083
2219
  // `file:///` or `javascript:` in text that came from a model or a fetched page,
@@ -2272,16 +2408,35 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
2272
2408
  // ── dialog has the keyboard ──
2273
2409
  if (state.dialog) {
2274
2410
  const d = state.dialog;
2411
+ if (d.editing) {
2412
+ // Esc goes back to the options, not out of the question: closing it would throw
2413
+ // the typed answer away with one key.
2414
+ if (key.escape) { d.editing = false; return notifyNow(); }
2415
+ if (key.return || key.enter) {
2416
+ if (!d.text.trim()) return;
2417
+ return resolveDialog(dialogAnswer(d, { text: d.text.trim() }));
2418
+ }
2419
+ if (key.backspace || key.delete) { d.text = [...d.text].slice(0, -1).join(''); return notifyNow(); }
2420
+ if (input && !key.ctrl && !key.meta) {
2421
+ // A pasted block arrives as one chunk: its line breaks become spaces, and other
2422
+ // control characters (a stray escape sequence) are dropped.
2423
+ d.text = `${d.text}${String(input).replace(/[\r\n\t]+/g, ' ').replace(/[\x00-\x1f\x7f]/g, '')}`.slice(0, 2000);
2424
+ return notifyNow();
2425
+ }
2426
+ return;
2427
+ }
2275
2428
  if (key.escape) return resolveDialog(undefined);
2276
2429
  if (key.upArrow || key.up) { d.index = clamp(d.index - 1, 0, d.options.length - 1); return notifyNow(); }
2277
2430
  if (key.downArrow || key.down) { d.index = clamp(d.index + 1, 0, d.options.length - 1); return notifyNow(); }
2278
- if (key.return || key.enter) return resolveDialog({ ...d.options[d.index], comment: d.comment || '' });
2431
+ if (d.multi && input === ' ') { toggleDialogOption(d, d.index); return notifyNow(); }
2432
+ if (key.return || key.enter) return chooseDialogOption(d, d.index, { confirm: true });
2279
2433
  if (/^[1-9]$/.test(input)) {
2280
2434
  const index = clamp(Number(input) - 1, 0, d.options.length - 1);
2281
2435
  d.index = index;
2282
2436
  // A number is a decision, not a cursor move: waiting for Enter after it is the
2283
- // most common reason a permission prompt feels unresponsive.
2284
- return resolveDialog({ ...d.options[index], comment: d.comment || '' });
2437
+ // most common reason a permission prompt feels unresponsive. With several picks
2438
+ // allowed it ticks the box instead — Enter confirms the set.
2439
+ return chooseDialogOption(d, index);
2285
2440
  }
2286
2441
  return;
2287
2442
  }
@@ -2691,6 +2846,8 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
2691
2846
  state.historyIndex = state.history.length;
2692
2847
  state.active = true;
2693
2848
  seedBanner();
2849
+ // The waiting-line ad, once the first screen has settled — never in the way of it.
2850
+ if (adSlots) { const t = setTimeout(() => { if (state.active && !state.busy) adSlots.want('idle'); }, 1500); t.unref?.(); }
2694
2851
  // `alternateScreen` is not a cosmetic preference — three separate defects came
2695
2852
  // from not passing it.
2696
2853
  //
@@ -2736,6 +2893,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
2736
2893
  stop() {
2737
2894
  state.active = false;
2738
2895
  exited = true;
2896
+ adSlots?.stop();
2739
2897
  if (pending) { clearTimeout(pending); pending = null; }
2740
2898
  // Before unmount: the terminal keeps reporting after the process is gone
2741
2899
  // otherwise, and every click in the shell afterwards prints escape codes.
@@ -2870,6 +3028,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
2870
3028
  state.busy = !!value;
2871
3029
  if (value) { state.turnStartedAt = Date.now(); state.turnTokens = 0; state.turnCredits = 0; }
2872
3030
  else { state.status = ''; state.turnStartedAt = null; }
3031
+ adSlots?.want(value ? 'thinking' : 'idle');
2873
3032
  notifyNow();
2874
3033
  },
2875
3034
  setStatus(value) { state.status = plain(value); notify(); },
@@ -2898,6 +3057,8 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
2898
3057
  addAttachment(attachment) { return attachImage(attachment); },
2899
3058
  /** Attach image files by path, shrinking any that are too big. */
2900
3059
  addImageFiles(files) { return attachImageFiles(files); },
3060
+ /** askDialog shows option descriptions, several picks and a text field itself. */
3061
+ richDialog: true,
2901
3062
  /**
2902
3063
  * The images to send with `line`: only those whose chip is still in it, in the order
2903
3064
  * the chips appear. A chip deleted, cleared with Ctrl+U or never sent is dropped —
@@ -2928,7 +3089,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
2928
3089
  state.dialog = {
2929
3090
  question: plain(question),
2930
3091
  options: options.map((option) => (option && typeof option === 'object'
2931
- ? { label: plain(option.label ?? option.value), value: option.value }
3092
+ ? { label: plain(option.label ?? option.value), value: option.value, description: option.description ? plain(option.description) : '', text: !!option.text }
2932
3093
  : { label: plain(option), value: option })),
2933
3094
  preview: preview.map(plain),
2934
3095
  index: 0,
@@ -2937,6 +3098,12 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
2937
3098
  tone: opts.tone,
2938
3099
  hideComment: opts.hideComment,
2939
3100
  comment: '',
3101
+ // A question (ask_user): several picks, and an option that opens a text field.
3102
+ kind: opts.kind,
3103
+ multi: !!opts.multi,
3104
+ checked: new Set(),
3105
+ editing: false,
3106
+ text: '',
2940
3107
  };
2941
3108
  state.menu = [];
2942
3109
  notifyNow();
@@ -2961,10 +3128,14 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
2961
3128
  * the conversation and Esc closes it: none of it is written into the transcript,
2962
3129
  * where a usage table used to stay for good between the messages.
2963
3130
  */
2964
- showMeters(title, sections) {
3131
+ showMeters(title, sections, opts = {}) {
2965
3132
  return new Promise((resolve) => {
2966
3133
  if (state.panel?.resolve) { const prev = state.panel.resolve; state.panel = null; prev(null); }
2967
- state.panel = { title: plain(title), lines: [], meters: sections, rows: null, resolve };
3134
+ // /usage carries the 'usage' sponsored line, appended under the meters when it
3135
+ // arrives (adSlots onChange) and counted only while this panel is open.
3136
+ const adSlot = Boolean(opts.ad === 'usage' && adSlots);
3137
+ state.panel = { title: plain(title), lines: [], meters: sections, rows: null, resolve, adSlot, adBase: sections };
3138
+ if (adSlot) adSlots.want('usage');
2968
3139
  notifyNow();
2969
3140
  });
2970
3141
  },
package/src/tui.js CHANGED
@@ -2132,6 +2132,10 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
2132
2132
  if (state.dialog) {
2133
2133
  const dialog = state.dialog;
2134
2134
  if (dialog.editing) {
2135
+ // A text question (askText) is answered by Enter and closed by Esc; a decision
2136
+ // comment just stops being edited.
2137
+ if (dialog.textOnly && (name === 'escape' || (key.ctrl && name === 'c'))) { dialog.comment = ''; finishDialog(null); return; }
2138
+ if (dialog.textOnly && (name === 'return' || name === 'enter')) { if ((dialog.comment || '').trim()) finishDialog('send'); return; }
2135
2139
  if (name === 'escape' || (key.ctrl && name === 'c')) { dialog.editing = false; draw(); return; }
2136
2140
  if (name === 'return' || name === 'enter') { dialog.editing = false; draw(); return; }
2137
2141
  if (name === 'backspace') { dialog.comment = (dialog.comment || '').slice(0, -1); draw(); return; }
@@ -2536,6 +2540,18 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
2536
2540
  };
2537
2541
  draw();
2538
2542
  });
2543
+ },
2544
+ /** One line of free text (ask_user's "own words"). Resolves the text, or null. */
2545
+ askText(question, opts = {}) {
2546
+ return new Promise((resolve) => {
2547
+ state.dialog = {
2548
+ question, options: [{ label: 'Send', value: 'send' }], preview: [], index: 0,
2549
+ resolve: (answer) => resolve(answer?.value === 'send' ? answer.comment : null),
2550
+ comment: '', editing: true, textOnly: true,
2551
+ title: opts.title || null, hideComment: false, icon: null, pills: false,
2552
+ };
2553
+ draw();
2554
+ });
2539
2555
  }, redraw: draw,
2540
2556
  setHome(on) { state.home = !!on; draw(); },
2541
2557
  showPanel(title, lines) { return new Promise((resolve) => { state.panel = { title, lines: Array.isArray(lines) ? lines : String(lines).split('\n'), offset: 0, resolve }; draw(); }); },
@@ -0,0 +1,54 @@
1
+ // ─── "Update Jaitus to continue", even mid-task ─────────────────────────────────
2
+ //
3
+ // The admin can require a newer CLI (backend services/cliUpdate.js). Every request is
4
+ // checked there, but a CLI sitting idle — or deep in a long tool run — would not hear about
5
+ // it until its next request. So the CLI asks once at start and every two minutes while it
6
+ // is open. The question goes through the same gate: a CLI that is too old is refused with
7
+ // jaitus_update_required, and that refusal is the answer. When it comes, the running task
8
+ // is stopped and the reason is shown once, short.
9
+ //
10
+ // Any failure (offline, timeout, an older server) means "nothing to say": this never
11
+ // blocks or slows the CLI on its own.
12
+
13
+ import { endpoints, version } from './config.js';
14
+
15
+ const EVERY_MS = 2 * 60 * 1000;
16
+ const TIMEOUT_MS = 6000;
17
+
18
+ /** null when this CLI may keep working; otherwise { message } to show. */
19
+ export async function checkPolicy({ fetchImpl = fetch, baseUrl = endpoints().baseUrl } = {}) {
20
+ const ctrl = new AbortController();
21
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
22
+ try {
23
+ const res = await fetchImpl(`${baseUrl}/api/public/cli-policy`, {
24
+ headers: { accept: 'application/json', 'user-agent': `jaitus-cli/${version()}`, 'x-jaitus-features': 'usage-window plan-expired update-required' },
25
+ signal: ctrl.signal,
26
+ });
27
+ if (res.status !== 426) return null;
28
+ const body = await res.json().catch(() => null);
29
+ if (body?.error?.code !== 'jaitus_update_required') return null;
30
+ return { message: String(body.error.message || '').replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 240) };
31
+ } catch {
32
+ return null;
33
+ } finally {
34
+ clearTimeout(timer);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Check now and every two minutes; call onBlocked({ message }) once, the first time this
40
+ * CLI is refused. Returns a stop function. The timer never keeps the process alive.
41
+ */
42
+ export function watchPolicy(onBlocked, { everyMs = EVERY_MS, check = checkPolicy } = {}) {
43
+ let done = false;
44
+ let timer = null;
45
+ const run = async () => {
46
+ if (done) return;
47
+ const r = await check();
48
+ if (r && !done) { done = true; clearInterval(timer); onBlocked(r); }
49
+ };
50
+ void run();
51
+ timer = setInterval(() => { void run(); }, everyMs);
52
+ timer.unref?.();
53
+ return () => { done = true; clearInterval(timer); };
54
+ }