@higherdev/cli 0.3.0 → 0.5.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 +47 -172
- package/dist/api.js +85 -0
- package/dist/config.js +80 -0
- package/dist/host.js +179 -0
- package/dist/index.js +259 -9257
- package/dist/out/format.js +96 -0
- package/dist/out/theme.js +73 -0
- package/dist/out.js +76 -0
- package/dist/tui/App.js +418 -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 +165 -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 +8 -34
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { HELP_FOOTER } from "./Help.js";
|
|
2
|
+
/**
|
|
3
|
+
* How many terminal rows each part of the app is about to occupy.
|
|
4
|
+
*
|
|
5
|
+
* This exists because of one Ink behaviour: while the frame fits the window,
|
|
6
|
+
* Ink erases and repaints in place, but the moment the frame is taller than
|
|
7
|
+
* the window it stops emitting erase sequences altogether and appends the
|
|
8
|
+
* whole frame again. Every repaint then scrolls a fresh copy past. The app
|
|
9
|
+
* budgets its live frame against these, and the banner uses the same numbers
|
|
10
|
+
* to decide whether it is safe to keep flashing.
|
|
11
|
+
*
|
|
12
|
+
* Estimates run high on purpose. Guessing tall costs a still banner; guessing
|
|
13
|
+
* short costs the terminal.
|
|
14
|
+
*/
|
|
15
|
+
/** Rows a run of text takes once Ink wraps it into `width` columns. */
|
|
16
|
+
export function wrappedRows(text, width) {
|
|
17
|
+
if (width <= 0)
|
|
18
|
+
return 1;
|
|
19
|
+
return text
|
|
20
|
+
.split("\n")
|
|
21
|
+
.reduce((total, line) => total + Math.max(1, Math.ceil(line.length / width)), 0);
|
|
22
|
+
}
|
|
23
|
+
export function bubbleRows(message, width) {
|
|
24
|
+
const inner = Math.max(1, width - 4);
|
|
25
|
+
const body = message.body.trim() ? wrappedRows(message.body.trim(), inner) : message.pending ? 1 : 0;
|
|
26
|
+
// Two border rows, the speaker label, the tool lines, then a blank row after.
|
|
27
|
+
return 2 + 1 + (message.steps?.length ?? 0) + body + 1;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The name column is as wide as the longest command needs. Fixing it at
|
|
31
|
+
* eighteen meant a longer command wrapped onto a second row, which the count
|
|
32
|
+
* below did not know about, and an undercounted command list is a splash
|
|
33
|
+
* taller than the window.
|
|
34
|
+
*/
|
|
35
|
+
export function helpNameColumn(commands) {
|
|
36
|
+
return commands.reduce((widest, command) => Math.max(widest, `${command.name}${command.args ? ` ${command.args}` : ""}`.length + 1), 12);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Rows the command list takes, borders and padding included. A narrow terminal
|
|
40
|
+
* wraps the longer descriptions onto a second row, so they are counted rather
|
|
41
|
+
* than assumed.
|
|
42
|
+
*/
|
|
43
|
+
export function helpRows(commands, width) {
|
|
44
|
+
const inner = Math.max(1, width - 6);
|
|
45
|
+
const column = helpNameColumn(commands);
|
|
46
|
+
const lines = commands.reduce((total, command) => total +
|
|
47
|
+
Math.max(wrappedRows(command.help, Math.max(1, inner - column)),
|
|
48
|
+
// The name itself can be wider than the room left for it.
|
|
49
|
+
Math.ceil(column / Math.max(1, inner))), 0);
|
|
50
|
+
// Two rows of border, the title, the commands, and the footer.
|
|
51
|
+
return 2 + 1 + lines + wrappedRows(HELP_FOOTER, inner);
|
|
52
|
+
}
|
|
53
|
+
/** The status line and the margin above it, and the prompt under it. */
|
|
54
|
+
export const FRAME_CHROME = 3;
|
|
55
|
+
/** The blank row between the cockpit and the stream. */
|
|
56
|
+
export const PANEL_GAP = 1;
|
|
57
|
+
/** Rows the one-line stand-in for the command list takes, its margin included. */
|
|
58
|
+
export const HELP_HINT_ROWS = 2;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { loadConfig } from "../config.js";
|
|
2
|
+
import { canLaunchApp } from "./capability.js";
|
|
3
|
+
import { loadSnapshot } from "./data.js";
|
|
4
|
+
/** Opens HigherDEV. Shared by `hd` and by the end of `hd login`. */
|
|
5
|
+
export async function launchApp(slug) {
|
|
6
|
+
// Keep this guard here as well as at command call sites. Ink cannot enter raw
|
|
7
|
+
// mode unless both streams are terminals.
|
|
8
|
+
if (!canLaunchApp())
|
|
9
|
+
return;
|
|
10
|
+
const initial = await loadSnapshot(loadConfig(slug));
|
|
11
|
+
const [{ render }, React, { App }] = await Promise.all([
|
|
12
|
+
import("ink"),
|
|
13
|
+
import("react"),
|
|
14
|
+
import("./App.js"),
|
|
15
|
+
]);
|
|
16
|
+
const instance = render(React.createElement(App, {
|
|
17
|
+
initial,
|
|
18
|
+
}), { exitOnCtrlC: false });
|
|
19
|
+
await instance.waitUntilExit();
|
|
20
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { BANNER_HEIGHT, bannerSize } from "./Banner.js";
|
|
2
|
+
import { COMMANDS } from "./Help.js";
|
|
3
|
+
import { FRAME_CHROME, HELP_HINT_ROWS, PANEL_GAP, helpRows } from "./height.js";
|
|
4
|
+
export function planLayout(input) {
|
|
5
|
+
const banner = input.splash ? BANNER_HEIGHT[bannerSize(input.columns, input.rows)] + 1 : 0;
|
|
6
|
+
const notice = input.notice ? 2 : 0;
|
|
7
|
+
// One row spare, so the frame never lands exactly on the window's last line.
|
|
8
|
+
const other = input.inFlight + notice + FRAME_CHROME + 1;
|
|
9
|
+
// What yields first, and in what order, is the whole design here. The
|
|
10
|
+
// command list yields before the decision does: a decision is blocking work
|
|
11
|
+
// and the list is reference material that `/help` reprints into the log.
|
|
12
|
+
const wantHelp = input.splash && input.ready ? helpRows(COMMANDS, input.width) : 0;
|
|
13
|
+
const hint = input.splash && input.ready ? HELP_HINT_ROWS : 0;
|
|
14
|
+
const helpFull = wantHelp > 0 && banner + wantHelp + input.decision + other <= input.rows;
|
|
15
|
+
const help = wantHelp === 0 ? 0 : helpFull ? wantHelp : hint;
|
|
16
|
+
// Whatever is left is the flag's, down to the one line it collapses to. It
|
|
17
|
+
// never disappears: a decision nobody sees is the thing this panel exists to
|
|
18
|
+
// prevent.
|
|
19
|
+
const decision = input.decision === 0
|
|
20
|
+
? 0
|
|
21
|
+
: Math.max(1, Math.min(input.decision, input.rows - banner - help - other));
|
|
22
|
+
const fixed = banner + help + decision + input.inFlight + notice;
|
|
23
|
+
const empty = { cockpit: 0, stream: 0, panels: 0 };
|
|
24
|
+
const finish = (parts) => {
|
|
25
|
+
const total = fixed + parts.panels + FRAME_CHROME;
|
|
26
|
+
return { banner, help, helpFull, decision, ...parts, total, fits: total < input.rows };
|
|
27
|
+
};
|
|
28
|
+
// The splash shows the banner and the list instead of the board, and a
|
|
29
|
+
// window with no room for a panel worth reading goes without one. Drawing a
|
|
30
|
+
// two row panel anyway is what put the frame over the window before.
|
|
31
|
+
if (input.splash)
|
|
32
|
+
return finish(empty);
|
|
33
|
+
const gap = input.home ? PANEL_GAP : 0;
|
|
34
|
+
const room = input.rows - fixed - FRAME_CHROME - gap - 1;
|
|
35
|
+
if (room < 4)
|
|
36
|
+
return finish(empty);
|
|
37
|
+
// The stream stops growing once it is deep enough to follow; past that the
|
|
38
|
+
// extra rows go to the board, which is the thing you are actually reading.
|
|
39
|
+
const stream = Math.max(2, Math.min(14, Math.round(room * 0.4)));
|
|
40
|
+
const cockpit = Math.max(2, room - stream);
|
|
41
|
+
return finish({ cockpit, stream, panels: input.home ? cockpit + stream + gap : room });
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A view that stacks two panels with a blank row between them, inside one
|
|
45
|
+
* budget. Below the size where both are worth reading it stops splitting and
|
|
46
|
+
* gives everything to the first, rather than flooring each at two rows and
|
|
47
|
+
* spending one more than it was given, which is how the frame has outgrown the
|
|
48
|
+
* window before.
|
|
49
|
+
*/
|
|
50
|
+
export function splitPanels(budget) {
|
|
51
|
+
const bottom = Math.min(14, Math.round((budget - PANEL_GAP) * 0.4));
|
|
52
|
+
const top = budget - PANEL_GAP - bottom;
|
|
53
|
+
return bottom >= 3 && top >= 3 ? { top, bottom } : { top: Math.max(0, budget), bottom: 0 };
|
|
54
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a typed line means. Kept separate from the component so the behaviour
|
|
3
|
+
* can be tested without a terminal, a database, or a render.
|
|
4
|
+
*/
|
|
5
|
+
export function parseLine(raw) {
|
|
6
|
+
const text = raw.trim();
|
|
7
|
+
if (!text)
|
|
8
|
+
return { kind: "noop" };
|
|
9
|
+
if (!text.startsWith("/"))
|
|
10
|
+
return { kind: "say", text };
|
|
11
|
+
const [word, ...rest] = text.slice(1).split(/\s+/);
|
|
12
|
+
const argument = rest.join(" ").trim();
|
|
13
|
+
switch (word.toLowerCase()) {
|
|
14
|
+
case "orchestrator":
|
|
15
|
+
return { kind: "mode", mode: "orchestrator" };
|
|
16
|
+
case "board":
|
|
17
|
+
case "agents":
|
|
18
|
+
case "feed":
|
|
19
|
+
case "inbox":
|
|
20
|
+
case "settings":
|
|
21
|
+
return { kind: "view", view: word.toLowerCase() };
|
|
22
|
+
case "help":
|
|
23
|
+
return { kind: "help" };
|
|
24
|
+
case "workspace":
|
|
25
|
+
case "ws": {
|
|
26
|
+
return { kind: "workspace", slug: argument || null };
|
|
27
|
+
}
|
|
28
|
+
case "ticket":
|
|
29
|
+
return argument
|
|
30
|
+
? { kind: "ticket", key: argument.toUpperCase() }
|
|
31
|
+
: { kind: "unknown", command: "ticket needs a key" };
|
|
32
|
+
case "decide": {
|
|
33
|
+
const dismiss = /(^|\s)--(skip|dismiss)(\s|$)/.test(argument);
|
|
34
|
+
return {
|
|
35
|
+
kind: "decide",
|
|
36
|
+
answer: argument.replace(/(^|\s)--(skip|dismiss)(\s|$)/g, " ").trim(),
|
|
37
|
+
dismiss,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
case "refresh":
|
|
41
|
+
return { kind: "refresh" };
|
|
42
|
+
case "exit":
|
|
43
|
+
case "quit":
|
|
44
|
+
return { kind: "exit" };
|
|
45
|
+
default:
|
|
46
|
+
return { kind: "unknown", command: word };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { efforts, providers } from "./data.js";
|
|
2
|
+
export function settingsRows(workspace, agents) {
|
|
3
|
+
const rows = [
|
|
4
|
+
{ key: "h:workspace", kind: "heading", label: workspace.slug, value: workspace.repo },
|
|
5
|
+
{ key: "w:branch", kind: "readonly", label: "branch", value: workspace.default_branch },
|
|
6
|
+
{ key: "w:host", kind: "readonly", label: "host", value: workspace.default_host },
|
|
7
|
+
];
|
|
8
|
+
for (const provider of providers) {
|
|
9
|
+
rows.push({
|
|
10
|
+
key: `w:cap:${provider}`,
|
|
11
|
+
kind: "number",
|
|
12
|
+
label: `cap ${provider}`,
|
|
13
|
+
value: String(workspace.provider_caps[provider] ?? 0),
|
|
14
|
+
hint: "a whole number, 0 to stop using it",
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
for (const agent of agents) {
|
|
18
|
+
rows.push({ key: `h:${agent.id}`, kind: "heading", label: agent.display_name, value: agent.role });
|
|
19
|
+
rows.push({
|
|
20
|
+
key: `a:${agent.id}:enabled`, kind: "toggle", label: "enabled",
|
|
21
|
+
value: agent.enabled ? "yes" : "no", agent: agent.display_name,
|
|
22
|
+
});
|
|
23
|
+
rows.push({
|
|
24
|
+
key: `a:${agent.id}:provider`, kind: "choice", label: "provider",
|
|
25
|
+
value: agent.provider, choices: providers, agent: agent.display_name,
|
|
26
|
+
});
|
|
27
|
+
rows.push({
|
|
28
|
+
key: `a:${agent.id}:model`, kind: "text", label: "model",
|
|
29
|
+
value: agent.model, agent: agent.display_name, hint: "the provider's model id",
|
|
30
|
+
});
|
|
31
|
+
rows.push({
|
|
32
|
+
key: `a:${agent.id}:effort`, kind: "choice", label: "effort",
|
|
33
|
+
value: agent.effort, choices: efforts, agent: agent.display_name,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return rows;
|
|
37
|
+
}
|
|
38
|
+
export function editableKeys(rows) {
|
|
39
|
+
return rows.filter((row) => !["heading", "readonly"].includes(row.kind)).map((row) => row.key);
|
|
40
|
+
}
|
|
41
|
+
export function nextValue(row) {
|
|
42
|
+
if (row.kind === "toggle")
|
|
43
|
+
return row.value === "yes" ? "no" : "yes";
|
|
44
|
+
if (row.kind === "choice" && row.choices?.length) {
|
|
45
|
+
const at = row.choices.indexOf(row.value);
|
|
46
|
+
return row.choices[(at + 1) % row.choices.length];
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
export function seedFor(row) {
|
|
51
|
+
return row.value;
|
|
52
|
+
}
|
|
53
|
+
export function editFor(row, raw) {
|
|
54
|
+
const value = raw.trim();
|
|
55
|
+
const [scope, id, field] = row.key.split(":");
|
|
56
|
+
if (scope === "w" && id === "cap" && field) {
|
|
57
|
+
const cap = Number(value);
|
|
58
|
+
if (!Number.isInteger(cap) || cap < 0)
|
|
59
|
+
return { ok: false, error: `${field} cap must be an integer >= 0.` };
|
|
60
|
+
return { ok: true, value: { target: "cap", provider: field, cap } };
|
|
61
|
+
}
|
|
62
|
+
if (scope !== "a" || !id || !field)
|
|
63
|
+
return { ok: false, error: `Nothing to change on ${row.label}.` };
|
|
64
|
+
if (field === "enabled") {
|
|
65
|
+
return { ok: true, value: { target: "agent", id, fields: { enabled: value === "yes" } } };
|
|
66
|
+
}
|
|
67
|
+
if (field === "provider" && !providers.includes(value)) {
|
|
68
|
+
return { ok: false, error: `provider must be one of ${providers.join(", ")}.` };
|
|
69
|
+
}
|
|
70
|
+
if (field === "effort" && !efforts.includes(value)) {
|
|
71
|
+
return { ok: false, error: `effort must be one of ${efforts.join(", ")}.` };
|
|
72
|
+
}
|
|
73
|
+
if (field === "model" && !value)
|
|
74
|
+
return { ok: false, error: "model is required." };
|
|
75
|
+
return { ok: true, value: { target: "agent", id, fields: { [field]: value } } };
|
|
76
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/** How much of the stream is kept. Older lines have already scrolled past. */
|
|
2
|
+
export const STREAM_LIMIT = 200;
|
|
3
|
+
/**
|
|
4
|
+
* Events to lines, attributed to whoever produced them. Events from a run the
|
|
5
|
+
* caller did not name are dropped, which is how another workspace's traffic
|
|
6
|
+
* stays out of this one's stream.
|
|
7
|
+
*/
|
|
8
|
+
export function toStreamLines(events, runs) {
|
|
9
|
+
const lines = [];
|
|
10
|
+
for (const event of events) {
|
|
11
|
+
const run = runs.get(event.run_id);
|
|
12
|
+
if (!run)
|
|
13
|
+
continue;
|
|
14
|
+
// The id has to be a property of the event, not of the batch it arrived in.
|
|
15
|
+
// Live delivery converts one row at a time and a backfill converts eighty,
|
|
16
|
+
// so an id carrying a batch position would give the same event two ids and
|
|
17
|
+
// defeat the dedupe below. One event can still produce several lines, which
|
|
18
|
+
// is what the second half counts.
|
|
19
|
+
const produced = transcriptLines(event);
|
|
20
|
+
produced.forEach((line, index) => {
|
|
21
|
+
lines.push({
|
|
22
|
+
id: `${event.id}:${index}`,
|
|
23
|
+
runId: event.run_id,
|
|
24
|
+
agent: run.agent,
|
|
25
|
+
at: String(event.at),
|
|
26
|
+
seq: event.seq,
|
|
27
|
+
kind: line.kind,
|
|
28
|
+
title: line.title,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return lines;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Adds lines to the stream without letting it grow forever and without showing
|
|
36
|
+
* the same event twice. Realtime redelivers on reconnect, and a backfill for a
|
|
37
|
+
* run that has already started overlaps whatever arrived live while it loaded,
|
|
38
|
+
* so late arrivals are sorted back into place rather than appended.
|
|
39
|
+
*/
|
|
40
|
+
export function appendLines(prior, incoming, limit = STREAM_LIMIT) {
|
|
41
|
+
if (incoming.length === 0)
|
|
42
|
+
return prior;
|
|
43
|
+
const seen = new Set(prior.map((line) => line.id));
|
|
44
|
+
const fresh = incoming.filter((line) => !seen.has(line.id));
|
|
45
|
+
if (fresh.length === 0)
|
|
46
|
+
return prior;
|
|
47
|
+
const next = [...prior, ...fresh].sort((left, right) => left.at.localeCompare(right.at) || left.seq - right.seq);
|
|
48
|
+
return next.length > limit ? next.slice(next.length - limit) : next;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Loads the tail of the live runs and adds it to the stream, provided the
|
|
52
|
+
* workspace it was asked for is still the one on screen.
|
|
53
|
+
*
|
|
54
|
+
* run_events is subscribed without a workspace filter, because the table has no
|
|
55
|
+
* workspace id to filter on. Dropping rows whose run is not in `runs` is the
|
|
56
|
+
* only thing keeping one workspace's traffic out of another's stream, so the
|
|
57
|
+
* run map is captured when the request goes out rather than read back when it
|
|
58
|
+
* returns. A switch that lands mid-flight would otherwise resolve against the
|
|
59
|
+
* new workspace's labels, or append the old workspace's rows onto a stream that
|
|
60
|
+
* has just been cleared, where they would sit until they aged out.
|
|
61
|
+
*/
|
|
62
|
+
export async function backfill(opts) {
|
|
63
|
+
if (opts.runIds.length === 0)
|
|
64
|
+
return;
|
|
65
|
+
// Copied, not referenced. The caller rebuilds this map on every board change,
|
|
66
|
+
// and a backfill that outlives one rebuild has to convert against the runs it
|
|
67
|
+
// was asked about rather than whichever ones are live when it lands.
|
|
68
|
+
const runs = new Map(opts.runs);
|
|
69
|
+
let events;
|
|
70
|
+
try {
|
|
71
|
+
events = await opts.load(opts.runIds);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// A failed backfill costs the lines that arrived before the board caught
|
|
75
|
+
// up with the run. The live feed carries everything after them.
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (!opts.isCurrent())
|
|
79
|
+
return;
|
|
80
|
+
const lines = toStreamLines(events, runs);
|
|
81
|
+
if (lines.length === 0)
|
|
82
|
+
return;
|
|
83
|
+
opts.apply((prior) => appendLines(prior, lines));
|
|
84
|
+
}
|
|
85
|
+
function transcriptLines(event) {
|
|
86
|
+
const payload = event.payload && typeof event.payload === "object" && !Array.isArray(event.payload)
|
|
87
|
+
? event.payload
|
|
88
|
+
: {};
|
|
89
|
+
const text = [payload.text, payload.message, payload.summary, payload.command, payload.path]
|
|
90
|
+
.find((value) => typeof value === "string");
|
|
91
|
+
const title = text?.trim() || JSON.stringify(event.payload) || event.type;
|
|
92
|
+
if (!title)
|
|
93
|
+
return [];
|
|
94
|
+
const kind = event.type === "error"
|
|
95
|
+
? "error"
|
|
96
|
+
: ["tool_use", "tool_result", "command", "file_changed"].includes(event.type)
|
|
97
|
+
? "tool"
|
|
98
|
+
: event.type === "status"
|
|
99
|
+
? "status"
|
|
100
|
+
: "text";
|
|
101
|
+
return [{ kind, title }];
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The live runs, keyed by id. Realtime hands over a run event before the board
|
|
105
|
+
* has caught up with the run that produced it, so anything this does not know
|
|
106
|
+
* about is dropped and recovered by the backfill when the board does catch up.
|
|
107
|
+
*/
|
|
108
|
+
export function runLabels(board) {
|
|
109
|
+
const map = new Map();
|
|
110
|
+
for (const run of board.runs) {
|
|
111
|
+
if (run.status !== "running")
|
|
112
|
+
continue;
|
|
113
|
+
const agent = board.agents.find((row) => row.id === run.agent_id);
|
|
114
|
+
const ticket = board.tickets.find((row) => row.id === run.ticket_id);
|
|
115
|
+
map.set(run.id, {
|
|
116
|
+
runId: run.id,
|
|
117
|
+
agent: agent?.display_name ?? run.kind,
|
|
118
|
+
ticket: ticket?.key ?? null,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return map;
|
|
122
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal palette. Text is white everywhere; the boxes carry the meaning,
|
|
3
|
+
* so who is speaking is legible without reading a label.
|
|
4
|
+
*/
|
|
5
|
+
export const UI = {
|
|
6
|
+
/** Everything anyone says. */
|
|
7
|
+
text: "#FFFFFF",
|
|
8
|
+
dim: "gray",
|
|
9
|
+
/** The operator's own turns. */
|
|
10
|
+
cream: "#F5E9C8",
|
|
11
|
+
/** The brain's turns. */
|
|
12
|
+
bright: "#FFFFFF",
|
|
13
|
+
/** The orchestrator speaks from the platform, not from here. */
|
|
14
|
+
orchestratorBorder: "#FFFFFF",
|
|
15
|
+
orchestratorBg: "#000000",
|
|
16
|
+
accent: "#7FB2FF",
|
|
17
|
+
warn: "yellow",
|
|
18
|
+
danger: "red",
|
|
19
|
+
ok: "green",
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* A wide-spaced dotted frame. Ink repeats a single character across the width,
|
|
23
|
+
* so the spacing has to live in the glyph: U+2508 and U+250A are the quadruple
|
|
24
|
+
* dash pair, which reads as dotted rather than as a solid rule.
|
|
25
|
+
*/
|
|
26
|
+
export const DOTTED = {
|
|
27
|
+
topLeft: "┌",
|
|
28
|
+
top: "┈",
|
|
29
|
+
topRight: "┐",
|
|
30
|
+
left: "┊",
|
|
31
|
+
right: "┊",
|
|
32
|
+
bottomLeft: "└",
|
|
33
|
+
bottom: "┈",
|
|
34
|
+
bottomRight: "┘",
|
|
35
|
+
};
|
|
36
|
+
/** A solid thin frame, for the panel the orchestrator speaks from. */
|
|
37
|
+
export const THIN = "single";
|
|
38
|
+
export function speakerStyle(speaker) {
|
|
39
|
+
switch (speaker) {
|
|
40
|
+
case "you":
|
|
41
|
+
return { borderStyle: DOTTED, borderColor: UI.cream, label: "you" };
|
|
42
|
+
case "orchestrator":
|
|
43
|
+
return {
|
|
44
|
+
borderStyle: THIN,
|
|
45
|
+
borderColor: UI.orchestratorBorder,
|
|
46
|
+
backgroundColor: UI.orchestratorBg,
|
|
47
|
+
label: "orchestrator",
|
|
48
|
+
};
|
|
49
|
+
default:
|
|
50
|
+
return { borderStyle: DOTTED, borderColor: UI.dim, label: "" };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Tags asynchronous snapshots so a workspace switch can invalidate them. */
|
|
2
|
+
export class WorkspaceLoads {
|
|
3
|
+
#workspaceId;
|
|
4
|
+
#generation = 0;
|
|
5
|
+
constructor(workspaceId) {
|
|
6
|
+
this.#workspaceId = workspaceId;
|
|
7
|
+
}
|
|
8
|
+
start(workspaceId) {
|
|
9
|
+
return { workspaceId, generation: this.#generation };
|
|
10
|
+
}
|
|
11
|
+
switchTo(workspaceId) {
|
|
12
|
+
this.#workspaceId = workspaceId;
|
|
13
|
+
this.#generation += 1;
|
|
14
|
+
}
|
|
15
|
+
isCurrent(token) {
|
|
16
|
+
return token.workspaceId === this.#workspaceId && token.generation === this.#generation;
|
|
17
|
+
}
|
|
18
|
+
}
|
package/package.json
CHANGED
|
@@ -1,52 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@higherdev/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "HigherDEV in your terminal. Full control of the board, live.",
|
|
6
5
|
"bin": {
|
|
7
6
|
"hd": "dist/index.js"
|
|
8
7
|
},
|
|
8
|
+
"main": "dist/index.js",
|
|
9
9
|
"files": [
|
|
10
10
|
"dist"
|
|
11
11
|
],
|
|
12
12
|
"scripts": {
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"test": "vitest run",
|
|
17
|
-
"prepublishOnly": "pnpm build",
|
|
18
|
-
"sync-version": "node scripts/sync-version.mjs"
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"test": "pnpm build && node --experimental-strip-types --test test/*.test.ts",
|
|
15
|
+
"prepublishOnly": "npm run build"
|
|
19
16
|
},
|
|
20
17
|
"dependencies": {
|
|
21
|
-
"@modelcontextprotocol/sdk": "1.30.0",
|
|
22
|
-
"@supabase/supabase-js": "2.112.3",
|
|
23
|
-
"commander": "15.0.0",
|
|
24
18
|
"ink": "7.1.1",
|
|
25
|
-
"react": "19.2.8"
|
|
26
|
-
"zod": "4.4.3"
|
|
19
|
+
"react": "19.2.8"
|
|
27
20
|
},
|
|
28
21
|
"devDependencies": {
|
|
29
|
-
"@
|
|
30
|
-
"@higherdev/runner": "workspace:*",
|
|
31
|
-
"@types/node": "22.20.1",
|
|
22
|
+
"@types/node": "24.13.3",
|
|
32
23
|
"@types/react": "19.2.18",
|
|
33
|
-
"
|
|
34
|
-
"tsup": "8.5.1",
|
|
35
|
-
"tsx": "4.23.12",
|
|
36
|
-
"typescript": "5.9.3",
|
|
37
|
-
"vitest": "4.1.11"
|
|
38
|
-
},
|
|
39
|
-
"license": "UNLICENSED",
|
|
40
|
-
"repository": {
|
|
41
|
-
"type": "git",
|
|
42
|
-
"url": "git+https://github.com/craig-higherops/higherdev.git",
|
|
43
|
-
"directory": "apps/cli"
|
|
44
|
-
},
|
|
45
|
-
"homepage": "https://dev.higherops.io",
|
|
46
|
-
"engines": {
|
|
47
|
-
"node": ">=22"
|
|
48
|
-
},
|
|
49
|
-
"publishConfig": {
|
|
50
|
-
"access": "public"
|
|
24
|
+
"typescript": "7.0.2"
|
|
51
25
|
}
|
|
52
26
|
}
|