@ryuu-reinzz/ryuu-cli 0.4.2 → 0.5.0

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.
@@ -311,6 +311,15 @@ export class Renderer {
311
311
  return this.c.hex(PALETTE[name] || PALETTE.white)(String(text));
312
312
  }
313
313
 
314
+ /**
315
+ * Tool name colour: built-ins stay flat blue, plugin tools are sky blue and
316
+ * bold so an installed integration is visible at a glance.
317
+ */
318
+ #toolName(name, plugin) {
319
+ const text = String(name ?? '');
320
+ return plugin ? this.c.bold(this.c.hex(PALETTE.sky)(text)) : this.c.hex(PALETTE.blue)(text);
321
+ }
322
+
314
323
  #visibleLength(text) {
315
324
  return displayWidth(text, { ansi: true });
316
325
  }
@@ -510,6 +519,29 @@ export class Renderer {
510
519
  for (const line of String(text).split('\n')) this.#write(line);
511
520
  }
512
521
 
522
+ /**
523
+ * A framed monospace block with a `⧉ copy` chip — used by commands that must
524
+ * show something copyable, such as a login URL. It reuses the Markdown code
525
+ * block path, so the whole box is the clipboard hit target.
526
+ */
527
+ showCode({ label = '', text = '' } = {}) {
528
+ const body = String(text ?? '');
529
+ if (!body) return;
530
+ // A piped or plain run has no frame and no click target: print the value.
531
+ if (this.plain) {
532
+ this.lines(body);
533
+ return;
534
+ }
535
+ if (/(^|\n)\s*```/.test(body)) {
536
+ this.lines(body);
537
+ return;
538
+ }
539
+ const lang = String(label ?? '')
540
+ .replace(/[^A-Za-z0-9_+#.-]+/g, '-')
541
+ .replace(/^-+|-+$/g, '');
542
+ this.#markdown('```' + `${lang}\n${body}\n` + '```');
543
+ }
544
+
513
545
  banner({ version, model, provider, workspace, node, approvalPolicy, logFile }) {
514
546
  const c = this.c;
515
547
  if (this.plain) {
@@ -1344,7 +1376,7 @@ export class Renderer {
1344
1376
  }
1345
1377
  }
1346
1378
 
1347
- toolStart({ name, args, index = 0, total = 0, detail = '' }) {
1379
+ toolStart({ name, args, index = 0, total = 0, detail = '', plugin = null }) {
1348
1380
  if (this.reasoning.active) this.reasoningEnd({});
1349
1381
  if (this.answer.active) this.answerEnd();
1350
1382
  // Running a tool is exactly the `working` phase the user asked for.
@@ -1365,11 +1397,11 @@ export class Renderer {
1365
1397
  return;
1366
1398
  }
1367
1399
  this.#write(
1368
- ` ${this.c.dim('│')} ${this.#paint('violet', '◆')} ${this.c.bold(this.c.hex(PALETTE.blue)(name))} ${this.c.dim(this.#fit(argsPreview, this.#innerWidth() - 20))}`,
1400
+ ` ${this.c.dim('│')} ${this.#paint('violet', '◆')} ${this.#toolName(name, plugin)} ${this.c.dim(this.#fit(argsPreview, this.#innerWidth() - 20))}`,
1369
1401
  );
1370
1402
  }
1371
1403
 
1372
- toolEnd({ name, ok, durationMs, summary, cached = false }) {
1404
+ toolEnd({ name, ok, durationMs, summary, cached = false, plugin = null }) {
1373
1405
  this.status.tool = '';
1374
1406
  this.status.toolIndex = 0;
1375
1407
  this.status.toolTotal = 0;
@@ -1384,7 +1416,7 @@ export class Renderer {
1384
1416
  this.#write(this.c.dim(` [cached] ${name} · ${detail}`));
1385
1417
  return;
1386
1418
  }
1387
- this.#write(` ${this.c.dim('│')} ${this.#paint('cyan', '↺')} ${this.c.hex(PALETTE.blue)(name)} ${this.c.dim(`· ${detail}`)}`);
1419
+ this.#write(` ${this.c.dim('│')} ${this.#paint('cyan', '↺')} ${this.#toolName(name, plugin)} ${this.c.dim(`· ${detail}`)}`);
1388
1420
  return;
1389
1421
  }
1390
1422
  if (this.plain) {
@@ -1396,8 +1428,9 @@ export class Renderer {
1396
1428
  }
1397
1429
  const symbol = ok ? this.#paint('green', '✓') : this.#paint('red', '✕');
1398
1430
  const stats = [duration, detail].filter(Boolean).join(' · ');
1431
+ const label = ok ? this.#toolName(name, plugin) : this.c.hex(PALETTE.red)(String(name ?? ''));
1399
1432
  this.#write(
1400
- ` ${this.c.dim('│')} ${symbol} ${this.c.hex(PALETTE.blue)(name)}${stats ? ` ${this.c.dim(`· ${stats}`)}` : ''}`,
1433
+ ` ${this.c.dim('│')} ${symbol} ${label}${stats ? ` ${this.c.dim(`· ${stats}`)}` : ''}`,
1401
1434
  );
1402
1435
  }
1403
1436
 
package/src/cli/tui.js CHANGED
@@ -31,7 +31,8 @@ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇',
31
31
  /**
32
32
  * One colour per kind of line, so a glance tells blocks apart:
33
33
  * prose white, Markdown accents and tables sky blue, tool names yellow,
34
- * reasoning green, failures red. This palette is the only place colours live.
34
+ * plugin tool names sky blue in bold, reasoning green, failures red.
35
+ * This palette is the only place colours live.
35
36
  */
36
37
  const PALETTE = {
37
38
  white: '#e2e8f0',
@@ -556,6 +557,35 @@ function buildRows(item, width, { c, color }) {
556
557
  return rows;
557
558
  }
558
559
 
560
+ if (item.kind === 'code') {
561
+ // A framed monospace block with a `⧉ copy` chip. The whole box is the click
562
+ // target, so a long URL is copied in one click instead of a text selection.
563
+ const body = String(item.text ?? '');
564
+ const label = String(item.label ?? '');
565
+ if (/(^|\n)\s*```/.test(body)) {
566
+ // A body that itself carries a fence cannot be framed safely: print it plain.
567
+ for (const line of wrapAnsi(body, inner)) rows.push(line ? ` ${line}` : '');
568
+ return rows;
569
+ }
570
+ const lang = label.replace(/[^A-Za-z0-9_+#.-]+/g, '-').replace(/^-+|-+$/g, '');
571
+ const copies = new Map();
572
+ const offset = rows.length;
573
+ const lines = renderMarkdown('```' + `${lang}\n${body}\n` + '```', {
574
+ width: Math.max(20, width - 2),
575
+ c,
576
+ color,
577
+ base: (text) => paint('white', text),
578
+ accent: (text) => paint('sky', text),
579
+ onCodeBlock: ({ start, end, code }) => {
580
+ const target = { lang: label, code: String(code ?? '').slice(0, MAX_CODE_COPY_CHARS) };
581
+ for (let row = start; row <= end; row += 1) copies.set(offset + row, target);
582
+ },
583
+ });
584
+ for (const line of lines) rows.push(line ? ` ${line}` : '');
585
+ if (copies.size > 0) rows.copies = copies;
586
+ return rows;
587
+ }
588
+
559
589
  if (item.kind === 'answer') {
560
590
  // Every block carries a small label, like reasoning does, so white response
561
591
  // text is never mistaken for the tail of a tool or a notice.
@@ -564,6 +594,9 @@ function buildRows(item, width, { c, color }) {
564
594
  // Every rendered row of a fenced code block is remembered so a click on its
565
595
  // `⧉ copy` chip (or anywhere in the block) copies exactly that code.
566
596
  const copies = new Map();
597
+ // Code-block row numbers are relative to this item's own rows, not to the
598
+ // transcript, so the click map keeps working after scrolling.
599
+ const base = rows.length;
567
600
  const lines = renderMarkdown(item.text || '', {
568
601
  width: Math.max(20, width - 2),
569
602
  c,
@@ -572,7 +605,7 @@ function buildRows(item, width, { c, color }) {
572
605
  accent: (text) => paint('sky', text),
573
606
  onCodeBlock: ({ lang, code, start, end }) => {
574
607
  const target = { lang, code: String(code ?? '').slice(0, MAX_CODE_COPY_CHARS) };
575
- for (let row = start; row <= end; row += 1) copies.set(row, target);
608
+ for (let row = start; row <= end; row += 1) copies.set(base + row, target);
576
609
  },
577
610
  });
578
611
  for (const line of lines) rows.push(line ? ` ${line}` : '');
@@ -583,7 +616,13 @@ function buildRows(item, width, { c, color }) {
583
616
  if (item.kind === 'tool') {
584
617
  const failed = item.ok === false;
585
618
  const mark = failed ? paint('red', '✕') : paint('green', '✓');
586
- const name = failed ? c.bold(paint('red', item.name)) : paint('yellow', item.name);
619
+ // Plugin tools wear their own colour — sky blue and bold — so an installed
620
+ // integration never looks like built-in tooling. A failure is still red.
621
+ const name = failed
622
+ ? c.bold(paint('red', item.name))
623
+ : item.plugin
624
+ ? c.bold(paint('sky', item.name))
625
+ : paint('yellow', item.name);
587
626
  if (failed) {
588
627
  pushHeader(`${mark} ${name}${item.durationMs ? ` ${dim(`· ${formatSeconds(item.durationMs)}`)}` : ''}`);
589
628
  pushWrapped(item.summary || 'tool failed', ' ', 'redSoft', { dimmed: true });
@@ -730,7 +769,7 @@ export class Tui {
730
769
  step: null,
731
770
  maxSteps: null,
732
771
  tool: '',
733
- promptTokens: 0,
772
+ toolPlugin: null,
734
773
  completionTokens: 0,
735
774
  frame: 0,
736
775
  output: '',
@@ -891,7 +930,29 @@ export class Tui {
891
930
  }
892
931
 
893
932
  lines(text = '') {
894
- this.transcript.message(text);
933
+ // One command usually prints many lines. They belong to a single block: a
934
+ // rule between every line is what made command output look ragged.
935
+ const value = String(text ?? '');
936
+ const items = this.transcript.items;
937
+ const last = items[items.length - 1];
938
+ if (last && last.kind === 'info' && last.fromLines) {
939
+ last.text = `${last.text}\n${value}`;
940
+ last.version += 1;
941
+ } else {
942
+ const item = this.transcript.message(value);
943
+ item.fromLines = true;
944
+ }
945
+ this.requestRender();
946
+ }
947
+
948
+ /**
949
+ * A monospace block with a `⧉ copy` chip (renderMarkdown draws the frame).
950
+ * The whole block is a click target, like a code block in an answer.
951
+ */
952
+ showCode({ label = '', text = '' } = {}) {
953
+ const body = String(text ?? '');
954
+ if (!body) return;
955
+ this.transcript.add({ kind: 'code', label: String(label ?? ''), text: body, version: 0 });
895
956
  this.requestRender();
896
957
  }
897
958
 
@@ -1067,14 +1128,17 @@ export class Tui {
1067
1128
  this.requestRender();
1068
1129
  }
1069
1130
 
1070
- toolStart({ name = '', detail = '' } = {}) {
1131
+ toolStart({ name = '', detail = '', plugin = null } = {}) {
1071
1132
  this.status.tool = name;
1133
+ // Plugin tools keep their own colour in the status bar too.
1134
+ this.status.toolPlugin = plugin || null;
1072
1135
  this.requestRender();
1073
1136
  }
1074
1137
 
1075
- toolEnd({ name = '', ok = true, durationMs = 0, summary = '' } = {}) {
1138
+ toolEnd({ name = '', ok = true, durationMs = 0, summary = '', plugin = null } = {}) {
1076
1139
  this.status.tool = '';
1077
- this.transcript.add({ kind: 'tool', name, ok, durationMs, summary, version: 0 });
1140
+ this.status.toolPlugin = null;
1141
+ this.transcript.add({ kind: 'tool', name, ok, durationMs, summary, plugin: plugin || null, version: 0 });
1078
1142
  this.requestRender();
1079
1143
  }
1080
1144
 
@@ -1433,7 +1497,14 @@ export class Tui {
1433
1497
  this.paint('blue', `step ${this.status.step}${this.status.maxSteps ? `/${this.status.maxSteps}` : ''}`),
1434
1498
  );
1435
1499
  }
1436
- if (this.status.tool) parts.push(this.paint('yellow', this.status.tool));
1500
+ if (this.status.tool) {
1501
+ // Plugin tools are sky blue and bold here too, matching the transcript.
1502
+ parts.push(
1503
+ this.status.toolPlugin
1504
+ ? this.c.bold(this.paint('sky', this.status.tool))
1505
+ : this.paint('yellow', this.status.tool),
1506
+ );
1507
+ }
1437
1508
  if (this.toast && Date.now() <= this.toast.until) parts.push(this.paint('green', this.toast.text));
1438
1509
  if (this.status.promptTokens || this.status.completionTokens) {
1439
1510
  parts.push(
@@ -76,6 +76,19 @@ export class ToolRegistry {
76
76
  return tool;
77
77
  }
78
78
 
79
+ /**
80
+ * The plugin a tool belongs to, or null for a built-in tool.
81
+ *
82
+ * The CLI uses this to give plugin tools their own colour, so an integration
83
+ * the user installed never looks like core tooling.
84
+ */
85
+ pluginFor(name) {
86
+ for (const plugin of this.plugins.values()) {
87
+ if (plugin.toolNames.includes(name)) return plugin;
88
+ }
89
+ return null;
90
+ }
91
+
79
92
  has(name) {
80
93
  return this.tools.has(name);
81
94
  }