@aragon-agent/cli 0.4.3

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.
Files changed (156) hide show
  1. package/CHANGELOG.md +292 -0
  2. package/README.md +558 -0
  3. package/dist/agent/coalesce.d.ts +14 -0
  4. package/dist/agent/coalesce.js +41 -0
  5. package/dist/agent/controller.d.ts +132 -0
  6. package/dist/agent/controller.js +332 -0
  7. package/dist/agent/headless.d.ts +22 -0
  8. package/dist/agent/headless.js +74 -0
  9. package/dist/agent/reducer.d.ts +181 -0
  10. package/dist/agent/reducer.js +420 -0
  11. package/dist/agent/system-prompt.d.ts +30 -0
  12. package/dist/agent/system-prompt.js +58 -0
  13. package/dist/agent/usage.d.ts +13 -0
  14. package/dist/agent/usage.js +41 -0
  15. package/dist/cli.d.ts +11 -0
  16. package/dist/cli.js +482 -0
  17. package/dist/commands/builtins.d.ts +16 -0
  18. package/dist/commands/builtins.js +223 -0
  19. package/dist/commands/registry.d.ts +48 -0
  20. package/dist/commands/registry.js +45 -0
  21. package/dist/commands/skills.d.ts +22 -0
  22. package/dist/commands/skills.js +625 -0
  23. package/dist/config/app-paths.d.ts +11 -0
  24. package/dist/config/app-paths.js +16 -0
  25. package/dist/config/env.d.ts +28 -0
  26. package/dist/config/env.js +92 -0
  27. package/dist/config/load.d.ts +48 -0
  28. package/dist/config/load.js +158 -0
  29. package/dist/config/migrate-legacy-state.d.ts +78 -0
  30. package/dist/config/migrate-legacy-state.js +181 -0
  31. package/dist/config/schema.d.ts +249 -0
  32. package/dist/config/schema.js +247 -0
  33. package/dist/config/store.d.ts +44 -0
  34. package/dist/config/store.js +126 -0
  35. package/dist/input/keymap.d.ts +20 -0
  36. package/dist/input/keymap.js +85 -0
  37. package/dist/session/persist.d.ts +24 -0
  38. package/dist/session/persist.js +51 -0
  39. package/dist/skills/archive.d.ts +41 -0
  40. package/dist/skills/archive.js +152 -0
  41. package/dist/skills/cli-commands.d.ts +52 -0
  42. package/dist/skills/cli-commands.js +502 -0
  43. package/dist/skills/fetch-source.d.ts +104 -0
  44. package/dist/skills/fetch-source.js +448 -0
  45. package/dist/skills/installer.d.ts +117 -0
  46. package/dist/skills/installer.js +414 -0
  47. package/dist/skills/lock.d.ts +75 -0
  48. package/dist/skills/lock.js +223 -0
  49. package/dist/skills/manifest.d.ts +47 -0
  50. package/dist/skills/manifest.js +95 -0
  51. package/dist/skills/node-host.d.ts +23 -0
  52. package/dist/skills/node-host.js +59 -0
  53. package/dist/skills/paths.d.ts +61 -0
  54. package/dist/skills/paths.js +107 -0
  55. package/dist/skills/service.d.ts +256 -0
  56. package/dist/skills/service.js +730 -0
  57. package/dist/skills/tools.d.ts +35 -0
  58. package/dist/skills/tools.js +164 -0
  59. package/dist/skills/updater.d.ts +101 -0
  60. package/dist/skills/updater.js +330 -0
  61. package/dist/skills/usage.d.ts +72 -0
  62. package/dist/skills/usage.js +216 -0
  63. package/dist/tools/bash-tool.d.ts +12 -0
  64. package/dist/tools/bash-tool.js +131 -0
  65. package/dist/tools/diff.d.ts +15 -0
  66. package/dist/tools/diff.js +50 -0
  67. package/dist/tools/fs-tools.d.ts +16 -0
  68. package/dist/tools/fs-tools.js +215 -0
  69. package/dist/tools/index.d.ts +93 -0
  70. package/dist/tools/index.js +157 -0
  71. package/dist/tools/search-tools.d.ts +11 -0
  72. package/dist/tools/search-tools.js +225 -0
  73. package/dist/ui/App.d.ts +24 -0
  74. package/dist/ui/App.js +522 -0
  75. package/dist/ui/AutocompletePopup.d.ts +21 -0
  76. package/dist/ui/AutocompletePopup.js +19 -0
  77. package/dist/ui/Composer.d.ts +40 -0
  78. package/dist/ui/Composer.js +61 -0
  79. package/dist/ui/Header.d.ts +30 -0
  80. package/dist/ui/Header.js +25 -0
  81. package/dist/ui/Logo.d.ts +50 -0
  82. package/dist/ui/Logo.js +85 -0
  83. package/dist/ui/Markdown.d.ts +23 -0
  84. package/dist/ui/Markdown.js +122 -0
  85. package/dist/ui/PromptInput.d.ts +81 -0
  86. package/dist/ui/PromptInput.js +383 -0
  87. package/dist/ui/SessionOpener.d.ts +30 -0
  88. package/dist/ui/SessionOpener.js +12 -0
  89. package/dist/ui/StatusBar.d.ts +39 -0
  90. package/dist/ui/StatusBar.js +43 -0
  91. package/dist/ui/ToastStack.d.ts +24 -0
  92. package/dist/ui/ToastStack.js +38 -0
  93. package/dist/ui/Transcript.d.ts +62 -0
  94. package/dist/ui/Transcript.js +128 -0
  95. package/dist/ui/capabilities.d.ts +25 -0
  96. package/dist/ui/capabilities.js +59 -0
  97. package/dist/ui/console-bridge.d.ts +19 -0
  98. package/dist/ui/console-bridge.js +52 -0
  99. package/dist/ui/density.d.ts +26 -0
  100. package/dist/ui/density.js +34 -0
  101. package/dist/ui/entries/AssistantEntry.d.ts +23 -0
  102. package/dist/ui/entries/AssistantEntry.js +13 -0
  103. package/dist/ui/entries/EntryFrame.d.ts +23 -0
  104. package/dist/ui/entries/EntryFrame.js +6 -0
  105. package/dist/ui/entries/ToolCard.d.ts +38 -0
  106. package/dist/ui/entries/ToolCard.js +72 -0
  107. package/dist/ui/entries/ToolPreview.d.ts +57 -0
  108. package/dist/ui/entries/ToolPreview.js +144 -0
  109. package/dist/ui/entries/UserEntry.d.ts +14 -0
  110. package/dist/ui/entries/UserEntry.js +5 -0
  111. package/dist/ui/exit-snapshot.d.ts +27 -0
  112. package/dist/ui/exit-snapshot.js +26 -0
  113. package/dist/ui/gauge.d.ts +24 -0
  114. package/dist/ui/gauge.js +36 -0
  115. package/dist/ui/glyphs.d.ts +87 -0
  116. package/dist/ui/glyphs.js +127 -0
  117. package/dist/ui/gradient.d.ts +11 -0
  118. package/dist/ui/gradient.js +50 -0
  119. package/dist/ui/layout/AppShell.d.ts +24 -0
  120. package/dist/ui/layout/AppShell.js +9 -0
  121. package/dist/ui/layout/Gutter.d.ts +67 -0
  122. package/dist/ui/layout/Gutter.js +42 -0
  123. package/dist/ui/layout/OverlayFrame.d.ts +62 -0
  124. package/dist/ui/layout/OverlayFrame.js +92 -0
  125. package/dist/ui/layout/ScrollViewport.d.ts +33 -0
  126. package/dist/ui/layout/ScrollViewport.js +71 -0
  127. package/dist/ui/layout/budget.d.ts +42 -0
  128. package/dist/ui/layout/budget.js +47 -0
  129. package/dist/ui/layout/frame.d.ts +58 -0
  130. package/dist/ui/layout/frame.js +75 -0
  131. package/dist/ui/layout/overlay-window.d.ts +43 -0
  132. package/dist/ui/layout/overlay-window.js +51 -0
  133. package/dist/ui/layout/scroll.d.ts +28 -0
  134. package/dist/ui/layout/scroll.js +76 -0
  135. package/dist/ui/layout/useTerminalSize.d.ts +13 -0
  136. package/dist/ui/layout/useTerminalSize.js +50 -0
  137. package/dist/ui/markdown-blocks.d.ts +62 -0
  138. package/dist/ui/markdown-blocks.js +120 -0
  139. package/dist/ui/overlays/ConfirmDialog.d.ts +30 -0
  140. package/dist/ui/overlays/ConfirmDialog.js +25 -0
  141. package/dist/ui/overlays/HelpOverlay.d.ts +17 -0
  142. package/dist/ui/overlays/HelpOverlay.js +60 -0
  143. package/dist/ui/overlays/ModelPicker.d.ts +29 -0
  144. package/dist/ui/overlays/ModelPicker.js +29 -0
  145. package/dist/ui/overlays/SettingsScreen.d.ts +37 -0
  146. package/dist/ui/overlays/SettingsScreen.js +106 -0
  147. package/dist/ui/palettes.d.ts +79 -0
  148. package/dist/ui/palettes.js +115 -0
  149. package/dist/ui/screen.d.ts +27 -0
  150. package/dist/ui/screen.js +57 -0
  151. package/dist/ui/theme.d.ts +78 -0
  152. package/dist/ui/theme.js +127 -0
  153. package/dist/ui/transcript-text.d.ts +27 -0
  154. package/dist/ui/transcript-text.js +110 -0
  155. package/package.json +73 -0
  156. package/skills/skill-creator/SKILL.md +96 -0
