@ryuu-reinzz/ryuu-cli 0.4.3 → 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.
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
  }