jaitus 0.3.6 → 0.3.8

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.
@@ -0,0 +1,102 @@
1
+ ---
2
+ description: Load BEFORE building or restyling anything people look at — a website, landing page, web app screen, dashboard, component, HTML email, or any HTML/CSS/React/Vue/Svelte UI. Makes it look designed and professional instead of generic.
3
+ ---
4
+
5
+ # Designing interfaces that look made by a designer
6
+
7
+ The default look of generated UI is instantly recognisable and reads as cheap: a blue or
8
+ purple gradient hero, Inter or system font everywhere, three identical rounded cards with
9
+ emoji icons, grey text on white, soft shadows on everything, centred everything. Avoid all
10
+ of it. Every screen must look like a deliberate choice by someone with taste.
11
+
12
+ ## 1. Decide the direction before writing code
13
+
14
+ Write these down (in your head or a comment) first — they drive every later decision:
15
+
16
+ - **Who it is for and what it must make them feel** (trust, speed, calm, delight, authority).
17
+ - **One visual direction**, named concretely. Pick what suits the subject, e.g.:
18
+ editorial (serif display, generous whitespace, rules and columns) · Swiss/International
19
+ (strict grid, one sans, big type, flat colour) · warm product (off-white paper, soft ink,
20
+ one saturated accent) · dark technical (near-black, luminous accent, mono details) ·
21
+ brutalist (raw borders, heavy type, no radius) · luxury (restrained, large margins, thin
22
+ serif, muted palette) · playful (bold colour blocks, chunky type, big radius).
23
+ - **The one memorable thing** — an oversized headline, an unusual colour, a striking
24
+ layout, a detail. A design with nothing memorable is the generic one.
25
+
26
+ If the project already has a design system, tokens, a brand colour or a component library,
27
+ **follow it** — consistency beats novelty. Read the existing CSS/theme files first.
28
+
29
+ ## 2. Tokens first
30
+
31
+ Define design tokens as CSS custom properties (or the theme file of the framework in use)
32
+ before any component, and use only them afterwards:
33
+
34
+ - **Colour:** a background, a surface, 2–3 text shades, ONE accent, borders, and semantic
35
+ success/warning/danger. Neutrals should be tinted toward the accent's hue (warm or cool
36
+ greys), never flat `#808080`. Avoid pure `#000` on pure `#fff` for large areas — use ink
37
+ like `#16181d` on paper like `#faf8f5`. Accent used sparingly: buttons, links, one
38
+ highlight. Check contrast: body text ≥ 4.5:1, large text ≥ 3:1.
39
+ - **Type:** two families at most — a characterful display face and a readable text face
40
+ (or one family with strong weight contrast). Good free pairs (Google Fonts):
41
+ Fraunces + Inter Tight · Instrument Serif + Geist · Space Grotesk + IBM Plex Sans ·
42
+ DM Serif Display + DM Sans · Playfair Display + Source Sans 3 · JetBrains Mono for code.
43
+ A modular scale (e.g. 1.25): 12 · 14 · 16 · 20 · 25 · 31 · 39 · 49 · 61. Headlines tight
44
+ (line-height 1.05–1.15, letter-spacing −0.01 to −0.03em); body 1.5–1.65; measure
45
+ 60–75 characters. Use weight and size for hierarchy, not colour alone.
46
+ - **Space:** a 4 or 8px scale (4 8 12 16 24 32 48 64 96 128). Be generous — cramped is the
47
+ most common amateur tell. Sections breathe with 96–160px vertical padding on desktop.
48
+ - **Radius and elevation:** choose one radius personality (0, 4–6, or 12–16) and keep it.
49
+ Prefer borders and background shifts over shadows; when shadows are used, make them
50
+ subtle and layered (`0 1px 2px rgb(0 0 0 / .06), 0 8px 24px rgb(0 0 0 / .06)`).
51
+
52
+ ## 3. Layout
53
+
54
+ - Build on a real grid (12 columns, or a clear 2–3 column system) with a max content width
55
+ (1120–1280px) and consistent gutters.
56
+ - Break symmetry on purpose: asymmetric hero (text left, visual right, offset), a wide
57
+ feature next to narrow ones, a pull quote, a full-bleed band. Not every section centred.
58
+ - Establish hierarchy in every section: one thing clearly first, then second, then detail.
59
+ - Align everything to the grid; mismatched left edges look broken.
60
+ - Mobile first-class: stack sensibly, keep 16–20px side padding, tap targets ≥ 44px, no
61
+ horizontal scroll. Test at 375px and 1440px widths mentally or in a browser.
62
+
63
+ ## 4. Components and detail
64
+
65
+ - **Buttons:** one primary style (solid accent), one secondary (outline or ghost); clear
66
+ hover, focus-visible (a visible ring), active and disabled states. Label with verbs.
67
+ - **Icons:** one consistent SVG set (Lucide, Heroicons, Phosphor, Tabler) at one stroke
68
+ weight — never emoji as icons.
69
+ - **Imagery:** real content or tasteful placeholders — CSS/SVG shapes, a gradient mesh used
70
+ subtly, product mock frames. Never broken image links or stock-photo clichés.
71
+ - **Copy:** specific and concrete; no "Lorem ipsum", no "Welcome to our website", no
72
+ "Revolutionize your workflow". Real numbers and nouns.
73
+ - **States:** empty, loading (skeletons that match the layout), error (says what happened
74
+ and what to do), success. Forms: labels above inputs, helpful inline errors.
75
+ - **Motion:** small and purposeful — 150–250ms ease-out for hover/press, a staggered fade-up
76
+ on first view at most. Respect `prefers-reduced-motion`.
77
+ - **Dark mode** if the product warrants it: redefine the tokens, don't invert colours; dark
78
+ surfaces are dark grey (`#111318`), not black, with slightly desaturated accents.
79
+
80
+ ## 5. Things that make it look generic — don't
81
+
82
+ - Purple/blue gradient backgrounds, gradient text on everything, glowing blobs by default.
83
+ - Three equal cards with an emoji, a bold title and two lines of grey text as the main idea.
84
+ - Every section centred with the same padding and the same card component.
85
+ - Default Tailwind blue (`blue-500`) or Bootstrap primary as the brand colour.
86
+ - Heavy drop shadows, borders AND shadows AND gradients on the same element.
87
+ - Tiny low-contrast grey text; ALL CAPS paragraphs; more than two font families.
88
+
89
+ ## 6. Build, then review like a designer
90
+
91
+ After building, check honestly and fix before reporting done:
92
+
93
+ 1. Squint test: is the hierarchy obvious — what is first, second, third?
94
+ 2. Is the spacing consistent and generous? Any cramped block or misaligned edge?
95
+ 3. Does the palette hold together — one accent, tinted neutrals, contrast passes?
96
+ 4. Is there one memorable, distinctive element? Would a designer recognise a direction?
97
+ 5. Every interactive element has hover and focus states; works at 375px wide.
98
+ 6. Nothing from section 5 slipped in.
99
+
100
+ If a browser or screenshot tool is available, look at the result and iterate. Deliver
101
+ single-file HTML with inline CSS unless the project has its own structure; load fonts from
102
+ Google Fonts with `display=swap` and give a system fallback stack.
package/src/skills.js CHANGED
@@ -8,6 +8,10 @@
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import os from 'node:os';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ // Skills that ship with Jaitus. A project or personal skill with the same name wins.
14
+ const BUILTIN = path.join(path.dirname(fileURLToPath(import.meta.url)), 'skills', 'builtin');
11
15
 
