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 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 with `Alt+V`, type `/image path.png`, or
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.5",
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
- const usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
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
- row.update({ status: 'ok', took, preview: previewFor(call.name, output) });
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: 'Your plan\'s daily allowance resets tomorrow. /usage shows where you are.' },
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
- extraSystem: `${skillCatalog(skills)}\n\nCurrent intent: ${intentMode}. ${intentGuidance[intentMode]}${flags.appendSystemPrompt ? `\n\nAdditional instructions:\n${flags.appendSystemPrompt}` : ''}`,
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
- messages.pop();
1031
- ledger.note('turn-failed', `A request to the gateway failed${e.status ? ` with HTTP ${e.status}` : ''}: ${e.message}. That turn was discarded and the user's message was not answered.`);
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('Billed usage is what your account records — see')} ${c.bold('jaitus usage')}.`);
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
- if (st?.monthlyTokens) {
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', `${fmtTokens(st.periodTokens)} of ${fmtTokens(st.monthlyTokens)} tokens${resets}`]);
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
- const blocks = Array.isArray(m.content) ? m.content : [];
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
  }
@@ -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
- rows.push([{ t: ` ${pick(['│', '|'])} `, color: p.lineSoft }, { t: `+${preview.more} more line${preview.more === 1 ? '' : 's'}`, color: p.faint, italic: true }]);
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, data.max || 40);
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
- rows.push([{ t: ' ' }, { t: `… ${lines.length - shown.length} more lines`, color: p.faint, italic: true }]);
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 };
@@ -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, Caret, Rows, Row, Reveal, motionOn, publishSettings } from './tui/widgets.js';
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(...built);
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
- const [left, right] = index === caretLine ? splitSpansAt(row, caretCol) : [row, []];
576
- return h(Box, { key: index, flexDirection: 'row' },
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 === caretLine ? h(Caret, { color: p.brand, char: g('▏', '_') }) : null,
584
- right.length ? h(Row, { spans: right, wrap: 'wrap' }) : null,
585
- !state.input && index === 0
586
- ? h(Text, { color: p.faint, italic: true, wrap: 'truncate-end' }, placeholder)
587
- : null,
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
- const rows = para.length ? wrapRow(para, width) : [[]];
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
- ['ctrl+l clear', p.faint],
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
- right.push({ t: `${((state.spent.input || 0) + (state.spent.cached || 0) + (state.spent.output || 0)).toLocaleString()} tok`, color: p.faint });
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
- if (!value) return;
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' : 'The clipboard is empty', 'info', 1600);
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);