@@ -0,0 +1,144 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { PREVIEW_TRUNCATION_MARK } from '../../agent/reducer.js';
4
+ /**
5
+ * Classify a unified-diff line. Meta prefixes (`@@`, `---`, `+++`) are tested
6
+ * FIRST so a `--- path` header is not mis-read as a single-char removal (P2-8);
7
+ * the `Applied edit to <path>:` lead-in falls through to `context`.
8
+ */
9
+ export function classifyDiffLine(line) {
10
+ if (line.startsWith('@@') || line.startsWith('---') || line.startsWith('+++'))
11
+ return 'meta';
12
+ if (line.startsWith('+'))
13
+ return 'add';
14
+ if (line.startsWith('-'))
15
+ return 'remove';
16
+ return 'context';
17
+ }
18
+ const BASH_FOOTER_RE = /^\[(?:exit code (\d+)|signal (\w+))\]$/;
19
+ /**
20
+ * Derive the bash badge. The exit status is authoritative (the tool returns an
21
+ * error result iff the exit code is non-zero), so the label is `exit 0` /
22
+ * `failed` from `status`; a real `[exit code N]` / `[signal X]` last line is
23
+ * parsed best-effort for the exact number, falling back to the status label
24
+ * when the footer was truncated out of the stored preview (P1-2).
25
+ */
26
+ export function bashBadge(status, preview) {
27
+ const ok = status === 'done';
28
+ const lines = preview.split('\n').map((l) => l.trimEnd()).filter((l) => l.length > 0);
29
+ const last = lines[lines.length - 1] ?? '';
30
+ const m = BASH_FOOTER_RE.exec(last);
31
+ if (m) {
32
+ if (m[1] !== undefined)
33
+ return { text: `exit ${m[1]}`, ok };
34
+ return { text: `signal ${m[2]}`, ok };
35
+ }
36
+ return { text: ok ? 'exit 0' : status === 'error' ? 'failed' : 'pending', ok };
37
+ }
38
+ const WRITE_RE = /^Wrote (\d+) bytes to (.+)$/;
39
+ /** Parse the real `Wrote <n> bytes to <path>` result — bytes+path only (P2-7). */
40
+ export function parseWriteFile(text) {
41
+ const line = text.split('\n').find((l) => l.trim().length > 0) ?? '';
42
+ const m = WRITE_RE.exec(line.trim());
43
+ if (!m)
44
+ return null;
45
+ return { bytes: Number.parseInt(m[1], 10), path: m[2] };
46
+ }
47
+ /** Parse a real `list_dir` row (`dir <name>/` / `file <name> (<n>b)`) (P2-9). */
48
+ export function parseListRow(line) {
49
+ const dir = /^dir\s+(.*?)\/?\s*$/.exec(line);
50
+ if (dir)
51
+ return { kind: 'dir', name: dir[1].trimEnd() };
52
+ const file = /^file\s+(.*?)(?:\s+\((\d+b)\))?\s*$/.exec(line);
53
+ if (file)
54
+ return { kind: 'file', name: file[1].trimEnd(), size: file[2] };
55
+ return null;
56
+ }
57
+ function diffColor(cls, theme) {
58
+ switch (cls) {
59
+ case 'add':
60
+ return theme.diff.add;
61
+ case 'remove':
62
+ return theme.diff.remove;
63
+ case 'meta':
64
+ return theme.diff.meta;
65
+ case 'context':
66
+ return theme.diff.context;
67
+ }
68
+ }
69
+ function renderDiff(lines, theme) {
70
+ return lines.map((line, i) => (_jsx(Text, { color: diffColor(classifyDiffLine(line), theme), children: line.length > 0 ? line : ' ' }, i)));
71
+ }
72
+ function renderWrite(lines, theme) {
73
+ const parsed = parseWriteFile(lines.join('\n'));
74
+ if (!parsed)
75
+ return renderPlain(lines, theme, false);
76
+ return [
77
+ _jsxs(Text, { color: theme.diff.add, children: ["+ wrote ", parsed.path, " (", parsed.bytes, " B)"] }, "w"),
78
+ ];
79
+ }
80
+ function renderRead(lines, theme) {
81
+ // Numbered lines are `%5d content`; dim the 7-char gutter, keep content plain.
82
+ return lines.map((line, i) => (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: line.slice(0, 7) }), line.slice(7)] }, i)));
83
+ }
84
+ function renderBash(lines, theme) {
85
+ return lines.map((line, i) => {
86
+ // The truncation marker is compared against the constant the reducer
87
+ // actually writes. These used to be two independently spelled `…` literals;
88
+ // once the producer moved to ASCII, a re-spelled copy here would have
89
+ // stopped matching and quietly lost the dimming.
90
+ const dim = line.startsWith('$ ') ||
91
+ BASH_FOOTER_RE.test(line.trimEnd()) ||
92
+ line === PREVIEW_TRUNCATION_MARK;
93
+ return (_jsx(Text, { color: dim ? theme.muted : undefined, children: line.length > 0 ? line : ' ' }, i));
94
+ });
95
+ }
96
+ function renderList(lines, theme) {
97
+ return lines.map((line, i) => {
98
+ const row = parseListRow(line);
99
+ if (!row)
100
+ return _jsx(Text, { children: line }, i);
101
+ if (row.kind === 'dir') {
102
+ return (_jsx(Text, { children: _jsxs(Text, { color: theme.accent, children: [row.name, "/"] }) }, i));
103
+ }
104
+ return (_jsxs(Text, { children: [row.name, row.size ? _jsxs(Text, { color: theme.muted, children: [" (", row.size, ")"] }) : null] }, i));
105
+ });
106
+ }
107
+ function renderGlob(lines, theme) {
108
+ // Flat relative paths — dim the directory portion of each path.
109
+ return lines.map((line, i) => {
110
+ const slash = line.lastIndexOf('/');
111
+ if (slash < 0)
112
+ return _jsx(Text, { children: line }, i);
113
+ return (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: line.slice(0, slash + 1) }), line.slice(slash + 1)] }, i));
114
+ });
115
+ }
116
+ function renderPlain(lines, theme, error) {
117
+ return lines.map((line, i) => (_jsx(Text, { color: error ? theme.toolError : theme.muted, children: line.length > 0 ? line : ' ' }, i)));
118
+ }
119
+ export function ToolPreview({ name, lines, theme, isError }) {
120
+ let body;
121
+ switch (name) {
122
+ case 'edit_file':
123
+ body = renderDiff(lines, theme);
124
+ break;
125
+ case 'write_file':
126
+ body = renderWrite(lines, theme);
127
+ break;
128
+ case 'read_file':
129
+ body = renderRead(lines, theme);
130
+ break;
131
+ case 'bash':
132
+ body = renderBash(lines, theme);
133
+ break;
134
+ case 'list_dir':
135
+ body = renderList(lines, theme);
136
+ break;
137
+ case 'glob':
138
+ body = renderGlob(lines, theme);
139
+ break;
140
+ default:
141
+ body = renderPlain(lines, theme, isError);
142
+ }
143
+ return _jsx(Box, { flexDirection: "column", children: body });
144
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Render a user message entry.
3
+ *
4
+ * The role glyph, the gutter rail and the inter-entry spacing all belong to
5
+ * `EntryFrame` now (§4.3), so this component renders content and nothing else.
6
+ * That is also why it takes no `caps`: it has no glyph of its own left to
7
+ * degrade.
8
+ */
9
+ import React from 'react';
10
+ import type { Theme } from '../theme.js';
11
+ export declare function UserEntry({ text, theme }: {
12
+ text: string;
13
+ theme: Theme;
14
+ }): React.ReactElement;
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ export function UserEntry({ text, theme }) {
4
+ return (_jsx(Box, { flexDirection: "column", children: text.split('\n').map((line, i) => (_jsx(Text, { color: theme.user, bold: i === 0, children: line }, i))) }));
5
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Module-level single-slot snapshot — the ONE way view state crosses the React
3
+ * boundary out to `cli.tsx` (spec §4.4, review P1-3).
4
+ *
5
+ * Why not `useImperativeHandle` / a ref callback: two of the four restore paths
6
+ * (`process.on('exit')` and the signal hooks) are process-level, and by the time
7
+ * they run the component tree may already be unmounted or mid-unmount. A module
8
+ * variable stays readable for the whole process lifetime; a React channel does
9
+ * not.
10
+ *
11
+ * Writes are overwriting, not appending: `entries` is passed by reference, so
12
+ * publishing costs one object literal per view-state change.
13
+ */
14
+ import type { Entry, UsageTotal } from '../agent/reducer.js';
15
+ export interface ExitSnapshot {
16
+ entries: Entry[];
17
+ usageTotal: UsageTotal;
18
+ provider: string;
19
+ model: string;
20
+ startedAt: number;
21
+ }
22
+ /** Called by `App` after every view-state change. Synchronous, side-effect free. */
23
+ export declare function publishExitSnapshot(next: ExitSnapshot): void;
24
+ /** Called by `cli.tsx` after `restore()`. `undefined` ⇒ skip the replay entirely. */
25
+ export declare function readExitSnapshot(): ExitSnapshot | undefined;
26
+ /** Test-only reset so specs do not leak state into one another. */
27
+ export declare function clearExitSnapshot(): void;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Module-level single-slot snapshot — the ONE way view state crosses the React
3
+ * boundary out to `cli.tsx` (spec §4.4, review P1-3).
4
+ *
5
+ * Why not `useImperativeHandle` / a ref callback: two of the four restore paths
6
+ * (`process.on('exit')` and the signal hooks) are process-level, and by the time
7
+ * they run the component tree may already be unmounted or mid-unmount. A module
8
+ * variable stays readable for the whole process lifetime; a React channel does
9
+ * not.
10
+ *
11
+ * Writes are overwriting, not appending: `entries` is passed by reference, so
12
+ * publishing costs one object literal per view-state change.
13
+ */
14
+ let snapshot;
15
+ /** Called by `App` after every view-state change. Synchronous, side-effect free. */
16
+ export function publishExitSnapshot(next) {
17
+ snapshot = next;
18
+ }
19
+ /** Called by `cli.tsx` after `restore()`. `undefined` ⇒ skip the replay entirely. */
20
+ export function readExitSnapshot() {
21
+ return snapshot;
22
+ }
23
+ /** Test-only reset so specs do not leak state into one another. */
24
+ export function clearExitSnapshot() {
25
+ snapshot = undefined;
26
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Context gauge builder (spec §3.4). Pure: turns a percentage into a block bar
3
+ * plus a threshold-based fill colour, with an ASCII fallback when the terminal
4
+ * has no Unicode. Kept out of the `.tsx` so it is unit-testable without Ink.
5
+ */
6
+ import type { Theme } from './theme.js';
7
+ import type { TermCapabilities } from './capabilities.js';
8
+ export interface Gauge {
9
+ /** The filled portion of the bar. */
10
+ filled: string;
11
+ /** The empty (track) portion of the bar. */
12
+ empty: string;
13
+ /** Fill colour chosen by threshold (`theme.gauge.*`). */
14
+ fillColor: string | undefined;
15
+ /** Track colour (`theme.gauge.track`). */
16
+ trackColor: string | undefined;
17
+ /** Clamped percentage 0–100. */
18
+ pct: number;
19
+ }
20
+ /**
21
+ * Build a `width`-cell gauge for `pct` (0–100). Uses block glyphs on Unicode
22
+ * terminals and `#`/`-` otherwise (e.g. `[######----]`).
23
+ */
24
+ export declare function buildGauge(pct: number, width: number, theme: Theme, caps: TermCapabilities): Gauge;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Context gauge builder (spec §3.4). Pure: turns a percentage into a block bar
3
+ * plus a threshold-based fill colour, with an ASCII fallback when the terminal
4
+ * has no Unicode. Kept out of the `.tsx` so it is unit-testable without Ink.
5
+ */
6
+ import { pickGlyphs } from './glyphs.js';
7
+ /** Pick the fill colour by threshold: <60 low · 60–85 mid · >85 high. */
8
+ function thresholdColor(pct, theme) {
9
+ if (pct > 85)
10
+ return theme.gauge.high;
11
+ if (pct >= 60)
12
+ return theme.gauge.mid;
13
+ return theme.gauge.low;
14
+ }
15
+ /**
16
+ * Build a `width`-cell gauge for `pct` (0–100). Uses block glyphs on Unicode
17
+ * terminals and `#`/`-` otherwise (e.g. `[######----]`).
18
+ */
19
+ export function buildGauge(pct, width, theme, caps) {
20
+ const clamped = Math.max(0, Math.min(100, Math.round(pct)));
21
+ const cells = Math.max(1, width);
22
+ const filledCells = Math.max(0, Math.min(cells, Math.round((clamped / 100) * cells)));
23
+ // Was `caps.unicode ? '█' : '#'` inline. The branch was correct but the
24
+ // literals still had to move: leaving even a well-formed pair here would mean
25
+ // the A-1 scan needs an exception, and exceptions get copied (§4.1).
26
+ const glyphs = pickGlyphs(caps);
27
+ const full = glyphs.gaugeFull;
28
+ const empty = glyphs.gaugeEmpty;
29
+ return {
30
+ filled: full.repeat(filledCells),
31
+ empty: empty.repeat(cells - filledCells),
32
+ fillColor: thresholdColor(clamped, theme),
33
+ trackColor: theme.gauge.track,
34
+ pct: clamped,
35
+ };
36
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Glyphs — the SINGLE SOURCE OF TRUTH for every user-visible non-ASCII
3
+ * character in `src/ui/**` (spec §4.1).
4
+ *
5
+ * `capabilities.ts` already decides whether the terminal can render Unicode, and
6
+ * the theme already carried an `ASCII_SYMBOLS` table — but 16 files wrote their
7
+ * glyphs as bare literals and bypassed the whole mechanism, so a legacy
8
+ * `cmd.exe` showed mojibake for the prompt caret, the assistant marker, every
9
+ * list bullet, the scroll indicator and all four overlay titles.
10
+ *
11
+ * The fix is structural rather than per-site: EVERY glyph lives in one of the
12
+ * two tables below, components read them through `pickGlyphs(caps)`, and
13
+ * `glyphs.test.ts` runs a static scan that fails the build if any other file
14
+ * under the scanned tree grows a non-ASCII literal again.
15
+ *
16
+ * THIS FILE AND `Logo.tsx`'s art block are the only two files allowed to hold
17
+ * non-ASCII literals under `src/ui/**` (plus the explicit `agent/headless.ts`
18
+ * exemption). Adding a third exception means the guard rail stops guarding —
19
+ * add a field here instead.
20
+ */
21
+ import type { TermCapabilities } from './capabilities.js';
22
+ export interface Glyphs {
23
+ user: string;
24
+ assistant: string;
25
+ thinking: string;
26
+ toolPending: string;
27
+ toolRunning: string;
28
+ toolDone: string;
29
+ toolError: string;
30
+ info: string;
31
+ warn: string;
32
+ error: string;
33
+ bullet: string;
34
+ /** Static spinner glyph (reduced motion / no-Unicode fallback). */
35
+ spinnerStill: string;
36
+ gaugeFull: string;
37
+ gaugeEmpty: string;
38
+ keyOn: string;
39
+ keyOff: string;
40
+ wordmark: string;
41
+ ellipsis: string;
42
+ emDash: string;
43
+ midDot: string;
44
+ arrowUp: string;
45
+ arrowDown: string;
46
+ arrowLeft: string;
47
+ arrowRight: string;
48
+ check: string;
49
+ times: string;
50
+ caret: string;
51
+ steer: string;
52
+ enterKey: string;
53
+ /**
54
+ * The Shift+Enter CHORD, not the modifier alone.
55
+ *
56
+ * Concatenating a separate `shiftKey` + `enterKey` works in the Unicode tier
57
+ * but spells "ShiftEnter" in ASCII: the modifier symbol carries an implicit
58
+ * "+" that the word does not. One field per chord keeps both tiers readable.
59
+ */
60
+ shiftEnter: string;
61
+ /** Horizontal rule fill used by the markdown `---` block (§4.7). */
62
+ hrule: string;
63
+ railVertical: string;
64
+ railBranch: string;
65
+ railEnd: string;
66
+ /** Redaction dot for `maskSecret` in the settings screen (§4.1 tier B). */
67
+ maskDot: string;
68
+ /**
69
+ * Ink `borderStyle` name for every boxed region (composer, completion popup,
70
+ * overlay frame).
71
+ *
72
+ * Not a glyph we spell ourselves, which is exactly why it was missed: Ink
73
+ * draws `round` borders from `cli-boxes`, and those are Unicode. Every one of
74
+ * our own literals could degrade perfectly and a legacy console would still
75
+ * show a frame of mojibake around the input. `classic` is the ASCII box.
76
+ */
77
+ boxStyle: 'round' | 'classic';
78
+ tool: Readonly<Record<string, string>>;
79
+ toolDefault: string;
80
+ }
81
+ /**
82
+ * The glyph set for a terminal. Memoized on `caps.unicode` — the only input —
83
+ * so the returned object is referentially stable and safe in `useMemo` deps.
84
+ */
85
+ export declare function pickGlyphs(caps: TermCapabilities): Glyphs;
86
+ /** Per-tool icon with the shared fallback (formerly `ToolCard::toolGlyph`). */
87
+ export declare function toolGlyph(glyphs: Glyphs, name: string): string;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Glyphs — the SINGLE SOURCE OF TRUTH for every user-visible non-ASCII
3
+ * character in `src/ui/**` (spec §4.1).
4
+ *
5
+ * `capabilities.ts` already decides whether the terminal can render Unicode, and
6
+ * the theme already carried an `ASCII_SYMBOLS` table — but 16 files wrote their
7
+ * glyphs as bare literals and bypassed the whole mechanism, so a legacy
8
+ * `cmd.exe` showed mojibake for the prompt caret, the assistant marker, every
9
+ * list bullet, the scroll indicator and all four overlay titles.
10
+ *
11
+ * The fix is structural rather than per-site: EVERY glyph lives in one of the
12
+ * two tables below, components read them through `pickGlyphs(caps)`, and
13
+ * `glyphs.test.ts` runs a static scan that fails the build if any other file
14
+ * under the scanned tree grows a non-ASCII literal again.
15
+ *
16
+ * THIS FILE AND `Logo.tsx`'s art block are the only two files allowed to hold
17
+ * non-ASCII literals under `src/ui/**` (plus the explicit `agent/headless.ts`
18
+ * exemption). Adding a third exception means the guard rail stops guarding —
19
+ * add a field here instead.
20
+ */
21
+ const UNICODE_GLYPHS = {
22
+ user: '›',
23
+ assistant: '●',
24
+ thinking: '✱',
25
+ toolPending: '◦',
26
+ toolRunning: '◍',
27
+ toolDone: '✔',
28
+ toolError: '✖',
29
+ info: 'ℹ',
30
+ warn: '▲',
31
+ error: '✖',
32
+ bullet: '•',
33
+ spinnerStill: '·',
34
+ gaugeFull: '█',
35
+ gaugeEmpty: '░',
36
+ keyOn: '●',
37
+ keyOff: '○',
38
+ wordmark: '◇',
39
+ ellipsis: '…',
40
+ emDash: '—',
41
+ midDot: '·',
42
+ arrowUp: '↑',
43
+ arrowDown: '↓',
44
+ arrowLeft: '←',
45
+ arrowRight: '→',
46
+ check: '✓',
47
+ times: '×',
48
+ caret: '❯',
49
+ steer: '⇢',
50
+ enterKey: '⏎',
51
+ shiftEnter: '⇧⏎',
52
+ hrule: '─',
53
+ railVertical: '│',
54
+ railBranch: '├',
55
+ railEnd: '╰',
56
+ maskDot: '•',
57
+ boxStyle: 'round',
58
+ tool: {
59
+ read_file: '▤',
60
+ write_file: '✎',
61
+ edit_file: '✎',
62
+ bash: '❯',
63
+ glob: '⌕',
64
+ grep: '⌕',
65
+ list_dir: '▤',
66
+ },
67
+ toolDefault: '•',
68
+ };
69
+ const ASCII_GLYPHS = {
70
+ user: '>',
71
+ assistant: '*',
72
+ thinking: '*',
73
+ toolPending: 'o',
74
+ toolRunning: '*',
75
+ toolDone: '[ok]',
76
+ toolError: '[x]',
77
+ info: 'i',
78
+ warn: '!',
79
+ error: 'x',
80
+ bullet: '-',
81
+ spinnerStill: '*',
82
+ gaugeFull: '#',
83
+ gaugeEmpty: '-',
84
+ keyOn: '*',
85
+ keyOff: 'o',
86
+ wordmark: '<>',
87
+ ellipsis: '...',
88
+ emDash: '--',
89
+ midDot: '-',
90
+ arrowUp: '^',
91
+ arrowDown: 'v',
92
+ arrowLeft: '<',
93
+ arrowRight: '->',
94
+ check: '*',
95
+ times: 'x',
96
+ caret: '>',
97
+ steer: '>>',
98
+ enterKey: 'Enter',
99
+ shiftEnter: 'Shift+Enter',
100
+ hrule: '-',
101
+ railVertical: '|',
102
+ railBranch: '|',
103
+ railEnd: '\\',
104
+ maskDot: '*',
105
+ boxStyle: 'classic',
106
+ tool: {
107
+ read_file: '=',
108
+ write_file: '+',
109
+ edit_file: '+',
110
+ bash: '>',
111
+ glob: '/',
112
+ grep: '/',
113
+ list_dir: '=',
114
+ },
115
+ toolDefault: '*',
116
+ };
117
+ /**
118
+ * The glyph set for a terminal. Memoized on `caps.unicode` — the only input —
119
+ * so the returned object is referentially stable and safe in `useMemo` deps.
120
+ */
121
+ export function pickGlyphs(caps) {
122
+ return caps.unicode ? UNICODE_GLYPHS : ASCII_GLYPHS;
123
+ }
124
+ /** Per-tool icon with the shared fallback (formerly `ToolCard::toolGlyph`). */
125
+ export function toolGlyph(glyphs, name) {
126
+ return glyphs.tool[name] ?? glyphs.toolDefault;
127
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Gradient wordmark helper (spec §3.2). Pure: interpolates an RGB gradient
3
+ * across a short string using chalk. Below ansi-256 (or with no stops) it
4
+ * returns the text unchanged so the header falls back to a bold single-color
5
+ * wordmark. No new runtime dependency — `chalk` is already present.
6
+ */
7
+ /**
8
+ * Render `text` with a per-character gradient across `stops` (hex colors).
9
+ * Returns the text unchanged when `colorLevel < 2` or `stops` is empty.
10
+ */
11
+ export declare function gradientLine(text: string, stops: string[], colorLevel: 0 | 1 | 2 | 3): string;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Gradient wordmark helper (spec §3.2). Pure: interpolates an RGB gradient
3
+ * across a short string using chalk. Below ansi-256 (or with no stops) it
4
+ * returns the text unchanged so the header falls back to a bold single-color
5
+ * wordmark. No new runtime dependency — `chalk` is already present.
6
+ */
7
+ import { Chalk } from 'chalk';
8
+ /** Parse a `#rrggbb` (or `#rgb`) string into an `[r, g, b]` triple. */
9
+ function parseHex(hex) {
10
+ let h = hex.replace('#', '').trim();
11
+ if (h.length === 3)
12
+ h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
13
+ const n = Number.parseInt(h, 16);
14
+ if (!Number.isFinite(n))
15
+ return [255, 255, 255];
16
+ return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
17
+ }
18
+ function lerp(a, b, t) {
19
+ return Math.round(a + (b - a) * t);
20
+ }
21
+ /** Interpolate a colour at position `t` ∈ [0,1] across the RGB `stops`. */
22
+ function colorAt(stops, t) {
23
+ if (stops.length === 1)
24
+ return stops[0];
25
+ const clamped = Math.max(0, Math.min(1, t));
26
+ const scaled = clamped * (stops.length - 1);
27
+ const idx = Math.min(stops.length - 2, Math.floor(scaled));
28
+ const local = scaled - idx;
29
+ const from = stops[idx];
30
+ const to = stops[idx + 1];
31
+ return [lerp(from[0], to[0], local), lerp(from[1], to[1], local), lerp(from[2], to[2], local)];
32
+ }
33
+ /**
34
+ * Render `text` with a per-character gradient across `stops` (hex colors).
35
+ * Returns the text unchanged when `colorLevel < 2` or `stops` is empty.
36
+ */
37
+ export function gradientLine(text, stops, colorLevel) {
38
+ if (colorLevel < 2 || stops.length === 0 || text.length === 0)
39
+ return text;
40
+ const rgbStops = stops.map(parseHex);
41
+ const c = new Chalk({ level: colorLevel === 3 ? 3 : 2 });
42
+ const chars = [...text];
43
+ const denom = chars.length > 1 ? chars.length - 1 : 1;
44
+ return chars
45
+ .map((ch, i) => {
46
+ const [r, g, b] = colorAt(rgbStops, i / denom);
47
+ return c.rgb(r, g, b)(ch);
48
+ })
49
+ .join('');
50
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * AppShell (spec §4.3) — the frame that makes R2 true.
3
+ *
4
+ * In `fullscreen` the root box has a FIXED height of `frameHeight(rows)` and the
5
+ * middle viewport is the only `flexGrow` child, so the bottom chrome (toast /
6
+ * composer / status bar) is physically the last thing on the screen from the
7
+ * very first frame — empty session, long session, mid-scroll, overlay open.
8
+ *
9
+ * In `inline` the same children are emitted in the same order with no height
10
+ * constraint, i.e. byte-for-byte the v0.2.0 document-flow behavior.
11
+ */
12
+ import React from 'react';
13
+ import { type RenderMode } from './frame.js';
14
+ export interface AppShellProps {
15
+ mode: RenderMode;
16
+ rows: number;
17
+ cols: number;
18
+ header: React.ReactNode;
19
+ viewport: React.ReactNode;
20
+ toast: React.ReactNode;
21
+ composer: React.ReactNode;
22
+ status: React.ReactNode;
23
+ }
24
+ export declare function AppShell({ mode, rows, cols, header, viewport, toast, composer, status, }: AppShellProps): React.ReactElement;
@@ -0,0 +1,9 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box } from 'ink';
3
+ import { frameHeight } from './frame.js';
4
+ export function AppShell({ mode, rows, cols, header, viewport, toast, composer, status, }) {
5
+ if (mode === 'inline') {
6
+ return (_jsxs(Box, { flexDirection: "column", children: [header, viewport, toast, composer, status] }));
7
+ }
8
+ return (_jsxs(Box, { flexDirection: "column", height: frameHeight(rows), width: cols, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", flexShrink: 0, children: header }), _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: viewport }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [toast, composer, status] })] }));
9
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Gutter (spec §4.3) — the two-column left rail every transcript entry sits in.
3
+ *
4
+ * Replaces the previous "blank line + round box per entry" idiom, which cost
5
+ * 3-4 fixed rows for every entry and made one ordinary tool call fill the entire
6
+ * viewport on a 24-row terminal. The rail carries the role glyph on the first
7
+ * row and the status colour along the whole entry, so the grouping that the
8
+ * border used to provide survives at a fraction of the vertical cost.
9
+ *
10
+ * INVARIANT I-2 — the content column MUST keep `flexShrink={0}`.
11
+ * `ScrollViewport` measures content height with `measureElement`; if yoga is
12
+ * allowed to squeeze this column down to the viewport height, the measurement
13
+ * reports `content === viewport`, `overflowLines` is permanently 0, and
14
+ * scrolling stops working with no error anywhere. Read the comment at
15
+ * `ScrollViewport.tsx:94-99` before touching this. Guarded by A-6.
16
+ */
17
+ import React from 'react';
18
+ /** Columns reserved for the rail, glyph included. */
19
+ export declare const GUTTER_WIDTH = 2;
20
+ export interface GutterProps {
21
+ /**
22
+ * Role marker on the first row (user / assistant / tool status / notice).
23
+ *
24
+ * A node rather than a string because while an answer streams the marker IS
25
+ * the spinner — that used to live inside `AssistantEntry`, and moving the
26
+ * glyph out here without moving the spinner would have silently dropped the
27
+ * "something is happening" affordance.
28
+ */
29
+ glyph: React.ReactNode;
30
+ color?: string;
31
+ children: React.ReactNode;
32
+ }
33
+ export declare function Gutter({ glyph, color, children }: GutterProps): React.ReactElement;
34
+ /**
35
+ * The one-row gap between turns: a dim rail stub rather than a blank line, so
36
+ * the eye still follows a continuous left edge across the turn boundary.
37
+ */
38
+ export declare function RailSpacer({ continuation, color, }: {
39
+ continuation: string;
40
+ color?: string;
41
+ }): React.ReactElement;
42
+ /**
43
+ * A left-only border whose vertical character is the rail glyph.
44
+ *
45
+ * Used where a block owns its own lines (tool preview, fenced code) and used to
46
+ * be wrapped in a round box: the border cost 2 rows plus 2 columns of padding
47
+ * and nested visibly inside other round boxes. Ink repeats `box.left` for the
48
+ * measured height of the child, so this is a continuous rail that costs 1 column
49
+ * and 0 rows.
50
+ */
51
+ export declare function railBorderProps(rail: string, color?: string): {
52
+ readonly borderStyle: {
53
+ readonly topLeft: string;
54
+ readonly top: "";
55
+ readonly topRight: "";
56
+ readonly right: "";
57
+ readonly bottomRight: "";
58
+ readonly bottom: "";
59
+ readonly bottomLeft: string;
60
+ readonly left: string;
61
+ };
62
+ readonly borderTop: false;
63
+ readonly borderBottom: false;
64
+ readonly borderRight: false;
65
+ readonly borderLeft: true;
66
+ readonly borderColor: string | undefined;
67
+ };