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/package.json +2 -2
- package/src/adSlots.js +140 -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/errors.js +126 -126
- package/src/index.js +3273 -3189
- package/src/mcp.js +251 -9
- package/src/project.js +33 -0
- package/src/tui-ink.js +187 -16
- package/src/tui.js +16 -0
- package/src/updatePolicy.js +54 -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,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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|