jaitus 0.3.5 → 0.3.7
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/README.md +3 -1
- package/package.json +2 -2
- package/src/agent/loop.js +75 -6
- package/src/errors.js +1 -1
- package/src/index.js +48 -11
- package/src/sessions.js +22 -1
- package/src/tui/entries.js +36 -5
- package/src/tui/widgets.js +32 -0
- package/src/tui-ink.js +116 -20
package/README.md
CHANGED
|
@@ -119,7 +119,9 @@ scrolls to the line you pick.
|
|
|
119
119
|
|
|
120
120
|
**Pasting.** Right-click or `Ctrl+V` pastes the clipboard; a long paste becomes a
|
|
121
121
|
`[#1 pasted 120 lines]` chip (the full text is sent). **Images:** drag an image file
|
|
122
|
-
onto the terminal, paste a copied screenshot
|
|
122
|
+
onto the terminal, paste a copied screenshot (`Alt+V` on Windows — Windows Terminal and
|
|
123
|
+
VS Code keep `Ctrl+V` for themselves; `Ctrl+V` on macOS and Linux, where `Cmd+V` /
|
|
124
|
+
`Ctrl+Shift+V` belong to the terminal), type `/image path.png`, or
|
|
123
125
|
mention `@screenshot.png` — each becomes an `[Image #N]` chip listed under the
|
|
124
126
|
composer. Backspace over a chip removes it; only images whose chip is still in the
|
|
125
127
|
message are sent. Screenshots over 3.5 MB are shrunk automatically.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jaitus",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"description": "Jaitus — a coding agent in your terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"node": ">=20"
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
|
-
"test": "node tests/source-sanity.js && node tests/rules.js && node tests/run.js && node tests/commands.js && node tests/login.js && node tests/session.js && node tests/openUrl.js && node tests/ink-tui.js && node tests/tui-render.js && node tests/mouse-e2e.js && node tests/environment.js && node tests/update.js && node tests/continuation.js && node tests/steps.js && node tests/files.js && node tests/processes.js && node tests/security.js && node tests/context-budget.js",
|
|
19
|
+
"test": "node tests/source-sanity.js && node tests/rules.js && node tests/run.js && node tests/commands.js && node tests/login.js && node tests/session.js && node tests/openUrl.js && node tests/ink-tui.js && node tests/tui-render.js && node tests/mouse-e2e.js && node tests/environment.js && node tests/update.js && node tests/continuation.js && node tests/steps.js && node tests/files.js && node tests/processes.js && node tests/security.js && node tests/context-budget.js && node tests/context-trim.js",
|
|
20
20
|
"preview": "node scripts/tui-preview.js",
|
|
21
21
|
"prepublishOnly": "npm test"
|
|
22
22
|
},
|
package/src/agent/loop.js
CHANGED
|
@@ -229,6 +229,65 @@ export function repairToolPairs(messages) {
|
|
|
229
229
|
return messages;
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
+
// ── Keeping the conversation small ────────────────────────────────────────────
|
|
233
|
+
//
|
|
234
|
+
// Every request re-sends the whole conversation, and in real use that re-send — cached,
|
|
235
|
+
// but not free — was ~75% of what a session cost: 109 requests averaged 46k tokens of
|
|
236
|
+
// context each, most of it tool output from many steps ago (a file read at step 3 is
|
|
237
|
+
// still paid for at step 60). This is the documented "clear old tool results" strategy
|
|
238
|
+
// (Anthropic's context editing, clear_tool_uses; Claude Code's micro-compaction): once
|
|
239
|
+
// the conversation passes TRIM_AT, the outputs of all but the KEEP_RECENT latest tool
|
|
240
|
+
// calls are replaced with a line saying they were cleared and can be re-run. It clears
|
|
241
|
+
// at least TRIM_AT_LEAST at a time, because each clearing breaks the prompt cache once
|
|
242
|
+
// — clearing a little on every step would cost more than it saves. Small results (a
|
|
243
|
+
// "file written", an exit code) are kept: they carry meaning and cost nothing.
|
|
244
|
+
export const TRIM_AT = 100_000; // estimated tokens of conversation (the documented default)
|
|
245
|
+
export const KEEP_RECENT = 6; // latest tool results always kept whole
|
|
246
|
+
export const TRIM_AT_LEAST = 12_000; // estimated tokens a clearing must free
|
|
247
|
+
const TRIM_MIN_CHARS = 1_500; // results shorter than this are never cleared
|
|
248
|
+
const CLEARED = '[Output cleared';
|
|
249
|
+
|
|
250
|
+
const blockChars = (b) => {
|
|
251
|
+
if (b?.type === 'text') return (b.text || '').length;
|
|
252
|
+
if (b?.type === 'tool_use') return JSON.stringify(b.input || {}).length;
|
|
253
|
+
if (b?.type === 'tool_result') return typeof b.content === 'string' ? b.content.length : JSON.stringify(b.content || '').length;
|
|
254
|
+
return 0;
|
|
255
|
+
};
|
|
256
|
+
export function conversationTokens(messages) {
|
|
257
|
+
let chars = 0;
|
|
258
|
+
for (const m of messages || []) chars += typeof m?.content === 'string' ? m.content.length : (Array.isArray(m?.content) ? m.content.reduce((n, b) => n + blockChars(b), 0) : 0);
|
|
259
|
+
return Math.ceil(chars / 4);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Clear old tool outputs in place when the conversation is large. Returns how many
|
|
264
|
+
* estimated tokens were freed (0 when nothing was worth clearing).
|
|
265
|
+
*/
|
|
266
|
+
export function clearOldToolResults(messages, { at = TRIM_AT, keep = KEEP_RECENT, atLeast = TRIM_AT_LEAST } = {}) {
|
|
267
|
+
if (conversationTokens(messages) < at) return 0;
|
|
268
|
+
const names = new Map();
|
|
269
|
+
for (const m of messages) {
|
|
270
|
+
if (m?.role === 'assistant' && Array.isArray(m.content)) for (const b of m.content) if (b?.type === 'tool_use') names.set(b.id, b.name);
|
|
271
|
+
}
|
|
272
|
+
const results = [];
|
|
273
|
+
for (const m of messages) {
|
|
274
|
+
if (m?.role !== 'user' || !Array.isArray(m.content)) continue;
|
|
275
|
+
m.content.forEach((b, i) => { if (b?.type === 'tool_result') results.push({ m, i, b }); });
|
|
276
|
+
}
|
|
277
|
+
const candidates = results.slice(0, Math.max(0, results.length - keep)).filter(({ b }) => {
|
|
278
|
+
const text = typeof b.content === 'string' ? b.content : '';
|
|
279
|
+
return text.length >= TRIM_MIN_CHARS && !text.startsWith(CLEARED);
|
|
280
|
+
});
|
|
281
|
+
const freed = Math.ceil(candidates.reduce((n, { b }) => n + blockChars(b), 0) / 4);
|
|
282
|
+
if (freed < atLeast) return 0;
|
|
283
|
+
for (const { m, i, b } of candidates) {
|
|
284
|
+
const chars = blockChars(b);
|
|
285
|
+
const name = names.get(b.tool_use_id) || 'this tool';
|
|
286
|
+
m.content[i] = { ...b, content: `${CLEARED}: ${name} returned ${chars.toLocaleString()} characters here, removed to keep the conversation small. Run it again if you need it.]` };
|
|
287
|
+
}
|
|
288
|
+
return freed;
|
|
289
|
+
}
|
|
290
|
+
|
|
232
291
|
export const IMAGE_BUDGET = 6_000_000;
|
|
233
292
|
export function withinImageBudget(messages, budget = IMAGE_BUDGET) {
|
|
234
293
|
let used = 0;
|
|
@@ -273,14 +332,16 @@ async function streamTurn({ client, messages, model, system, tools, onText, onTo
|
|
|
273
332
|
// — no message_stop, no stop reason — was cut, and used to be taken as a complete,
|
|
274
333
|
// silent answer: the "it stops midway for no reason" bug.
|
|
275
334
|
let sawStop = false;
|
|
276
|
-
|
|
335
|
+
// `credits` is what the gateway counted against the plan for this request — real cost,
|
|
336
|
+
// the one unit the plan, the dashboard and this CLI all show.
|
|
337
|
+
const usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, credits: 0 };
|
|
277
338
|
// Usage can arrive at the START of a stream (Anthropic-format providers) or only at
|
|
278
339
|
// the END, in message_delta (OpenAI-format providers, translated by the gateway —
|
|
279
340
|
// their usage exists only once generation finishes). Reading input from message_start
|
|
280
341
|
// alone recorded every OpenAI-format turn as 0 input tokens.
|
|
281
342
|
const takeUsage = (u) => {
|
|
282
343
|
if (!u) return;
|
|
283
|
-
for (const k of ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']) {
|
|
344
|
+
for (const k of ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens', 'credits']) {
|
|
284
345
|
if (u[k] != null && Number.isFinite(Number(u[k]))) usage[k] = Number(u[k]);
|
|
285
346
|
}
|
|
286
347
|
};
|
|
@@ -395,7 +456,7 @@ export async function runAgent({
|
|
|
395
456
|
client, messages, model, cwd, permissions, signal, tui = null,
|
|
396
457
|
extraSystem = '', maxIterations = MAX_ITERATIONS, silent = false,
|
|
397
458
|
onText = null, onEvent = null, systemOverride = null, journal = null,
|
|
398
|
-
subagents = true, onContext = null,
|
|
459
|
+
subagents = true, onContext = null, trimContext = true,
|
|
399
460
|
}) {
|
|
400
461
|
// Loop detection. A call is identified by its tool, its input, and how many files had
|
|
401
462
|
// been changed when it was made — so edit → test → edit → test is progress (the count
|
|
@@ -404,7 +465,7 @@ export async function runAgent({
|
|
|
404
465
|
const repeats = new Map();
|
|
405
466
|
// input = fresh input, cached = prompt-cache reads (billed far cheaper), and
|
|
406
467
|
// context = the real size of the last prompt sent, as the provider counted it.
|
|
407
|
-
const totals = { input: 0, cached: 0, output: 0, tools: 0, context: 0 };
|
|
468
|
+
const totals = { input: 0, cached: 0, output: 0, tools: 0, context: 0, credits: 0 };
|
|
408
469
|
// Read once per request, not per turn: an agent that rewrites JAITUS.md mid-task
|
|
409
470
|
// should not change the rules it is being judged by halfway through.
|
|
410
471
|
// The model is told when it is in plan mode — it used to find out only by having its
|
|
@@ -438,6 +499,12 @@ export async function runAgent({
|
|
|
438
499
|
let continuing = false;
|
|
439
500
|
|
|
440
501
|
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
502
|
+
// Old tool outputs are cleared once the conversation is large (see TRIM_AT) — in
|
|
503
|
+
// place, so the saved session shrinks too and the cache stays warm afterwards.
|
|
504
|
+
if (trimContext) {
|
|
505
|
+
const freed = clearOldToolResults(messages);
|
|
506
|
+
if (freed && tui?.notice) tui.notice(`Cleared old tool output (~${Math.round(freed / 1000)}k tokens) to keep each request small.`, 'info');
|
|
507
|
+
}
|
|
441
508
|
let streamed = false;
|
|
442
509
|
const spin = tui ? { stop: () => {}, update: () => {} } : (silent ? { stop: () => {}, update: () => {} } : spinner(glyph('Working…', 'Working...')));
|
|
443
510
|
if (tui) tui.setStatus(`${c.brand(glyph('•', '*'))} ${c.brand(glyph('Working…', 'Working...'))}`);
|
|
@@ -532,6 +599,7 @@ export async function runAgent({
|
|
|
532
599
|
totals.input += turn.usage.input_tokens || 0;
|
|
533
600
|
totals.cached += (turn.usage.cache_read_input_tokens || 0) + (turn.usage.cache_creation_input_tokens || 0);
|
|
534
601
|
totals.output += turn.usage.output_tokens || 0;
|
|
602
|
+
totals.credits += Number(turn.usage.credits) || 0;
|
|
535
603
|
const promptSize = (turn.usage.input_tokens || 0) + (turn.usage.cache_read_input_tokens || 0) + (turn.usage.cache_creation_input_tokens || 0);
|
|
536
604
|
if (promptSize) {
|
|
537
605
|
totals.context = promptSize + (turn.usage.output_tokens || 0);
|
|
@@ -708,7 +776,8 @@ export async function runAgent({
|
|
|
708
776
|
const done = ` ${toolMark(call.name)} ${c.dim(String(label).slice(0, 100))} ${c.dim(`· ${took}s`)}`;
|
|
709
777
|
if (row) {
|
|
710
778
|
tui.clearStatus();
|
|
711
|
-
|
|
779
|
+
// The whole output travels with the row (capped), so a click can open it.
|
|
780
|
+
row.update({ status: 'ok', took, preview: previewFor(call.name, output), full: String(output ?? '').slice(0, 200_000) });
|
|
712
781
|
} else if (tui) { tui.clearStatus(); tui.print(done, 'tool'); } else if (!silent) out(done);
|
|
713
782
|
tui?.recordActivity?.({ id: activityId, name: call.name, label, status: 'ok', detail: String(output).slice(0, 2000) });
|
|
714
783
|
onEvent?.({ type: 'tool_result', name: call.name, input: call.input, ok: true, output });
|
|
@@ -761,7 +830,7 @@ export async function runAgent({
|
|
|
761
830
|
const bad = ` ${c.red(glyph('✗', 'x'))} ${c.dim(String(label).slice(0, 80))} ${c.dim(`· ${took}s`)} — ${msg.slice(0, 120)}`;
|
|
762
831
|
if (row) {
|
|
763
832
|
tui.clearStatus();
|
|
764
|
-
row.update({ status: 'failed', took, error: msg.split('\n')[0].slice(0, 200), preview: msg.includes('\n') ? msg : '' });
|
|
833
|
+
row.update({ status: 'failed', took, error: msg.split('\n')[0].slice(0, 200), preview: msg.includes('\n') ? msg : '', full: msg.slice(0, 200_000) });
|
|
765
834
|
} else if (tui) { tui.clearStatus(); tui.print(bad, 'tool'); } else if (!silent) out(bad);
|
|
766
835
|
tui?.recordActivity?.({ id: activityId, name: call.name, label, status: 'failed', detail: msg.slice(0, 2000) });
|
|
767
836
|
onEvent?.({ type: 'tool_result', name: call.name, input: call.input, ok: false, error: msg });
|
package/src/errors.js
CHANGED
|
@@ -18,7 +18,7 @@ const CATALOG = {
|
|
|
18
18
|
rate_limited: { title: 'Slow down', hint: 'Wait a moment, then retry.' },
|
|
19
19
|
context_too_long: { title: 'Conversation too long', hint: 'Run /compact, or start a new session.' },
|
|
20
20
|
request_too_large: { title: 'Request too large', hint: 'Usually images: remove some, or run /compact to drop old ones. Images over 3.5 MB are shrunk automatically.' },
|
|
21
|
-
daily_limit_reached: { title: 'Daily limit reached', hint: '
|
|
21
|
+
daily_limit_reached: { title: 'Daily limit reached', hint: 'Today\'s credits reset at midnight. /usage shows where you are.' },
|
|
22
22
|
token_expired: { title: 'Signed out', hint: 'Type /login to sign in again (or run `jaitus login`).' },
|
|
23
23
|
refresh_race: { title: 'Sign-in was being renewed', hint: 'Try again.' },
|
|
24
24
|
content_blocked: { title: 'Request refused', hint: 'Rephrase the request and try again.' },
|
package/src/index.js
CHANGED
|
@@ -311,6 +311,17 @@ function requireAuth({ quiet = false } = {}) {
|
|
|
311
311
|
*/
|
|
312
312
|
let activeTui = null;
|
|
313
313
|
|
|
314
|
+
// Failures caused by the message itself: sending it again would fail again, so a turn
|
|
315
|
+
// that failed this way is taken out of the conversation instead of kept with its error.
|
|
316
|
+
const MESSAGE_FAULTS = new Set(['content_blocked', 'request_too_large', 'free_request_too_large', 'context_too_long', 'bad_request']);
|
|
317
|
+
|
|
318
|
+
/** The typed text of a message, whatever its shape. */
|
|
319
|
+
function textOfMessage(m) {
|
|
320
|
+
if (!m) return '';
|
|
321
|
+
if (typeof m.content === 'string') return m.content;
|
|
322
|
+
return (Array.isArray(m.content) ? m.content : []).filter((b) => b?.type === 'text').map((b) => b.text).join('\n');
|
|
323
|
+
}
|
|
324
|
+
|
|
314
325
|
function reportAgentError(e, context = {}) {
|
|
315
326
|
const error = e instanceof Error ? e : new Error(String(e?.message ?? e ?? 'Unknown error'));
|
|
316
327
|
if (activeTui?.error) {
|
|
@@ -789,7 +800,7 @@ async function session({ flags }) {
|
|
|
789
800
|
// continues its count instead of starting again from zero.
|
|
790
801
|
const saved = sessionState.spent || {};
|
|
791
802
|
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
|
|
792
|
-
const spent = { input: num(saved.input), cached: num(saved.cached), output: num(saved.output), tools: num(saved.tools), turns: num(saved.turns) };
|
|
803
|
+
const spent = { input: num(saved.input), cached: num(saved.cached), output: num(saved.output), tools: num(saved.tools), turns: num(saved.turns), credits: num(saved.credits) };
|
|
793
804
|
sessionState.spent = spent;
|
|
794
805
|
// The real size of the last prompt, as the provider counted it; 0 until one is known.
|
|
795
806
|
let lastContext = num(sessionState.lastContext);
|
|
@@ -964,6 +975,7 @@ async function session({ flags }) {
|
|
|
964
975
|
spent.input += result.input || 0;
|
|
965
976
|
spent.cached += result.cached || 0;
|
|
966
977
|
spent.output += result.output || 0;
|
|
978
|
+
spent.credits = Math.round(((spent.credits || 0) + (result.credits || 0)) * 100) / 100;
|
|
967
979
|
spent.tools += result.tools || 0;
|
|
968
980
|
try { tui?.setSpent?.(spent); } catch { /* header keeps last value */ }
|
|
969
981
|
}
|
|
@@ -989,7 +1001,12 @@ async function session({ flags }) {
|
|
|
989
1001
|
const result = await runAgent({
|
|
990
1002
|
client, messages, model, cwd: flags.cwd, permissions, signal: ctrl.signal, tui, journal,
|
|
991
1003
|
onContext: (n) => { lastContext = n; sessionState.lastContext = n; showContext(); },
|
|
992
|
-
|
|
1004
|
+
// `jaitus config trimToolResults off` keeps every old tool output in context.
|
|
1005
|
+
trimContext: loadSettings().trimToolResults !== false,
|
|
1006
|
+
// What happened since the model's last turn (a failed request, an interrupt, a
|
|
1007
|
+
// compaction, a blocked hook), told once. This was recorded all along and never
|
|
1008
|
+
// read — the model never heard about any of it.
|
|
1009
|
+
extraSystem: `${skillCatalog(skills)}\n\nCurrent intent: ${intentMode}. ${intentGuidance[intentMode]}${flags.appendSystemPrompt ? `\n\nAdditional instructions:\n${flags.appendSystemPrompt}` : ''}${ledger.drain()}`,
|
|
993
1010
|
systemOverride: flags.systemPrompt || null,
|
|
994
1011
|
});
|
|
995
1012
|
// An interrupted turn still spent what it spent before Esc — count it.
|
|
@@ -997,6 +1014,7 @@ async function session({ flags }) {
|
|
|
997
1014
|
spent.input += result.input || 0;
|
|
998
1015
|
spent.cached += result.cached || 0;
|
|
999
1016
|
spent.output += result.output || 0;
|
|
1017
|
+
spent.credits = Math.round(((spent.credits || 0) + (result.credits || 0)) * 100) / 100;
|
|
1000
1018
|
spent.tools += result.tools || 0;
|
|
1001
1019
|
if (result.context) { lastContext = result.context; sessionState.lastContext = lastContext; }
|
|
1002
1020
|
}
|
|
@@ -1026,9 +1044,21 @@ async function session({ flags }) {
|
|
|
1026
1044
|
// that, its next turn begins as though nothing went wrong and it re-attempts the
|
|
1027
1045
|
// thing that just failed.
|
|
1028
1046
|
if (ctrl.signal.aborted) lastRunInterrupted = true;
|
|
1047
|
+
const what = isJaitusError(e) ? `${e.title} — ${e.message}` : 'an unexpected error inside the CLI';
|
|
1029
1048
|
if (messages.length === startLength && messages[messages.length - 1]?.role === 'user') {
|
|
1030
|
-
|
|
1031
|
-
|
|
1049
|
+
if (MESSAGE_FAULTS.has(e?.code)) {
|
|
1050
|
+
// The message ITSELF was the problem (refused content, too large): sending it
|
|
1051
|
+
// again with every later request would make every one of them fail too. It
|
|
1052
|
+
// stays out, and the model is told what was asked and why it was rejected.
|
|
1053
|
+
const failed = messages.pop();
|
|
1054
|
+
const preview = e.code === 'content_blocked' ? '' : ` It began: "${textOfMessage(failed).replace(/\s+/g, ' ').slice(0, 240)}"`;
|
|
1055
|
+
ledger.note('turn-failed', `The user's previous message got no answer: it was rejected (${what}) and removed from the conversation, because sending it again would fail again.${preview}`);
|
|
1056
|
+
} else {
|
|
1057
|
+
// Anything else — the network, a busy provider, a plan limit — says nothing
|
|
1058
|
+
// about the message. It stays in the conversation, followed by what happened to
|
|
1059
|
+
// it, so the model can see what was asked and pick it up.
|
|
1060
|
+
messages.push({ role: 'assistant', content: `[No reply — this request failed: ${what}]` });
|
|
1061
|
+
}
|
|
1032
1062
|
} else {
|
|
1033
1063
|
ledger.note('turn-failed', `A request to the gateway failed${e.status ? ` with HTTP ${e.status}` : ''} partway through the task: ${e.message}. The steps before it are kept; it did not finish.`);
|
|
1034
1064
|
}
|
|
@@ -1698,7 +1728,7 @@ async function slashCommand(verb, args, ctx) {
|
|
|
1698
1728
|
[` ${c.dim('account')}`, creds?.user?.email || 'not signed in'],
|
|
1699
1729
|
[` ${c.dim('directory')}`, ctx.flags.cwd],
|
|
1700
1730
|
[` ${c.dim('context')}`, `${ctx.messages.length} messages · ~${estimateTokens(ctx.messages).toLocaleString()} tokens`],
|
|
1701
|
-
[` ${c.dim('session')}`, `${ctx.spent.turns} turns · ${ctx.spent.tools} tool calls`],
|
|
1731
|
+
[` ${c.dim('session')}`, `${ctx.spent.turns} turns · ${ctx.spent.tools} tool calls${ctx.spent.credits ? ` · ${Math.round(ctx.spent.credits * 10) / 10} credits` : ''}`],
|
|
1702
1732
|
]);
|
|
1703
1733
|
return null;
|
|
1704
1734
|
}
|
|
@@ -1982,7 +2012,7 @@ async function slashCommand(verb, args, ctx) {
|
|
|
1982
2012
|
// sessionState.spent and every reader hold this same object.
|
|
1983
2013
|
const t = target.spent || {};
|
|
1984
2014
|
const n = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
|
|
1985
|
-
Object.assign(ctx.spent, { input: n(t.input), cached: n(t.cached), output: n(t.output), tools: n(t.tools), turns: n(t.turns) });
|
|
2015
|
+
Object.assign(ctx.spent, { input: n(t.input), cached: n(t.cached), output: n(t.output), tools: n(t.tools), turns: n(t.turns), credits: n(t.credits) });
|
|
1986
2016
|
ctx.resetContext?.();
|
|
1987
2017
|
ctx.tui?.setSpent?.(ctx.spent);
|
|
1988
2018
|
if (ctx.tui) replaySession(ctx.tui, target.messages);
|
|
@@ -2528,11 +2558,12 @@ async function slashCommand(verb, args, ctx) {
|
|
|
2528
2558
|
table([
|
|
2529
2559
|
[' Turns', String(spent.turns)],
|
|
2530
2560
|
[' Tool calls', String(spent.tools)],
|
|
2561
|
+
[' Credits', spent.credits ? String(Math.round(spent.credits * 100) / 100) : '—'],
|
|
2531
2562
|
[' Input tokens', spent.input.toLocaleString()],
|
|
2532
2563
|
[' Cached input tokens', (spent.cached || 0).toLocaleString()],
|
|
2533
2564
|
[' Output tokens', spent.output.toLocaleString()],
|
|
2534
2565
|
]);
|
|
2535
|
-
info(`${c.dim('
|
|
2566
|
+
info(`${c.dim('Credits are what your plan counts: real cost, cached context included at its lower price. See')} ${c.bold('/usage')} ${c.dim('for your plan.')}`);
|
|
2536
2567
|
return null;
|
|
2537
2568
|
|
|
2538
2569
|
case '/cwd':
|
|
@@ -2565,9 +2596,13 @@ function planRows(u) {
|
|
|
2565
2596
|
const st = u.planStatus || null;
|
|
2566
2597
|
if (!u.isPaid) return [['Plan', 'Free'], ['Free prompts', `${u.freeRequestsUsed ?? 0} of ${u.freeRequestLimit ?? '?'} used`]];
|
|
2567
2598
|
const rows = [['Plan', st?.name || 'Paid']];
|
|
2568
|
-
|
|
2599
|
+
// Credits: what the plan counts (real cost), the same number the website shows.
|
|
2600
|
+
const cr = (n) => ((Number(n) || 0) >= 1000 ? Math.round(n).toLocaleString('en-IN') : String(Math.round((Number(n) || 0) * 10) / 10));
|
|
2601
|
+
if (st?.monthlyCredits) {
|
|
2569
2602
|
const resets = st.periodResetsAt ? ` · resets ${new Date(st.periodResetsAt).toLocaleDateString()}` : '';
|
|
2570
|
-
rows.push(['This period', `${
|
|
2603
|
+
rows.push(['This period', `${cr(st.periodCredits)} of ${cr(st.monthlyCredits)} credits${resets}`]);
|
|
2604
|
+
if (st.dailyCredits) rows.push(['Today', `${cr(st.todayCredits)} of ${cr(st.dailyCredits)} credits · resets at midnight`]);
|
|
2605
|
+
if (st.periodTokens) rows.push(['Behind them', `${fmtTokens(st.periodTokens)} tokens of input and replies`]);
|
|
2571
2606
|
}
|
|
2572
2607
|
if (u.planExpiresAt) rows.push(['Renews by', new Date(u.planExpiresAt).toLocaleDateString()]);
|
|
2573
2608
|
return rows;
|
|
@@ -2722,7 +2757,7 @@ const commands = {
|
|
|
2722
2757
|
'jaitus config copyOnSelect <on|off> auto-copy mouse selection',
|
|
2723
2758
|
],
|
|
2724
2759
|
detail: [
|
|
2725
|
-
'Settable: model, baseUrl, siteUrl, theme, copyOnSelect, scrollSpeed, wheelAccel, autoScroll, animations, autoCompact, mouse, statusLine,',
|
|
2760
|
+
'Settable: model, baseUrl, siteUrl, theme, copyOnSelect, scrollSpeed, wheelAccel, autoScroll, animations, autoCompact, trimToolResults, mouse, statusLine,',
|
|
2726
2761
|
'braveSearchKey, tavilyKey, serperKey. Stored in ~/.jaitus/settings.json.',
|
|
2727
2762
|
'',
|
|
2728
2763
|
'A search API key is the single biggest improvement available to web_search: the free',
|
|
@@ -2737,7 +2772,7 @@ const commands = {
|
|
|
2737
2772
|
const unset = rest.length === 1 && /^(--unset|none|unset)$/i.test(rest[0]);
|
|
2738
2773
|
const value = unset ? '' : rest.join(' ').trim();
|
|
2739
2774
|
const settings = loadSettings();
|
|
2740
|
-
const ONOFF = ['copyOnSelect', 'wheelAccel', 'autoScroll', 'animations', 'autoCompact', 'mouse', 'autoUpdate'];
|
|
2775
|
+
const ONOFF = ['copyOnSelect', 'wheelAccel', 'autoScroll', 'animations', 'autoCompact', 'trimToolResults', 'mouse', 'autoUpdate'];
|
|
2741
2776
|
const SECRET = ['braveSearchKey', 'tavilyKey', 'serperKey'];
|
|
2742
2777
|
if (!key) {
|
|
2743
2778
|
const e = endpoints();
|
|
@@ -2753,6 +2788,7 @@ const commands = {
|
|
|
2753
2788
|
autoScroll: settings.autoScroll !== false,
|
|
2754
2789
|
animations: settings.animations !== false,
|
|
2755
2790
|
autoCompact: settings.autoCompact !== false,
|
|
2791
|
+
trimToolResults: settings.trimToolResults !== false,
|
|
2756
2792
|
mouse: settings.mouse !== false,
|
|
2757
2793
|
// Presence and length only. A key echoed into a transcript is a key that has
|
|
2758
2794
|
// been published, and the transcript is on disk.
|
|
@@ -2770,6 +2806,7 @@ const commands = {
|
|
|
2770
2806
|
['Auto-scroll', data.autoScroll ? 'on' : 'off'],
|
|
2771
2807
|
['Animations', data.animations ? 'on' : 'off'],
|
|
2772
2808
|
['Auto-compact', data.autoCompact ? 'on (summarises near the context limit)' : 'off'],
|
|
2809
|
+
['Trim old tool output', data.trimToolResults ? 'on (clears old outputs past ~100k tokens, keeps the last 6)' : 'off'],
|
|
2773
2810
|
['Mouse', data.mouse ? 'on (wheel scrolls, clicks act, drag or double-click copies)' : 'off (the terminal selects text itself)'],
|
|
2774
2811
|
['Search API', data.searchApi ? `${data.searchApi} (set)` : 'none — using free engines + npm/GitHub/MDN/Stack Overflow'],
|
|
2775
2812
|
['Status line', data.statusLine || '(unset — set a command to render one)'],
|
package/src/sessions.js
CHANGED
|
@@ -8,6 +8,7 @@ import path from 'node:path';
|
|
|
8
8
|
import crypto from 'node:crypto';
|
|
9
9
|
import { SESSIONS } from './config.js';
|
|
10
10
|
import { c } from './ui.js';
|
|
11
|
+
import { unifiedDiff } from './diff.js';
|
|
11
12
|
|
|
12
13
|
function safeId(id) {
|
|
13
14
|
return String(id || '').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 80);
|
|
@@ -145,7 +146,9 @@ export function replaySession(tui, messages) {
|
|
|
145
146
|
}
|
|
146
147
|
|
|
147
148
|
if (m.role !== 'assistant') continue;
|
|
148
|
-
|
|
149
|
+
// A plain-string assistant turn (a failed request's "[No reply — …]" note) was
|
|
150
|
+
// skipped entirely, as though the turn never happened.
|
|
151
|
+
const blocks = typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : Array.isArray(m.content) ? m.content : [];
|
|
149
152
|
const text = blocks.filter((b) => b?.type === 'text' && b.text).map((b) => b.text).join('\n\n');
|
|
150
153
|
if (text.trim()) {
|
|
151
154
|
if (structured) tui.assistant(text); else tui.print(`${c.brand('◆')} ${text}`, 'assistant');
|
|
@@ -161,7 +164,25 @@ export function replaySession(tui, messages) {
|
|
|
161
164
|
status: outcome ? (outcome.failed ? 'failed' : 'ok') : 'ok',
|
|
162
165
|
file: call.input?.path,
|
|
163
166
|
error: outcome?.failed ? outcome.text.split('\n')[0].slice(0, 200) : '',
|
|
167
|
+
// The whole result, so the row can be opened again after a resume.
|
|
168
|
+
full: outcome ? outcome.text.slice(0, 200_000) : '',
|
|
164
169
|
});
|
|
170
|
+
// An edit reads back as the change it made. The file's full before/after is not
|
|
171
|
+
// in the conversation, but the replaced text and its replacement are.
|
|
172
|
+
if (!outcome?.failed && typeof tui.diff === 'function') {
|
|
173
|
+
const input = call.input || {};
|
|
174
|
+
try {
|
|
175
|
+
if (call.name === 'edit_file') {
|
|
176
|
+
const pairs = (Array.isArray(input.edits) ? input.edits : [input])
|
|
177
|
+
.filter((e) => typeof e?.old === 'string' && typeof e?.new === 'string');
|
|
178
|
+
const patch = pairs.map((e) => unifiedDiff(e.old, e.new, { file: input.path })).filter(Boolean).join('\n');
|
|
179
|
+
if (patch) tui.diff(String(input.path || 'edit'), patch);
|
|
180
|
+
} else if (call.name === 'write_file' && typeof input.content === 'string') {
|
|
181
|
+
const patch = unifiedDiff('', input.content, { file: input.path });
|
|
182
|
+
if (patch) tui.diff(String(input.path || 'file'), patch);
|
|
183
|
+
}
|
|
184
|
+
} catch { /* a missing diff never blocks the replay */ }
|
|
185
|
+
}
|
|
165
186
|
} else {
|
|
166
187
|
tui.print(` ${c.accent('✓')} ${c.dim(`${call.name} ${label}`.slice(0, 100))}`, 'tool');
|
|
167
188
|
}
|
package/src/tui/entries.js
CHANGED
|
@@ -167,8 +167,10 @@ const BUILDERS = {
|
|
|
167
167
|
const style = styles[data.status] || styles.running;
|
|
168
168
|
const icon = pick(TOOL_ICON[data.name] || ['⚙', '*']);
|
|
169
169
|
const rows = [];
|
|
170
|
+
const openable = data.status !== 'running' && expandable(data);
|
|
170
171
|
|
|
171
172
|
const head = [
|
|
173
|
+
...(openable ? [{ t: `${pick(data.expanded ? ['▾', 'v'] : ['▸', '>'])} `, color: p.dim }] : []),
|
|
172
174
|
{ t: `${pick(style.mark)} `, color: style.color },
|
|
173
175
|
{ t: `${icon} `, color: data.status === 'ok' ? p.dim : style.color },
|
|
174
176
|
{ t: prettyName(data.name), color: data.status === 'failed' ? p.red : p.muted, bold: data.status === 'running' },
|
|
@@ -186,8 +188,8 @@ const BUILDERS = {
|
|
|
186
188
|
}
|
|
187
189
|
|
|
188
190
|
// A preview is a sample, never the whole output: a 4000-line test run in the
|
|
189
|
-
// transcript buries the answer that follows it.
|
|
190
|
-
const preview = previewLines(data);
|
|
191
|
+
// transcript buries the answer that follows it. A click on the row opens all of it.
|
|
192
|
+
const preview = data.expanded ? fullLines(data) : previewLines(data);
|
|
191
193
|
if (preview.lines.length) {
|
|
192
194
|
const language = data.name === 'run_command' ? 'sh' : languageForPath(data.file || data.label);
|
|
193
195
|
let state = {};
|
|
@@ -198,7 +200,8 @@ const BUILDERS = {
|
|
|
198
200
|
rows.push([{ t: ` ${pick(['│', '|'])} `, color: p.lineSoft }, ...truncateRow(spans, Math.max(8, width - 6))]);
|
|
199
201
|
}
|
|
200
202
|
if (preview.more > 0) {
|
|
201
|
-
|
|
203
|
+
const hint = openable && !data.expanded ? ' — click the row to show all' : '';
|
|
204
|
+
rows.push([{ t: ` ${pick(['│', '|'])} `, color: p.lineSoft }, { t: `+${preview.more} more line${preview.more === 1 ? '' : 's'}${hint}`, color: p.faint, italic: true }]);
|
|
202
205
|
}
|
|
203
206
|
}
|
|
204
207
|
return rows;
|
|
@@ -211,8 +214,11 @@ const BUILDERS = {
|
|
|
211
214
|
const lines = String(data.patch || '').split('\n');
|
|
212
215
|
const added = lines.filter((l) => l.startsWith('+') && !l.startsWith('+++')).length;
|
|
213
216
|
const removed = lines.filter((l) => l.startsWith('-') && !l.startsWith('---')).length;
|
|
217
|
+
const limit = data.expanded ? DIFF_OPEN_LINES : (data.max || 40);
|
|
218
|
+
const openable = lines.length > (data.max || 40);
|
|
214
219
|
|
|
215
220
|
rows.push([
|
|
221
|
+
...(openable ? [{ t: `${pick(data.expanded ? ['▾', 'v'] : ['▸', '>'])} `, color: p.dim }] : []),
|
|
216
222
|
{ t: `${pick(['◈', '~'])} `, color: p.brand },
|
|
217
223
|
{ t: String(data.file || 'diff'), color: p.body, bold: true },
|
|
218
224
|
{ t: ' ' },
|
|
@@ -222,7 +228,7 @@ const BUILDERS = {
|
|
|
222
228
|
]);
|
|
223
229
|
|
|
224
230
|
const language = languageForPath(data.file);
|
|
225
|
-
const shown = lines.slice(0,
|
|
231
|
+
const shown = lines.slice(0, limit);
|
|
226
232
|
for (const line of shown) {
|
|
227
233
|
if (/^(diff |index |--- |\+\+\+ )/.test(line)) continue;
|
|
228
234
|
if (line.startsWith('@@')) {
|
|
@@ -241,7 +247,8 @@ const BUILDERS = {
|
|
|
241
247
|
}
|
|
242
248
|
}
|
|
243
249
|
if (lines.length > shown.length) {
|
|
244
|
-
|
|
250
|
+
const hint = data.expanded ? '' : ' — click the file name to show all';
|
|
251
|
+
rows.push([{ t: ' ' }, { t: `… ${lines.length - shown.length} more lines${hint}`, color: p.faint, italic: true }]);
|
|
245
252
|
}
|
|
246
253
|
return rows;
|
|
247
254
|
},
|
|
@@ -354,6 +361,30 @@ function bar(value, total, width) {
|
|
|
354
361
|
* puts the command at the top and the failure at the bottom, and a build puts the error
|
|
355
362
|
* last. Showing both ends beats showing the first twenty lines of a stack trace.
|
|
356
363
|
*/
|
|
364
|
+
// How much an opened row shows. Enough for any real diff or test run; a 50,000-line
|
|
365
|
+
// log is still a sample (the head and the tail), never the whole screen's worth of rows.
|
|
366
|
+
const OPEN_LINES = 400;
|
|
367
|
+
const DIFF_OPEN_LINES = 2000;
|
|
368
|
+
|
|
369
|
+
/** Is there more behind this tool row than the collapsed view shows? */
|
|
370
|
+
export function expandable(data) {
|
|
371
|
+
const full = String(data?.full ?? '').trimEnd();
|
|
372
|
+
if (!full) return false;
|
|
373
|
+
const shown = previewLines({ ...data, expanded: false });
|
|
374
|
+
const lines = full.split('\n').length;
|
|
375
|
+
return lines > shown.lines.length || (!shown.lines.length && full.length > 0);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** An opened row: the whole output, or its head and tail past OPEN_LINES. */
|
|
379
|
+
function fullLines(data) {
|
|
380
|
+
const raw = String(data.full ?? data.preview ?? '').replace(/\r\n?/g, '\n').trimEnd();
|
|
381
|
+
if (!raw) return { lines: [], more: 0 };
|
|
382
|
+
const all = raw.split('\n');
|
|
383
|
+
if (all.length <= OPEN_LINES) return { lines: all, more: 0 };
|
|
384
|
+
const head = Math.ceil(OPEN_LINES * 0.75);
|
|
385
|
+
return { lines: [...all.slice(0, head), ...all.slice(all.length - (OPEN_LINES - head))], more: all.length - OPEN_LINES };
|
|
386
|
+
}
|
|
387
|
+
|
|
357
388
|
function previewLines(data) {
|
|
358
389
|
const raw = String(data.preview ?? data.detail ?? '').replace(/\r\n?/g, '\n').trimEnd();
|
|
359
390
|
if (!raw) return { lines: [], more: 0 };
|
package/src/tui/widgets.js
CHANGED
|
@@ -160,6 +160,38 @@ export function Elapsed({ since, color, interval = 1000 }) {
|
|
|
160
160
|
return h(Text, { color: color || p.dim }, label);
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/**
|
|
164
|
+
* One composer line with the cursor drawn OVER the character at the cursor (inverse
|
|
165
|
+
* video, like a terminal's own block cursor) — never inserted between characters.
|
|
166
|
+
*
|
|
167
|
+
* The inserted glyph this replaced took a column of its own, so every character after
|
|
168
|
+
* the cursor moved one cell right as the cursor passed and back as it left ("the
|
|
169
|
+
* characters go left and right while I type"), and on a line that was exactly full the
|
|
170
|
+
* extra cell made the line re-wrap mid-typing. Here the width never changes: the cursor
|
|
171
|
+
* sits on the next character, or on the one spare column kept at the end of the line.
|
|
172
|
+
*/
|
|
173
|
+
export function CursorRow({ left = [], right = [], color }) {
|
|
174
|
+
const p = palette();
|
|
175
|
+
const active = motionOn();
|
|
176
|
+
const { frame } = useAnimation({ interval: 530, isActive: active });
|
|
177
|
+
const on = !active || frame % 2 === 0;
|
|
178
|
+
const rest = right.slice();
|
|
179
|
+
let under = ' ';
|
|
180
|
+
let underSpan = {};
|
|
181
|
+
if (rest.length) {
|
|
182
|
+
const first = { ...rest[0] };
|
|
183
|
+
const chars = Array.from(String(first.t));
|
|
184
|
+
under = chars[0] || ' ';
|
|
185
|
+
underSpan = first;
|
|
186
|
+
if (chars.length > 1) rest[0] = { ...first, t: chars.slice(1).join('') };
|
|
187
|
+
else rest.shift();
|
|
188
|
+
}
|
|
189
|
+
const cursor = on
|
|
190
|
+
? { ...underSpan, t: under, inverse: !color, backgroundColor: color || undefined, color: color ? (p.name === 'light' ? '#ffffff' : '#000000') : underSpan.color }
|
|
191
|
+
: { ...underSpan, t: under };
|
|
192
|
+
return h(Row, { spans: [...left, cursor, ...rest] });
|
|
193
|
+
}
|
|
194
|
+
|
|
163
195
|
/** A block cursor that blinks. Off under reduced motion, where it stands solid. */
|
|
164
196
|
export function Caret({ color, char = '▏' }) {
|
|
165
197
|
const p = palette();
|
package/src/tui-ink.js
CHANGED
|
@@ -26,9 +26,9 @@ import path from 'node:path';
|
|
|
26
26
|
import React from 'react';
|
|
27
27
|
import { Box, Spacer, Text, render, useAnimation, useApp, useCursor, useInput, usePaste, useWindowSize } from 'ink';
|
|
28
28
|
import { palette, resetPalette, mix, ramp } from './tui/theme.js';
|
|
29
|
-
import { makeEntry, rowsFor, invalidate } from './tui/entries.js';
|
|
29
|
+
import { makeEntry, rowsFor, invalidate, expandable } from './tui/entries.js';
|
|
30
30
|
import { plain, stringWidth, truncateRow, wrapRow, rowText, inverseRange } from './tui/ansi.js';
|
|
31
|
-
import { Spinner, Shimmer, GradientText, FlowingRule, Meter, Elapsed,
|
|
31
|
+
import { Spinner, Shimmer, GradientText, FlowingRule, Meter, Elapsed, CursorRow, Rows, Row, Reveal, motionOn, publishSettings } from './tui/widgets.js';
|
|
32
32
|
import { wordRows, wordSize } from './tui/wordmark.js';
|
|
33
33
|
import { enableMouse, disableMouse, parseMouse, isMouse, isMouseFragment, createHitMap } from './tui/mouse.js';
|
|
34
34
|
import { openUrl } from './open.js';
|
|
@@ -320,21 +320,25 @@ function Hero({ state, width, height }) {
|
|
|
320
320
|
function layoutTranscript(state, width, height, top) {
|
|
321
321
|
const inner = Math.max(24, width - 2);
|
|
322
322
|
const rows = [];
|
|
323
|
+
// Which entry each row belongs to, and whether it is that entry's first row — so a
|
|
324
|
+
// click on a tool row's or a diff's header line can open or close it.
|
|
325
|
+
const owners = [];
|
|
323
326
|
// Where each prompt starts, so the sticky header can name the one whose answer is
|
|
324
327
|
// currently on screen rather than always the newest.
|
|
325
328
|
const prompts = [];
|
|
326
329
|
for (const entry of state.transcript) {
|
|
327
330
|
const built = rowsFor(entry, inner);
|
|
328
331
|
if (!built.length) continue;
|
|
329
|
-
if (rows.length) rows.push([]);
|
|
332
|
+
if (rows.length) { rows.push([]); owners.push(null); }
|
|
330
333
|
if (entry.kind === 'user') prompts.push({ at: rows.length, text: String(entry.data.text || '') });
|
|
331
|
-
rows.push(
|
|
334
|
+
built.forEach((row, i) => { rows.push(row); owners.push({ entry, head: i === 0 }); });
|
|
332
335
|
}
|
|
333
336
|
if (state.live && state.live.text) {
|
|
334
337
|
const built = rowsFor(state.live.entry, inner);
|
|
335
338
|
if (built.length) {
|
|
336
|
-
if (rows.length) rows.push([]);
|
|
339
|
+
if (rows.length) { rows.push([]); owners.push(null); }
|
|
337
340
|
rows.push(...built);
|
|
341
|
+
for (let i = 0; i < built.length; i++) owners.push(null);
|
|
338
342
|
}
|
|
339
343
|
}
|
|
340
344
|
|
|
@@ -355,6 +359,7 @@ function layoutTranscript(state, width, height, top) {
|
|
|
355
359
|
const end = rows.length - offset;
|
|
356
360
|
const first = Math.max(0, end - height);
|
|
357
361
|
const visible = rows.slice(first, end);
|
|
362
|
+
const visibleOwners = owners.slice(first, end);
|
|
358
363
|
// Bottom-anchored: a short conversation sits at the composer, not floating at the top
|
|
359
364
|
// of an empty screen with the input a dozen rows below it.
|
|
360
365
|
const pad = Math.max(0, height - visible.length);
|
|
@@ -363,6 +368,7 @@ function layoutTranscript(state, width, height, top) {
|
|
|
363
368
|
// The drag handler needs the text under the pointer, and it must be the text actually
|
|
364
369
|
// on screen rather than the transcript's idea of it.
|
|
365
370
|
state.screen = { top: screenTop, left: 2, rows: visible };
|
|
371
|
+
state.totalRows = rows.length;
|
|
366
372
|
|
|
367
373
|
// The sticky header names the prompt whose answer you are looking at — the last one
|
|
368
374
|
// that has scrolled off the top. Scroll far enough that the prompt itself is back on
|
|
@@ -370,7 +376,53 @@ function layoutTranscript(state, width, height, top) {
|
|
|
370
376
|
const owning = prompts.filter((entry) => entry.at < first).at(-1);
|
|
371
377
|
state.stickyPrompt = owning ? owning.text : '';
|
|
372
378
|
|
|
373
|
-
return { visible, pad, screenTop };
|
|
379
|
+
return { visible, pad, screenTop, owners: visibleOwners };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Can a click open this entry? A finished tool row with more behind it, or a long diff. */
|
|
383
|
+
function openable(entry) {
|
|
384
|
+
if (!entry) return false;
|
|
385
|
+
if (entry.kind === 'tool') return entry.data.status !== 'running' && expandable(entry.data);
|
|
386
|
+
if (entry.kind === 'diff') return String(entry.data.patch || '').split('\n').length > (entry.data.max || 40);
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** The header line of every openable entry on screen is a click target. */
|
|
391
|
+
function registerToggleHits(owners, screenTop, width, hits) {
|
|
392
|
+
if (!hits || !owners) return;
|
|
393
|
+
owners.forEach((owner, index) => {
|
|
394
|
+
if (!owner?.head || !openable(owner.entry)) return;
|
|
395
|
+
hits.add({ x1: 1, x2: width, y1: screenTop + index, y2: screenTop + index, action: { type: 'toggle', entry: owner.entry } });
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Credits as the website writes them: 1,234 · 45.6 · 0.42. */
|
|
400
|
+
export function fmtCredits(n) {
|
|
401
|
+
const v = Number(n) || 0;
|
|
402
|
+
if (v >= 1000) return Math.round(v).toLocaleString('en-IN');
|
|
403
|
+
if (v >= 10) return String(Math.round(v * 10) / 10);
|
|
404
|
+
return String(Math.round(v * 100) / 100);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Is this burst of input a paste rather than typing? True when a line break is followed
|
|
409
|
+
* by at least two more characters, or when it holds two or more line breaks outside a
|
|
410
|
+
* single \r\n pair. "abc\r" (typed fast, then Enter) is typing.
|
|
411
|
+
*/
|
|
412
|
+
export function isPasteBurst(text) {
|
|
413
|
+
const s = String(text || '');
|
|
414
|
+
const breaks = (s.replace(/\r\n/g, '\n').match(/[\r\n]/g) || []).length;
|
|
415
|
+
if (breaks >= 2) return true;
|
|
416
|
+
return /[\r\n][^\r\n\x1b]{2,}/.test(s);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* The key that pastes an image here. Windows Terminal and VS Code keep Ctrl+V for their
|
|
421
|
+
* own text paste, so on Windows it is Alt+V; on macOS Cmd+V is the terminal's and Ctrl+V
|
|
422
|
+
* reaches the app (Option+V types "√" unless Option is set to act as Meta).
|
|
423
|
+
*/
|
|
424
|
+
export function imagePasteHint(platform = process.platform) {
|
|
425
|
+
return platform === 'win32' ? 'alt+v paste image' : 'ctrl+v paste image';
|
|
374
426
|
}
|
|
375
427
|
|
|
376
428
|
/**
|
|
@@ -572,19 +624,20 @@ function Composer({ state, width }) {
|
|
|
572
624
|
// The caret sits AT the cursor. It used to be drawn after the whole line, so
|
|
573
625
|
// moving left with the arrow keys showed no movement at all while typing went
|
|
574
626
|
// in somewhere in the middle.
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
h(Row, { spans: left, wrap: 'wrap' }),
|
|
627
|
+
// One text run per line, the cursor drawn over the character at the cursor
|
|
628
|
+
// (CursorRow) — the line's width never changes as the cursor moves.
|
|
578
629
|
// The caret is always present, including at an empty prompt. Hiding it there
|
|
579
630
|
// was meant to keep it off the placeholder, but the result is a prompt with
|
|
580
631
|
// no cursor at all — there is nothing on screen saying the app is waiting for
|
|
581
632
|
// typing, which is the one thing a prompt has to say. The placeholder sits
|
|
582
633
|
// AFTER the caret instead, so the caret is at the insertion point either way.
|
|
583
|
-
index
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
634
|
+
if (index !== caretLine) return h(Box, { key: index }, h(Row, { spans: row }));
|
|
635
|
+
const [left, right] = splitSpansAt(row, caretCol);
|
|
636
|
+
return h(Box, { key: index, flexDirection: 'row' },
|
|
637
|
+
h(CursorRow, { left, right, color: p.brand }),
|
|
638
|
+
!state.input && index === 0
|
|
639
|
+
? h(Text, { color: p.faint, italic: true, wrap: 'truncate-end' }, placeholder)
|
|
640
|
+
: null,
|
|
588
641
|
);
|
|
589
642
|
}),
|
|
590
643
|
),
|
|
@@ -645,7 +698,9 @@ function composerLayout(state, width, spans = null) {
|
|
|
645
698
|
const cursor = Math.max(0, Math.min(input.length, state.cursor ?? input.length));
|
|
646
699
|
for (const para of paragraphs) {
|
|
647
700
|
const text = para.map((s) => s.t).join('');
|
|
648
|
-
|
|
701
|
+
// One column is kept spare for the cursor to sit on after a line's last character,
|
|
702
|
+
// so a full line never grows a cell when the cursor reaches its end.
|
|
703
|
+
const rows = para.length ? wrapRow(para, Math.max(4, width - 1)) : [[]];
|
|
649
704
|
if (cursor >= start && cursor <= start + text.length) {
|
|
650
705
|
let rowStart = start;
|
|
651
706
|
let line = rows.length - 1;
|
|
@@ -704,7 +759,10 @@ function StatusBar({ state, width }) {
|
|
|
704
759
|
: [
|
|
705
760
|
['ctrl+p palette', p.faint],
|
|
706
761
|
['shift+tab mode', p.faint],
|
|
707
|
-
|
|
762
|
+
// How to paste a screenshot was written only in the README, and the key people
|
|
763
|
+
// try first (Ctrl+V in Windows Terminal, Cmd+V on a Mac) belongs to the terminal,
|
|
764
|
+
// which has no text to paste and sends the app nothing.
|
|
765
|
+
[imagePasteHint(), p.faint],
|
|
708
766
|
['esc esc rewind', p.faint],
|
|
709
767
|
// Mouse reporting takes the terminal's own text selection away, so the way to
|
|
710
768
|
// get it back has to be on screen — otherwise the first thing a new user
|
|
@@ -720,7 +778,12 @@ function StatusBar({ state, width }) {
|
|
|
720
778
|
if (state.spent?.turns) {
|
|
721
779
|
right.push({ t: `${state.spent.turns} turn${state.spent.turns === 1 ? '' : 's'}`, color: p.faint });
|
|
722
780
|
right.push({ t: ` ${g('·', '|')} `, color: p.faint });
|
|
723
|
-
|
|
781
|
+
// Credits — what the plan counts — when the gateway reports them. The raw token sum
|
|
782
|
+
// (input + cached + output) this used to show was ~20× the number the plan counted,
|
|
783
|
+
// for the same session, and matched neither the allowance nor the cost.
|
|
784
|
+
right.push(state.spent.credits
|
|
785
|
+
? { t: `${fmtCredits(state.spent.credits)} credits`, color: p.faint }
|
|
786
|
+
: { t: `${((state.spent.input || 0) + (state.spent.cached || 0) + (state.spent.output || 0)).toLocaleString()} tok`, color: p.faint });
|
|
724
787
|
}
|
|
725
788
|
// The counter on the right keeps its width; the hints get what is left, with a gap.
|
|
726
789
|
// Both used to shrink together, so the hints were cut off flush against the counter
|
|
@@ -1015,6 +1078,7 @@ function App({ controller }) {
|
|
|
1015
1078
|
? { visible: [], pad: 0, screenTop: transcriptTop + 1 }
|
|
1016
1079
|
: layoutTranscript(state, width, transcriptHeight, transcriptTop);
|
|
1017
1080
|
hits.add({ x1: 1, x2: width, y1: transcriptTop + 1, y2: transcriptTop + transcriptHeight, action: { type: 'transcript' } });
|
|
1081
|
+
registerToggleHits(layout.owners, layout.screenTop, width, hits);
|
|
1018
1082
|
registerLinkHits(layout.visible, layout.screenTop, hits);
|
|
1019
1083
|
registerHeaderHits(state, width, hits);
|
|
1020
1084
|
hits.add({ x1: 1, x2: width, y1: height, y2: height, action: { type: 'status' } });
|
|
@@ -1132,7 +1196,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1132
1196
|
onLine: null, onEscape: null, onRewind: null, onCommand: null, onCopy: null, onModeCycle,
|
|
1133
1197
|
debug: !!process.env.JAITUS_DEBUG, introDone: false,
|
|
1134
1198
|
turnStartedAt: null, turnTokens: 0,
|
|
1135
|
-
spent: { turns: 0, input: 0, cached: 0, output: 0 },
|
|
1199
|
+
spent: { turns: 0, input: 0, cached: 0, output: 0, credits: 0 },
|
|
1136
1200
|
meta: { model: 'loading…', mode: 'default', ctx: '0 tok', ctxUsed: 0, ctxMax: 1, intent: 'build', ...meta },
|
|
1137
1201
|
};
|
|
1138
1202
|
|
|
@@ -1280,7 +1344,9 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1280
1344
|
*/
|
|
1281
1345
|
function handlePaste(text) {
|
|
1282
1346
|
const value = String(text ?? '').replace(/\r\n?/g, '\n');
|
|
1283
|
-
|
|
1347
|
+
// An empty paste is what a terminal sends for its paste key when the clipboard holds
|
|
1348
|
+
// only an image — it has no text to give. The image is right there: fetch it.
|
|
1349
|
+
if (!value) { void pasteFromClipboard({ imageOnly: true }); return; }
|
|
1284
1350
|
// Dragging image files onto the terminal pastes their paths: attach them instead.
|
|
1285
1351
|
const images = imagePathsIn(value);
|
|
1286
1352
|
if (images) { void attachImageFiles(images); return; }
|
|
@@ -1323,7 +1389,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1323
1389
|
await attachImageFiles([file], { name: 'clipboard image' });
|
|
1324
1390
|
return;
|
|
1325
1391
|
}
|
|
1326
|
-
flash(imageOnly ? 'No image on the clipboard' :
|
|
1392
|
+
flash(imageOnly ? 'No image on the clipboard — copy a screenshot first' : `The clipboard is empty · ${imagePasteHint()}`, 'info', 1800);
|
|
1327
1393
|
} catch {
|
|
1328
1394
|
flash('Could not read the clipboard', 'warn', 2000);
|
|
1329
1395
|
} finally {
|
|
@@ -1664,6 +1730,26 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1664
1730
|
return;
|
|
1665
1731
|
case 'status':
|
|
1666
1732
|
return openPalette();
|
|
1733
|
+
case 'toggle': {
|
|
1734
|
+
// Open or close a tool row / diff. Everything it adds or removes is BELOW the
|
|
1735
|
+
// header, and the scroll offset counts rows up from the bottom — so the offset
|
|
1736
|
+
// moves by exactly that much and the header stays under the pointer.
|
|
1737
|
+
const entry = region.action.entry;
|
|
1738
|
+
const inner = Math.max(24, (state._width || process.stdout.columns || 80) - 2);
|
|
1739
|
+
const rowsOf = () => rowsFor(entry, inner).length;
|
|
1740
|
+
const before = rowsOf();
|
|
1741
|
+
entry.data.expanded = !entry.data.expanded;
|
|
1742
|
+
entry._w = -1;
|
|
1743
|
+
entry._rows = null;
|
|
1744
|
+
const delta = rowsOf() - before;
|
|
1745
|
+
if (delta !== 0) {
|
|
1746
|
+
state.viewingTranscript = true;
|
|
1747
|
+
state.transcriptOffset = Math.max(0, (state.transcriptOffset || 0) + delta);
|
|
1748
|
+
}
|
|
1749
|
+
clearTimeout(returnToLiveTimer);
|
|
1750
|
+
notifyNow();
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1667
1753
|
case 'transcript':
|
|
1668
1754
|
// A click in the body is how you get back to the live end after scrolling.
|
|
1669
1755
|
// Deferred, and cancelled by a second click: a double-click to select a word
|
|
@@ -1841,6 +1927,16 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
|
|
|
1841
1927
|
// Fast PTYs on Windows deliver a typed line and its carriage return in one chunk.
|
|
1842
1928
|
// Split here or the return becomes a literal character in the composer.
|
|
1843
1929
|
const newlineAt = String(input || '').search(/[\r\n]/);
|
|
1930
|
+
// A paste the terminal did not mark as one (no bracketed-paste codes — some Windows
|
|
1931
|
+
// consoles, SSH, right-click paste in older hosts) arrives as a burst with a return
|
|
1932
|
+
// at every line end, and each return used to SEND: pasting a 17-line error log sent
|
|
1933
|
+
// 16 messages in a few seconds (seen in production, all refused). A person never
|
|
1934
|
+
// types a return and then more text inside one burst; a paste always does. So a
|
|
1935
|
+
// burst with text after a line break, or several line breaks, is a paste.
|
|
1936
|
+
if (newlineAt >= 0 && isPasteBurst(String(input))) {
|
|
1937
|
+
handlePaste(String(input).replace(/[\r\n]+$/, ''));
|
|
1938
|
+
return notifyNow();
|
|
1939
|
+
}
|
|
1844
1940
|
if (newlineAt >= 0) {
|
|
1845
1941
|
const text = String(input);
|
|
1846
1942
|
const typed = text.slice(0, newlineAt);
|