jaitus 0.3.24 → 0.3.26

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,29 @@ 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
+
1496
+ let adBusyTimer = null;
1404
1497
  let inkInstance = null;
1405
1498
  let update = () => {};
1406
1499
  let pending = null;
@@ -1560,6 +1653,14 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
1560
1653
  * hidden the conversation you were pasting INTO.
1561
1654
  */
1562
1655
  function handlePaste(text) {
1656
+ // A question's answer field is open: the paste is the answer, not composer text
1657
+ // hidden behind the dialog.
1658
+ if (state.dialog?.editing) {
1659
+ const d = state.dialog;
1660
+ d.text = `${d.text}${String(text ?? '').replace(/[\r\n\t]+/g, ' ').replace(/[\x00-\x1f\x7f]/g, '')}`.slice(0, 2000);
1661
+ notifyNow();
1662
+ return;
1663
+ }
1563
1664
  const value = String(text ?? '').replace(/\r\n?/g, '\n');
1564
1665
  // An empty paste is what a terminal sends for its paste key when the clipboard holds
1565
1666
  // only an image — it has no text to give. The image is right there: fetch it.
@@ -1746,6 +1847,35 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
1746
1847
  return match ? state.cursor + match[0].length : state.input.length;
1747
1848
  }
1748
1849
 
1850
+ /** What a dialog resolves with: the option, or for several picks the ticked values. */
1851
+ function dialogAnswer(d, extra = {}) {
1852
+ const option = d.options[d.index];
1853
+ if (!d.multi) return { ...option, comment: d.comment || '', ...extra };
1854
+ const values = d.options.filter((o) => !o.text && d.checked.has(o.value)).map((o) => o.value);
1855
+ return { value: values[0], values, comment: '', ...extra };
1856
+ }
1857
+
1858
+ function toggleDialogOption(d, index) {
1859
+ const option = d.options[index];
1860
+ if (!option || option.text) return;
1861
+ if (d.checked.has(option.value)) d.checked.delete(option.value); else d.checked.add(option.value);
1862
+ }
1863
+
1864
+ /**
1865
+ * An option was picked (number, click, or Enter when `confirm`). The text option opens
1866
+ * the field; with several picks allowed a number or click ticks a box and Enter
1867
+ * confirms — the highlighted one counts when nothing is ticked yet.
1868
+ */
1869
+ function chooseDialogOption(d, index, { confirm = false } = {}) {
1870
+ const option = d.options[index];
1871
+ if (!option) return undefined;
1872
+ if (option.text) { d.editing = true; return notifyNow(); }
1873
+ if (!d.multi) return resolveDialog(dialogAnswer(d));
1874
+ if (!confirm) { toggleDialogOption(d, index); return notifyNow(); }
1875
+ if (!d.checked.size) d.checked.add(option.value);
1876
+ return resolveDialog(dialogAnswer(d));
1877
+ }
1878
+
1749
1879
  function resolveDialog(value) {
1750
1880
  const dialog = state.dialog;
1751
1881
  if (!dialog) return;
@@ -1760,6 +1890,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
1760
1890
  if (!panel) return;
1761
1891
  state.panel = null;
1762
1892
  refreshMenu();
1893
+ if (panel.adSlot) adSlots?.want(state.busy ? 'thinking' : 'idle');
1763
1894
  panel.resolve?.();
1764
1895
  notifyNow();
1765
1896
  }
@@ -2052,7 +2183,8 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
2052
2183
  const d = state.dialog;
2053
2184
  if (!d) return;
2054
2185
  d.index = clamp(region.action.index, 0, d.options.length - 1);
2055
- return resolveDialog({ ...d.options[d.index], comment: d.comment || '' });
2186
+ if (d.editing) { d.editing = false; return notifyNow(); }
2187
+ return chooseDialogOption(d, d.index);
2056
2188
  }
2057
2189
  case 'paletteRow': {
2058
2190
  const panel = state.panel;
@@ -2078,6 +2210,11 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
2078
2210
  return void state.onCommand?.('/model');
2079
2211
  case 'context':
2080
2212
  return void state.onCommand?.('/context');
2213
+ case 'adLink':
2214
+ // Through Jaitus's own link (counted, then sent on); named by the advertiser's
2215
+ // domain, which is what the line showed.
2216
+ if (openUrl(region.action.url)) flash(`Opened ${region.action.domain}`, 'info', 1600);
2217
+ return;
2081
2218
  case 'link':
2082
2219
  // Only http(s) reaches here — see openUrl. A terminal will happily render
2083
2220
  // `file:///` or `javascript:` in text that came from a model or a fetched page,
@@ -2272,16 +2409,35 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
2272
2409
  // ── dialog has the keyboard ──
2273
2410
  if (state.dialog) {
2274
2411
  const d = state.dialog;
2412
+ if (d.editing) {
2413
+ // Esc goes back to the options, not out of the question: closing it would throw
2414
+ // the typed answer away with one key.
2415
+ if (key.escape) { d.editing = false; return notifyNow(); }
2416
+ if (key.return || key.enter) {
2417
+ if (!d.text.trim()) return;
2418
+ return resolveDialog(dialogAnswer(d, { text: d.text.trim() }));
2419
+ }
2420
+ if (key.backspace || key.delete) { d.text = [...d.text].slice(0, -1).join(''); return notifyNow(); }
2421
+ if (input && !key.ctrl && !key.meta) {
2422
+ // A pasted block arrives as one chunk: its line breaks become spaces, and other
2423
+ // control characters (a stray escape sequence) are dropped.
2424
+ d.text = `${d.text}${String(input).replace(/[\r\n\t]+/g, ' ').replace(/[\x00-\x1f\x7f]/g, '')}`.slice(0, 2000);
2425
+ return notifyNow();
2426
+ }
2427
+ return;
2428
+ }
2275
2429
  if (key.escape) return resolveDialog(undefined);
2276
2430
  if (key.upArrow || key.up) { d.index = clamp(d.index - 1, 0, d.options.length - 1); return notifyNow(); }
2277
2431
  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 || '' });
