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,566 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NOAH space TUI — a cinematic, minimal interactive console built directly on
|
|
3
|
+
* pi-tui's render engine + AgentSession (no Pi InteractiveMode). Hero logo,
|
|
4
|
+
* rounded input, blue-on-black, with an arrow-selectable model picker.
|
|
5
|
+
*/
|
|
6
|
+
import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { Container, Input, ProcessTerminal, TUI } from "@earendil-works/pi-tui";
|
|
8
|
+
import { buildRegistry } from "../../llm/registry.js";
|
|
9
|
+
import { resolveModel } from "../../llm/resolve.js";
|
|
10
|
+
import { safetyExtension } from "../../safety/extension.js";
|
|
11
|
+
import { readAudit, appendAudit } from "../../safety/audit.js";
|
|
12
|
+
import { packageTool } from "../../tools/package.js";
|
|
13
|
+
import { serviceTool } from "../../tools/service.js";
|
|
14
|
+
import { networkTool } from "../../tools/network.js";
|
|
15
|
+
import { systemTool } from "../../tools/system.js";
|
|
16
|
+
import { logsTool } from "../../tools/logs.js";
|
|
17
|
+
import { NOAH_SYSTEM_PROMPT } from "../../prompt/system.js";
|
|
18
|
+
import { Dashboard, dashboardData } from "./dashboard.js";
|
|
19
|
+
import { collectSnapshot } from "../../sys/probe.js";
|
|
20
|
+
import { assessHealth } from "../../sys/health.js";
|
|
21
|
+
import { formatDoctor } from "../../sys/report.js";
|
|
22
|
+
import { cavemanExtension, isCavemanLevel, CAVEMAN_LEVELS } from "../../agent/caveman.js";
|
|
23
|
+
import { authGate } from "../../agent/auth-gate.js";
|
|
24
|
+
import { spawn } from "node:child_process";
|
|
25
|
+
import { AssistantBlock, Footer, InputBox, Palette, Selector, Splash, SystemBlock, ToolBlock, UserBlock, } from "./components.js";
|
|
26
|
+
import { listLoginProviders, runLogin } from "../../agent/login.js";
|
|
27
|
+
import { G } from "./theme.js";
|
|
28
|
+
const NOAH_TOOLS = ["read", "bash", "edit", "write", "grep", "find", "ls", "package", "service", "network", "system", "logs"];
|
|
29
|
+
const CUSTOM_TOOLS = [packageTool, serviceTool, networkTool, systemTool, logsTool];
|
|
30
|
+
const COMMANDS = [
|
|
31
|
+
{ name: "model", desc: "choose a model" },
|
|
32
|
+
{ name: "login", desc: "auth status / set an API key" },
|
|
33
|
+
{ name: "logout", desc: "sign out of a provider" },
|
|
34
|
+
{ name: "caveman", desc: "token-saver terse mode (off/lite/full/ultra/micro)" },
|
|
35
|
+
{ name: "doctor", desc: "full machine health report" },
|
|
36
|
+
{ name: "compact", desc: "compress context to save tokens" },
|
|
37
|
+
{ name: "extensions", desc: "active extensions" },
|
|
38
|
+
{ name: "theme", desc: "appearance" },
|
|
39
|
+
{ name: "audit", desc: "recent actions" },
|
|
40
|
+
{ name: "clear", desc: "clear the console" },
|
|
41
|
+
{ name: "help", desc: "what NOAH can do" },
|
|
42
|
+
{ name: "quit", desc: "exit NOAH" },
|
|
43
|
+
];
|
|
44
|
+
const ESC = "\x1b";
|
|
45
|
+
const UP = "\x1b[A";
|
|
46
|
+
const DOWN = "\x1b[B";
|
|
47
|
+
const isEnter = (s) => s === "\r" || s === "\n";
|
|
48
|
+
export async function runNoahSpace(opts) {
|
|
49
|
+
if (opts.dryRun)
|
|
50
|
+
process.env.NOAH_DRY_RUN = "1";
|
|
51
|
+
// --- agent wiring ---------------------------------------------------------
|
|
52
|
+
const { authStorage, modelRegistry } = await buildRegistry();
|
|
53
|
+
const model = resolveModel(modelRegistry, {
|
|
54
|
+
flagModel: opts.model,
|
|
55
|
+
envModel: process.env.NOAH_MODEL,
|
|
56
|
+
});
|
|
57
|
+
const scopedModels = modelRegistry.getAvailable().map((m) => ({ model: m }));
|
|
58
|
+
const entries = [];
|
|
59
|
+
let cavemanLevel = opts.caveman ?? "off";
|
|
60
|
+
const loggedOut = new Set(); // session-local logout (does not delete ~/.pi creds)
|
|
61
|
+
const state = { busy: false, model: model.id, safety: opts.dryRun ? "dry-run" : "on" };
|
|
62
|
+
let pendingConfirm = null;
|
|
63
|
+
const confirm = (req) => new Promise((resolve) => {
|
|
64
|
+
pushEntry(new SystemBlock([
|
|
65
|
+
`${G.node} safety review — ${req.reason}`,
|
|
66
|
+
`${req.toolName}${req.command ? `: ${req.command}` : ""}`,
|
|
67
|
+
`approve? ${G.arrow} y / n`,
|
|
68
|
+
], "danger"));
|
|
69
|
+
pendingConfirm = (ok) => {
|
|
70
|
+
pendingConfirm = null;
|
|
71
|
+
pushEntry(new SystemBlock([ok ? `${G.check} approved` : `${G.cross} declined`], ok ? "info" : "warn"));
|
|
72
|
+
resolve(ok);
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
76
|
+
cwd: process.cwd(),
|
|
77
|
+
agentDir: getAgentDir(),
|
|
78
|
+
systemPromptOverride: () => NOAH_SYSTEM_PROMPT,
|
|
79
|
+
appendSystemPromptOverride: () => [],
|
|
80
|
+
extensionFactories: [
|
|
81
|
+
safetyExtension({ dryRun: opts.dryRun, autoYes: opts.autoYes, confirm }),
|
|
82
|
+
cavemanExtension(() => cavemanLevel),
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
await resourceLoader.reload();
|
|
86
|
+
const { session } = await createAgentSession({
|
|
87
|
+
resourceLoader,
|
|
88
|
+
model: model,
|
|
89
|
+
authStorage,
|
|
90
|
+
modelRegistry,
|
|
91
|
+
scopedModels,
|
|
92
|
+
tools: NOAH_TOOLS,
|
|
93
|
+
customTools: CUSTOM_TOOLS,
|
|
94
|
+
sessionManager: SessionManager.create(process.cwd()),
|
|
95
|
+
});
|
|
96
|
+
// --- layout ---------------------------------------------------------------
|
|
97
|
+
const terminal = new ProcessTerminal();
|
|
98
|
+
const tui = new TUI(terminal, true);
|
|
99
|
+
const input = new Input();
|
|
100
|
+
const transcript = new Container();
|
|
101
|
+
const palette = new Palette();
|
|
102
|
+
const inputArea = new Container();
|
|
103
|
+
const inputBox = new InputBox(input, () => ({ busy: state.busy }));
|
|
104
|
+
const footer = new Footer(() => ({ model: state.model, safety: state.safety, busy: state.busy, caveman: cavemanLevel }));
|
|
105
|
+
inputArea.addChild(inputBox);
|
|
106
|
+
// Startup health dashboard — visible on the fresh screen, hidden once chatting.
|
|
107
|
+
let dash = null;
|
|
108
|
+
const dashArea = new Container();
|
|
109
|
+
dashArea.addChild(new Dashboard(() => dash));
|
|
110
|
+
void collectSnapshot().then((snap) => {
|
|
111
|
+
dash = dashboardData(snap);
|
|
112
|
+
tui.requestRender();
|
|
113
|
+
});
|
|
114
|
+
tui.addChild(new Splash(() => entries.length === 0));
|
|
115
|
+
tui.addChild(dashArea);
|
|
116
|
+
tui.addChild(transcript);
|
|
117
|
+
tui.addChild(palette);
|
|
118
|
+
tui.addChild(inputArea);
|
|
119
|
+
tui.addChild(footer);
|
|
120
|
+
tui.setFocus(input);
|
|
121
|
+
const sync = () => {
|
|
122
|
+
transcript.clear();
|
|
123
|
+
for (const e of entries)
|
|
124
|
+
transcript.addChild(e);
|
|
125
|
+
tui.requestRender();
|
|
126
|
+
};
|
|
127
|
+
function pushEntry(c) {
|
|
128
|
+
entries.push(c);
|
|
129
|
+
transcript.addChild(c);
|
|
130
|
+
tui.requestRender();
|
|
131
|
+
}
|
|
132
|
+
let resolveRun;
|
|
133
|
+
const done = new Promise((r) => (resolveRun = r));
|
|
134
|
+
const shutdown = () => {
|
|
135
|
+
tui.stop();
|
|
136
|
+
session.dispose();
|
|
137
|
+
resolveRun();
|
|
138
|
+
};
|
|
139
|
+
// --- streaming ------------------------------------------------------------
|
|
140
|
+
let stream = null;
|
|
141
|
+
let turnProduced = false; // did this turn show the user anything?
|
|
142
|
+
const toolBlocks = new Map();
|
|
143
|
+
session.subscribe((event) => {
|
|
144
|
+
switch (event.type) {
|
|
145
|
+
case "message_update": {
|
|
146
|
+
const e = event.assistantMessageEvent;
|
|
147
|
+
if (e.type === "text_delta") {
|
|
148
|
+
if (!stream) {
|
|
149
|
+
stream = new AssistantBlock();
|
|
150
|
+
pushEntry(stream);
|
|
151
|
+
}
|
|
152
|
+
stream.append(e.delta);
|
|
153
|
+
turnProduced = true;
|
|
154
|
+
tui.requestRender();
|
|
155
|
+
}
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
case "tool_execution_start": {
|
|
159
|
+
stream = null;
|
|
160
|
+
turnProduced = true;
|
|
161
|
+
const block = new ToolBlock(event.toolName, describeArgs(event.toolName, event.args), "running");
|
|
162
|
+
toolBlocks.set(event.toolCallId, block);
|
|
163
|
+
pushEntry(block);
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
case "tool_execution_end": {
|
|
167
|
+
toolBlocks.get(event.toolCallId)?.set(event.isError ? "err" : "ok");
|
|
168
|
+
tui.requestRender();
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
case "compaction_start":
|
|
172
|
+
sys([`${G.node} compacting context to save tokens…`]);
|
|
173
|
+
break;
|
|
174
|
+
case "compaction_end":
|
|
175
|
+
if (!event.aborted)
|
|
176
|
+
sys([`${G.check} context compacted.`]);
|
|
177
|
+
break;
|
|
178
|
+
case "message_end": {
|
|
179
|
+
// Surface model/provider errors (auth, deprecation, overflow) that arrive
|
|
180
|
+
// on the final assistant message instead of as streamed text.
|
|
181
|
+
const m = event.message;
|
|
182
|
+
if (m?.role === "assistant" && m.errorMessage) {
|
|
183
|
+
turnProduced = true;
|
|
184
|
+
sys([`${G.cross} ${m.errorMessage}`, hintForError(m.errorMessage)], "danger");
|
|
185
|
+
appendAudit({ ts: new Date().toISOString(), tool: "model", input: { command: m.errorMessage }, ok: false });
|
|
186
|
+
}
|
|
187
|
+
stream = null;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
// --- generic dropdown selector + line-prompt (used by /model, /login) -----
|
|
193
|
+
let active = null;
|
|
194
|
+
let pendingInput = null;
|
|
195
|
+
const openSelector = (title, items, onPick, onCancel = () => { }) => {
|
|
196
|
+
const sel = new Selector(title, items);
|
|
197
|
+
active = { sel, onPick, onCancel };
|
|
198
|
+
inputArea.clear();
|
|
199
|
+
inputArea.addChild(sel);
|
|
200
|
+
tui.requestRender();
|
|
201
|
+
};
|
|
202
|
+
const closeSelector = () => {
|
|
203
|
+
active = null;
|
|
204
|
+
inputArea.clear();
|
|
205
|
+
inputArea.addChild(inputBox);
|
|
206
|
+
tui.setFocus(input);
|
|
207
|
+
tui.requestRender();
|
|
208
|
+
};
|
|
209
|
+
const openModelSelector = () => {
|
|
210
|
+
const items = modelRegistry.getAvailable().map((m) => ({ id: `${m.provider}/${m.id}`, label: `${m.provider}/${m.id}` }));
|
|
211
|
+
if (items.length === 0) {
|
|
212
|
+
sys([`${G.cross} no models available. Sign in with /login or start Ollama.`], "warn");
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
openSelector("select model", items, (it) => {
|
|
216
|
+
closeSelector();
|
|
217
|
+
const i = it.id.indexOf("/");
|
|
218
|
+
const found = modelRegistry.find(it.id.slice(0, i), it.id.slice(i + 1));
|
|
219
|
+
if (found)
|
|
220
|
+
void session.setModel(found).then(() => {
|
|
221
|
+
state.model = session.model?.id ?? it.id;
|
|
222
|
+
sys([`${G.check} model → ${state.model}`]);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
// line-prompt: capture the next submitted input (for OAuth code paste)
|
|
227
|
+
const uiPrompt = (message) => new Promise((resolve) => {
|
|
228
|
+
sys([`${G.node} ${message}`]);
|
|
229
|
+
pendingInput = (s) => {
|
|
230
|
+
pendingInput = null;
|
|
231
|
+
resolve(s);
|
|
232
|
+
};
|
|
233
|
+
});
|
|
234
|
+
const openInBrowser = (url) => {
|
|
235
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
236
|
+
try {
|
|
237
|
+
spawn(cmd, [url], { stdio: "ignore", detached: true }).unref();
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
/* user can copy the url from the note */
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
const loginUI = {
|
|
244
|
+
openUrl: openInBrowser,
|
|
245
|
+
note: (m) => sys([m]),
|
|
246
|
+
prompt: uiPrompt,
|
|
247
|
+
select: (message, options) => new Promise((resolve) => {
|
|
248
|
+
openSelector(message, options.map((o) => ({ id: o.id, label: o.label })), (it) => {
|
|
249
|
+
closeSelector();
|
|
250
|
+
resolve(it.id);
|
|
251
|
+
}, () => {
|
|
252
|
+
closeSelector();
|
|
253
|
+
resolve(undefined);
|
|
254
|
+
});
|
|
255
|
+
}),
|
|
256
|
+
};
|
|
257
|
+
const startLogin = async () => {
|
|
258
|
+
const provs = await listLoginProviders();
|
|
259
|
+
if (provs.length === 0) {
|
|
260
|
+
sys([`${G.cross} no sign-in providers available.`], "warn");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
openSelector("sign in", provs.map((p) => ({ id: p.id, label: p.name })), (it) => {
|
|
264
|
+
closeSelector();
|
|
265
|
+
sys([`${G.node} signing in to ${it.label}…`]);
|
|
266
|
+
void runLogin(it.id, loginUI, authStorage, modelRegistry).then((r) => {
|
|
267
|
+
if (r.ok) {
|
|
268
|
+
loggedOut.delete(it.id);
|
|
269
|
+
sys([`${G.check} connected to ${it.label}. Use /model to pick a model.`]);
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
sys([`${G.cross} sign-in failed: ${r.error}`], "danger");
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
};
|
|
277
|
+
// --- commands -------------------------------------------------------------
|
|
278
|
+
const sys = (lines, kind = "info") => pushEntry(new SystemBlock(lines, kind));
|
|
279
|
+
async function handleCommand(raw) {
|
|
280
|
+
const [cmd, ...rest] = raw.slice(1).trim().split(/\s+/);
|
|
281
|
+
const arg = rest.join(" ");
|
|
282
|
+
switch (cmd) {
|
|
283
|
+
case "help":
|
|
284
|
+
sys([
|
|
285
|
+
`${G.node} NOAH operates your OS from natural language.`,
|
|
286
|
+
"Try: “install htop and start it” · “what’s using port 3000” · “tidy ~/Downloads”.",
|
|
287
|
+
"Tools: bash · files · package · service · network — safety-gated and audited.",
|
|
288
|
+
"Commands: /model /login /logout /extensions /audit /clear /quit · esc interrupts.",
|
|
289
|
+
]);
|
|
290
|
+
break;
|
|
291
|
+
case "model":
|
|
292
|
+
if (arg) {
|
|
293
|
+
const found = modelRegistry.find(arg.split("/")[0], arg.split("/").slice(1).join("/"));
|
|
294
|
+
if (!found)
|
|
295
|
+
sys([`${G.cross} unknown model “${arg}”.`], "warn");
|
|
296
|
+
else {
|
|
297
|
+
await session.setModel(found);
|
|
298
|
+
state.model = session.model?.id ?? arg;
|
|
299
|
+
sys([`${G.check} model → ${state.model}`]);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
openModelSelector();
|
|
304
|
+
}
|
|
305
|
+
break;
|
|
306
|
+
case "login": {
|
|
307
|
+
const [prov, key] = arg.split(/\s+/);
|
|
308
|
+
if (prov && key) {
|
|
309
|
+
authStorage.set(prov, { type: "api_key", key });
|
|
310
|
+
modelRegistry.refresh();
|
|
311
|
+
loggedOut.delete(prov);
|
|
312
|
+
sys([`${G.check} saved API key for ${prov}. Use /model to pick one of its models.`]);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
// No args → pi-style provider dropdown (Anthropic / GitHub Copilot / Codex).
|
|
316
|
+
await startLogin();
|
|
317
|
+
}
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
case "logout": {
|
|
321
|
+
const prov = (arg.trim() || session.model?.provider) ?? "";
|
|
322
|
+
if (!prov)
|
|
323
|
+
sys(["usage: /logout <provider>"], "warn");
|
|
324
|
+
else {
|
|
325
|
+
loggedOut.add(prov);
|
|
326
|
+
sys([`${G.check} signed out of ${prov} (this session). Run /login ${prov} to reconnect.`]);
|
|
327
|
+
}
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
case "caveman": {
|
|
331
|
+
const a = arg.trim().toLowerCase();
|
|
332
|
+
if (!a)
|
|
333
|
+
cavemanLevel = cavemanLevel === "off" ? "full" : "off";
|
|
334
|
+
else if (a === "stop" || a === "off")
|
|
335
|
+
cavemanLevel = "off";
|
|
336
|
+
else if (isCavemanLevel(a))
|
|
337
|
+
cavemanLevel = a;
|
|
338
|
+
else {
|
|
339
|
+
sys([`${G.cross} levels: off, ${CAVEMAN_LEVELS.join(", ")}`], "warn");
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
sys([
|
|
343
|
+
cavemanLevel === "off"
|
|
344
|
+
? `${G.node} caveman mode off — normal responses.`
|
|
345
|
+
: `${G.check} caveman:${cavemanLevel} — terse replies, ~75% fewer output tokens. Applies next message.`,
|
|
346
|
+
]);
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
case "doctor": {
|
|
350
|
+
const snap = await collectSnapshot();
|
|
351
|
+
sys(formatDoctor(snap, assessHealth(snap)));
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
case "compact": {
|
|
355
|
+
sys([`${G.node} compacting…`]);
|
|
356
|
+
try {
|
|
357
|
+
await session.compact();
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
sys([`${G.cross} compact failed: ${err.message}`], "warn");
|
|
361
|
+
}
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
case "extensions":
|
|
365
|
+
sys([
|
|
366
|
+
`${G.node} active extensions:`,
|
|
367
|
+
`${G.check} safety-gate confirmation + blocklist + dry-run`,
|
|
368
|
+
`${G.check} audit-log every action recorded to .noah/audit.jsonl`,
|
|
369
|
+
`${cavemanLevel === "off" ? G.dot : G.check} caveman token-saver terse mode (/caveman)`,
|
|
370
|
+
`${G.check} auto-compact context compression to cut tokens (/compact)`,
|
|
371
|
+
]);
|
|
372
|
+
break;
|
|
373
|
+
case "theme":
|
|
374
|
+
sys([`${G.node} NOAH uses a fixed cinematic theme (blue · black). More coming soon.`]);
|
|
375
|
+
break;
|
|
376
|
+
case "audit": {
|
|
377
|
+
const e = readAudit().slice(-10);
|
|
378
|
+
if (!e.length)
|
|
379
|
+
sys(["no actions recorded yet."]);
|
|
380
|
+
else
|
|
381
|
+
sys([
|
|
382
|
+
`${G.node} last ${e.length} actions:`,
|
|
383
|
+
...e.map((x) => {
|
|
384
|
+
const c = x.input && typeof x.input === "object" && "command" in x.input
|
|
385
|
+
? x.input.command
|
|
386
|
+
: JSON.stringify(x.input);
|
|
387
|
+
return `${x.ok ? G.check : G.cross} [${x.tool}] ${c}`;
|
|
388
|
+
}),
|
|
389
|
+
]);
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
case "clear":
|
|
393
|
+
entries.length = 0;
|
|
394
|
+
if (dash && dashArea.children.length === 0)
|
|
395
|
+
dashArea.addChild(new Dashboard(() => dash));
|
|
396
|
+
sync();
|
|
397
|
+
break;
|
|
398
|
+
case "quit":
|
|
399
|
+
case "exit":
|
|
400
|
+
shutdown();
|
|
401
|
+
break;
|
|
402
|
+
default:
|
|
403
|
+
sys([`${G.cross} unknown command “/${cmd}”. Type /help.`], "warn");
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
async function submitPrompt(text) {
|
|
407
|
+
dashArea.clear(); // hide the launch dashboard once the conversation starts
|
|
408
|
+
// Auth gate: session-local /logout, then real credential check.
|
|
409
|
+
const prov = session.model?.provider;
|
|
410
|
+
if (prov && loggedOut.has(prov)) {
|
|
411
|
+
pushEntry(new UserBlock(text));
|
|
412
|
+
sys([`${G.cross} Signed out of ${prov}. Run /login ${prov} to reconnect.`], "warn");
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const gate = authGate(session.model ?? undefined, authStorage);
|
|
416
|
+
if (!gate.ok) {
|
|
417
|
+
pushEntry(new UserBlock(text));
|
|
418
|
+
sys([`${G.cross} ${gate.reason}`], "warn");
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
pushEntry(new UserBlock(text));
|
|
422
|
+
appendAudit({ ts: new Date().toISOString(), tool: "message", input: { command: text }, ok: true });
|
|
423
|
+
state.busy = true;
|
|
424
|
+
stream = null;
|
|
425
|
+
turnProduced = false;
|
|
426
|
+
tui.requestRender();
|
|
427
|
+
try {
|
|
428
|
+
await session.prompt(text);
|
|
429
|
+
if (!turnProduced)
|
|
430
|
+
sys([`${G.node} the model returned no output. Try /model to switch, or rephrase.`], "warn");
|
|
431
|
+
}
|
|
432
|
+
catch (err) {
|
|
433
|
+
sys([`${G.cross} ${err.message}`, hintForError(err.message)], "danger");
|
|
434
|
+
}
|
|
435
|
+
finally {
|
|
436
|
+
state.busy = false;
|
|
437
|
+
tui.requestRender();
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
input.onSubmit = (raw) => {
|
|
441
|
+
const text = raw.trim();
|
|
442
|
+
input.setValue("");
|
|
443
|
+
palette.visible = false;
|
|
444
|
+
// OAuth code paste / interactive prompt capture
|
|
445
|
+
if (pendingInput) {
|
|
446
|
+
pendingInput(text);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (!text)
|
|
450
|
+
return;
|
|
451
|
+
if (text.startsWith("/"))
|
|
452
|
+
void handleCommand(text);
|
|
453
|
+
else if (!state.busy)
|
|
454
|
+
void submitPrompt(text);
|
|
455
|
+
};
|
|
456
|
+
// --- key routing ----------------------------------------------------------
|
|
457
|
+
const syncPalette = () => {
|
|
458
|
+
const v = input.getValue();
|
|
459
|
+
if (!state.busy && v.startsWith("/")) {
|
|
460
|
+
const q = v.slice(1).toLowerCase();
|
|
461
|
+
const matches = COMMANDS.filter((c) => c.name.startsWith(q));
|
|
462
|
+
palette.set(matches);
|
|
463
|
+
palette.visible = matches.length > 0;
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
palette.visible = false;
|
|
467
|
+
}
|
|
468
|
+
tui.requestRender();
|
|
469
|
+
};
|
|
470
|
+
tui.addInputListener((data) => {
|
|
471
|
+
if (pendingConfirm) {
|
|
472
|
+
if (/^y/i.test(data) || isEnter(data))
|
|
473
|
+
pendingConfirm(true);
|
|
474
|
+
else if (/^n/i.test(data) || data === ESC)
|
|
475
|
+
pendingConfirm(false);
|
|
476
|
+
return { consume: true };
|
|
477
|
+
}
|
|
478
|
+
if (active) {
|
|
479
|
+
if (data === UP)
|
|
480
|
+
active.sel.move(-1);
|
|
481
|
+
else if (data === DOWN)
|
|
482
|
+
active.sel.move(1);
|
|
483
|
+
else if (isEnter(data)) {
|
|
484
|
+
const it = active.sel.current();
|
|
485
|
+
const onPick = active.onPick;
|
|
486
|
+
if (it)
|
|
487
|
+
onPick(it);
|
|
488
|
+
else
|
|
489
|
+
closeSelector();
|
|
490
|
+
return { consume: true };
|
|
491
|
+
}
|
|
492
|
+
else if (data === ESC) {
|
|
493
|
+
const onCancel = active.onCancel;
|
|
494
|
+
onCancel();
|
|
495
|
+
return { consume: true };
|
|
496
|
+
}
|
|
497
|
+
tui.requestRender();
|
|
498
|
+
return { consume: true };
|
|
499
|
+
}
|
|
500
|
+
if (palette.visible) {
|
|
501
|
+
if (data === UP) {
|
|
502
|
+
palette.move(-1);
|
|
503
|
+
tui.requestRender();
|
|
504
|
+
return { consume: true };
|
|
505
|
+
}
|
|
506
|
+
if (data === DOWN) {
|
|
507
|
+
palette.move(1);
|
|
508
|
+
tui.requestRender();
|
|
509
|
+
return { consume: true };
|
|
510
|
+
}
|
|
511
|
+
if (isEnter(data)) {
|
|
512
|
+
const item = palette.current();
|
|
513
|
+
if (item) {
|
|
514
|
+
input.setValue("");
|
|
515
|
+
palette.visible = false;
|
|
516
|
+
void handleCommand(`/${item.name}`);
|
|
517
|
+
}
|
|
518
|
+
return { consume: true };
|
|
519
|
+
}
|
|
520
|
+
if (data === ESC) {
|
|
521
|
+
palette.visible = false;
|
|
522
|
+
tui.requestRender();
|
|
523
|
+
return { consume: true };
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
else if (state.busy && data === ESC) {
|
|
527
|
+
void session.abort();
|
|
528
|
+
return { consume: true };
|
|
529
|
+
}
|
|
530
|
+
queueMicrotask(syncPalette);
|
|
531
|
+
return undefined;
|
|
532
|
+
});
|
|
533
|
+
// --- go -------------------------------------------------------------------
|
|
534
|
+
tui.start();
|
|
535
|
+
tui.requestRender();
|
|
536
|
+
if (opts.initialMessage)
|
|
537
|
+
void submitPrompt(opts.initialMessage);
|
|
538
|
+
await done;
|
|
539
|
+
}
|
|
540
|
+
/** A short, actionable hint for common model errors. */
|
|
541
|
+
function hintForError(msg) {
|
|
542
|
+
const m = msg.toLowerCase();
|
|
543
|
+
if (/auth|api key|unauthor|401|credential|login/.test(m))
|
|
544
|
+
return "→ run /login, or pick another model with /model.";
|
|
545
|
+
if (/deprecat|end-of-life|not found|404|model/.test(m))
|
|
546
|
+
return "→ choose a current model with /model.";
|
|
547
|
+
if (/overflow|context|too long|429|rate/.test(m))
|
|
548
|
+
return "→ try again, or /clear to reset the conversation.";
|
|
549
|
+
return "→ try /model to switch, or /login to re-authenticate.";
|
|
550
|
+
}
|
|
551
|
+
function describeArgs(toolName, args) {
|
|
552
|
+
if (!args || typeof args !== "object")
|
|
553
|
+
return "";
|
|
554
|
+
const a = args;
|
|
555
|
+
if (typeof a.command === "string")
|
|
556
|
+
return a.command;
|
|
557
|
+
if (toolName === "package")
|
|
558
|
+
return `${a.action ?? ""} ${a.pkg ?? ""}`.trim();
|
|
559
|
+
if (toolName === "service")
|
|
560
|
+
return `${a.action ?? ""} ${a.name ?? ""}`.trim();
|
|
561
|
+
if (toolName === "network")
|
|
562
|
+
return `${a.action ?? ""} ${a.target ?? ""}`.trim();
|
|
563
|
+
if (typeof a.path === "string")
|
|
564
|
+
return String(a.path);
|
|
565
|
+
return JSON.stringify(a);
|
|
566
|
+
}
|