@higherdev/cli 0.4.0 → 0.6.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/README.md +10 -3
- package/dist/api.js +51 -25
- package/dist/config.js +56 -7
- package/dist/index.js +29 -3
- package/dist/out/format.js +96 -0
- package/dist/out/theme.js +73 -0
- package/dist/out.js +1 -1
- package/dist/tui/App.js +441 -0
- package/dist/tui/Banner.js +301 -0
- package/dist/tui/Bubble.js +12 -0
- package/dist/tui/Dashboard.js +206 -0
- package/dist/tui/Decision.js +48 -0
- package/dist/tui/Help.js +30 -0
- package/dist/tui/Panels.js +195 -0
- package/dist/tui/Settings.js +45 -0
- package/dist/tui/Splash.js +15 -0
- package/dist/tui/TextInput.js +137 -0
- package/dist/tui/alert.js +19 -0
- package/dist/tui/bounded.js +37 -0
- package/dist/tui/capability.js +4 -0
- package/dist/tui/data.js +171 -0
- package/dist/tui/height.js +58 -0
- package/dist/tui/launch.js +20 -0
- package/dist/tui/layout.js +54 -0
- package/dist/tui/parse.js +48 -0
- package/dist/tui/settings-model.js +76 -0
- package/dist/tui/stream.js +122 -0
- package/dist/tui/theme.js +52 -0
- package/dist/tui/workspace-load.js +18 -0
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -14,19 +14,24 @@ Create `~/.config/hd/config.json` with an existing workspace API key:
|
|
|
14
14
|
{
|
|
15
15
|
"url": "https://hdx-higher-ops.vercel.app",
|
|
16
16
|
"api_key": "hdx_...",
|
|
17
|
-
"
|
|
17
|
+
"current": "workspace"
|
|
18
18
|
}
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
One workspace key reaches every workspace. Previous single-workspace and
|
|
22
|
+
workspace-map config shapes are migrated automatically when they are read.
|
|
23
|
+
|
|
21
24
|
## Commands
|
|
22
25
|
|
|
23
26
|
| Command | Purpose |
|
|
24
27
|
| --- | --- |
|
|
25
|
-
| `hd` |
|
|
28
|
+
| `hd` | Open the live TUI when attached to a terminal |
|
|
29
|
+
| `hd --help` | Show the HigherDEV banner and usage |
|
|
26
30
|
| `hd status` | Show workspace, ticket, run, and decision status |
|
|
27
31
|
| `hd ticket list` | List tickets |
|
|
28
32
|
| `hd ticket show KEY` | Show one ticket |
|
|
29
33
|
| `hd ticket new --title TITLE [options]` | Create a ticket |
|
|
34
|
+
| `hd workspace ls` | List every workspace available to the configured key |
|
|
30
35
|
| `hd workspace new --name NAME --repo OWNER/NAME [options]` | Create a paused workspace |
|
|
31
36
|
| `hd agents` | List agents |
|
|
32
37
|
| `hd agents set ROLE --provider P --model M [--effort E]` | Update an agent |
|
|
@@ -39,4 +44,6 @@ Create `~/.config/hd/config.json` with an existing workspace API key:
|
|
|
39
44
|
| `hd init [options]` | Configure this host and runner service |
|
|
40
45
|
| `hd upgrade [options]` | Refresh this host configuration |
|
|
41
46
|
|
|
42
|
-
|
|
47
|
+
Inside the TUI, use `/board`, `/inbox`, `/ticket`, `/decide`, `/agents`,
|
|
48
|
+
`/settings`, `/workspace`, `/feed`, `/orchestrator`, `/refresh`, `/help`, or
|
|
49
|
+
`/exit`. The display refreshes from the HDX API every five seconds.
|
package/dist/api.js
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
import { loadConfig } from "./config.js";
|
|
2
|
+
export const MAX_API_ERROR_BODY = 500;
|
|
3
|
+
export function apiErrorMessage(status, parsed, fallback) {
|
|
4
|
+
const raw = parsed && typeof parsed === "object" && "error" in parsed
|
|
5
|
+
&& typeof parsed.error === "string"
|
|
6
|
+
? parsed.error : fallback;
|
|
7
|
+
const duplicate = raw.match(/agents_one_enabled_(orchestrator|reviewer)_idx/i)?.[1]?.toLowerCase();
|
|
8
|
+
if (duplicate) {
|
|
9
|
+
return `hd: ${status} Only one enabled ${duplicate} is allowed per workspace. Disable the current ${duplicate} before enabling another.`;
|
|
10
|
+
}
|
|
11
|
+
const trimmed = raw.trim();
|
|
12
|
+
const body = trimmed.length > MAX_API_ERROR_BODY
|
|
13
|
+
? `${trimmed.slice(0, MAX_API_ERROR_BODY)}... [truncated ${trimmed.length - MAX_API_ERROR_BODY} chars]`
|
|
14
|
+
: trimmed;
|
|
15
|
+
return `hd: ${status} ${body}`;
|
|
16
|
+
}
|
|
2
17
|
async function request(config, method, path, body) {
|
|
3
18
|
const response = await fetch(`${config.url}${path}`, {
|
|
4
19
|
method,
|
|
@@ -14,32 +29,26 @@ async function request(config, method, path, body) {
|
|
|
14
29
|
parsed = text ? JSON.parse(text) : null;
|
|
15
30
|
}
|
|
16
31
|
catch {
|
|
17
|
-
throw new Error(
|
|
32
|
+
throw new Error(apiErrorMessage(response.status, null, text || response.statusText));
|
|
18
33
|
}
|
|
19
34
|
if (!response.ok) {
|
|
20
|
-
|
|
21
|
-
throw new Error(`hd: ${response.status} ${err.error ?? text}`);
|
|
35
|
+
throw new Error(apiErrorMessage(response.status, parsed, text || response.statusText));
|
|
22
36
|
}
|
|
23
37
|
return parsed;
|
|
24
38
|
}
|
|
25
|
-
export async function getStatus() {
|
|
26
|
-
const config = loadConfig();
|
|
39
|
+
export async function getStatus(config = loadConfig()) {
|
|
27
40
|
return request(config, "GET", `/api/w/${config.slug}/status`);
|
|
28
41
|
}
|
|
29
|
-
export async function listTickets() {
|
|
30
|
-
const config = loadConfig();
|
|
42
|
+
export async function listTickets(config = loadConfig()) {
|
|
31
43
|
return request(config, "GET", `/api/w/${config.slug}/tickets`);
|
|
32
44
|
}
|
|
33
|
-
export async function showTicket(key) {
|
|
34
|
-
const config = loadConfig();
|
|
45
|
+
export async function showTicket(key, config = loadConfig()) {
|
|
35
46
|
return request(config, "GET", `/api/w/${config.slug}/tickets/${encodeURIComponent(key)}`);
|
|
36
47
|
}
|
|
37
|
-
export async function createTicket(fields) {
|
|
38
|
-
const config = loadConfig();
|
|
48
|
+
export async function createTicket(fields, config = loadConfig()) {
|
|
39
49
|
return request(config, "POST", `/api/w/${config.slug}/tickets`, fields);
|
|
40
50
|
}
|
|
41
|
-
export async function listTicketEvents(key, afterAt, afterId) {
|
|
42
|
-
const config = loadConfig();
|
|
51
|
+
export async function listTicketEvents(key, afterAt, afterId, config = loadConfig()) {
|
|
43
52
|
const query = new URLSearchParams();
|
|
44
53
|
if (afterAt)
|
|
45
54
|
query.set("after_at", afterAt);
|
|
@@ -48,29 +57,46 @@ export async function listTicketEvents(key, afterAt, afterId) {
|
|
|
48
57
|
const suffix = query.size ? `?${query.toString()}` : "";
|
|
49
58
|
return request(config, "GET", `/api/w/${config.slug}/tickets/${encodeURIComponent(key)}/events${suffix}`);
|
|
50
59
|
}
|
|
51
|
-
export async function postMessage(fields) {
|
|
52
|
-
const config = loadConfig();
|
|
60
|
+
export async function postMessage(fields, config = loadConfig()) {
|
|
53
61
|
return request(config, "POST", `/api/w/${config.slug}/messages`, fields);
|
|
54
62
|
}
|
|
55
|
-
export async function answerDecision(id, answer_md) {
|
|
56
|
-
const config = loadConfig();
|
|
63
|
+
export async function answerDecision(id, answer_md, config = loadConfig()) {
|
|
57
64
|
return request(config, "POST", `/api/w/${config.slug}/decisions/${encodeURIComponent(id)}/answer`, { answer_md });
|
|
58
65
|
}
|
|
59
|
-
export async function setPaused(paused) {
|
|
60
|
-
const config = loadConfig();
|
|
66
|
+
export async function setPaused(paused, config = loadConfig()) {
|
|
61
67
|
const path = paused ? `/api/w/${config.slug}/pause` : `/api/w/${config.slug}/resume`;
|
|
62
68
|
return request(config, "POST", path);
|
|
63
69
|
}
|
|
64
|
-
export async function createWorkspace(fields) {
|
|
65
|
-
const config = loadConfig();
|
|
70
|
+
export async function createWorkspace(fields, config = loadConfig()) {
|
|
66
71
|
const result = await request(config, "POST", "/api/workspaces", fields);
|
|
67
72
|
return { ...result, url: config.url };
|
|
68
73
|
}
|
|
69
|
-
export async function
|
|
70
|
-
|
|
74
|
+
export async function listWorkspaces(config = loadConfig()) {
|
|
75
|
+
return request(config, "GET", "/api/workspaces");
|
|
76
|
+
}
|
|
77
|
+
export async function listAgents(config = loadConfig()) {
|
|
71
78
|
return request(config, "GET", `/api/w/${config.slug}/agents`);
|
|
72
79
|
}
|
|
73
|
-
export async function updateAgent(id, fields) {
|
|
74
|
-
const config = loadConfig();
|
|
80
|
+
export async function updateAgent(id, fields, config = loadConfig()) {
|
|
75
81
|
return request(config, "PATCH", `/api/w/${config.slug}/agents`, { id, ...fields });
|
|
76
82
|
}
|
|
83
|
+
export async function listEpics(config = loadConfig()) {
|
|
84
|
+
return request(config, "GET", `/api/w/${config.slug}/epics`);
|
|
85
|
+
}
|
|
86
|
+
export async function listMessages(options = {}, config = loadConfig()) {
|
|
87
|
+
const query = new URLSearchParams();
|
|
88
|
+
if (options.ticketId)
|
|
89
|
+
query.set("ticket_id", options.ticketId);
|
|
90
|
+
if (options.since)
|
|
91
|
+
query.set("since", options.since);
|
|
92
|
+
if (options.limit)
|
|
93
|
+
query.set("limit", String(options.limit));
|
|
94
|
+
const suffix = query.size ? `?${query.toString()}` : "";
|
|
95
|
+
return request(config, "GET", `/api/w/${config.slug}/messages${suffix}`);
|
|
96
|
+
}
|
|
97
|
+
export async function updateCaps(provider_caps, config = loadConfig()) {
|
|
98
|
+
return request(config, "PATCH", `/api/w/${config.slug}/caps`, { provider_caps });
|
|
99
|
+
}
|
|
100
|
+
export async function listFeed(config = loadConfig()) {
|
|
101
|
+
return request(config, "GET", `/api/w/${config.slug}/feed`);
|
|
102
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -4,23 +4,72 @@ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
4
4
|
export function configPath() {
|
|
5
5
|
return process.env.HD_CONFIG ?? join(homedir(), ".config/hd/config.json");
|
|
6
6
|
}
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
function normalizeConnection(value) {
|
|
8
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
9
|
+
return null;
|
|
10
|
+
const row = value;
|
|
11
|
+
if (typeof row.url !== "string" || typeof row.api_key !== "string" || !row.url || !row.api_key) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
return { url: row.url.replace(/\/$/, ""), api_key: row.api_key };
|
|
15
|
+
}
|
|
16
|
+
function missing(path) {
|
|
17
|
+
return new Error(`missing ${path}\nWrite { "url": "https://hdx-higher-ops.vercel.app", "api_key": "hdx_...", "current": "workspace" }`);
|
|
18
|
+
}
|
|
19
|
+
export function loadStoredConfig(path = configPath()) {
|
|
9
20
|
let raw;
|
|
10
21
|
try {
|
|
11
22
|
raw = readFileSync(path, "utf8");
|
|
12
23
|
}
|
|
13
24
|
catch {
|
|
14
|
-
throw
|
|
25
|
+
throw missing(path);
|
|
15
26
|
}
|
|
16
27
|
const parsed = JSON.parse(raw);
|
|
17
|
-
|
|
18
|
-
|
|
28
|
+
const connection = normalizeConnection(parsed);
|
|
29
|
+
const current = typeof parsed.current === "string" ? parsed.current : "";
|
|
30
|
+
if (connection && current)
|
|
31
|
+
return { ...connection, current };
|
|
32
|
+
// 0.4 stored one workspace at the top level with `slug` rather than
|
|
33
|
+
// `current`. One key can now reach every workspace, so only the selected
|
|
34
|
+
// slug needs to survive.
|
|
35
|
+
if (connection && typeof parsed.slug === "string" && parsed.slug) {
|
|
36
|
+
const migrated = { ...connection, current: parsed.slug };
|
|
37
|
+
writeStoredConfig(migrated, path);
|
|
38
|
+
return migrated;
|
|
19
39
|
}
|
|
20
|
-
|
|
40
|
+
// 0.5 stored one connection per workspace. Keep the current workspace's
|
|
41
|
+
// credentials as the shared operator connection and discard the duplicates.
|
|
42
|
+
const source = parsed.workspaces;
|
|
43
|
+
if (!current || !source || typeof source !== "object" || Array.isArray(source)) {
|
|
44
|
+
throw new Error(`${path} needs url, api_key, and current`);
|
|
45
|
+
}
|
|
46
|
+
const selected = normalizeConnection(source[current]);
|
|
47
|
+
if (!selected)
|
|
48
|
+
throw new Error(`${path} current workspace ${current} is not configured`);
|
|
49
|
+
const migrated = { ...selected, current };
|
|
50
|
+
writeStoredConfig(migrated, path);
|
|
51
|
+
return migrated;
|
|
21
52
|
}
|
|
22
|
-
export function
|
|
53
|
+
export function loadConfig(slug) {
|
|
54
|
+
const stored = loadStoredConfig();
|
|
55
|
+
const selected = slug ?? stored.current;
|
|
56
|
+
return { url: stored.url, api_key: stored.api_key, slug: selected };
|
|
57
|
+
}
|
|
58
|
+
export function writeStoredConfig(config, path = configPath()) {
|
|
23
59
|
mkdirSync(dirname(path), { recursive: true });
|
|
24
60
|
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
25
61
|
chmodSync(path, 0o600);
|
|
26
62
|
}
|
|
63
|
+
/** Store one operator connection and make its selected workspace current. */
|
|
64
|
+
export function writeHdConfig(config, path = configPath()) {
|
|
65
|
+
writeStoredConfig({
|
|
66
|
+
url: config.url.replace(/\/$/, ""),
|
|
67
|
+
api_key: config.api_key,
|
|
68
|
+
current: config.slug,
|
|
69
|
+
}, path);
|
|
70
|
+
}
|
|
71
|
+
export function switchWorkspace(slug, path = configPath()) {
|
|
72
|
+
const stored = loadStoredConfig(path);
|
|
73
|
+
writeStoredConfig({ ...stored, current: slug }, path);
|
|
74
|
+
return { url: stored.url, api_key: stored.api_key, slug };
|
|
75
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
|
-
import { answerDecision, createTicket, createWorkspace, getStatus, listAgents, listTicketEvents, listTickets, postMessage, setPaused, showTicket, updateAgent, } from "./api.js";
|
|
4
|
+
import { answerDecision, createTicket, createWorkspace, getStatus, listAgents, listTicketEvents, listTickets, listWorkspaces, postMessage, setPaused, showTicket, updateAgent, } from "./api.js";
|
|
5
5
|
import { initHost, parseHostFlags } from "./host.js";
|
|
6
|
+
import { loadConfig, writeHdConfig } from "./config.js";
|
|
6
7
|
import { banner, c, statusChip, table, truncate, usage } from "./out.js";
|
|
7
8
|
function fail(message) {
|
|
8
9
|
console.error(message);
|
|
@@ -175,8 +176,20 @@ async function cmdDecide(argv) {
|
|
|
175
176
|
}
|
|
176
177
|
async function cmdWorkspace(argv) {
|
|
177
178
|
const [action, ...rest] = argv;
|
|
179
|
+
if (action === "ls") {
|
|
180
|
+
const current = loadConfig().slug;
|
|
181
|
+
const workspaces = await listWorkspaces();
|
|
182
|
+
console.log(table(["", "SLUG", "NAME", "REPO", "STATE"], workspaces.map((workspace) => [
|
|
183
|
+
workspace.slug === current ? "*" : "",
|
|
184
|
+
workspace.slug,
|
|
185
|
+
workspace.name,
|
|
186
|
+
workspace.repo,
|
|
187
|
+
workspace.on ? c.green("on") : c.dim("off"),
|
|
188
|
+
])));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
178
191
|
if (action !== "new")
|
|
179
|
-
fail("usage: hd workspace new --name NAME --repo OWNER/NAME [--slug S] [--branch main] [--host box]");
|
|
192
|
+
fail("usage: hd workspace ls | new --name NAME --repo OWNER/NAME [--slug S] [--branch main] [--host box]");
|
|
180
193
|
const { opts } = flags(rest);
|
|
181
194
|
if (!opts.name || !opts.repo) {
|
|
182
195
|
fail("usage: hd workspace new --name NAME --repo OWNER/NAME [--slug S] [--branch main] [--host box]");
|
|
@@ -184,7 +197,8 @@ async function cmdWorkspace(argv) {
|
|
|
184
197
|
const result = await createWorkspace({ name: opts.name, repo: opts.repo, slug: opts.slug,
|
|
185
198
|
default_branch: opts.branch ?? "main", default_host: opts.host ?? "box" });
|
|
186
199
|
console.log(`workspace: ${result.workspace.slug}`);
|
|
187
|
-
|
|
200
|
+
writeHdConfig({ url: result.url, api_key: result.api_key, slug: result.workspace.slug });
|
|
201
|
+
console.log("saved and switched; configure another machine with:");
|
|
188
202
|
console.log(`hd init --url ${result.url} --api-key ${result.api_key} --slug ${result.workspace.slug}`);
|
|
189
203
|
}
|
|
190
204
|
function selectAgent(agents, role, provider) {
|
|
@@ -225,6 +239,18 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
225
239
|
const [cmd, ...rest] = argv;
|
|
226
240
|
try {
|
|
227
241
|
if (!cmd) {
|
|
242
|
+
const { canLaunchApp } = await import("./tui/capability.js");
|
|
243
|
+
if (canLaunchApp()) {
|
|
244
|
+
const { launchApp } = await import("./tui/launch.js");
|
|
245
|
+
await launchApp();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
console.log(banner());
|
|
249
|
+
console.log("");
|
|
250
|
+
console.log(usage());
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (cmd === "--help" || cmd === "-h") {
|
|
228
254
|
console.log(banner());
|
|
229
255
|
console.log("");
|
|
230
256
|
console.log(usage());
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { statusTone } from "../tui/data.js";
|
|
2
|
+
import { c, DOT, ELLIPSIS, tone } from "./theme.js";
|
|
3
|
+
export function truncate(text, max) {
|
|
4
|
+
const flat = String(text ?? "")
|
|
5
|
+
.replace(/\s+/g, " ")
|
|
6
|
+
.trim();
|
|
7
|
+
if (flat.length <= max)
|
|
8
|
+
return flat;
|
|
9
|
+
return `${flat.slice(0, Math.max(0, max - 1))}${ELLIPSIS}`;
|
|
10
|
+
}
|
|
11
|
+
const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
|
|
12
|
+
export function displayWidth(text) {
|
|
13
|
+
return text.replace(ANSI, "").length;
|
|
14
|
+
}
|
|
15
|
+
export function pad(text, width) {
|
|
16
|
+
const gap = width - displayWidth(text);
|
|
17
|
+
return gap > 0 ? text + " ".repeat(gap) : text;
|
|
18
|
+
}
|
|
19
|
+
export function padStart(text, width) {
|
|
20
|
+
const gap = width - displayWidth(text);
|
|
21
|
+
return gap > 0 ? " ".repeat(gap) + text : text;
|
|
22
|
+
}
|
|
23
|
+
/** Fixed-width table. Widths measure printable characters, not escape codes. */
|
|
24
|
+
export function table(columns, rows) {
|
|
25
|
+
if (rows.length === 0)
|
|
26
|
+
return c.dim("none");
|
|
27
|
+
const widths = columns.map((column, i) => Math.max(displayWidth(column.header), ...rows.map((row) => displayWidth(row[i] ?? ""))));
|
|
28
|
+
const line = (cells) => cells
|
|
29
|
+
.map((cell, i) => {
|
|
30
|
+
if (i === cells.length - 1 && columns[i]?.align !== "right")
|
|
31
|
+
return cell;
|
|
32
|
+
return columns[i]?.align === "right" ? padStart(cell, widths[i]) : pad(cell, widths[i]);
|
|
33
|
+
})
|
|
34
|
+
.join(" ")
|
|
35
|
+
.trimEnd();
|
|
36
|
+
return [c.dim(line(columns.map((column) => column.header))), ...rows.map(line)].join("\n");
|
|
37
|
+
}
|
|
38
|
+
export function statusChip(status) {
|
|
39
|
+
return tone(statusTone(status), status.replaceAll("_", " "));
|
|
40
|
+
}
|
|
41
|
+
export function stateDot(state) {
|
|
42
|
+
return tone(state, DOT);
|
|
43
|
+
}
|
|
44
|
+
export function relativeTime(iso, now = Date.now()) {
|
|
45
|
+
if (!iso)
|
|
46
|
+
return "never";
|
|
47
|
+
const at = Date.parse(iso);
|
|
48
|
+
if (Number.isNaN(at))
|
|
49
|
+
return "never";
|
|
50
|
+
const seconds = Math.round((now - at) / 1000);
|
|
51
|
+
if (seconds < 0)
|
|
52
|
+
return "just now";
|
|
53
|
+
if (seconds < 60)
|
|
54
|
+
return `${seconds}s ago`;
|
|
55
|
+
const minutes = Math.round(seconds / 60);
|
|
56
|
+
if (minutes < 60)
|
|
57
|
+
return `${minutes}m ago`;
|
|
58
|
+
const hours = Math.round(minutes / 60);
|
|
59
|
+
if (hours < 48)
|
|
60
|
+
return `${hours}h ago`;
|
|
61
|
+
return `${Math.round(hours / 24)}d ago`;
|
|
62
|
+
}
|
|
63
|
+
export function elapsed(from, to, now = Date.now()) {
|
|
64
|
+
if (!from)
|
|
65
|
+
return "";
|
|
66
|
+
const start = Date.parse(from);
|
|
67
|
+
if (Number.isNaN(start))
|
|
68
|
+
return "";
|
|
69
|
+
const parsedEnd = to ? Date.parse(to) : now;
|
|
70
|
+
const end = Number.isNaN(parsedEnd) ? now : parsedEnd;
|
|
71
|
+
const seconds = Math.max(0, Math.round((end - start) / 1000));
|
|
72
|
+
if (seconds < 60)
|
|
73
|
+
return `${seconds}s`;
|
|
74
|
+
const minutes = Math.floor(seconds / 60);
|
|
75
|
+
if (minutes < 60)
|
|
76
|
+
return `${minutes}m${seconds % 60 ? ` ${seconds % 60}s` : ""}`;
|
|
77
|
+
const hours = Math.floor(minutes / 60);
|
|
78
|
+
return `${hours}h${minutes % 60 ? ` ${minutes % 60}m` : ""}`;
|
|
79
|
+
}
|
|
80
|
+
export function heading(text) {
|
|
81
|
+
return c.bold(text);
|
|
82
|
+
}
|
|
83
|
+
export function bullet(text) {
|
|
84
|
+
return `${c.dim("-")} ${text}`;
|
|
85
|
+
}
|
|
86
|
+
export function section(title, body) {
|
|
87
|
+
return `${heading(title)}\n${body}`;
|
|
88
|
+
}
|
|
89
|
+
/** A one-line horizontal bar for spend and progress. */
|
|
90
|
+
export function bar(value, max, width = 20) {
|
|
91
|
+
if (!(max > 0))
|
|
92
|
+
return c.dim("-".repeat(width));
|
|
93
|
+
const filled = Math.max(0, Math.min(width, Math.round((value / max) * width)));
|
|
94
|
+
return c.blue("█".repeat(filled)) + c.dim("░".repeat(width - filled));
|
|
95
|
+
}
|
|
96
|
+
export { DOT };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal palette. The web app is light only by decision; a terminal is the
|
|
3
|
+
* user's own surface, so nothing here paints a background. Foreground and dim
|
|
4
|
+
* only, blue as the single accent, status colour confined to state.
|
|
5
|
+
*/
|
|
6
|
+
const ESC = `${String.fromCharCode(27)}[`;
|
|
7
|
+
export function colorEnabled(stream = process.stdout) {
|
|
8
|
+
if (process.env.HIGHERDEV_FORCE_COLOR === "1")
|
|
9
|
+
return true;
|
|
10
|
+
if (process.env.NO_COLOR)
|
|
11
|
+
return false;
|
|
12
|
+
return Boolean(stream.isTTY);
|
|
13
|
+
}
|
|
14
|
+
const CODES = {
|
|
15
|
+
bold: 1,
|
|
16
|
+
dim: 2,
|
|
17
|
+
red: 31,
|
|
18
|
+
green: 32,
|
|
19
|
+
yellow: 33,
|
|
20
|
+
magenta: 35,
|
|
21
|
+
cyan: 36,
|
|
22
|
+
grey: 90,
|
|
23
|
+
blue: 94,
|
|
24
|
+
};
|
|
25
|
+
function wrap(name, text) {
|
|
26
|
+
if (!colorEnabled())
|
|
27
|
+
return text;
|
|
28
|
+
return `${ESC}${CODES[name]}m${text}${ESC}0m`;
|
|
29
|
+
}
|
|
30
|
+
export const c = {
|
|
31
|
+
bold: (t) => wrap("bold", t),
|
|
32
|
+
dim: (t) => wrap("dim", t),
|
|
33
|
+
blue: (t) => wrap("blue", t),
|
|
34
|
+
green: (t) => wrap("green", t),
|
|
35
|
+
yellow: (t) => wrap("yellow", t),
|
|
36
|
+
red: (t) => wrap("red", t),
|
|
37
|
+
grey: (t) => wrap("grey", t),
|
|
38
|
+
cyan: (t) => wrap("cyan", t),
|
|
39
|
+
magenta: (t) => wrap("magenta", t),
|
|
40
|
+
};
|
|
41
|
+
export function tone(value, text) {
|
|
42
|
+
switch (value) {
|
|
43
|
+
case "blue":
|
|
44
|
+
return c.blue(text);
|
|
45
|
+
case "success":
|
|
46
|
+
return c.green(text);
|
|
47
|
+
case "warning":
|
|
48
|
+
return c.yellow(text);
|
|
49
|
+
case "danger":
|
|
50
|
+
return c.red(text);
|
|
51
|
+
default:
|
|
52
|
+
return c.dim(text);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Ink colour name for the same tone, so plain and TUI output agree. */
|
|
56
|
+
export function inkColor(value) {
|
|
57
|
+
switch (value) {
|
|
58
|
+
case "blue":
|
|
59
|
+
return "blueBright";
|
|
60
|
+
case "success":
|
|
61
|
+
return "green";
|
|
62
|
+
case "warning":
|
|
63
|
+
return "yellow";
|
|
64
|
+
case "danger":
|
|
65
|
+
return "red";
|
|
66
|
+
default:
|
|
67
|
+
return "gray";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export const DOT = "●";
|
|
71
|
+
export const ARROW = "→";
|
|
72
|
+
export const BAR = "│";
|
|
73
|
+
export const ELLIPSIS = "…";
|
package/dist/out.js
CHANGED
|
@@ -65,7 +65,7 @@ export function usage() {
|
|
|
65
65
|
c.bold("Usage"),
|
|
66
66
|
` ${c.blue("hd status")} workspace overview`,
|
|
67
67
|
` ${c.blue("hd ticket list | show | new")} ticket operations`,
|
|
68
|
-
` ${c.blue("hd workspace new")}
|
|
68
|
+
` ${c.blue("hd workspace ls | new")} list or create workspaces`,
|
|
69
69
|
` ${c.blue("hd agents [set]")} inspect or update agents`,
|
|
70
70
|
` ${c.blue("hd logs KEY [-f]")} run events`,
|
|
71
71
|
` ${c.blue("hd msg KEY TEXT")} message a builder`,
|