12
16
  const MAX_SKILL = 32_000;
13
17
 
@@ -47,6 +51,7 @@ export function discoverSkills(cwd) {
47
51
  [path.join(cwd, '.claude', 'skills'), 'project'],
48
52
  [path.join(os.homedir(), '.jaitus', 'skills'), 'user'],
49
53
  [path.join(os.homedir(), '.claude', 'skills'), 'user'],
54
+ [BUILTIN, 'builtin'],
50
55
  ];
51
56
  const skills = new Map();
52
57
  for (const [root, scope] of roots) {
@@ -58,5 +63,5 @@ export function discoverSkills(cwd) {
58
63
  export function skillCatalog(skills) {
59
64
  const entries = Object.values(skills || {});
60
65
  if (!entries.length) return '';
61
- return `\n\nAvailable project workflows (invoke with /<name>):\n${entries.map((s) => `- /${s.name}: ${s.description}`).join('\n')}`;
66
+ return `\n\nSkills — instructions for particular kinds of work. When the task matches one, call load_skill with its name before you start, and follow it. The user can also run one as /<name>.\n${entries.map((s) => `- ${s.name}: ${s.description}`).join('\n')}`;
62
67
  }
@@ -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,44 @@ 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);
374
417
  }
375
418
 
376
419
  /**
@@ -581,19 +624,20 @@ function Composer({ state, width }) {
581
624
  // The caret sits AT the cursor. It used to be drawn after the whole line, so
582
625
  // moving left with the arrow keys showed no movement at all while typing went
583
626
  // in somewhere in the middle.
584
- const [left, right] = index === caretLine ? splitSpansAt(row, caretCol) : [row, []];
585
- return h(Box, { key: index, flexDirection: 'row' },
586
- 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.
587
629
  // The caret is always present, including at an empty prompt. Hiding it there
588
630
  // was meant to keep it off the placeholder, but the result is a prompt with
589
631
  // no cursor at all — there is nothing on screen saying the app is waiting for
590
632
  // typing, which is the one thing a prompt has to say. The placeholder sits
591
633
  // AFTER the caret instead, so the caret is at the insertion point either way.
592
- index === caretLine ? h(Caret, { color: p.brand, char: g('▏', '_') }) : null,
593
- right.length ? h(Row, { spans: right, wrap: 'wrap' }) : null,
594
- !state.input && index === 0
595
- ? h(Text, { color: p.faint, italic: true, wrap: 'truncate-end' }, placeholder)
596
- : 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,
597
641
  );
598
642
  }),
599
643
  ),
@@ -654,7 +698,9 @@ function composerLayout(state, width, spans = null) {
654
698
  const cursor = Math.max(0, Math.min(input.length, state.cursor ?? input.length));
655
699
  for (const para of paragraphs) {
656
700
  const text = para.map((s) => s.t).join('');
657
- 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)) : [[]];
658
704
  if (cursor >= start && cursor <= start + text.length) {
659
705
  let rowStart = start;
660
706
  let line = rows.length - 1;
@@ -732,7 +778,12 @@ function StatusBar({ state, width }) {
732
778
  if (state.spent?.turns) {
733
779
  right.push({ t: `${state.spent.turns} turn${state.spent.turns === 1 ? '' : 's'}`, color: p.faint });
734
780
  right.push({ t: ` ${g('·', '|')} `, color: p.faint });
735
- 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 });
736
787
  }
737
788
  // The counter on the right keeps its width; the hints get what is left, with a gap.
738
789
  // Both used to shrink together, so the hints were cut off flush against the counter
@@ -1027,6 +1078,7 @@ function App({ controller }) {
1027
1078
  ? { visible: [], pad: 0, screenTop: transcriptTop + 1 }
1028
1079
  : layoutTranscript(state, width, transcriptHeight, transcriptTop);
1029
1080
  hits.add({ x1: 1, x2: width, y1: transcriptTop + 1, y2: transcriptTop + transcriptHeight, action: { type: 'transcript' } });
1081
+ registerToggleHits(layout.owners, layout.screenTop, width, hits);
1030
1082
  registerLinkHits(layout.visible, layout.screenTop, hits);
1031
1083
  registerHeaderHits(state, width, hits);
1032
1084
  hits.add({ x1: 1, x2: width, y1: height, y2: height, action: { type: 'status' } });
@@ -1144,7 +1196,7 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
1144
1196
  onLine: null, onEscape: null, onRewind: null, onCommand: null, onCopy: null, onModeCycle,
1145
1197
  debug: !!process.env.JAITUS_DEBUG, introDone: false,
1146
1198
  turnStartedAt: null, turnTokens: 0,
1147
- spent: { turns: 0, input: 0, cached: 0, output: 0 },
1199
+ spent: { turns: 0, input: 0, cached: 0, output: 0, credits: 0 },
1148
1200
  meta: { model: 'loading…', mode: 'default', ctx: '0 tok', ctxUsed: 0, ctxMax: 1, intent: 'build', ...meta },
1149
1201
  };
1150
1202
 
@@ -1678,6 +1730,26 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
1678
1730
  return;
1679
1731
  case 'status':
1680
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
+ }
1681
1753
  case 'transcript':
1682
1754
  // A click in the body is how you get back to the live end after scrolling.
1683
1755
  // Deferred, and cancelled by a second click: a double-click to select a word
@@ -1855,6 +1927,16 @@ export function createTui({ slashCommands = [], fileProvider = null, meta = {},
1855
1927
  // Fast PTYs on Windows deliver a typed line and its carriage return in one chunk.
1856
1928
  // Split here or the return becomes a literal character in the composer.
1857
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
+ }
1858
1940
  if (newlineAt >= 0) {
1859
1941
  const text = String(input);
1860
1942
  const typed = text.slice(0, newlineAt);