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/package.json +2 -2
- package/src/adSlots.js +153 -0
- package/src/agent/askUser.js +161 -0
- package/src/agent/loop.js +49 -13
- package/src/agent/permissions.js +2 -0
- package/src/agent/tools.js +52 -0
- package/src/index.js +100 -25
- package/src/mcp.js +251 -9
- package/src/project.js +33 -0
- package/src/tui-ink.js +201 -16
- package/src/tui.js +16 -0
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
|
|
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
|
-
|
|
1031
|
-
h(
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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(); }); },
|