@staix/agent-hub 0.12.19 → 0.12.21
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/CHANGELOG.md +38 -0
- package/README.md +7 -6
- package/docs/agent-notes/adapters.md +10 -2
- package/docs/agent-notes/bus.md +6 -2
- package/docs/agent-notes/daemon.md +10 -3
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/models.md +3 -0
- package/docs/agent-notes/tasks.md +6 -3
- package/docs/agent-notes/tests.md +3 -2
- package/docs/bench.md +143 -0
- package/docs/events.md +6 -2
- package/docs/operations.md +655 -34
- package/docs/quickstart.md +12 -10
- package/docs/research.md +92 -0
- package/docs/security.md +59 -17
- package/docs/smoke.md +137 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +586 -16
- package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +345 -0
- package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
- package/docs/verified.json +29 -18
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +65 -26
- package/src/adapters/acp.ts +67 -6
- package/src/adapters/claude-channel.ts +61 -20
- package/src/adapters/codex-appserver.ts +70 -2
- package/src/adapters/local-worker.ts +8 -3
- package/src/adapters/pi.ts +58 -4
- package/src/cli/bench.ts +287 -0
- package/src/cli/console-state.ts +531 -110
- package/src/cli/console.ts +59 -26
- package/src/cli/facts-hook.ts +50 -8
- package/src/cli/help.ts +130 -0
- package/src/cli/identity.ts +11 -5
- package/src/cli/launch.ts +98 -17
- package/src/cli/main.ts +325 -88
- package/src/cli/preview.ts +14 -3
- package/src/cli/reset.ts +194 -0
- package/src/cli/status-lines.ts +10 -5
- package/src/cli/terminal-recovery.ts +196 -40
- package/src/cli/upgrade-runtime.ts +327 -60
- package/src/cli/upgrade.ts +442 -45
- package/src/hub/ask.ts +1 -1
- package/src/hub/bench.ts +211 -0
- package/src/hub/board.ts +15 -7
- package/src/hub/bus.ts +79 -8
- package/src/hub/child-process.ts +1 -0
- package/src/hub/conductor.ts +54 -21
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/control-client.ts +9 -4
- package/src/hub/daemon.ts +401 -48
- package/src/hub/delivery-journal.ts +6 -6
- package/src/hub/events.ts +9 -2
- package/src/hub/execution-budget.ts +15 -6
- package/src/hub/hub-tools.ts +4 -4
- package/src/hub/inference.ts +136 -1
- package/src/hub/lifecycle.ts +13 -12
- package/src/hub/manager.ts +16 -14
- package/src/hub/peers.ts +2 -0
- package/src/hub/permission-mode.ts +44 -0
- package/src/hub/recovery-store.ts +68 -8
- package/src/hub/registry.ts +36 -22
- package/src/hub/report.ts +43 -1
- package/src/hub/research.ts +255 -0
- package/src/hub/restart.ts +25 -0
- package/src/hub/routing.ts +48 -16
- package/src/hub/snapshots.ts +3 -2
- package/src/hub/tasks.ts +295 -115
- package/src/hub/tool-refusal.ts +7 -0
- package/src/hub/ui.ts +4 -1
- package/src/local/deny.ts +2 -1
- package/src/local/tools.ts +14 -10
- package/src/models/mlx.ts +11 -8
- package/src/models/ollama.ts +2 -2
- package/src/models/relay.ts +190 -42
- package/src/models/route/relay-selector.ts +43 -31
- package/src/models/route/runtime.ts +17 -7
- package/src/models/route/signals.ts +12 -1
- package/src/models/route/stage.ts +57 -3
- package/src/omniroute/client.ts +1 -1
- package/src/pi/process-signature.ts +23 -0
- package/src/pi/tool-receipts.ts +7 -1
- package/src/ui/index.html +84 -6
- package/src/ui/task-progress.ts +33 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/routing.toml +8 -0
package/src/cli/console-state.ts
CHANGED
|
@@ -1,24 +1,75 @@
|
|
|
1
|
+
import { PERMISSION_MODES } from "../hub/permission-mode.ts";
|
|
2
|
+
import { taskProgress } from "../ui/task-progress.ts";
|
|
3
|
+
import type { ProgressStage } from "../ui/task-progress.ts";
|
|
1
4
|
import { sanitize } from "../hub/envelope.ts";
|
|
2
5
|
|
|
3
6
|
export interface Approval {
|
|
4
7
|
id: string; peer: string; title: string; expiresAt: number;
|
|
5
8
|
options: { optionId: string; name: string; kind: string }[];
|
|
6
9
|
}
|
|
10
|
+
export interface PermissionDefault { peer: string; mode: "never-ask"; source: string }
|
|
7
11
|
export interface ConsoleEvent { text: string; peer?: string; kind?: string; tone?: Tone }
|
|
8
12
|
export interface ConsoleState {
|
|
9
|
-
mode: "stream" | "panels"; panel: number; selection: number;
|
|
13
|
+
mode: "stream" | "panels"; panel: number; selection: number;
|
|
14
|
+
/** The selected request, by id: a, d and v act on it alone, and it is cleared, never moved, when the request closes. */
|
|
15
|
+
approvalId?: string;
|
|
16
|
+
/** The request whose detail is open: always the selected one (see `bound`). */
|
|
17
|
+
requestDetail?: string;
|
|
18
|
+
/** A request was selected on arrival once; after that only [ ], j or k select, so a closed selection never moves. */
|
|
19
|
+
autoSelected?: boolean;
|
|
10
20
|
input: string; editing: boolean; history: string[]; historyIndex: number;
|
|
11
21
|
approvals: Approval[]; events: ConsoleEvent[]; peers: Record<string, any>; budget: Record<string, any>;
|
|
12
|
-
tasks: any[]; queue: any[]; detail?:
|
|
13
|
-
peerFilter?: string; kindFilter?: string;
|
|
14
|
-
confirm?: { type: "permission"; id: string; option: string; peer: string } | { type: "command"; args: string[] };
|
|
22
|
+
tasks: any[]; tasksKnown?: boolean; taskCounts?: Record<string, number>; queue: any[]; detail?: Detail; detailOffset: number; help: boolean; notice: string; noticeAt?: number; noticeTone?: Tone;
|
|
23
|
+
peerFilter?: string; kindFilter?: string; project?: string;
|
|
24
|
+
confirm?: { type: "permission"; id: string; option: string; peer: string } | { type: "command"; args: string[] } | { type: "permission_default"; peer: string; source: string };
|
|
15
25
|
optionChoice?: string;
|
|
26
|
+
modeChoice?: string;
|
|
27
|
+
permissionDefaults: PermissionDefault[];
|
|
28
|
+
permissionDefaultsHandled: string[];
|
|
16
29
|
}
|
|
30
|
+
/** A detail view: text as the stream shows it, or labeled fields. */
|
|
31
|
+
export type Detail = string | Record<string, unknown>;
|
|
17
32
|
export type ConsoleEffect = { type: "exit" } | { type: "permit"; id: string; option?: string } |
|
|
18
|
-
{ type: "command"; args: string[] } | { type: "show"; panel: number; id: string };
|
|
33
|
+
{ type: "permission_default"; peer: string; confirmed: boolean } | { type: "command"; args: string[] } | { type: "show"; panel: number; id: string } | { type: "print"; text: string; kind: string; tone?: Tone } | { type: "keys" };
|
|
19
34
|
export function initialConsoleState(panels = false): ConsoleState {
|
|
20
|
-
return { mode: panels ? "panels" : "stream", panel: 1, selection: 0,
|
|
21
|
-
input: "", editing: false, history: [], historyIndex: 0, approvals: [], events: [], peers: {}, budget: {}, tasks: [], queue: [], detailOffset: 0, help: false, notice: "" };
|
|
35
|
+
return { mode: panels ? "panels" : "stream", panel: 1, selection: 0,
|
|
36
|
+
input: "", editing: false, history: [], historyIndex: 0, approvals: [], events: [], peers: {}, budget: {}, tasks: [], tasksKnown: false, queue: [], permissionDefaults: [], permissionDefaultsHandled: [], detailOffset: 0, help: false, notice: "" };
|
|
37
|
+
}
|
|
38
|
+
/** Approval frequency does not create a common sandbox across native peers. */
|
|
39
|
+
export function permissionBoundary(peer: string): string {
|
|
40
|
+
if (peer === "pi" || peer === "local") return `${peer}: inside hub sandbox, path guard and denylist`;
|
|
41
|
+
if (peer === "claude" || peer === "codex") return `${peer}: native vendor bounds; no hub sandbox`;
|
|
42
|
+
return `${peer}: runs its own tools; NO hub sandbox`;
|
|
43
|
+
}
|
|
44
|
+
function permissionModeRefusal(peer: string, mode: unknown): string | undefined {
|
|
45
|
+
if (mode === "unmanaged") return `${peer} permission mode is unmanaged; use its native controls`;
|
|
46
|
+
if (mode === "unverified") return `${peer} permission mode is unverified; start with ahub ${peer} and run a tool first`;
|
|
47
|
+
if (mode === "unknown") return `${peer} permission mode is unknown; inspect or reconnect its native session`;
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
/** Queue sizes and timestamps do not make a refused/default-deferred peer ready. */
|
|
51
|
+
function permissionDefaultFingerprint(s: ConsoleState, row: Pick<PermissionDefault, "peer" | "source">): string {
|
|
52
|
+
const peer = s.peers[row.peer];
|
|
53
|
+
// Readiness, not activity: a peer going idle or busy does not make a deferred default worth asking about again.
|
|
54
|
+
return JSON.stringify([row.peer, row.source, !peer ? "absent" : peer.state === "offline" ? "offline" : "attached", peer?.permissionMode ?? "ask", peer?.attached ?? !!peer, peer?.claiming ?? false, peer?.toolsOnly ?? false]);
|
|
55
|
+
}
|
|
56
|
+
/** Offer one file default only when no other keyboard decision or edit is active. */
|
|
57
|
+
export function syncPermissionDefaults(s: ConsoleState, rows: unknown): boolean {
|
|
58
|
+
if (!Array.isArray(rows)) rows = [];
|
|
59
|
+
s.permissionDefaults = (rows as unknown[]).filter((row): row is PermissionDefault => !!row && typeof row === "object" &&
|
|
60
|
+
typeof (row as PermissionDefault).peer === "string" && (row as PermissionDefault).mode === "never-ask" && typeof (row as PermissionDefault).source === "string");
|
|
61
|
+
const current = new Set(s.permissionDefaults.map(row => permissionDefaultFingerprint(s, row)));
|
|
62
|
+
s.permissionDefaultsHandled = s.permissionDefaultsHandled.filter(fingerprint => current.has(fingerprint));
|
|
63
|
+
const pending = s.confirm;
|
|
64
|
+
if (pending?.type === "permission_default" && !s.permissionDefaults.some(row => row.peer === pending.peer && row.source === pending.source)) s.confirm = undefined;
|
|
65
|
+
if (s.confirm || s.editing || s.input || s.modeChoice || s.optionChoice || s.help) return false;
|
|
66
|
+
const next = s.permissionDefaults.find(row => !s.permissionDefaultsHandled.includes(permissionDefaultFingerprint(s, row)));
|
|
67
|
+
if (!next) return false;
|
|
68
|
+
s.confirm = { type: "permission_default", peer: next.peer, source: next.source };
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
export function permissionDefaultText(row: Pick<PermissionDefault, "peer" | "source">): string {
|
|
72
|
+
return `never-ask default for ${row.peer} from ${quoted(row.source)}\n${permissionBoundary(row.peer)}\nUntil you answer y, this default stays ask. y enables; n cancels for this hub; Esc defers.`;
|
|
22
73
|
}
|
|
23
74
|
/** Strip terminal controls before any daemon or child output reaches a terminal. Preserve printable Unicode. */
|
|
24
75
|
export function terminalText(value: unknown): string {
|
|
@@ -28,10 +79,12 @@ export function terminalText(value: unknown): string {
|
|
|
28
79
|
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
29
80
|
.replace(/\x1b[^\n]?/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, ""));
|
|
30
81
|
}
|
|
31
|
-
export type Tone = "info" | "strong" | "success" | "attention" | "failure" | "muted";
|
|
82
|
+
export type Tone = "info" | "strong" | "success" | "attention" | "failure" | "muted" | "peerClaude" | "peerCodex" | "taskKeyword" | "number" | "issueRef" | "taskRef";
|
|
32
83
|
export interface Span { text: string; tone?: Tone }
|
|
33
84
|
export const PALETTE: Readonly<Record<Tone, string>> = Object.freeze({
|
|
34
85
|
info: "\x1b[36m", strong: "\x1b[1;36m", success: "\x1b[32m", attention: "\x1b[33m", failure: "\x1b[31m", muted: "\x1b[90m",
|
|
86
|
+
peerClaude: "\x1b[94m", peerCodex: "\x1b[96m",
|
|
87
|
+
taskKeyword: "\x1b[35m", number: "\x1b[1m", issueRef: "\x1b[4m", taskRef: "\x1b[4;35m",
|
|
35
88
|
});
|
|
36
89
|
export function paint(line: Span[], color: boolean): string {
|
|
37
90
|
return line.map(span => {
|
|
@@ -40,6 +93,83 @@ export function paint(line: Span[], color: boolean): string {
|
|
|
40
93
|
return color && sgr && text ? sgr + text + "\x1b[0m" : text;
|
|
41
94
|
}).join("");
|
|
42
95
|
}
|
|
96
|
+
/** Console stream headers only. Tokenize sanitized text; bodies and command output stay plain. */
|
|
97
|
+
export function streamTokens(value: string, eventTone?: Tone, kind?: string): Span[] {
|
|
98
|
+
const text = terminalText(value);
|
|
99
|
+
if (kind === "command" || kind === "console") return [{ text }];
|
|
100
|
+
const peers: Readonly<Record<string, Tone>> = { claude: "peerClaude", codex: "peerCodex" };
|
|
101
|
+
// Hub-written peer slots only; words such as local/pi/hub in a title do not identify a speaker.
|
|
102
|
+
const peerSlots = new Set<number>();
|
|
103
|
+
const leading = text.match(/^\s*(?:[.?*!]\s+)?([A-Za-z_][A-Za-z_0-9-]*)(?=:| is | asks permission:| permission )/);
|
|
104
|
+
if (leading) peerSlots.add(leading[0].lastIndexOf(leading[1]!));
|
|
105
|
+
const route = text.match(/^\s*(?:[0-9:]+ (?:AM|PM) )?([A-Za-z_][A-Za-z_0-9-]*) -> ([A-Za-z_][A-Za-z_0-9,-]*|\*)/);
|
|
106
|
+
if (route) {
|
|
107
|
+
const start = route[0].indexOf(route[1]!); peerSlots.add(start);
|
|
108
|
+
let at = route[0].lastIndexOf(route[2]!);
|
|
109
|
+
for (const recipient of route[2]!.split(",")) { peerSlots.add(at); at += recipient.length + 1; }
|
|
110
|
+
}
|
|
111
|
+
if (/^\s*\* task #[0-9]+ .+ (?:accepted by|assigned to) [A-Za-z_][A-Za-z_0-9-]*$/.test(text) && !/ declined| reason:/.test(text)) {
|
|
112
|
+
const owner = text.match(/(?:accepted by|assigned to) ([A-Za-z_][A-Za-z_0-9-]*)$/);
|
|
113
|
+
if (owner) peerSlots.add(owner.index! + owner[0].lastIndexOf(owner[1]!));
|
|
114
|
+
}
|
|
115
|
+
const marker = text.match(/^\s*([.?*!])/);
|
|
116
|
+
const markerAt = marker ? marker[0].length - 1 : -1;
|
|
117
|
+
const state = kind === "state" ? text.match(/ is ([A-Za-z_]+)$/) : undefined;
|
|
118
|
+
const stateAt = state ? state.index! + 4 : -1;
|
|
119
|
+
const permissionIds = new Set<number>();
|
|
120
|
+
for (const match of text.matchAll(/\b(?:permission|request|permit) ([0-9]{8})(?=\b)/g)) permissionIds.add(match.index + match[0].length - 8);
|
|
121
|
+
const taskKeyword = text.match(/^\s*\*?\s*(task)(?![\w-])/i);
|
|
122
|
+
const taskKeywordAt = taskKeyword ? taskKeyword[0].length - taskKeyword[1]!.length : -1;
|
|
123
|
+
const out: Span[] = []; let end = 0; let previousToken = ""; let previousStart = -1;
|
|
124
|
+
for (const match of text.matchAll(/(?<![\p{L}\p{N}_#-])#[0-9]+(?![\p{L}\p{N}_-])|(?<![\p{L}\p{N}_#.:-])(?:[0-9]+(?::[0-9]+)+(?: AM| PM)?|[0-9]+(?:\.[0-9]+)?(?:%|ms|s|m|h)?)(?![\p{L}\p{N}_%-]|[.:][\p{L}\p{N}])|[A-Za-z_][A-Za-z_0-9-]*|->|[.?*!]/gu)) {
|
|
125
|
+
const token = match[0]; const start = match.index;
|
|
126
|
+
const gap = text.slice(end, start);
|
|
127
|
+
if (gap) out.push({ text: gap });
|
|
128
|
+
let tone: Tone | undefined;
|
|
129
|
+
// ponytail: legacy notices have no typed reference ranges. Classify task/review prefixes textually;
|
|
130
|
+
// assign/which #N remains an issue reference until notices carry structured reference spans.
|
|
131
|
+
if (token.startsWith("#")) {
|
|
132
|
+
const task = previousToken.toLowerCase() === "task" && /^\s*$/.test(gap);
|
|
133
|
+
const review = previousToken.toLowerCase() === "review" && /^\s*$/.test(gap) && text[previousStart - 1] === "[" && text[start + token.length] === "]";
|
|
134
|
+
tone = task || review ? "taskRef" : "issueRef";
|
|
135
|
+
}
|
|
136
|
+
else if (start === taskKeywordAt) tone = "taskKeyword";
|
|
137
|
+
else if (/^[0-9]/.test(token) && !permissionIds.has(start)) tone = "number";
|
|
138
|
+
else if (peerSlots.has(start) && Object.hasOwn(peers, token)) tone = peers[token];
|
|
139
|
+
else if (start === markerAt || start === stateAt || (route && token === "->" && start < route[0].length)) tone = eventTone;
|
|
140
|
+
out.push({ text: token, ...(tone ? { tone } : {}) });
|
|
141
|
+
previousToken = token; previousStart = start; end = start + token.length;
|
|
142
|
+
}
|
|
143
|
+
if (end < text.length) out.push({ text: text.slice(end) });
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
/** Wrap plain text first, then project original token spans onto it, preserving trusted slots across continuations. */
|
|
147
|
+
export function wrapStreamTokens(value: string, columns: number, tone?: Tone, kind?: string): Span[][] {
|
|
148
|
+
const spans = streamTokens(terminalText(value).replace(/\t/g, " "), tone, kind);
|
|
149
|
+
const text = spans.map(s => s.text).join("");
|
|
150
|
+
let cursor = 0; let projecting = true; let spanIndex = 0; let spanAt = 0;
|
|
151
|
+
return wrap(text, columns).map((line, index) => {
|
|
152
|
+
const content = line.trimStart();
|
|
153
|
+
const start = projecting ? text.indexOf(content, cursor) : -1;
|
|
154
|
+
if (!content || start < 0) {
|
|
155
|
+
if (content) projecting = false;
|
|
156
|
+
// Only the first physical header can have structural markers. Never infer structure from a continuation.
|
|
157
|
+
return index === 0 ? streamTokens(line, tone, kind).map(s => ({ text: s.text, ...(s.tone === tone && tone ? { tone } : {}) })) : [{ text: line }];
|
|
158
|
+
}
|
|
159
|
+
const end = start + content.length; cursor = end;
|
|
160
|
+
const out: Span[] = [{ text: line.slice(0, line.length - content.length) }];
|
|
161
|
+
while (spanIndex < spans.length && spanAt + spans[spanIndex]!.text.length <= start) {
|
|
162
|
+
spanAt += spans[spanIndex++]!.text.length;
|
|
163
|
+
}
|
|
164
|
+
while (spanIndex < spans.length && spanAt < end) {
|
|
165
|
+
const span = spans[spanIndex]!; const next = spanAt + span.text.length;
|
|
166
|
+
out.push({ ...span, text: span.text.slice(Math.max(0, start - spanAt), Math.min(span.text.length, end - spanAt)) });
|
|
167
|
+
if (next > end) break;
|
|
168
|
+
spanAt = next; spanIndex++;
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
43
173
|
export function resolveColor(flag: string | undefined, env: { isTTY: boolean; TERM?: string; NO_COLOR?: string }): boolean | Error {
|
|
44
174
|
if (flag === "always") return true;
|
|
45
175
|
if (flag === "never") return false;
|
|
@@ -53,46 +183,168 @@ export function stateTone(state: string): Tone | undefined {
|
|
|
53
183
|
return undefined;
|
|
54
184
|
}
|
|
55
185
|
const span = (text: unknown, tone?: Tone): Span => ({ text: String(text ?? ""), ...(tone ? { tone } : {}) });
|
|
56
|
-
/**
|
|
186
|
+
/** One line of sanitized text: a newline or a tab becomes a space, as wrap() counts a tab (stringWidth("\t") is 0). */
|
|
187
|
+
const flat = (value: unknown) => terminalText(value).replace(/[\n\t]/g, " ");
|
|
188
|
+
/**
|
|
189
|
+
* Clip sanitized plain text to the width and keep the tones of the surviving prefix. Widths are measured as paint()
|
|
190
|
+
* prints them: paint() sanitizes each span again, and a cut that ends a span in `[agent-hub` earns it a `> `.
|
|
191
|
+
*/
|
|
57
192
|
function fitLine(line: Span[], columns: number): Span[] {
|
|
58
|
-
const clean = line.map(s => ({ ...s, text:
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
193
|
+
const clean = line.map(s => ({ ...s, text: flat(s.text) }));
|
|
194
|
+
if (Bun.stringWidth(clean.map(s => s.text).join("")) <= columns) return clean;
|
|
195
|
+
const marker = ".".repeat(Math.max(0, Math.min(3, columns)));
|
|
196
|
+
const out: Span[] = []; let room = columns - marker.length;
|
|
197
|
+
for (const s of clean) {
|
|
198
|
+
let part = "";
|
|
199
|
+
for (const char of s.text) { if (Bun.stringWidth(sanitize(part + char)) > room) break; part += char; }
|
|
200
|
+
if (part) out.push({ ...s, text: part });
|
|
201
|
+
if (part !== s.text) break;
|
|
202
|
+
room -= Bun.stringWidth(part);
|
|
203
|
+
}
|
|
65
204
|
if (marker) out.push(span(marker));
|
|
66
205
|
return out;
|
|
67
206
|
}
|
|
68
207
|
export function fit(value: unknown, columns: number): string {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
208
|
+
return paint(fitLine([span(value)], columns), false);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* A source line keeps its indent and its continuations hang `hang` columns deeper, both capped at half the width, so
|
|
212
|
+
* below that cap wrapped untrusted text never starts where its source line, or a hub line at that indent, starts. Breaks fall
|
|
213
|
+
* at whitespace, which they drop; only a word longer than a whole line is split. A tab counts as one space.
|
|
214
|
+
* Lines come out sanitized: a width counts the `> ` that sanitize() puts before a line starting like a hub header.
|
|
215
|
+
*/
|
|
216
|
+
export function wrap(value: unknown, columns: number, hang = 4): string[] {
|
|
77
217
|
const lines: string[] = [];
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
218
|
+
const half = Math.floor(columns / 2);
|
|
219
|
+
const width = (text: string) => Bun.stringWidth(sanitize(text));
|
|
220
|
+
for (const part of terminalText(value).replace(/\t/g, " ").split("\n")) {
|
|
221
|
+
const indent = /^\s*/.exec(part)![0];
|
|
222
|
+
const lead = Math.min(Bun.stringWidth(indent), half);
|
|
223
|
+
const pad = " ".repeat(Math.min(lead + hang, half));
|
|
224
|
+
const first = lines.length;
|
|
225
|
+
let start = " ".repeat(lead);
|
|
226
|
+
let line = start;
|
|
227
|
+
const push = () => { lines.push(sanitize(line.trimEnd())); line = start = pad; };
|
|
228
|
+
for (const token of part.slice(indent.length).match(/\s+|\S+/g) ?? []) {
|
|
229
|
+
if (width(line + token) <= columns) { line += token; continue; }
|
|
230
|
+
if (line !== start) push();
|
|
231
|
+
if (/^\s/.test(token)) continue;
|
|
232
|
+
for (const char of token) {
|
|
233
|
+
if (line !== start && width(line + char) > columns) push();
|
|
234
|
+
line += char;
|
|
235
|
+
}
|
|
83
236
|
}
|
|
84
|
-
lines.push(line);
|
|
237
|
+
if (line !== start || lines.length === first) lines.push(sanitize(line.trimEnd()));
|
|
238
|
+
}
|
|
239
|
+
return lines;
|
|
240
|
+
}
|
|
241
|
+
export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
242
|
+
/** `45s`, `12m`, `3h05m`, `2d03h`. */
|
|
243
|
+
export function duration(ms: number): string {
|
|
244
|
+
const s = Math.max(0, Math.floor(ms / 1000)), m = Math.floor(s / 60), h = Math.floor(m / 60);
|
|
245
|
+
return s < 60 ? `${s}s` : m < 60 ? `${m}m` : h < 48 ? `${h}h${String(m % 60).padStart(2, "0")}m` : `${Math.floor(h / 24)}d${String(h % 24).padStart(2, "0")}h`;
|
|
246
|
+
}
|
|
247
|
+
/** A moment as `in 2h13m` or `12m ago`. */
|
|
248
|
+
export const relative = (at: number, now: number) => at > now ? `in ${duration(at - now)}` : `${duration(now - at)} ago`;
|
|
249
|
+
const WORD = /^\p{L}[\p{L}\p{N}_./:@#+-]*$/u; // a letter first: `1 2` must not read as two numbers
|
|
250
|
+
/**
|
|
251
|
+
* An agent-written string inside hub-built text (a detail below its field, an option name in a prompt): a plain word as
|
|
252
|
+
* it is, anything else JSON-quoted, so no `;`, `,`, `)`, quote or newline in it can end it and pass for hub structure.
|
|
253
|
+
*/
|
|
254
|
+
export const quoted = (text: unknown) => { const value = String(text ?? ""); return WORD.test(value) ? value : JSON.stringify(value); };
|
|
255
|
+
const TIME_KEY = /^(?:at|created|updated|since|expires)$|At$/;
|
|
256
|
+
/**
|
|
257
|
+
* A detail value as text, never JSON: times relative, shares as percent, objects as `key value` pairs, nested lists
|
|
258
|
+
* inline. Only a field's own string value shows as written; every string below it goes through `quoted`.
|
|
259
|
+
*/
|
|
260
|
+
function detailText(value: unknown, key: string, now: number, nested = false): string {
|
|
261
|
+
if (value === null || value === undefined || (value === "" && !nested)) return "-";
|
|
262
|
+
if (typeof value === "number" && TIME_KEY.test(key)) return relative(value, now);
|
|
263
|
+
if (typeof value === "number" && key === "used") return `${Math.round(value * 100)}%`;
|
|
264
|
+
if (Array.isArray(value)) return value.map(item => item && typeof item === "object" && !Array.isArray(item) ? `(${detailText(item, key, now, true)})` : detailText(item, key, now, true)).join(", ") || "-";
|
|
265
|
+
if (typeof value === "object") return Object.entries(value).map(([k, v]) => `${k} ${detailText(v, k, now, true)}`).join("; ") || "-";
|
|
266
|
+
return nested ? quoted(value) : String(value);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Labels at column 0, values one column past the longest label. A list puts the hub's `- ` before each item, and every
|
|
270
|
+
* other line of a value (its own newlines, wrapped continuations) starts two columns deeper: untrusted text can pass
|
|
271
|
+
* neither for a label nor for an item. Allow options keep one line each, cut rather than wrapped.
|
|
272
|
+
*/
|
|
273
|
+
function fieldLines(fields: [string, unknown][], columns: number, now: number): Span[][][] {
|
|
274
|
+
const pad = Math.min(16, Math.max(0, ...fields.map(([label]) => Bun.stringWidth(label))) + 2);
|
|
275
|
+
const width = columns - pad - 2;
|
|
276
|
+
return fields.map(([label, value]) => {
|
|
277
|
+
const list = Array.isArray(value) && value.length > 0;
|
|
278
|
+
const items = list ? (value as unknown[]).map(item => detailText(item, label, now, true)) : [detailText(value, label, now)];
|
|
279
|
+
const lines = items.flatMap(item => (label === "a allow" ? [fit(item, width)] : wrap(item, width)).map((text, i) => (i ? " " : list ? "- " : "") + text));
|
|
280
|
+
return lines.map((text, i) => {
|
|
281
|
+
const head = i ? "" : fit(label, pad - 2);
|
|
282
|
+
return [span(head + " ".repeat(pad - Bun.stringWidth(head)), i ? undefined : "info"), span(text)];
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
const detailLines = (detail: Detail, columns: number, now: number): Span[][] =>
|
|
287
|
+
typeof detail === "string" ? wrap(detail, columns).map(text => [span(text)]) : fieldLines(Object.entries(detail), columns, now).flat();
|
|
288
|
+
const allowOptions = (a: Approval) => a.options.filter(o => o.kind.startsWith("allow"));
|
|
289
|
+
/** A pushed request as the console keeps it: every agent-supplied field a string, so no draw can throw on one. */
|
|
290
|
+
export function approvalFrom(msg: any): Approval {
|
|
291
|
+
return { id: String(msg.id ?? ""), peer: String(msg.peer ?? ""), title: String(msg.title ?? ""), expiresAt: Number(msg.expiresAt),
|
|
292
|
+
options: (Array.isArray(msg.options) ? msg.options : []).map((o: any) => ({ optionId: String(o?.optionId ?? ""), name: String(o?.name ?? ""), kind: String(o?.kind ?? "") })) };
|
|
293
|
+
}
|
|
294
|
+
/** Errors and refusals take the failure tone; a notice that only informs passes `attention`. */
|
|
295
|
+
export function notify(s: ConsoleState, text: string, now: number, tone: Tone = "failure"): void {
|
|
296
|
+
s.notice = text; s.noticeAt = now; s.noticeTone = tone;
|
|
297
|
+
}
|
|
298
|
+
/** A request as labeled fields: allow options numbered as `a` offers them (`{ 2: name }`: the number is the hub's, the name quoted), then deny. */
|
|
299
|
+
function approvalFields(a: Approval): [string, unknown][] {
|
|
300
|
+
const allow = allowOptions(a);
|
|
301
|
+
return [["request", a.id], ["peer", a.peer], ["expires", a.expiresAt], ["title", a.title],
|
|
302
|
+
["a allow", allow.length > 1 ? allow.map((o, i) => ({ [i + 1]: o.name })) : allow[0] && quoted(allow[0].name)], ["d deny", "at once, no confirmation"]];
|
|
303
|
+
}
|
|
304
|
+
/** How the stream shows a permission request: hub text at columns 2 and 4, title lines framed at column 6. */
|
|
305
|
+
export function permissionText(a: Pick<Approval, "id" | "peer" | "title" | "options">): string {
|
|
306
|
+
return ` ? ${a.peer} asks permission: ${String(a.title).replace(/\n/g, "\n | ")}\n answer with: ahub permit ${a.id} <${a.options.map(o => `${quoted(o.optionId)} (${quoted(o.name)})`).join(", ")}> | deny`;
|
|
307
|
+
}
|
|
308
|
+
const KEYS: [string, string[]][] = [
|
|
309
|
+
["Everywhere", ["Tab stream/panels", ": command", "? keys", "Esc back", "q quit"]],
|
|
310
|
+
["Command", ["Enter run", "Esc cancel", "Up/Down history", "Ctrl-U clear"]],
|
|
311
|
+
["Panels", ["1-5 panel", "j/k or arrows move", "Enter view", "j/k scroll a view"]],
|
|
312
|
+
["Approvals", ["a allow (then y)", "d deny", "v view", "[ ] select"]],
|
|
313
|
+
["Peers", ["p pause", "r resume", "m permission mode"]],
|
|
314
|
+
["Tasks", ["a assign (then y)", "r review"]],
|
|
315
|
+
["Queue", ["r resolve (reason, then y)"]],
|
|
316
|
+
["Events", ["f peer filter", "g kind filter"]],
|
|
317
|
+
];
|
|
318
|
+
/** The `?` key table: one group per panel, its keys kept whole and packed into the width after the group column. */
|
|
319
|
+
export function keyTable(columns: number): Span[][] {
|
|
320
|
+
const lines: Span[][] = []; const pad = 12;
|
|
321
|
+
for (const [group, items] of KEYS) {
|
|
322
|
+
let text = ""; let head = group;
|
|
323
|
+
const flush = () => { lines.push([span(head.padEnd(pad), head ? "info" : undefined), span(text)]); head = ""; text = ""; };
|
|
324
|
+
for (const item of items) { if (text && pad + Bun.stringWidth(`${text} ${item}`) > columns) flush(); text = text ? `${text} ${item}` : item; }
|
|
325
|
+
flush();
|
|
85
326
|
}
|
|
86
327
|
return lines;
|
|
87
328
|
}
|
|
88
329
|
export function pruneApprovals(s: ConsoleState, now: number): ConsoleState {
|
|
89
330
|
const approvals = s.approvals.filter(a => a.expiresAt > now);
|
|
90
331
|
const live = (id: string) => approvals.some(a => a.id === id);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
...(
|
|
332
|
+
const gone = s.approvalId !== undefined && !live(s.approvalId);
|
|
333
|
+
return bound({ ...s, approvals,
|
|
334
|
+
...(gone ? { approvalId: undefined, notice: `request ${s.approvalId} closed${approvals.length ? "; [ ] selects another" : ""}`, noticeAt: now, noticeTone: "attention" as const } : {}) });
|
|
94
335
|
}
|
|
95
|
-
|
|
336
|
+
/**
|
|
337
|
+
* Everything pending on a request belongs to the selected one: a choice or confirmation for any other request is
|
|
338
|
+
* dropped, and an open detail follows the selection from its top (it closes when nothing is selected). Every reducer
|
|
339
|
+
* exit and every prune passes through here, so no key can act on a request other than the one shown.
|
|
340
|
+
*/
|
|
341
|
+
function bound(s: ConsoleState): ConsoleState {
|
|
342
|
+
if (s.optionChoice && s.optionChoice !== s.approvalId) s.optionChoice = undefined;
|
|
343
|
+
if (s.confirm?.type === "permission" && s.confirm.id !== s.approvalId) s.confirm = undefined;
|
|
344
|
+
if (s.requestDetail && s.requestDetail !== s.approvalId) { s.requestDetail = s.approvalId; s.detailOffset = 0; }
|
|
345
|
+
return s;
|
|
346
|
+
}
|
|
347
|
+
const COMMANDS = new Set(["status", "board", "task", "review", "say", "pause", "resume", "budget", "queue", "permit", "permission", "ask", "remember", "route", "turns", "undo", "check-path", "report"]);
|
|
96
348
|
/** A tiny argv parser, never a shell. Quotes group arguments; backslash escapes one character. */
|
|
97
349
|
export function parseConsoleCommand(input: string): string[] {
|
|
98
350
|
const words: string[] = []; let word = ""; let quote = ""; let escaped = false; let started = false;
|
|
@@ -111,7 +363,8 @@ export function parseConsoleCommand(input: string): string[] {
|
|
|
111
363
|
return words;
|
|
112
364
|
}
|
|
113
365
|
export function commandNeedsConfirmation(args: string[]): boolean {
|
|
114
|
-
|
|
366
|
+
const positional = args.filter(arg => arg !== "--yes");
|
|
367
|
+
return (positional[0] === "permission" && positional[2] === "never-ask") || (args[0] === "task" && args[1] === "assign") || (args[0] === "queue" && args[1] === "resolve") || (args[0] === "permit" && args[2] !== "deny");
|
|
115
368
|
}
|
|
116
369
|
export function panelRows(s: ConsoleState): any[] {
|
|
117
370
|
if (s.panel === 1) return Object.entries(s.peers).map(([id, peer]) => ({ id, ...peer, budget: s.budget[id] }));
|
|
@@ -123,21 +376,47 @@ export function panelRows(s: ConsoleState): any[] {
|
|
|
123
376
|
if (s.panel === 4) return s.queue.filter(q => q.state === "needs_review" || q.state === "queued");
|
|
124
377
|
return s.events.filter(e => (!s.peerFilter || e.peer === s.peerFilter) && (!s.kindFilter || e.kind === s.kindFilter));
|
|
125
378
|
}
|
|
379
|
+
export const selectedApproval = (s: ConsoleState) => s.approvals.find(a => a.id === s.approvalId);
|
|
380
|
+
/** The selected row: the Approvals panel's is the selected request (-1 when none is). */
|
|
381
|
+
const selection = (s: ConsoleState) => s.panel === 2 ? s.approvals.findIndex(a => a.id === s.approvalId) : s.selection;
|
|
126
382
|
export function reduceConsole(state: ConsoleState, key: string, now = Date.now()): { state: ConsoleState; effects: ConsoleEffect[] } {
|
|
127
|
-
|
|
128
|
-
|
|
383
|
+
// A notice answers the key before this one; one that pruning raises now stays.
|
|
384
|
+
let s = pruneApprovals({ ...state, notice: "" }, now); const effects: ConsoleEffect[] = [];
|
|
385
|
+
const done = () => ({ state: bound(s), effects });
|
|
386
|
+
const answered = (id: string) => { s.approvals = s.approvals.filter(a => a.id !== id); if (s.approvalId === id) s.approvalId = undefined; };
|
|
129
387
|
if (key === "\x03") { effects.push({ type: "exit" }); return done(); }
|
|
130
388
|
if (s.confirm) {
|
|
131
389
|
const confirm = s.confirm; s.confirm = undefined;
|
|
390
|
+
if (confirm.type === "permission_default") {
|
|
391
|
+
if (key !== "y" && key !== "n" && key !== "\x1b") { s.confirm = confirm; return done(); }
|
|
392
|
+
s.permissionDefaultsHandled = [...s.permissionDefaultsHandled, permissionDefaultFingerprint(s, confirm)];
|
|
393
|
+
if (key === "\x1b") { notify(s, `${confirm.peer} never-ask default deferred; remains pending and ask`, now, "attention"); return done(); }
|
|
394
|
+
effects.push({ type: "permission_default", peer: confirm.peer, confirmed: key === "y" });
|
|
395
|
+
return done();
|
|
396
|
+
}
|
|
132
397
|
if (key === "y") {
|
|
133
|
-
if (confirm.type === "command") effects.push({ type: "command", args: confirm.args });
|
|
134
|
-
else if (s.approvals.some(a => a.id === confirm.id && a.
|
|
398
|
+
if (confirm.type === "command") effects.push({ type: "command", args: confirm.args[0] === "permission" && confirm.args[2] === "never-ask" ? [...confirm.args.filter(arg => arg !== "--yes"), "--yes"] : confirm.args });
|
|
399
|
+
else if (s.approvals.some(a => a.id === confirm.id && allowOptions(a).some(o => o.optionId === confirm.option))) {
|
|
135
400
|
effects.push({ type: "permit", id: confirm.id, option: confirm.option });
|
|
136
|
-
|
|
401
|
+
answered(confirm.id);
|
|
137
402
|
}
|
|
138
403
|
}
|
|
139
404
|
return done();
|
|
140
405
|
}
|
|
406
|
+
if (s.modeChoice) {
|
|
407
|
+
const peer = s.modeChoice;
|
|
408
|
+
if (key === "\x1b") { s.modeChoice = undefined; return done(); }
|
|
409
|
+
const mode = PERMISSION_MODES[Number(key) - 1];
|
|
410
|
+
if (/^[1-3]$/.test(key) && mode) {
|
|
411
|
+
s.modeChoice = undefined;
|
|
412
|
+
if (!s.peers[peer] || s.peers[peer].state === "offline") notify(s, `${peer} is no longer attached`, now);
|
|
413
|
+
else if (permissionModeRefusal(peer, s.peers[peer].permissionMode)) notify(s, permissionModeRefusal(peer, s.peers[peer].permissionMode)!, now);
|
|
414
|
+
else if (mode === "never-ask") s.confirm = { type: "command", args: ["permission", peer, mode] };
|
|
415
|
+
else effects.push({ type: "command", args: ["permission", peer, mode] });
|
|
416
|
+
}
|
|
417
|
+
return done();
|
|
418
|
+
}
|
|
419
|
+
if (s.help && s.mode === "panels" && !["?", "\x1b", "q"].includes(key)) return done(); // the key table is modal
|
|
141
420
|
if (s.editing || s.input) {
|
|
142
421
|
if (key === "\x1b") { s.input = ""; s.editing = false; }
|
|
143
422
|
else if (key === "\x7f" || key === "\b") s.input = Array.from(s.input).slice(0, -1).join("");
|
|
@@ -149,7 +428,8 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
149
428
|
const input = s.input; s.input = ""; s.editing = false;
|
|
150
429
|
if (!input.trim()) return done();
|
|
151
430
|
try {
|
|
152
|
-
const
|
|
431
|
+
const parsed = parseConsoleCommand(input);
|
|
432
|
+
const args = parsed[0] === "permission" ? [...parsed.filter(arg => arg !== "--yes"), ...(parsed.includes("--yes") ? ["--yes"] : [])] : parsed;
|
|
153
433
|
if (args[0] === "queue" && args[1] === "resolve") {
|
|
154
434
|
const reason = args.indexOf("--reason");
|
|
155
435
|
if (reason < 0 || !args[reason + 1]?.trim()) throw new Error("queue resolve requires --reason");
|
|
@@ -157,52 +437,75 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
157
437
|
s.history = [...s.history, input].slice(-100); s.historyIndex = s.history.length;
|
|
158
438
|
if (commandNeedsConfirmation(args)) s.confirm = { type: "command", args };
|
|
159
439
|
else effects.push({ type: "command", args });
|
|
160
|
-
} catch (error) { s
|
|
440
|
+
} catch (error) { notify(s, String((error as Error).message), now); }
|
|
161
441
|
} else if (!key.startsWith("\x1b")) s.input += terminalText(key).replace(/\n/g, "");
|
|
162
442
|
return done();
|
|
163
443
|
}
|
|
164
444
|
if (key === "q") { effects.push({ type: "exit" }); return done(); }
|
|
165
|
-
if (key === "\t") { s.mode = s.mode === "panels" ? "stream" : "panels"; s.detail = undefined; return done(); }
|
|
445
|
+
if (key === "\t") { s.mode = s.mode === "panels" ? "stream" : "panels"; s.detail = undefined; s.requestDetail = undefined; return done(); }
|
|
166
446
|
if (key === ":") { s.editing = true; return done(); }
|
|
167
|
-
if (s.mode === "panels" && /^[1-5]$/.test(key) && !s.optionChoice) { s.panel = Number(key); s.selection = 0; s.detail = undefined; return done(); }
|
|
168
|
-
if (key === "?") {
|
|
169
|
-
|
|
447
|
+
if (s.mode === "panels" && /^[1-5]$/.test(key) && !s.optionChoice) { s.panel = Number(key); s.selection = 0; s.detail = undefined; s.requestDetail = undefined; return done(); }
|
|
448
|
+
if (key === "?") {
|
|
449
|
+
if (s.mode === "stream") effects.push({ type: "keys" });
|
|
450
|
+
else s.help = !s.help;
|
|
451
|
+
return done();
|
|
452
|
+
}
|
|
453
|
+
if (key === "\x1b") { s.detail = undefined; s.requestDetail = undefined; s.help = false; s.optionChoice = undefined; return done(); }
|
|
170
454
|
if (s.mode === "panels" && ["j", "k", "\x1b[A", "\x1b[B"].includes(key)) {
|
|
171
|
-
if (s.detail) { s.detailOffset = Math.max(0, s.detailOffset + (["j", "\x1b[B"].includes(key) ? 1 : -1)); return done(); }
|
|
172
|
-
s.selection = Math.max(0, Math.min(panelRows(s).length - 1, s
|
|
173
|
-
if (s.panel === 2) s.
|
|
455
|
+
if (s.detail !== undefined || s.requestDetail) { s.detailOffset = Math.max(0, s.detailOffset + (["j", "\x1b[B"].includes(key) ? 1 : -1)); return done(); }
|
|
456
|
+
s.selection = Math.max(0, Math.min(panelRows(s).length - 1, selection(s) + (["j", "\x1b[B"].includes(key) ? 1 : -1)));
|
|
457
|
+
if (s.panel === 2) s.approvalId = s.approvals[s.selection]?.id;
|
|
174
458
|
return done();
|
|
175
459
|
}
|
|
176
460
|
if (key === "[" || key === "]") {
|
|
177
|
-
|
|
461
|
+
const i = s.approvals.findIndex(a => a.id === s.approvalId), n = s.approvals.length;
|
|
462
|
+
if (n) s.approvalId = s.approvals[i < 0 ? (key === "]" ? 0 : n - 1) : (i + (key === "[" ? -1 : 1) + n) % n]!.id;
|
|
463
|
+
return done();
|
|
178
464
|
}
|
|
179
|
-
const approval = s
|
|
465
|
+
const approval = selectedApproval(s);
|
|
180
466
|
if (s.optionChoice) {
|
|
181
|
-
const a = s.approvals.find(a => a.id === s.optionChoice);
|
|
182
|
-
const option =
|
|
467
|
+
const a = s.approvals.find(a => a.id === s.optionChoice);
|
|
468
|
+
const option = a && allowOptions(a)[Number(key) - 1];
|
|
183
469
|
if (/^[1-9]$/.test(key) && a && option) { s.confirm = { type: "permission", id: a.id, peer: a.peer, option: option.optionId }; s.optionChoice = undefined; }
|
|
184
470
|
return done();
|
|
185
471
|
}
|
|
186
|
-
|
|
187
|
-
|
|
472
|
+
const requestShown = s.mode === "stream" || (s.panel === 2 && s.detail === undefined);
|
|
473
|
+
if (!approval && s.approvals.length && requestShown && ["a", "d", "v"].includes(key)) {
|
|
474
|
+
notify(s, "no request selected; [ ] selects one", now, "attention"); return done();
|
|
475
|
+
}
|
|
476
|
+
if (approval && requestShown) {
|
|
477
|
+
if (key === "d") { effects.push({ type: "permit", id: approval.id }); answered(approval.id); return done(); }
|
|
188
478
|
if (key === "a") {
|
|
189
|
-
const options = approval
|
|
479
|
+
const options = allowOptions(approval);
|
|
190
480
|
if (options.length === 1) s.confirm = { type: "permission", id: approval.id, peer: approval.peer, option: options[0]!.optionId };
|
|
191
481
|
else if (options.length) s.optionChoice = approval.id;
|
|
192
482
|
return done();
|
|
193
483
|
}
|
|
194
|
-
if (key === "v") {
|
|
484
|
+
if (key === "v" && s.mode === "stream") { effects.push({ type: "print", text: permissionText(approval), kind: "permission", tone: "attention" }); return done(); }
|
|
485
|
+
if (key === "v") { s.requestDetail = approval.id; s.detailOffset = 0; return done(); }
|
|
195
486
|
}
|
|
196
|
-
const row = panelRows(s)[s
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
487
|
+
const row = panelRows(s)[selection(s)];
|
|
488
|
+
// With a detail open the keys act on the item it shows (its id), never on what a refresh moved under the selection;
|
|
489
|
+
// a detail that is only a message names no item.
|
|
490
|
+
const item = s.detail === undefined ? row?.id : typeof s.detail === "object" ? s.detail.id : undefined;
|
|
491
|
+
if (s.mode === "panels" && row && s.detail === undefined && (key === "\r" || key === "\n")) {
|
|
492
|
+
if (s.panel === 3 || s.panel === 4) effects.push({ type: "show", panel: s.panel, id: String(row.id) });
|
|
493
|
+
else if (s.panel === 2) { s.requestDetail = row.id; s.detailOffset = 0; } // the row is the selected request
|
|
494
|
+
else { s.detail = s.panel === 5 ? String(row.text) : { ...row }; s.detailOffset = 0; }
|
|
495
|
+
return done();
|
|
496
|
+
}
|
|
497
|
+
if (s.mode === "panels" && item !== undefined && item !== null) {
|
|
498
|
+
if (s.panel === 1 && key === "m") {
|
|
499
|
+
const peer = String(item);
|
|
500
|
+
const info = s.peers[peer];
|
|
501
|
+
if (!info || info.state === "offline") notify(s, `${peer} is not attached`, now);
|
|
502
|
+
else if (permissionModeRefusal(peer, info.permissionMode)) notify(s, permissionModeRefusal(peer, info.permissionMode)!, now);
|
|
503
|
+
else s.modeChoice = peer;
|
|
201
504
|
return done();
|
|
202
505
|
}
|
|
203
|
-
const prefill = s.panel === 1 && key === "p" ? `pause ${
|
|
204
|
-
s.panel === 3 && key === "a" ? `task assign ${
|
|
205
|
-
s.panel === 4 && key === "r" ? `queue resolve ${
|
|
506
|
+
const prefill = s.panel === 1 && key === "p" ? `pause ${item}` : s.panel === 1 && key === "r" ? `resume ${item}` :
|
|
507
|
+
s.panel === 3 && key === "a" ? `task assign ${item} ` : s.panel === 3 && key === "r" ? `review ${item} ` :
|
|
508
|
+
s.panel === 4 && key === "r" ? `queue resolve ${item} --action retry --reason ` : undefined;
|
|
206
509
|
if (prefill) { s.input = prefill; s.editing = true; return done(); }
|
|
207
510
|
}
|
|
208
511
|
if (s.mode === "panels" && s.panel === 5 && (key === "f" || key === "g")) {
|
|
@@ -218,62 +521,180 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
218
521
|
export function renderConsole(s: ConsoleState, columns: number, rows = 24, now = Date.now()): string[] {
|
|
219
522
|
return renderConsoleLines(s, columns, rows, now).map(line => paint(line, false));
|
|
220
523
|
}
|
|
524
|
+
const NOTICE_MS = 10_000;
|
|
525
|
+
const TABLES: Record<number, string[]> = {
|
|
526
|
+
1: ["PEER", "STATE", "LINK", "Q", "!", "REVIEW", "PAUSE", "QUOTA", "MODEL"],
|
|
527
|
+
2: ["ID", "PEER", "LEFT", "TITLE"],
|
|
528
|
+
3: ["ID", "STATE", "OWNER", "REVIEWER", "CLASS", "AGE", "TITLE", "STAGE"],
|
|
529
|
+
4: ["ID", "PEER", "STATE", "REV", "AGE"],
|
|
530
|
+
};
|
|
531
|
+
const showPermissionModes = (s: ConsoleState) => Object.values(s.peers).some(peer => peer.permissionMode && peer.permissionMode !== "ask");
|
|
532
|
+
const count = (n: unknown) => typeof n === "number" && n ? String(n) : "-";
|
|
533
|
+
/** One span per column of a panel row; zero counters and unknown values read `-`. */
|
|
534
|
+
function cells(s: ConsoleState, row: any, selected: boolean, now: number, stages?: Map<number, ProgressStage>): Span[] {
|
|
535
|
+
const id = (text: unknown, tone: Tone | undefined) => span(text, tone && (selected ? "strong" : tone));
|
|
536
|
+
if (s.panel === 1) {
|
|
537
|
+
const b = s.budget[row.id];
|
|
538
|
+
const paused = b?.paused ? `budget ${relative(b.paused.resetsAt, now)}` : row.paused && typeof row.paused === "object"
|
|
539
|
+
? `${row.paused.by ?? "paused"}${typeof row.paused.at === "number" ? ` ${duration(now - row.paused.at)}` : ""}`
|
|
540
|
+
: row.paused === "manual" ? "user" : row.paused ? String(row.paused).split(":")[0] : "-";
|
|
541
|
+
return [id(row.id, row.state === "offline" ? undefined : "info"), span(row.state, stateTone(row.state)),
|
|
542
|
+
...(showPermissionModes(s) ? [span(row.permissionMode && row.permissionMode !== "ask" ? row.permissionMode : "-", row.permissionMode === "never-ask" ? "attention" : undefined)] : []),
|
|
543
|
+
span(row.toolsOnly ? "tools-only" : row.attached === false ? "detached" : "attached", row.toolsOnly ? "attention" : undefined),
|
|
544
|
+
span(count(row.queued)), span(count(row.queuedImportant), row.queuedImportant ? "attention" : undefined),
|
|
545
|
+
span(count(row.needsReview), row.needsReview ? "failure" : undefined), span(paused, paused === "-" ? undefined : "attention"),
|
|
546
|
+
span(b?.windows?.length ? b.windows.map((w: any) => `${w.id} ${Math.round(w.used * 100)}%${typeof w.resetsAt === "number" ? ` ${relative(w.resetsAt, now)}` : ""}`).join(", ") : "-"),
|
|
547
|
+
span(row.servedBy ?? row.requestedModel ?? "-")];
|
|
548
|
+
}
|
|
549
|
+
if (s.panel === 2) return [id(row.id, "attention"), span(row.peer, "attention"), span(duration(row.expiresAt - now), "muted"), span(row.title)];
|
|
550
|
+
if (s.panel === 3) {
|
|
551
|
+
const last = row.history?.at(-1);
|
|
552
|
+
const at = last?.at ?? row.updated ?? row.created;
|
|
553
|
+
const failed = last?.event === "check failed" || last?.event === "failed";
|
|
554
|
+
const stage = stages?.get(row.id);
|
|
555
|
+
const meter = stage ? "[" + "#".repeat(stage.stage - (stage.changes ? 1 : 0)) + (stage.changes ? "!" : "") + "-".repeat(4 - stage.stage) + "]" : "[----]";
|
|
556
|
+
return [id(`#${row.id}`, "info"), span(`${row.state}${failed && row.state !== last.event ? ` ${last.event}` : ""}${row.ready ? " ready" : ""}${stage?.waiting ? " waiting" : ""}`, failed ? "failure" : row.ready || stage?.waiting ? "attention" : stateTone(row.state)),
|
|
557
|
+
span(row.owner ?? "-"), span(row.reviewer ?? "-"), span(row.class ?? "-"), span(typeof at === "number" ? duration(now - at) : "-", "muted"), span(row.title), span(meter, stage?.changes ? "failure" : stateTone(row.state))];
|
|
558
|
+
}
|
|
559
|
+
if (s.panel === 4) return [id(row.id, "info"), span(row.peer), span(row.state, stateTone(row.state)), span(row.revision ?? "-"), span(typeof row.createdAt === "number" ? duration(now - row.createdAt) : "-", "muted")];
|
|
560
|
+
const [header, ...body] = String(row.text).split("\n");
|
|
561
|
+
return [span(header, row.tone), ...(body.length ? [span("\n" + body.join("\n"))] : [])];
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* A header row, then the rows, each column starting at the same place on every row: two spaces apart, as wide as its
|
|
565
|
+
* widest cell or header (Bun.stringWidth, at most a third of the width, wider cells cut with the marker); the last column
|
|
566
|
+
* takes the rest.
|
|
567
|
+
*/
|
|
568
|
+
function table(head: string[], rows: Span[][], columns: number): Span[][] {
|
|
569
|
+
const cap = Math.max(24, Math.floor(columns / 3));
|
|
570
|
+
const width = (cell: Span | undefined) => Bun.stringWidth(flat(cell?.text));
|
|
571
|
+
const widths = head.map((h, i) => Math.min(cap, rows.reduce((max, row) => Math.max(max, width(row[i])), Bun.stringWidth(h))));
|
|
572
|
+
const flexible = head.includes("STAGE") ? head.indexOf("TITLE") : head.length - 1;
|
|
573
|
+
if (head.includes("STAGE")) {
|
|
574
|
+
// Keep the original columns adjacent; stage meters occupy a fixed right-edge column.
|
|
575
|
+
const room = () => columns - 2 - widths.reduce((sum, value, index) => sum + (index === flexible ? 0 : value), 0) - 2 * (head.length - 1);
|
|
576
|
+
for (const key of ["CLASS", "OWNER", "REVIEWER", "AGE", "STATE"]) {
|
|
577
|
+
const metadata = head.indexOf(key);
|
|
578
|
+
while (room() < 7 && widths[metadata]! > head[metadata]!.length) widths[metadata] = widths[metadata]! - 1;
|
|
579
|
+
}
|
|
580
|
+
widths[flexible] = Math.max(0, room());
|
|
581
|
+
}
|
|
582
|
+
return [head.map(h => span(h, "info")), ...rows].map(row => row.map((cell, i) => {
|
|
583
|
+
if (i === head.length - 1 && flexible === i) return cell;
|
|
584
|
+
const text = fit(cell.text, widths[i]!);
|
|
585
|
+
return { ...cell, text: text + " ".repeat(Math.max(0, widths[i]! - Bun.stringWidth(text)) + (i === head.length - 1 ? 0 : 2)) };
|
|
586
|
+
}));
|
|
587
|
+
}
|
|
588
|
+
/** The footer's key hint: only keys that act in this mode, panel and state. */
|
|
589
|
+
function hint(s: ConsoleState, columns: number): string {
|
|
590
|
+
const pending = selectedApproval(s) ? ["a allow", "d deny"] : [];
|
|
591
|
+
if (s.mode === "stream") return [...pending, ...(pending.length ? ["v view"] : []), ...(s.approvals.length > (pending.length ? 1 : 0) ? ["[ ] select"] : []), "Tab panels", ": command", "? keys", "q quit"].join(" ");
|
|
592
|
+
if (s.help) return "? or Esc close q quit";
|
|
593
|
+
const approvals = s.panel === 2 ? pending : [];
|
|
594
|
+
if (s.detail !== undefined || s.requestDetail) return [...approvals, "j/k scroll", "Esc back", "? keys", "q quit"].join(" ");
|
|
595
|
+
const rows = panelRows(s); const row = rows[selection(s)];
|
|
596
|
+
const context = !row ? [] : s.panel === 1 ? ["p pause", "r resume", "m permission mode"] : s.panel === 3 ? ["a assign", "r review"] : s.panel === 4 ? ["r resolve"] : [];
|
|
597
|
+
const text = [...approvals, ...context, ...(s.panel === 5 ? ["f peer", "g kind"] : []), ...(rows.length ? ["j/k move"] : []), ...(row ? ["Enter view"] : []), "? keys", ": command", "Tab stream", "q quit"].join(" ");
|
|
598
|
+
return s.panel === 1 && Bun.stringWidth(text) > columns ? text.replace("m permission mode", "m mode").replace("j/k move", "j/k").replace(": command", ": cmd") : text;
|
|
599
|
+
}
|
|
600
|
+
/** The project, then the tabs: the active one in brackets, pending approvals and held deliveries counted. */
|
|
601
|
+
function header(s: ConsoleState, columns: number): Span[] {
|
|
602
|
+
const counts = [0, s.approvals.length, 0, s.queue.filter(q => q.state === "needs_review").length, 0];
|
|
603
|
+
const tabs: Span[] = [];
|
|
604
|
+
for (const [i, name] of ["Peers", "Approvals", "Tasks", "Queue", "Events"].entries()) {
|
|
605
|
+
const active = i + 1 === s.panel; const tone = active ? "strong" : "info";
|
|
606
|
+
tabs.push(span(active ? " [" : " ", tone), span(`${i + 1} ${name}`, tone));
|
|
607
|
+
if (counts[i]) tabs.push(span(` ${counts[i]}`, i === 1 ? "attention" : "failure"));
|
|
608
|
+
tabs.push(span(active ? "]" : " ", tone));
|
|
609
|
+
}
|
|
610
|
+
return fitLine([span(fit(s.project ?? "agent-hub", Math.max(0, columns - Bun.stringWidth(tabs.map(t => t.text).join("")))), "info"), ...tabs], columns);
|
|
611
|
+
}
|
|
612
|
+
/** The label column is the hub's own: a cut title or option list is marked `(more)` there; Enter shows it whole. */
|
|
613
|
+
const more = (line: Span[], text?: string): Span[] => [span("(more)".padEnd(Bun.stringWidth(line[0]!.text)), "attention"), ...(text === undefined ? line.slice(1) : [span(text)])];
|
|
221
614
|
export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24, now = Date.now()): Span[][] {
|
|
222
|
-
const permission = s
|
|
223
|
-
let prompt = s.editing ? `: ${s.input}` :
|
|
224
|
-
if (s.confirm?.type === "permission") prompt = `allow ${s.confirm.option} for ${s.confirm.peer} (request ${s.confirm.id})? y/N`;
|
|
615
|
+
const permission = selectedApproval(s);
|
|
616
|
+
let prompt = s.editing ? `: ${s.input}` : hint(s, columns);
|
|
617
|
+
if (s.confirm?.type === "permission") prompt = `allow ${quoted(s.confirm.option)} for ${s.confirm.peer} (request ${s.confirm.id})? y/N`;
|
|
225
618
|
if (s.confirm?.type === "command") prompt = `${s.confirm.args.join(" ")}? y/N`;
|
|
226
|
-
if (s.
|
|
619
|
+
if (s.modeChoice) prompt = `permission ${s.modeChoice}: 1 ask 2 ask-when-needed 3 never-ask Esc cancel`;
|
|
620
|
+
const choice = s.optionChoice ? s.approvals.find(a => a.id === s.optionChoice) : undefined;
|
|
621
|
+
if (choice) prompt = `allow request ${choice.id} (${choice.peer}) with: ${allowOptions(choice).map((o, i) => `${i + 1} ${quoted(o.name)}`).join(" ")} Esc cancel`;
|
|
227
622
|
const summary: Span[] = [];
|
|
228
623
|
for (const [id, p] of Object.entries(s.peers)) {
|
|
229
624
|
if (summary.length) summary.push(span(" "));
|
|
230
625
|
summary.push(span(id, p.state === "offline" ? undefined : "info"), span(":"), span(p.state, stateTone(p.state)), span(` q${p.queued ?? 0}`));
|
|
626
|
+
if (p.permissionMode && p.permissionMode !== "ask") summary.push(span(` ${p.permissionMode}`, "attention"));
|
|
231
627
|
if (p.needsReview) summary.push(span(` review${p.needsReview}`, "failure"));
|
|
232
628
|
if (p.paused) summary.push(span(" paused", "attention"));
|
|
629
|
+
if (p.toolsOnly) summary.push(span(" tools-only: ahub claude", "attention"));
|
|
233
630
|
}
|
|
234
631
|
const quotas = Object.entries(s.budget).map(([id, b]) => `${id}:${b.windows?.map((w: any) => `${w.id} ${Math.round(w.used * 100)}%`).join("/") ?? "?"}`).join(" ");
|
|
235
|
-
if (quotas) summary.push(span(
|
|
236
|
-
const approvals = [span(
|
|
237
|
-
if (permission) approvals.push(span(` | ${permission.peer} ${permission.id}`, "attention"), span(` ${
|
|
238
|
-
if (s.
|
|
239
|
-
const
|
|
240
|
-
if (s.
|
|
241
|
-
const
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
632
|
+
if (quotas) summary.push(span(`${summary.length ? " | " : ""}${quotas}`));
|
|
633
|
+
const approvals = [span(plural(s.approvals.length, "approval"), s.approvals.length ? "attention" : undefined)];
|
|
634
|
+
if (permission) approvals.push(span(` | ${permission.peer} ${permission.id}`, "attention"), span(` ${duration(permission.expiresAt - now)} left`, "muted"));
|
|
635
|
+
else if (s.approvals.length) approvals.push(span(" | none selected", "muted"));
|
|
636
|
+
const noticeShown = !!s.notice && now - (s.noticeAt ?? now) < NOTICE_MS;
|
|
637
|
+
if (noticeShown) approvals.push(span(" | "), span(s.notice, s.noticeTone ?? "failure"));
|
|
638
|
+
const neverPeer = s.confirm?.type === "permission_default" ? s.confirm.peer : s.confirm?.type === "command" && s.confirm.args[0] === "permission" && s.confirm.args[2] === "never-ask" ? s.confirm.args[1] : undefined;
|
|
639
|
+
const defaultSource = s.confirm?.type === "permission_default" ? s.confirm.source : undefined;
|
|
640
|
+
if (s.confirm?.type === "permission_default") prompt = `enable never-ask default for ${s.confirm.peer}? y/n Esc defer`;
|
|
641
|
+
const footer = [fitLine(neverPeer ? [span(permissionBoundary(neverPeer), "attention")] : summary, columns),
|
|
642
|
+
fitLine(defaultSource !== undefined ? [span(`source ${quoted(defaultSource.split(/[\\/]/).at(-1))}; stays ask until y`, "attention")] : approvals, columns),
|
|
643
|
+
fitLine([span(prompt, s.confirm || s.optionChoice ? "attention" : undefined)], columns)];
|
|
644
|
+
const rule = [span("-".repeat(Math.max(0, columns)), "muted")];
|
|
645
|
+
if (s.mode === "stream") {
|
|
646
|
+
const counts = s.taskCounts;
|
|
647
|
+
const total = counts ? Object.values(counts).reduce((sum, value) => sum + value, 0) : 0;
|
|
648
|
+
const taskCount = span(counts ? `tasks ${counts.approved ?? 0}/${total} approved` : "tasks loading...", counts ? "success" : "attention");
|
|
649
|
+
if (!noticeShown && defaultSource === undefined && (counts || !s.approvals.length) && Bun.stringWidth(taskCount.text + " | " + approvals.map(s => s.text).join("")) <= columns) {
|
|
650
|
+
footer[1] = fitLine([taskCount, span(" | "), ...approvals], columns);
|
|
651
|
+
}
|
|
652
|
+
return [rule, ...footer];
|
|
653
|
+
}
|
|
654
|
+
const progress = taskProgress(s.tasks);
|
|
655
|
+
const height = rows - 6; // header, rule, body, rule, three footer lines
|
|
656
|
+
const lines: Span[][] = [];
|
|
657
|
+
if (s.confirm?.type === "permission_default") lines.push(...fieldLines([["default", `${s.confirm.peer} never-ask`], ["source", s.confirm.source], ["bounds", permissionBoundary(s.confirm.peer)], ["decision", "y enables; n cancels for this hub; Esc defers; until then ask"]], columns, now).flat());
|
|
658
|
+
else if (s.help) lines.push(...keyTable(columns));
|
|
659
|
+
else if (s.detail !== undefined || (s.requestDetail && permission)) {
|
|
660
|
+
const detail = s.detail !== undefined ? detailLines(s.detail, columns, now) : fieldLines(approvalFields(permission!), columns, now).flat();
|
|
661
|
+
lines.push(...detail.slice(Math.min(s.detailOffset, Math.max(0, detail.length - height))));
|
|
662
|
+
}
|
|
249
663
|
else {
|
|
250
|
-
const data = panelRows(s); const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
664
|
+
const data = panelRows(s); const head = s.panel === 1 && showPermissionModes(s) ? ["PEER", "STATE", "MODE", ...TABLES[1]!.slice(2)] : TABLES[s.panel]; const at = selection(s);
|
|
665
|
+
// Below the Approvals table: the selected request's title, its allow options and deny, one line each. Deny always
|
|
666
|
+
// shows; options that do not fit are counted, rows give way to the rest, and the title gets what is left.
|
|
667
|
+
const [title = [], allow = [], deny = []] = s.panel === 2 && permission ? fieldLines(approvalFields(permission).slice(3), columns, now) : [];
|
|
668
|
+
const fits = Math.max(1, height - 5); // table header, one row, rule, one title line, deny
|
|
669
|
+
if (allow.length > fits) allow.splice(fits - 1, Infinity, more(allow[0]!, `${plural(allow.length - fits + 1, "more option")}: Enter shows them all`));
|
|
670
|
+
const tail = [...allow, ...deny];
|
|
671
|
+
const capacity = Math.max(1, s.panel === 2 ? Math.min(Math.floor(height / 2) - 1, height - 3 - tail.length) : height - (s.panel === 3 ? 2 : 1)); const start = Math.max(0, at - capacity + 1);
|
|
672
|
+
const stages = new Map(progress.stages.map(stage => [stage.id, stage]));
|
|
673
|
+
const rowsOf = head ? table(head, data.map((row, i) => cells(s, row, i === at, now, stages)), columns) : undefined;
|
|
674
|
+
if (s.panel === 3) {
|
|
675
|
+
const done = progress.total ? Math.floor(progress.counts.approved * 20 / progress.total) : 0;
|
|
676
|
+
const main = s.tasksKnown === false && !progress.total ? "tasks loading..." : `${progress.counts.approved}/${progress.total} approved [${"#".repeat(done)}${".".repeat(20 - done)}] ${progress.total ? Math.floor(progress.counts.approved * 100 / progress.total) : 0}%`;
|
|
677
|
+
const known = s.tasksKnown !== false || progress.total > 0;
|
|
678
|
+
const parts = [span(main, known ? "success" : "attention")];
|
|
679
|
+
for (const [label, value, tone] of [["changes", progress.counts.changes_requested, "failure"], ["review", progress.counts.in_review, "attention"], ["waiting", progress.counts.waiting, "attention"], ["in progress", progress.counts.in_progress, undefined], ["proposed", progress.counts.proposed, undefined], ...(progress.counts.unknown ? [["unknown", progress.counts.unknown, "attention"] as const] : [])] as const) {
|
|
680
|
+
const extra = ` ${label} ${value}`;
|
|
681
|
+
if (!known || Bun.stringWidth(parts.map(p => p.text).join("") + extra) > columns) break;
|
|
682
|
+
parts.push(span(extra, tone));
|
|
683
|
+
}
|
|
684
|
+
lines.push(fitLine(parts, columns));
|
|
685
|
+
}
|
|
686
|
+
lines.push(fitLine([span(" "), ...(rowsOf?.[0] ?? [span(`peer ${s.peerFilter ?? "all"} kind ${s.kindFilter ?? "all"}`, "info")])], columns));
|
|
687
|
+
for (let i = start; i < Math.min(data.length, start + capacity); i++) {
|
|
688
|
+
const selected = i === at;
|
|
689
|
+
lines.push(fitLine([span(selected ? "> " : " ", selected ? "strong" : undefined), ...(rowsOf?.[i + 1] ?? cells(s, data[i], selected, now))], columns));
|
|
690
|
+
}
|
|
691
|
+
if (!data.length) lines.push([span(" (empty)")]);
|
|
692
|
+
if (title.length) {
|
|
693
|
+
const room = Math.max(1, height - lines.length - 1 - tail.length);
|
|
694
|
+
if (title.length > room) { title.length = room; title[room - 1] = more(title[room - 1]!); }
|
|
695
|
+
lines.push(rule, ...title, ...tail);
|
|
273
696
|
}
|
|
274
|
-
if (!data.length) lines.push([span("(empty)")]);
|
|
275
|
-
if (s.panel === 2 && permission) lines.push(...wrap(permission.title, columns).slice(0, Math.max(0, rows - 3 - lines.length)).map(text => [span(text)]));
|
|
276
697
|
}
|
|
277
|
-
const body = lines.slice(0,
|
|
278
|
-
return [...body, ...footer];
|
|
698
|
+
const body = lines.slice(0, height); while (body.length < height) body.push([]);
|
|
699
|
+
return [header(s, columns), rule, ...body, rule, ...footer];
|
|
279
700
|
}
|