hostwares-cli 2.4.1 → 2.4.2

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/dist/ui/render.js DELETED
@@ -1,355 +0,0 @@
1
- import { c, glyph } from "./theme.js";
2
- /**
3
- * Terminal output: streaming text, activity rows, and the status line.
4
- *
5
- * The streaming writer is the part that needs care. Text arrives token by
6
- * token, and an activity row ("Running df -h") can need to be printed in the
7
- * middle of it. Writing the row straight to stdout would splice it into the
8
- * middle of a sentence, so `beginStream`/`endStream` track whether a partial
9
- * line is on screen and insert a newline first.
10
- */
11
- // ── streaming text ──────────────────────────────────────────────────────────
12
- let atLineStart = true;
13
- export function write(text) {
14
- if (!text)
15
- return;
16
- process.stdout.write(text);
17
- atLineStart = text.endsWith("\n");
18
- }
19
- export function line(text = "") {
20
- if (!atLineStart)
21
- process.stdout.write("\n");
22
- process.stdout.write(`${text}\n`);
23
- atLineStart = true;
24
- }
25
- /** Ensure anything printed next starts on a fresh line. */
26
- export function breakLine() {
27
- if (!atLineStart) {
28
- process.stdout.write("\n");
29
- atLineStart = true;
30
- }
31
- }
32
- // ── spinner ─────────────────────────────────────────────────────────────────
33
- /**
34
- * A single-line spinner used while waiting for the first token.
35
- *
36
- * It writes nothing at all when stdout is not a TTY: a spinner in a CI log is
37
- * thousands of lines of carriage returns.
38
- */
39
- export class Spinner {
40
- timer = null;
41
- frame = 0;
42
- text = "";
43
- active = false;
44
- start(text) {
45
- this.text = text;
46
- if (!process.stdout.isTTY)
47
- return;
48
- if (this.active) {
49
- this.update(text);
50
- return;
51
- }
52
- breakLine();
53
- this.active = true;
54
- this.timer = setInterval(() => {
55
- const f = glyph.spinner[this.frame++ % glyph.spinner.length];
56
- process.stdout.write(`\r${c.green(f)} ${c.dim(this.text)}\x1b[K`);
57
- }, 80);
58
- // Never hold the event loop open just to animate.
59
- this.timer.unref?.();
60
- }
61
- update(text) { this.text = text; }
62
- /** Stop and erase the line, leaving the cursor where the spinner began. */
63
- stop() {
64
- if (this.timer) {
65
- clearInterval(this.timer);
66
- this.timer = null;
67
- }
68
- const erased = this.active && process.stdout.isTTY === true;
69
- if (erased)
70
- process.stdout.write("\r\x1b[K");
71
- this.active = false;
72
- // Only claim the cursor is at line start if we actually erased the line.
73
- // Asserting it unconditionally made the NEXT writer skip its leading
74
- // newline, so streamed text and the activity row that followed it ran
75
- // together: "Building your todo app. ⟳ Get System Info".
76
- if (erased)
77
- atLineStart = true;
78
- }
79
- }
80
- // ── activity rows ───────────────────────────────────────────────────────────
81
- //
82
- // Cloned from Kiro's CLI output, adapted to the HW green palette (never
83
- // purple — platform rule). Each tool action renders as:
84
- //
85
- // I will run the following command: <command> (using tool: shell)
86
- // Purpose: <one line, from the model>
87
- //
88
- // <full command output>
89
- // — Completed in 12ms
90
- //
91
- // File-mutating tools use "Updating: <path>" / "Creating: <path>" /
92
- // "Reading file: <path>" headers, exactly like Kiro. The "Purpose:" line is
93
- // produced by the model (see the tool-use prompt), not the renderer.
94
- /** The tool bucket named in the "(using tool: X)" suffix, Kiro-style. */
95
- function toolBucket(name) {
96
- if (["run_command", "start_process", "stop_process", "open_browser", "wait_for_url", "ssh_run", "ssh_upload", "ssh_download", "docker_exec", "docker_ps", "docker_logs", "install_package", "kill_process", "check_port", "list_processes", "get_process_output", "curl_request", "check_github_auth"].includes(name))
97
- return "shell";
98
- if (["read_file", "write_file", "str_replace_file", "append_file", "delete_path", "list_directory", "file_search"].includes(name))
99
- return "fs";
100
- if (["git_status", "git_diff", "git_log", "git_add", "git_commit", "git_push", "git_pull", "git_branch", "git_clone"].includes(name))
101
- return "git";
102
- if (["search_files"].includes(name))
103
- return "code";
104
- if (["web_search", "web_fetch"].includes(name))
105
- return "web";
106
- if (["spawn_subagents"].includes(name))
107
- return "agent";
108
- if (["todo_list"].includes(name))
109
- return "session";
110
- return name;
111
- }
112
- /**
113
- * Shorten a path for display: workspace-relative when it's under the cwd, and
114
- * ~ for home. The absolute path repeated three times was most of the line width.
115
- */
116
- function shortPath(p) {
117
- if (!p)
118
- return "";
119
- const cwd = process.cwd();
120
- if (p.startsWith(cwd + "/"))
121
- return p.slice(cwd.length + 1);
122
- const home = process.env.HOME;
123
- if (home && p.startsWith(home + "/"))
124
- return "~/" + p.slice(home.length + 1);
125
- return p;
126
- }
127
- /**
128
- * The header line for a tool. Mirrors Kiro's wording:
129
- * - shell tools: "I will run the following command: <cmd>"
130
- * - file writes: "Writing: <path>" (completion says Created/Updated)
131
- * - file reads: "Reading file: <path>" / "Reading directory: <path>"
132
- * - search/misc: a short present-tense phrase
133
- * Returns { head, command } — command is echoed on its own coloured line.
134
- */
135
- function headerFor(name, target) {
136
- switch (name) {
137
- // shell — command echoed verbatim
138
- case "run_command": return { head: "I will run the following command:", command: target };
139
- case "ssh_run": return { head: `I will run the following command${target ? ` on ${target}` : ""}:`, command: target };
140
- case "docker_exec": return { head: `I will run the following command in ${target ?? "the container"}:`, command: target };
141
- case "curl_request": return { head: "Making an HTTP request", command: target };
142
- case "install_package": return { head: `Installing package: ${target ?? ""}`.trimEnd() };
143
- case "kill_process": return { head: `Stopping process: ${target ?? ""}`.trimEnd() };
144
- // background processes
145
- case "start_process": return { head: "I will start the following in the background:", command: target };
146
- case "stop_process": return { head: `Stopping background process: ${target ?? ""}`.trimEnd() };
147
- case "get_process_output": return { head: `Reading process output${target ? `: ${target}` : ""}` };
148
- case "open_browser": return { head: `Opening in the browser: ${target ?? ""}`.trimEnd() };
149
- case "wait_for_url": return { head: `Waiting for ${target ?? "the server"} to respond` };
150
- // file writes — one neutral verb before; the completion line says created/updated
151
- case "write_file": return { head: `Writing: ${shortPath(target)}`.trimEnd() };
152
- case "str_replace_file": return { head: `Editing: ${shortPath(target)}`.trimEnd() };
153
- case "append_file": return { head: `Appending to: ${shortPath(target)}`.trimEnd() };
154
- case "delete_path": return { head: `Deleting: ${shortPath(target)}`.trimEnd() };
155
- // file reads
156
- case "read_file": return { head: `Reading file: ${shortPath(target)}`.trimEnd() };
157
- case "list_directory": return { head: `Reading directory: ${shortPath(target) || "."}` };
158
- case "search_files": return { head: `Searching for: ${target ?? ""}`.trimEnd() };
159
- case "file_search": return { head: `Searching for files: ${target ?? ""}`.trimEnd() };
160
- // inspection
161
- case "get_system_info": return { head: "Inspecting the system" };
162
- case "check_port": return { head: `Checking port${target ? ` ${target}` : ""}` };
163
- case "list_processes": return { head: "Listing running processes" };
164
- // git
165
- case "git_status": return { head: "Checking git status" };
166
- case "git_diff": return { head: "Reading the git diff" };
167
- case "git_log": return { head: "Reading the git log" };
168
- case "git_add": return { head: `Staging${target ? ` ${target}` : " changes"}` };
169
- case "git_commit": return { head: "Creating a commit" };
170
- case "git_push": return { head: "Pushing to the remote" };
171
- case "git_pull": return { head: "Pulling from the remote" };
172
- case "git_branch": return { head: "Managing branches" };
173
- case "git_clone": return { head: `Cloning ${target ?? "repository"}` };
174
- case "check_github_auth": return { head: "Checking GitHub auth" };
175
- // docker
176
- case "docker_ps": return { head: "Listing containers" };
177
- case "docker_logs": return { head: `Reading container logs${target ? `: ${target}` : ""}` };
178
- // ssh transfer
179
- case "ssh_upload": return { head: `Uploading to ${target ?? "the remote host"}` };
180
- case "ssh_download": return { head: `Downloading from ${target ?? "the remote host"}` };
181
- // web
182
- case "web_search": return { head: `Searching the web for: ${target ?? ""}`.trimEnd() };
183
- case "web_fetch": return { head: `Fetching: ${target ?? ""}`.trimEnd() };
184
- case "todo_list": return { head: "Updating the task list" };
185
- case "spawn_subagents": return { head: "Running independent tasks in parallel" };
186
- default: return { head: humanTool(name) };
187
- }
188
- }
189
- export function toolRunning(name, target) {
190
- breakLine();
191
- const { head, command } = headerFor(name, target);
192
- const suffix = c.dim(` (using tool: ${toolBucket(name)})`);
193
- if (command) {
194
- // Kiro prints: "I will run the following command: <cmd> (using tool: shell)"
195
- process.stdout.write(`${c.bold(head)} ${c.cyan(command)}${suffix}\n`);
196
- }
197
- else {
198
- process.stdout.write(`${c.bold(head)}${suffix}\n`);
199
- }
200
- atLineStart = true;
201
- }
202
- export function toolDone(name, status, durationMs, effect, bytes) {
203
- breakLine();
204
- const time = durationMs !== undefined ? formatMs(durationMs) : "0ms";
205
- if (status === "success" || status === "executed") {
206
- const patch = effect?.patch;
207
- if (patch) {
208
- for (const l of patch.lines) {
209
- if (l.kind === "ctx") {
210
- const nums = `${String(l.oldLine ?? "").padStart(3)},${String(l.newLine ?? "").padStart(3)}:`;
211
- process.stdout.write(` ${c.dim(nums)} ${c.dim(l.text)}\n`);
212
- }
213
- else if (l.kind === "del") {
214
- const nums = `-${String(l.oldLine ?? "").padStart(3)} :`;
215
- process.stdout.write(` ${c.red(nums)} ${c.red(l.text)}\n`);
216
- }
217
- else if (l.kind === "add") {
218
- const nums = `+${String(l.newLine ?? "").padStart(3)} :`;
219
- process.stdout.write(` ${c.green(nums)} ${c.green(l.text)}\n`);
220
- }
221
- }
222
- if (patch.truncated)
223
- process.stdout.write(` ${c.dim(`… +${patch.truncated} more lines`)}\n`);
224
- }
225
- if (effect && (effect.kind === "wrote" || effect.kind === "modified" || effect.kind === "deleted")) {
226
- const verb = effect.kind === "wrote" ? "Created" : effect.kind === "modified" ? "Updated" : "Deleted";
227
- const size = bytes && effect.kind !== "deleted" ? ` ${formatBytes(bytes)}` : "";
228
- process.stdout.write(c.dim(` — ${verb}${size} in ${time}\n`));
229
- }
230
- else {
231
- process.stdout.write(c.dim(` — Completed in ${time}\n`));
232
- }
233
- }
234
- else if (status === "queued") {
235
- process.stdout.write(` ${c.yellow(glyph.caret)} ${c.dim("Queued for approval")}\n`);
236
- }
237
- else {
238
- process.stdout.write(` ${c.red(glyph.cross)} ${c.red("Failed")} ${c.dim(`after ${time}`)}\n`);
239
- }
240
- atLineStart = true;
241
- }
242
- /**
243
- * One line of live output from a running tool, shown so the user watches it
244
- * work. Kiro prints raw tool output between the command and the "Completed in"
245
- * line; we indent it two spaces and dim it so it reads as subordinate.
246
- */
247
- export function toolOutputLine(line) {
248
- breakLine();
249
- const trimmed = line.length > 200 ? `${line.slice(0, 197)}…` : line;
250
- process.stdout.write(` ${c.dim(trimmed)}\n`);
251
- atLineStart = true;
252
- }
253
- /** Re-exported so the activity renderer can dim a line without importing theme. */
254
- export function dim(text) { return c.dim(text); }
255
- /**
256
- * A file chip: what changed, not what it contains.
257
- *
258
- * The whole reason this exists is that "write a todo app" used to print the
259
- * entire generated HTML document to the terminal, because the tool result was
260
- * the file and nothing decided what to show in its place.
261
- */
262
- export function fileChip(effect, bytes) {
263
- if (!effect.path)
264
- return;
265
- const size = bytes ? ` ${c.dim(formatBytes(bytes))}` : "";
266
- breakLine();
267
- if (effect.kind === "wrote")
268
- process.stdout.write(` ${c.green(glyph.tick)} ${c.dim("Written:")} ${effect.path}${size}\n`);
269
- else if (effect.kind === "modified")
270
- process.stdout.write(` ${c.yellow(glyph.tick)} ${c.dim("Modified:")} ${effect.path}${size}\n`);
271
- else if (effect.kind === "deleted")
272
- process.stdout.write(` ${c.red(glyph.cross)} ${c.dim("Deleted:")} ${effect.path}\n`);
273
- else
274
- return;
275
- atLineStart = true;
276
- }
277
- function formatBytes(n) {
278
- if (n < 1024)
279
- return `${n}B`;
280
- if (n < 1024 * 1024)
281
- return `${(n / 1024).toFixed(1)}KB`;
282
- return `${(n / 1024 / 1024).toFixed(1)}MB`;
283
- }
284
- export function toolSkipped(name) {
285
- breakLine();
286
- process.stdout.write(` ${c.dim(glyph.skip)} ${c.dim(`${humanTool(name)} — skipped`)}\n`);
287
- atLineStart = true;
288
- }
289
- export function note(text) { line(c.dim(` ${text}`)); }
290
- export function warn(text) { line(`${c.yellow(glyph.warn)} ${text}`); }
291
- export function error(text) { line(`${c.red(glyph.cross)} ${text}`); }
292
- export function success(text) { line(`${c.green(glyph.tick)} ${text}`); }
293
- /** snake_case tool name -> "Title Case", for humans. */
294
- export function humanTool(name) {
295
- return name.split("_").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
296
- }
297
- function formatMs(ms) {
298
- if (ms < 1000)
299
- return `${ms}ms`;
300
- if (ms < 60_000)
301
- return `${(ms / 1000).toFixed(1)}s`;
302
- return `${Math.round(ms / 60_000)}m`;
303
- }
304
- /**
305
- * The line under each reply.
306
- *
307
- * Two corrections from the old version, both of which made it misleading:
308
- *
309
- * - It printed `Credits: 73.00` from the balance field, so the label said
310
- * "credits" while the number meant "credits remaining". Both are shown now,
311
- * and each is named.
312
- * - The context gauge was computed locally from the CLI's own history array
313
- * (characters / 4 / 180000), which excluded the ~11k system prompt and tool
314
- * schemas and ignored the server's real transcript entirely. It read
315
- * `0.0K (0%)` on every turn. The number now comes from the server's
316
- * `usage.promptTokens`, which counts cached prefix tokens too.
317
- */
318
- export function statusLine(s) {
319
- const parts = [
320
- `${c.dim("cost")} ${s.turnCredits.toFixed(2)}`,
321
- `${c.dim("left")} ${s.balance.toFixed(2)}`,
322
- `${c.dim("took")} ${formatMs(s.elapsedMs)}`,
323
- ];
324
- const ctx = s.context;
325
- if (ctx?.promptTokens !== undefined && ctx.usagePercent !== undefined) {
326
- const k = (ctx.promptTokens / 1000).toFixed(1);
327
- const pct = ctx.usagePercent;
328
- const gauge = `${k}K (${pct}%)`;
329
- parts.push(`${c.dim("ctx")} ${pct >= 75 ? c.yellow(gauge) : c.dim(gauge)}`);
330
- }
331
- if (s.model)
332
- parts.push(c.dim(shortModel(s.model)));
333
- breakLine();
334
- process.stdout.write(`${c.dim(` ${glyph.caret} `)}${parts.join(c.dim(" · "))}\n\n`);
335
- atLineStart = true;
336
- }
337
- /** "claude-sonnet-5" -> "sonnet-5". The vendor prefix is noise in a status line. */
338
- function shortModel(id) {
339
- return id.replace(/^claude-/, "").replace(/-\d{8}$/, "");
340
- }
341
- // ── markdown-ish ────────────────────────────────────────────────────────────
342
- /**
343
- * Apply inline emphasis to a completed line of model output.
344
- *
345
- * Deliberately minimal and applied only to whole lines. The model streams
346
- * token by token, so a `**` can arrive split across two chunks; anything
347
- * cleverer than this needs a real incremental parser, and a half-parsed bold
348
- * marker looks worse than no formatting at all.
349
- */
350
- export function styleLine(text) {
351
- return text
352
- .replace(/\*\*([^*]+)\*\*/g, (_, s) => c.bold(s))
353
- .replace(/(^|\s)`([^`]+)`/g, (_, pre, s) => `${pre}${c.cyan(s)}`)
354
- .replace(/^(\s*)[-*] /, (_, indent) => `${indent}${c.dim(glyph.bullet)} `);
355
- }
package/dist/ui/theme.js DELETED
@@ -1,49 +0,0 @@
1
- /**
2
- * Terminal styling.
3
- *
4
- * Colour is opt-out, not unconditional: a CLI whose output is piped into a file
5
- * or a CI log should emit plain text, and NO_COLOR is the cross-tool convention
6
- * for asking it to. The old version hardcoded escape sequences everywhere, so
7
- * `hw ask ... > notes.txt` produced a file full of `\x1b[32m`.
8
- */
9
- const useColor = process.env.NO_COLOR === undefined &&
10
- process.env.TERM !== "dumb" &&
11
- process.stdout.isTTY === true;
12
- const wrap = (open, close) => (s) => (useColor ? `\x1b[${open}m${s}\x1b[${close}m` : s);
13
- export const c = {
14
- reset: "\x1b[0m",
15
- bold: wrap("1", "22"),
16
- dim: wrap("2", "22"),
17
- italic: wrap("3", "23"),
18
- under: wrap("4", "24"),
19
- // Brand green, matching the web product. Never violet - see the platform
20
- // design rules; the old banner used purple in install.sh and green in the CLI.
21
- green: wrap("32", "39"),
22
- cyan: wrap("36", "39"),
23
- yellow: wrap("33", "39"),
24
- red: wrap("31", "39"),
25
- gray: wrap("90", "39"),
26
- white: wrap("37", "39"),
27
- };
28
- export const hasColor = useColor;
29
- /** Glyphs degrade to ASCII where UTF-8 is unlikely to render (Windows cmd). */
30
- const unicode = process.platform !== "win32" || process.env.WT_SESSION !== undefined;
31
- export const glyph = {
32
- tick: unicode ? "✓" : "OK",
33
- cross: unicode ? "✗" : "X",
34
- arrow: unicode ? "→" : "->",
35
- bullet: unicode ? "•" : "*",
36
- run: unicode ? "⟳" : "~",
37
- skip: unicode ? "⊘" : "-",
38
- warn: unicode ? "⚠" : "!",
39
- caret: unicode ? "▸" : ">",
40
- spinner: unicode ? ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] : ["|", "/", "-", "\\"],
41
- };
42
- export const BANNER_LINES = [
43
- "██╗ ██╗██╗ ██╗",
44
- "██║ ██║██║ ██║",
45
- "███████║██║ █╗ ██║",
46
- "██╔══██║██║███╗██║",
47
- "██║ ██║╚███╔███╔╝",
48
- "╚═╝ ╚═╝ ╚══╝╚══╝ ",
49
- ];