noah-agent 0.1.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/LICENSE +21 -0
- package/README.md +169 -0
- package/dist/agent/auth-gate.js +23 -0
- package/dist/agent/caveman.js +44 -0
- package/dist/agent/login.js +59 -0
- package/dist/cli.js +130 -0
- package/dist/llm/ollama.js +32 -0
- package/dist/llm/providers.js +38 -0
- package/dist/llm/registry.js +19 -0
- package/dist/llm/resolve.js +44 -0
- package/dist/modes/rpc.js +13 -0
- package/dist/platform/adapter.js +47 -0
- package/dist/platform/detect.js +18 -0
- package/dist/platform/linux.js +61 -0
- package/dist/platform/macos.js +51 -0
- package/dist/platform/types.js +5 -0
- package/dist/prompt/system.js +52 -0
- package/dist/runtime.js +124 -0
- package/dist/safety/audit.js +46 -0
- package/dist/safety/confirm.js +17 -0
- package/dist/safety/extension.js +65 -0
- package/dist/safety/policy.js +100 -0
- package/dist/sdk.js +32 -0
- package/dist/session.js +113 -0
- package/dist/sys/health.js +51 -0
- package/dist/sys/probe.js +128 -0
- package/dist/sys/report.js +55 -0
- package/dist/tools/logs.js +24 -0
- package/dist/tools/network.js +47 -0
- package/dist/tools/package.js +40 -0
- package/dist/tools/service.js +45 -0
- package/dist/tools/system.js +33 -0
- package/dist/tui/app.js +104 -0
- package/dist/tui/branding.js +14 -0
- package/dist/tui/components/audit-line.js +37 -0
- package/dist/tui/components/header.js +33 -0
- package/dist/tui/components/noah-footer.js +33 -0
- package/dist/tui/components/request-panel.js +23 -0
- package/dist/tui/components/response-view.js +17 -0
- package/dist/tui/components/safety-block.js +31 -0
- package/dist/tui/components/safety-review.js +36 -0
- package/dist/tui/components/thinking-view.js +22 -0
- package/dist/tui/components/tool-card.js +45 -0
- package/dist/tui/components/util.js +3 -0
- package/dist/tui/preview.js +33 -0
- package/dist/tui/space/app.js +566 -0
- package/dist/tui/space/components.js +261 -0
- package/dist/tui/space/dashboard.js +63 -0
- package/dist/tui/space/theme.js +39 -0
- package/dist/ui/ansi.js +93 -0
- package/dist/ui/badge.js +31 -0
- package/dist/ui/box.js +61 -0
- package/dist/ui/preview.js +37 -0
- package/dist/ui/render.js +140 -0
- package/package.json +68 -0
- package/themes/noah-dark-blue.json +85 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { wordWrap, UNICODE } from "../../ui/ansi.js";
|
|
3
|
+
import { C, G, b, d } from "./theme.js";
|
|
4
|
+
/* ----------------------------------------------------------------- helpers */
|
|
5
|
+
// Use pi-tui's OWN width measure + truncation so our lines never exceed the
|
|
6
|
+
// renderer's width check (which throws otherwise).
|
|
7
|
+
const vw = (s) => visibleWidth(s);
|
|
8
|
+
const truncate = (s, w) => truncateToWidth(s, Math.max(0, w));
|
|
9
|
+
/** Truncate AND pad to exactly `w` visible columns. */
|
|
10
|
+
function fit(s, w) {
|
|
11
|
+
const t = truncate(s, w);
|
|
12
|
+
const len = vw(t);
|
|
13
|
+
return len < w ? t + " ".repeat(w - len) : t;
|
|
14
|
+
}
|
|
15
|
+
/** Clamp every line so it can never exceed `w` (defensive against long tokens). */
|
|
16
|
+
const clamp = (lines, w) => lines.map((l) => (vw(l) > w ? truncate(l, w) : l));
|
|
17
|
+
function center(s, width) {
|
|
18
|
+
const len = vw(s);
|
|
19
|
+
if (len >= width)
|
|
20
|
+
return truncate(s, width);
|
|
21
|
+
return " ".repeat(Math.floor((width - len) / 2)) + s;
|
|
22
|
+
}
|
|
23
|
+
const pad = (s, w) => fit(s, w);
|
|
24
|
+
/* -------------------------------------------------------------- hero logo */
|
|
25
|
+
const LOGO = [
|
|
26
|
+
"███╗ ██╗ ██████╗ █████╗ ██╗ ██╗",
|
|
27
|
+
"████╗ ██║ ██╔═══██╗ ██╔══██╗ ██║ ██║",
|
|
28
|
+
"██╔██╗ ██║ ██║ ██║ ███████║ ███████║",
|
|
29
|
+
"██║╚██╗██║ ██║ ██║ ██╔══██║ ██╔══██║",
|
|
30
|
+
"██║ ╚████║ ╚██████╔╝ ██║ ██║ ██║ ██║",
|
|
31
|
+
"╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝",
|
|
32
|
+
];
|
|
33
|
+
const LOGO_W = Math.max(...LOGO.map((l) => [...l].length));
|
|
34
|
+
// top-down "key light": ice-white crown fading into deep blue
|
|
35
|
+
const TINT = [C.hot, C.star, C.nebula, C.plasma, C.comet, C.comet];
|
|
36
|
+
/** The big NOAH wordmark, centered, cinematic gradient. */
|
|
37
|
+
export function heroLogo(width) {
|
|
38
|
+
if (!UNICODE || width < LOGO_W + 2) {
|
|
39
|
+
return ["", center(b(C.hot("N O A H")), width), ""];
|
|
40
|
+
}
|
|
41
|
+
const lines = LOGO.map((row, i) => center(TINT[i](row), width));
|
|
42
|
+
const glow = center(C.ghost("▔".repeat(Math.min(LOGO_W, width))), width);
|
|
43
|
+
const tag = center(d(C.faint("A G E N T I C O P E R A T I N G S Y S T E M")), width);
|
|
44
|
+
return clamp(["", "", ...lines, glow, "", tag, ""], width);
|
|
45
|
+
}
|
|
46
|
+
/** Splash shown above the conversation. Tips appear only on a fresh session. */
|
|
47
|
+
export class Splash {
|
|
48
|
+
showTips;
|
|
49
|
+
constructor(showTips) {
|
|
50
|
+
this.showTips = showTips;
|
|
51
|
+
}
|
|
52
|
+
render(width) {
|
|
53
|
+
const out = heroLogo(width);
|
|
54
|
+
if (this.showTips()) {
|
|
55
|
+
const tips = [
|
|
56
|
+
"Ask NOAH to install software, manage services, or inspect your system.",
|
|
57
|
+
"Destructive actions ask first — every action is logged.",
|
|
58
|
+
"Type / for commands · just describe what you want.",
|
|
59
|
+
];
|
|
60
|
+
out.push("");
|
|
61
|
+
for (const t of tips)
|
|
62
|
+
out.push(` ${C.comet(G.nodeOpen)} ${C.text(truncate(t, Math.max(10, width - 7)))}`);
|
|
63
|
+
out.push("");
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
invalidate() { }
|
|
68
|
+
}
|
|
69
|
+
/* --------------------------------------------------------------- chat blocks */
|
|
70
|
+
export class UserBlock {
|
|
71
|
+
text;
|
|
72
|
+
constructor(text) {
|
|
73
|
+
this.text = text;
|
|
74
|
+
}
|
|
75
|
+
render(width) {
|
|
76
|
+
const w = Math.max(10, width - 6);
|
|
77
|
+
const body = wordWrap(this.text, w);
|
|
78
|
+
return clamp(["", ` ${C.plasma(G.prompt)} ${b(C.star(body[0] ?? ""))}`, ...body.slice(1).map((l) => ` ${C.star(l)}`)], width);
|
|
79
|
+
}
|
|
80
|
+
invalidate() { }
|
|
81
|
+
}
|
|
82
|
+
export class AssistantBlock {
|
|
83
|
+
text = "";
|
|
84
|
+
append(delta) {
|
|
85
|
+
this.text += delta;
|
|
86
|
+
}
|
|
87
|
+
get value() {
|
|
88
|
+
return this.text;
|
|
89
|
+
}
|
|
90
|
+
render(width) {
|
|
91
|
+
const w = Math.max(10, width - 6);
|
|
92
|
+
const body = wordWrap(this.text || "…", w).map((l) => C.text(l));
|
|
93
|
+
return clamp(["", ` ${C.comet(G.node)} ${b(C.nebula("NOAH"))}`, ...body.map((l) => ` ${l}`)], width);
|
|
94
|
+
}
|
|
95
|
+
invalidate() { }
|
|
96
|
+
}
|
|
97
|
+
export class ToolBlock {
|
|
98
|
+
name;
|
|
99
|
+
arg;
|
|
100
|
+
state;
|
|
101
|
+
constructor(name, arg, state = "running") {
|
|
102
|
+
this.name = name;
|
|
103
|
+
this.arg = arg;
|
|
104
|
+
this.state = state;
|
|
105
|
+
}
|
|
106
|
+
set(state) {
|
|
107
|
+
this.state = state;
|
|
108
|
+
}
|
|
109
|
+
render(width) {
|
|
110
|
+
const glyph = this.state === "running" ? C.plasma(G.run) : this.state === "ok" ? C.good(G.check) : C.danger(G.cross);
|
|
111
|
+
const arg = this.arg ? " " + C.text(truncate(this.arg, Math.max(8, width - this.name.length - 14))) : "";
|
|
112
|
+
const tag = this.state === "running"
|
|
113
|
+
? d(C.faint(" · running"))
|
|
114
|
+
: this.state === "ok"
|
|
115
|
+
? d(C.good(" · done"))
|
|
116
|
+
: d(C.danger(" · failed"));
|
|
117
|
+
return clamp([` ${glyph} ${C.plasma(this.name)}${arg}${tag}`], width);
|
|
118
|
+
}
|
|
119
|
+
invalidate() { }
|
|
120
|
+
}
|
|
121
|
+
export class SystemBlock {
|
|
122
|
+
lines;
|
|
123
|
+
kind;
|
|
124
|
+
constructor(lines, kind = "info") {
|
|
125
|
+
this.lines = lines;
|
|
126
|
+
this.kind = kind;
|
|
127
|
+
}
|
|
128
|
+
render(width) {
|
|
129
|
+
const accent = this.kind === "danger" ? C.danger : this.kind === "warn" ? C.warn : C.comet;
|
|
130
|
+
const w = Math.max(10, width - 6);
|
|
131
|
+
const out = [""];
|
|
132
|
+
for (const raw of this.lines)
|
|
133
|
+
for (const l of wordWrap(raw, w))
|
|
134
|
+
out.push(` ${accent(G.bar)} ${C.text(l)}`);
|
|
135
|
+
return clamp(out, width);
|
|
136
|
+
}
|
|
137
|
+
invalidate() { }
|
|
138
|
+
}
|
|
139
|
+
/* ------------------------------------------------------------------ input box */
|
|
140
|
+
/** Rounded, cinematic input box framing pi-tui's Input (keeps the cursor marker). */
|
|
141
|
+
export class InputBox {
|
|
142
|
+
input;
|
|
143
|
+
getState;
|
|
144
|
+
focused = false;
|
|
145
|
+
constructor(input, getState) {
|
|
146
|
+
this.input = input;
|
|
147
|
+
this.getState = getState;
|
|
148
|
+
}
|
|
149
|
+
render(width) {
|
|
150
|
+
const inner = Math.max(8, width - 3); // interior between the rounded borders
|
|
151
|
+
const textW = inner - 4; // ' ' glyph ' ' <text> ' '
|
|
152
|
+
const busy = this.getState().busy;
|
|
153
|
+
const accent = busy ? C.faint : C.comet;
|
|
154
|
+
const top = ` ${accent("╭" + "─".repeat(inner) + "╮")}`;
|
|
155
|
+
const bot = ` ${accent("╰" + "─".repeat(inner) + "╯")}`;
|
|
156
|
+
const glyph = busy ? C.faint(G.run) : C.plasma(G.prompt);
|
|
157
|
+
const line = this.input.render(textW)[0] ?? "";
|
|
158
|
+
const mid = ` ${accent("│")} ${glyph} ${fit(line, textW)} ${accent("│")}`;
|
|
159
|
+
return clamp([top, mid, bot], width);
|
|
160
|
+
}
|
|
161
|
+
invalidate() {
|
|
162
|
+
this.input.invalidate();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/* --------------------------------------------------------------------- footer */
|
|
166
|
+
export class Footer {
|
|
167
|
+
get;
|
|
168
|
+
constructor(get) {
|
|
169
|
+
this.get = get;
|
|
170
|
+
}
|
|
171
|
+
render(width) {
|
|
172
|
+
const s = this.get();
|
|
173
|
+
const cave = s.caveman && s.caveman !== "off" ? ` ${C.ghost(G.dot)} ${C.nebula("caveman:" + s.caveman)}` : "";
|
|
174
|
+
const left = ` ${C.comet(G.node)} ${C.text(s.model)} ${C.ghost(G.dot)} ${C.faint("safety")} ${s.safety === "dry-run" ? C.warn(s.safety) : C.good(s.safety)}${cave}`;
|
|
175
|
+
const right = s.busy
|
|
176
|
+
? `${d(C.faint("esc interrupt"))} `
|
|
177
|
+
: `${d(C.faint("/ commands"))} ${C.ghost(G.dot)} ${d(C.faint("enter send"))} `;
|
|
178
|
+
const gap = Math.max(1, width - vw(left) - vw(right));
|
|
179
|
+
return [fit(left + " ".repeat(gap) + right, width)];
|
|
180
|
+
}
|
|
181
|
+
invalidate() { }
|
|
182
|
+
}
|
|
183
|
+
export class Palette {
|
|
184
|
+
visible = false;
|
|
185
|
+
items = [];
|
|
186
|
+
selected = 0;
|
|
187
|
+
set(items) {
|
|
188
|
+
this.items = items;
|
|
189
|
+
if (this.selected >= items.length)
|
|
190
|
+
this.selected = Math.max(0, items.length - 1);
|
|
191
|
+
}
|
|
192
|
+
move(delta) {
|
|
193
|
+
if (this.items.length)
|
|
194
|
+
this.selected = (this.selected + delta + this.items.length) % this.items.length;
|
|
195
|
+
}
|
|
196
|
+
current() {
|
|
197
|
+
return this.items[this.selected];
|
|
198
|
+
}
|
|
199
|
+
render(width) {
|
|
200
|
+
if (!this.visible || !this.items.length)
|
|
201
|
+
return [];
|
|
202
|
+
const rows = this.items.map((it, i) => {
|
|
203
|
+
const on = i === this.selected;
|
|
204
|
+
const mark = on ? C.plasma(G.caret) : " ";
|
|
205
|
+
const name = (on ? (s) => b(C.star(s)) : C.plasma)(`/${it.name}`.padEnd(10));
|
|
206
|
+
const row = ` ${mark} ${name} ${d(C.faint(it.desc))}`;
|
|
207
|
+
return on ? hi(row, width) : truncate(row, width);
|
|
208
|
+
});
|
|
209
|
+
return clamp(["", ...rows], width);
|
|
210
|
+
}
|
|
211
|
+
invalidate() { }
|
|
212
|
+
}
|
|
213
|
+
/* ------------------------------------------------------------- model selector */
|
|
214
|
+
import { bgHex } from "../../ui/ansi.js";
|
|
215
|
+
const selBg = bgHex("#13213f");
|
|
216
|
+
function hi(s, width) {
|
|
217
|
+
return selBg(pad(truncate(s, width), width));
|
|
218
|
+
}
|
|
219
|
+
/** Generic arrow-navigable dropdown shown in place of the input box (model, login, …). */
|
|
220
|
+
export class Selector {
|
|
221
|
+
title;
|
|
222
|
+
items;
|
|
223
|
+
selected = 0;
|
|
224
|
+
top = 0;
|
|
225
|
+
window = 10;
|
|
226
|
+
constructor(title, items) {
|
|
227
|
+
this.title = title;
|
|
228
|
+
this.items = items;
|
|
229
|
+
}
|
|
230
|
+
move(delta) {
|
|
231
|
+
if (!this.items.length)
|
|
232
|
+
return;
|
|
233
|
+
this.selected = Math.min(this.items.length - 1, Math.max(0, this.selected + delta));
|
|
234
|
+
if (this.selected < this.top)
|
|
235
|
+
this.top = this.selected;
|
|
236
|
+
if (this.selected >= this.top + this.window)
|
|
237
|
+
this.top = this.selected - this.window + 1;
|
|
238
|
+
}
|
|
239
|
+
current() {
|
|
240
|
+
return this.items[this.selected];
|
|
241
|
+
}
|
|
242
|
+
render(width) {
|
|
243
|
+
const w = Math.min(width, Math.max(24, width)) - 2;
|
|
244
|
+
const t = this.title.toUpperCase();
|
|
245
|
+
const head = ` ${C.comet("╭")} ${b(C.nebula(t))} ${C.comet("─".repeat(Math.max(0, w - t.length - 4)))}${C.comet("╮")}`;
|
|
246
|
+
const foot = ` ${C.comet("╰")} ${d(C.faint("↑↓ move · enter select · esc cancel"))} ${C.comet("─".repeat(Math.max(0, w - 42)))}${C.comet("╯")}`;
|
|
247
|
+
const slice = this.items.slice(this.top, this.top + this.window);
|
|
248
|
+
const rows = slice.map((m, i) => {
|
|
249
|
+
const idx = this.top + i;
|
|
250
|
+
const on = idx === this.selected;
|
|
251
|
+
const mark = on ? C.plasma(G.caret) : " ";
|
|
252
|
+
const label = (on ? (s) => b(C.star(s)) : C.text)(m.label);
|
|
253
|
+
const hint = m.hint ? ` ${d(C.faint(m.hint))}` : "";
|
|
254
|
+
const inner = ` ${mark} ${label}${hint}`;
|
|
255
|
+
const body = on ? hi(inner, w - 2) : pad(truncate(inner, w - 2), w - 2);
|
|
256
|
+
return ` ${C.comet("│")}${body}${C.comet("│")}`;
|
|
257
|
+
});
|
|
258
|
+
return clamp([head, ...rows, foot], width);
|
|
259
|
+
}
|
|
260
|
+
invalidate() { }
|
|
261
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { truncate, visibleLen, UNICODE } from "../../ui/ansi.js";
|
|
2
|
+
import { C, G, b, d } from "./theme.js";
|
|
3
|
+
import { humanBytes } from "../../sys/probe.js";
|
|
4
|
+
import { realDisks } from "../../sys/report.js";
|
|
5
|
+
import { assessHealth } from "../../sys/health.js";
|
|
6
|
+
const FULL = UNICODE ? "█" : "#";
|
|
7
|
+
const EMPTY = UNICODE ? "░" : "-";
|
|
8
|
+
/** A fixed-width meter bar for a percent. */
|
|
9
|
+
export function meter(pct, width) {
|
|
10
|
+
const p = Math.max(0, Math.min(100, pct));
|
|
11
|
+
const filled = Math.round((p / 100) * width);
|
|
12
|
+
const tint = p >= 90 ? C.danger : p >= 80 ? C.warn : C.good;
|
|
13
|
+
return tint(FULL.repeat(filled)) + C.ghost(EMPTY.repeat(Math.max(0, width - filled)));
|
|
14
|
+
}
|
|
15
|
+
const SEV_GLYPH = {
|
|
16
|
+
high: C.danger,
|
|
17
|
+
medium: C.warn,
|
|
18
|
+
low: C.faint,
|
|
19
|
+
};
|
|
20
|
+
const STATUS_TINT = { ok: C.good, warn: C.warn, critical: C.danger };
|
|
21
|
+
export class Dashboard {
|
|
22
|
+
get;
|
|
23
|
+
constructor(get) {
|
|
24
|
+
this.get = get;
|
|
25
|
+
}
|
|
26
|
+
render(width) {
|
|
27
|
+
const data = this.get();
|
|
28
|
+
if (!data) {
|
|
29
|
+
return ["", ` ${C.plasma(G.node)} ${d(C.faint("reading machine state\u2026"))}`, ""];
|
|
30
|
+
}
|
|
31
|
+
const { snap, health } = data;
|
|
32
|
+
const w = Math.max(20, width);
|
|
33
|
+
const out = [""];
|
|
34
|
+
const status = `${STATUS_TINT[health.status](health.status.toUpperCase())}`;
|
|
35
|
+
out.push(` ${C.plasma(G.node)} ${b(C.star("SYSTEM"))} ${d(C.faint(snap.os))} ${C.ghost(G.dot)} ${status}`);
|
|
36
|
+
const barW = Math.min(18, Math.max(8, w - 40));
|
|
37
|
+
if (snap.memory) {
|
|
38
|
+
out.push(` ${C.faint("memory")} ${meter(snap.memory.usedPct, barW)} ${C.text(`${snap.memory.usedPct}%`)} ${d(C.faint(`(${humanBytes(snap.memory.used)} / ${humanBytes(snap.memory.total)})`))}`);
|
|
39
|
+
}
|
|
40
|
+
const root = (realDisks(snap)[0] ?? snap.disks[0]);
|
|
41
|
+
if (root) {
|
|
42
|
+
out.push(` ${C.faint("disk ")} ${meter(root.usedPct, barW)} ${C.text(`${root.usedPct}%`)} ${d(C.faint(`(${humanBytes(root.available)} free on ${root.mount})`))}`);
|
|
43
|
+
}
|
|
44
|
+
if (health.items.length) {
|
|
45
|
+
out.push(` ${C.plasma(G.node)} ${d(C.faint("recommendations"))}`);
|
|
46
|
+
for (const it of health.items.slice(0, 3)) {
|
|
47
|
+
const line = ` ${SEV_GLYPH[it.severity](G.caret)} ${C.text(it.title)} ${d(C.faint(it.detail))}`;
|
|
48
|
+
out.push(truncate(line, w));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
out.push(` ${C.good(G.check)} ${C.text("all clear \u2014 nothing needs attention")}`);
|
|
53
|
+
}
|
|
54
|
+
out.push(` ${d(C.faint('ask "how healthy is my machine?" or /doctor for the full report'))}`);
|
|
55
|
+
out.push("");
|
|
56
|
+
return out.map((l) => (visibleLen(l) > w ? truncate(l, w) : l));
|
|
57
|
+
}
|
|
58
|
+
invalidate() { }
|
|
59
|
+
}
|
|
60
|
+
/** Build dashboard data from a snapshot. */
|
|
61
|
+
export function dashboardData(snap) {
|
|
62
|
+
return { snap, health: assessHealth(snap) };
|
|
63
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NOAH visual language — cinematic "blue & black" (Nolan-grade): deep blacks,
|
|
3
|
+
* cold steel blues, ice-white highlights. Minimal, high-contrast, spacious.
|
|
4
|
+
* Truecolor; degrades to plain text without color.
|
|
5
|
+
*/
|
|
6
|
+
import { hex, bold, dim as dimStyle } from "../../ui/ansi.js";
|
|
7
|
+
export const C = {
|
|
8
|
+
hot: hex("#eaf2ff"), // ice-white highlight (logo crown, emphasis)
|
|
9
|
+
star: hex("#dce8ff"), // bright text
|
|
10
|
+
text: hex("#aebfd9"), // body text (cool grey-blue)
|
|
11
|
+
faint: hex("#62719a"), // secondary
|
|
12
|
+
ghost: hex("#26365c"), // borders / hairlines (deep steel)
|
|
13
|
+
comet: hex("#3b82f6"), // core blue
|
|
14
|
+
plasma: hex("#5b9dff"), // azure accent (primary)
|
|
15
|
+
nebula: hex("#9ec5ff"), // glacier (logo light / secondary)
|
|
16
|
+
pulse: hex("#7fb0ff"),
|
|
17
|
+
good: hex("#57d9a3"),
|
|
18
|
+
warn: hex("#ffce6b"),
|
|
19
|
+
danger: hex("#ff6b81"),
|
|
20
|
+
};
|
|
21
|
+
export const BG = {
|
|
22
|
+
panel: hex("#0a1020"),
|
|
23
|
+
sel: hex("#13213f"),
|
|
24
|
+
};
|
|
25
|
+
/** Minimal, modern glyph set. */
|
|
26
|
+
export const G = {
|
|
27
|
+
prompt: "›",
|
|
28
|
+
node: "◆",
|
|
29
|
+
nodeOpen: "◇",
|
|
30
|
+
dot: "·",
|
|
31
|
+
arrow: "→",
|
|
32
|
+
check: "✓",
|
|
33
|
+
cross: "✕",
|
|
34
|
+
run: "◐",
|
|
35
|
+
bar: "▏",
|
|
36
|
+
caret: "▸",
|
|
37
|
+
};
|
|
38
|
+
export const b = bold;
|
|
39
|
+
export const d = dimStyle;
|
package/dist/ui/ansi.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ANSI primitives — TTY + NO_COLOR aware. Zero dependencies.
|
|
3
|
+
*
|
|
4
|
+
* When stdout is not a TTY (piped, smoke test) or NO_COLOR is set, colors and
|
|
5
|
+
* heavy box characters degrade gracefully so plain-text assertions still match.
|
|
6
|
+
*/
|
|
7
|
+
const noColorEnv = "NO_COLOR" in process.env && process.env.NO_COLOR !== "";
|
|
8
|
+
const forceColor = process.env.NOAH_COLOR === "1";
|
|
9
|
+
export const COLOR = forceColor || (process.stdout.isTTY === true && !noColorEnv);
|
|
10
|
+
export const UNICODE = process.env.NOAH_ASCII !== "1";
|
|
11
|
+
function wrap(open, close) {
|
|
12
|
+
return (s) => (COLOR ? `\x1b[${open}m${s}\x1b[${close}m` : s);
|
|
13
|
+
}
|
|
14
|
+
// Styles
|
|
15
|
+
export const bold = wrap(1, 22);
|
|
16
|
+
export const dim = wrap(2, 22);
|
|
17
|
+
export const italic = wrap(3, 23);
|
|
18
|
+
export const underline = wrap(4, 24);
|
|
19
|
+
// Foreground colors (256-color where useful, with basic fallback semantics)
|
|
20
|
+
export const red = wrap(31, 39);
|
|
21
|
+
export const green = wrap(32, 39);
|
|
22
|
+
export const yellow = wrap(33, 39);
|
|
23
|
+
export const blue = wrap(34, 39);
|
|
24
|
+
export const magenta = wrap(35, 39);
|
|
25
|
+
export const cyan = wrap(36, 39);
|
|
26
|
+
export const gray = wrap(90, 39);
|
|
27
|
+
export const white = wrap(97, 39);
|
|
28
|
+
// 256-color helpers for richer accents
|
|
29
|
+
export function fg256(code) {
|
|
30
|
+
return (s) => (COLOR ? `\x1b[38;5;${code}m${s}\x1b[39m` : s);
|
|
31
|
+
}
|
|
32
|
+
export function bg256(code) {
|
|
33
|
+
return (s) => (COLOR ? `\x1b[48;5;${code}m${s}\x1b[49m` : s);
|
|
34
|
+
}
|
|
35
|
+
/** Truecolor foreground (24-bit). Falls back to plain text without color. */
|
|
36
|
+
export function rgb(r, g, b) {
|
|
37
|
+
return (s) => (COLOR ? `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m` : s);
|
|
38
|
+
}
|
|
39
|
+
/** Truecolor background (24-bit). */
|
|
40
|
+
export function bgRgb(r, g, b) {
|
|
41
|
+
return (s) => (COLOR ? `\x1b[48;2;${r};${g};${b}m${s}\x1b[49m` : s);
|
|
42
|
+
}
|
|
43
|
+
/** Parse "#rrggbb" → truecolor foreground fn. */
|
|
44
|
+
export function hex(color) {
|
|
45
|
+
const n = parseInt(color.replace(/^#/, ""), 16);
|
|
46
|
+
return rgb((n >> 16) & 255, (n >> 8) & 255, n & 255);
|
|
47
|
+
}
|
|
48
|
+
export function bgHex(color) {
|
|
49
|
+
const n = parseInt(color.replace(/^#/, ""), 16);
|
|
50
|
+
return bgRgb((n >> 16) & 255, (n >> 8) & 255, n & 255);
|
|
51
|
+
}
|
|
52
|
+
// eslint-disable-next-line no-control-regex
|
|
53
|
+
const ESC_SEQ = /\x1b\[[0-9;]*m|\x1b[_\]][^\x07]*\x07/g;
|
|
54
|
+
/** Strip SGR colors and OSC/APC sequences (incl. pi-tui's CURSOR_MARKER). */
|
|
55
|
+
export function stripAnsi(s) {
|
|
56
|
+
return s.replace(ESC_SEQ, "");
|
|
57
|
+
}
|
|
58
|
+
/** Visible length, ignoring color + control sequences (cursor marker is zero-width). */
|
|
59
|
+
export function visibleLen(s) {
|
|
60
|
+
return stripAnsi(s).length;
|
|
61
|
+
}
|
|
62
|
+
/** Pad a string to width based on visible length. */
|
|
63
|
+
export function padEnd(s, width) {
|
|
64
|
+
const len = visibleLen(s);
|
|
65
|
+
return len >= width ? s : s + " ".repeat(width - len);
|
|
66
|
+
}
|
|
67
|
+
/** Truncate to width (visible), appending an ellipsis when cut. */
|
|
68
|
+
export function truncate(s, width) {
|
|
69
|
+
if (visibleLen(s) <= width)
|
|
70
|
+
return s;
|
|
71
|
+
const ell = UNICODE ? "…" : "...";
|
|
72
|
+
return s.slice(0, Math.max(0, width - ell.length)) + ell;
|
|
73
|
+
}
|
|
74
|
+
/** Word-wrap plain text to a visible width (no ANSI inside expected). */
|
|
75
|
+
export function wordWrap(text, width) {
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const para of text.split("\n")) {
|
|
78
|
+
let line = "";
|
|
79
|
+
for (const word of para.split(/\s+/)) {
|
|
80
|
+
if (!word)
|
|
81
|
+
continue;
|
|
82
|
+
if (line && visibleLen(line) + 1 + visibleLen(word) > width) {
|
|
83
|
+
out.push(line);
|
|
84
|
+
line = word;
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
line = line ? `${line} ${word}` : word;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
out.push(line);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
package/dist/ui/badge.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status badges — colored, consistent indicators.
|
|
3
|
+
* The literal words (INFO/RUNNING/SUCCESS/WARNING/BLOCKED/ALLOW/CONFIRM)
|
|
4
|
+
* are preserved in output so plain-text assertions keep matching.
|
|
5
|
+
*/
|
|
6
|
+
import { bold, cyan, gray, green, red, yellow, fg256, UNICODE } from "./ansi.js";
|
|
7
|
+
const orange = fg256(208);
|
|
8
|
+
const SPECS = {
|
|
9
|
+
info: { label: "INFO", dot: "●", paint: cyan },
|
|
10
|
+
running: { label: "RUNNING", dot: "◐", paint: (s) => orange(s) },
|
|
11
|
+
success: { label: "SUCCESS", dot: "●", paint: green },
|
|
12
|
+
warning: { label: "WARNING", dot: "▲", paint: yellow },
|
|
13
|
+
blocked: { label: "BLOCKED", dot: "■", paint: red },
|
|
14
|
+
allow: { label: "ALLOW", dot: "●", paint: green },
|
|
15
|
+
confirm: { label: "CONFIRM", dot: "▲", paint: yellow },
|
|
16
|
+
};
|
|
17
|
+
/** Inline badge, e.g. "● SUCCESS" (colored). */
|
|
18
|
+
export function badge(status) {
|
|
19
|
+
const s = SPECS[status];
|
|
20
|
+
const dot = UNICODE ? s.dot : "*";
|
|
21
|
+
return s.paint(bold(`${dot} ${s.label}`));
|
|
22
|
+
}
|
|
23
|
+
/** Just the colored label text (no dot), for panel right-aligned tags. */
|
|
24
|
+
export function badgeLabel(status) {
|
|
25
|
+
const s = SPECS[status];
|
|
26
|
+
return s.paint(bold(s.label));
|
|
27
|
+
}
|
|
28
|
+
export function statusColor(status) {
|
|
29
|
+
return SPECS[status].paint;
|
|
30
|
+
}
|
|
31
|
+
export const muted = gray;
|
package/dist/ui/box.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Box / panel drawing. Styles: round · square · heavy · block.
|
|
3
|
+
* Width is the inner content area; visible length math ignores ANSI codes.
|
|
4
|
+
*/
|
|
5
|
+
import { gray, padEnd, visibleLen, UNICODE } from "./ansi.js";
|
|
6
|
+
const STYLES = {
|
|
7
|
+
round: { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" },
|
|
8
|
+
square: { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│" },
|
|
9
|
+
heavy: { tl: "┏", tr: "┓", bl: "┗", br: "┛", h: "━", v: "┃" },
|
|
10
|
+
};
|
|
11
|
+
const ASCII = { tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|" };
|
|
12
|
+
export function drawBox(body, opts = {}) {
|
|
13
|
+
const width = opts.width ?? 64;
|
|
14
|
+
const accent = opts.accent ?? gray;
|
|
15
|
+
const pad = " ".repeat(opts.indent ?? 2);
|
|
16
|
+
if (opts.style === "block") {
|
|
17
|
+
return drawBlock(body, width, accent, pad, opts.title, opts.status);
|
|
18
|
+
}
|
|
19
|
+
const c = UNICODE ? STYLES[opts.style ?? "round"] : ASCII;
|
|
20
|
+
const seg = width + 2; // dashes span between corners
|
|
21
|
+
// Top border with optional title (left) and status (right).
|
|
22
|
+
let titleStr = opts.title ? ` ${opts.title} ` : "";
|
|
23
|
+
let statusStr = opts.status ? ` ${opts.status} ` : "";
|
|
24
|
+
const avail = seg - 2; // room between the lead and trail border dashes
|
|
25
|
+
// Drop the status tag, then the title, if they cannot fit the top border.
|
|
26
|
+
if (visibleLen(titleStr) + visibleLen(statusStr) > avail)
|
|
27
|
+
statusStr = "";
|
|
28
|
+
if (visibleLen(titleStr) > avail)
|
|
29
|
+
titleStr = "";
|
|
30
|
+
const used = 1 + visibleLen(titleStr) + visibleLen(statusStr) + 1; // lead + trail h
|
|
31
|
+
const fill = c.h.repeat(Math.max(0, seg - used));
|
|
32
|
+
const top = pad +
|
|
33
|
+
accent(c.tl) +
|
|
34
|
+
accent(c.h) +
|
|
35
|
+
titleStr +
|
|
36
|
+
accent(fill) +
|
|
37
|
+
statusStr +
|
|
38
|
+
accent(c.h) +
|
|
39
|
+
accent(c.tr);
|
|
40
|
+
const lines = body.map((line) => pad + accent(c.v) + " " + padEnd(line, width) + " " + accent(c.v));
|
|
41
|
+
const bottom = pad + accent(c.bl) + accent(c.h.repeat(seg)) + accent(c.br);
|
|
42
|
+
return [top, ...lines, bottom].join("\n");
|
|
43
|
+
}
|
|
44
|
+
function drawBlock(body, width, accent, pad, title, status) {
|
|
45
|
+
const block = UNICODE ? "█" : "#";
|
|
46
|
+
const total = width + 8; // ██ + " " + width + " " + ██
|
|
47
|
+
const solid = pad + accent(block.repeat(total));
|
|
48
|
+
const blank = pad + accent(block.repeat(2)) + " ".repeat(width + 4) + accent(block.repeat(2));
|
|
49
|
+
const row = (content) => pad + accent(block.repeat(2)) + " " + padEnd(content, width) + " " + accent(block.repeat(2));
|
|
50
|
+
const header = [];
|
|
51
|
+
if (title)
|
|
52
|
+
header.push(row(title), blank);
|
|
53
|
+
if (status)
|
|
54
|
+
header.push(row(status), blank);
|
|
55
|
+
return [solid, blank, ...header, ...body.map(row), blank, solid].join("\n");
|
|
56
|
+
}
|
|
57
|
+
/** A thin full-width rule for separating sections. */
|
|
58
|
+
export function rule(width = 68, indent = 2) {
|
|
59
|
+
const ch = UNICODE ? "─" : "-";
|
|
60
|
+
return " ".repeat(indent) + gray(ch.repeat(width));
|
|
61
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visual preview of every NOAH panel — wire-free.
|
|
3
|
+
* npm run build && node dist/ui/preview.js
|
|
4
|
+
* (or: npx tsx src/ui/preview.ts)
|
|
5
|
+
*
|
|
6
|
+
* Lets us validate the terminal experience before connecting it to live events.
|
|
7
|
+
*/
|
|
8
|
+
import * as ui from "./render.js";
|
|
9
|
+
const out = (s) => process.stdout.write(s + "\n");
|
|
10
|
+
out(ui.brand());
|
|
11
|
+
out(ui.requestPanel("install htop and then show me my five biggest files"));
|
|
12
|
+
out(ui.sectionHeader("PLAN", "info"));
|
|
13
|
+
out(ui.barLines([
|
|
14
|
+
"1. Inspect the largest files in the home directory (read-only)",
|
|
15
|
+
"2. Install htop via the native package manager",
|
|
16
|
+
"3. Report results",
|
|
17
|
+
].join("\n")));
|
|
18
|
+
out(ui.toolCard("bash", "du -ah ~ | sort -rh | head -5", "running"));
|
|
19
|
+
out(ui.toolCard("bash", "du -ah ~ | sort -rh | head -5", "success", [
|
|
20
|
+
"1.2G ~/Movies/demo.mov",
|
|
21
|
+
"480M ~/Downloads/node-v22.pkg",
|
|
22
|
+
"120M ~/Projects/NOAH/node_modules",
|
|
23
|
+
]));
|
|
24
|
+
out(ui.toolCard("package", "install htop", "running"));
|
|
25
|
+
out(ui.safetyReview("sudo apt-get install -y htop", "package install", "bash"));
|
|
26
|
+
out(ui.approvePrompt() + "y");
|
|
27
|
+
out(ui.safetyBlock("rm -rf / --no-preserve-root", "blocked: recursive delete of root/home"));
|
|
28
|
+
out(ui.safetyBlock(":(){ :|:& };:", "blocked: fork bomb"));
|
|
29
|
+
out("");
|
|
30
|
+
out(ui.auditLine("bash", "du -ah ~ | sort -rh | head -5", true));
|
|
31
|
+
out(ui.auditLine("package", "install htop", true));
|
|
32
|
+
out(ui.auditLine("bash", "cat /missing", false));
|
|
33
|
+
out(ui.resultPanel("Installed htop. Your five largest files are listed above; ~/Movies/demo.mov (1.2G) is the biggest."));
|
|
34
|
+
out("\n " + "\x1b[1m\x1b[97m— check verdicts —\x1b[0m");
|
|
35
|
+
out(ui.checkVerdict("ls -la", "allow", "read-only / safe command"));
|
|
36
|
+
out(ui.checkVerdict("sudo apt install nginx", "confirm", "potentially destructive command"));
|
|
37
|
+
out("");
|