2432
+ if (d.multi && input === ' ') { toggleDialogOption(d, d.index); return notifyNow(); }
2433
+ if (key.return || key.enter) return chooseDialogOption(d, d.index, { confirm: true });
2279
2434
  if (/^[1-9]$/.test(input)) {
2280
2435
  const index = clamp(Number(input) - 1, 0, d.options.length - 1);
2281
2436
  d.index = index;
2282
2437
  // 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 || '' });
2438
+ // most common reason a permission prompt feels unresponsive. With several picks
2439
+ // allowed it ticks the box instead — Enter confirms the set.
2440
+ return chooseDialogOption(d, index);
2285
2441
  }
2286
2442
  return;
2287
2443
  }
@@ -2691,6 +2847,8 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
2691
2847
  state.historyIndex = state.history.length;
2692
2848
  state.active = true;
2693
2849
  seedBanner();
2850
+ // The waiting-line ad, once the first screen has settled — never in the way of it.
2851
+ if (adSlots) { const t = setTimeout(() => { if (state.active && !state.busy) adSlots.want('idle'); }, 1500); t.unref?.(); }
2694
2852
  // `alternateScreen` is not a cosmetic preference — three separate defects came
2695
2853
  // from not passing it.
2696
2854
  //
@@ -2736,6 +2894,8 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
2736
2894
  stop() {
2737
2895
  state.active = false;
2738
2896
  exited = true;
2897
+ clearTimeout(adBusyTimer);
2898
+ adSlots?.stop();
2739
2899
  if (pending) { clearTimeout(pending); pending = null; }
2740
2900
  // Before unmount: the terminal keeps reporting after the process is gone
2741
2901
  // otherwise, and every click in the shell afterwards prints escape codes.
@@ -2870,6 +3030,19 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
2870
3030
  state.busy = !!value;
2871
3031
  if (value) { state.turnStartedAt = Date.now(); state.turnTokens = 0; state.turnCredits = 0; }
2872
3032
  else { state.status = ''; state.turnStartedAt = null; }
3033
+ if (adSlots) {
3034
+ clearTimeout(adBusyTimer);
3035
+ if (value) {
3036
+ // Only work that lasts gets a "thinking" line: an instant command (/help,
3037
+ // /usage) would otherwise fetch one just to throw it away.
3038
+ adBusyTimer = setTimeout(() => { if (state.busy && !state.panel?.adSlot) adSlots.want('thinking'); }, 800);
3039
+ adBusyTimer.unref?.();
3040
+ } else {
3041
+ // /usage runs as a command: its panel is still open when the command ends, and
3042
+ // it keeps its own slot until it is closed.
3043
+ adSlots.want(state.panel?.adSlot ? 'usage' : 'idle');
3044
+ }
3045
+ }
2873
3046
  notifyNow();
2874
3047
  },
2875
3048
  setStatus(value) { state.status = plain(value); notify(); },
@@ -2898,6 +3071,8 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
2898
3071
  addAttachment(attachment) { return attachImage(attachment); },
2899
3072
  /** Attach image files by path, shrinking any that are too big. */
2900
3073
  addImageFiles(files) { return attachImageFiles(files); },
3074
+ /** askDialog shows option descriptions, several picks and a text field itself. */
3075
+ richDialog: true,
2901
3076
  /**
2902
3077
  * The images to send with `line`: only those whose chip is still in it, in the order
2903
3078
  * the chips appear. A chip deleted, cleared with Ctrl+U or never sent is dropped —
@@ -2928,7 +3103,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
2928
3103
  state.dialog = {
2929
3104
  question: plain(question),
2930
3105
  options: options.map((option) => (option && typeof option === 'object'
2931
- ? { label: plain(option.label ?? option.value), value: option.value }
3106
+ ? { label: plain(option.label ?? option.value), value: option.value, description: option.description ? plain(option.description) : '', text: !!option.text }
2932
3107
  : { label: plain(option), value: option })),
2933
3108
  preview: preview.map(plain),
2934
3109
  index: 0,
@@ -2937,6 +3112,12 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
2937
3112
  tone: opts.tone,
2938
3113
  hideComment: opts.hideComment,
2939
3114
  comment: '',
3115
+ // A question (ask_user): several picks, and an option that opens a text field.
3116
+ kind: opts.kind,
3117
+ multi: !!opts.multi,
3118
+ checked: new Set(),
3119
+ editing: false,
3120
+ text: '',
2940
3121
  };
2941
3122
  state.menu = [];
2942
3123
  notifyNow();
@@ -2961,10 +3142,14 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
2961
3142
  * the conversation and Esc closes it: none of it is written into the transcript,
2962
3143
  * where a usage table used to stay for good between the messages.
2963
3144
  */
2964
- showMeters(title, sections) {
3145
+ showMeters(title, sections, opts = {}) {
2965
3146
  return new Promise((resolve) => {
2966
3147
  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 };
3148
+ // /usage carries the 'usage' sponsored line, appended under the meters when it
3149
+ // arrives (adSlots onChange) and counted only while this panel is open.
3150
+ const adSlot = Boolean(opts.ad === 'usage' && adSlots);
3151
+ state.panel = { title: plain(title), lines: [], meters: sections, rows: null, resolve, adSlot, adBase: sections };
3152
+ if (adSlot) adSlots.want('usage');
2968
3153
  notifyNow();
2969
3154
  });
2970
3155
  },
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(); }); },