@north-light/crouter 0.3.158 → 0.3.160
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/dto/nodes.d.ts +3 -0
- package/dist/builtin-memory/internal/INDEX.md +1 -0
- package/dist/builtin-memory/internal/storage-tiers.md +2 -0
- package/dist/builtin-memory/internal/workflow-codification.md +82 -0
- package/dist/clients/attach/__tests__/bash-call-width.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/bash-call-width.test.js +38 -0
- package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +20 -0
- package/dist/clients/attach/__tests__/crtr-output-coverage.test.js +3 -1
- package/dist/clients/attach/chrome/bash-jobs.d.ts +5 -8
- package/dist/clients/attach/chrome/bash-jobs.js +20 -32
- package/dist/clients/attach/chrome/canvas-panels.js +10 -3
- package/dist/clients/attach/chrome/roster.d.ts +10 -4
- package/dist/clients/attach/chrome/roster.js +96 -36
- package/dist/clients/attach/input/controller.d.ts +3 -0
- package/dist/clients/attach/input/controller.js +1 -0
- package/dist/clients/attach/render/assistant-message.d.ts +13 -0
- package/dist/clients/attach/render/assistant-message.js +55 -0
- package/dist/clients/attach/render/chat-view.d.ts +29 -0
- package/dist/clients/attach/render/chat-view.js +233 -12
- package/dist/clients/attach/render/context-message.js +1 -1
- package/dist/clients/attach/render/edit-diff.js +20 -1
- package/dist/clients/attach/render/tool-calls.d.ts +32 -0
- package/dist/clients/attach/render/tool-calls.js +251 -0
- package/dist/clients/attach/session/input-wiring.d.ts +3 -0
- package/dist/clients/attach/session/input-wiring.js +3 -0
- package/dist/clients/attach/slash/dispatch.d.ts +4 -0
- package/dist/clients/attach/slash/dispatch.js +8 -0
- package/dist/clients/attach/viewer.js +568 -566
- package/dist/commands/cron.js +2 -2
- package/dist/commands/node.js +141 -3
- package/dist/commands/search.js +1 -1
- package/dist/commands/surface-inspect.js +4 -3
- package/dist/commands/sys/config.js +10 -2
- package/dist/commands/sys/doctor.js +22 -1
- package/dist/commands/sys/setup-core.d.ts +5 -1
- package/dist/commands/sys/setup-core.js +16 -2
- package/dist/commands/sys/setup-wizard.d.ts +11 -0
- package/dist/commands/sys/setup-wizard.js +111 -6
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +5 -3
- package/dist/core/__tests__/tmux-surface.test.js +5 -2
- package/dist/core/bash-jobs.d.ts +11 -0
- package/dist/core/bash-jobs.js +41 -1
- package/dist/core/canvas/canvas.js +1 -1
- package/dist/core/canvas/labels.d.ts +1 -0
- package/dist/core/canvas/labels.js +3 -1
- package/dist/core/canvas/nav-render.d.ts +6 -0
- package/dist/core/canvas/nav-render.js +8 -0
- package/dist/core/canvas/types.d.ts +6 -0
- package/dist/core/config.js +3 -2
- package/dist/core/feed/inbox.d.ts +3 -3
- package/dist/core/feed/inbox.js +10 -6
- package/dist/core/inspector/core.d.ts +17 -2
- package/dist/core/inspector/core.js +172 -23
- package/dist/core/inspector/model.d.ts +30 -1
- package/dist/core/inspector/model.js +39 -0
- package/dist/core/inspector/text.js +14 -1
- package/dist/core/inspector/tui.js +71 -3
- package/dist/core/keybindings/__tests__/resolve.test.js +2 -2
- package/dist/core/keybindings/catalog.d.ts +3 -3
- package/dist/core/keybindings/catalog.js +5 -2
- package/dist/core/preview-registry.d.ts +5 -0
- package/dist/core/preview-registry.js +10 -0
- package/dist/core/runtime/canvas-extensions.d.ts +4 -0
- package/dist/core/runtime/canvas-extensions.js +4 -0
- package/dist/core/runtime/naming-persist.d.ts +4 -3
- package/dist/core/runtime/naming-persist.js +10 -9
- package/dist/core/runtime/naming.d.ts +28 -8
- package/dist/core/runtime/naming.js +168 -32
- package/dist/core/runtime/nerd-font.d.ts +28 -0
- package/dist/core/runtime/nerd-font.js +127 -0
- package/dist/core/runtime/tmux.js +16 -0
- package/dist/core/tui/host.js +21 -6
- package/dist/daemon/api/map.js +2 -0
- package/dist/pi-extensions/canvas-bash-valve.js +5 -0
- package/dist/pi-extensions/naming-tool.d.ts +24 -0
- package/dist/pi-extensions/naming-tool.js +67 -0
- package/dist/types.d.ts +5 -0
- package/dist/types.js +1 -0
- package/dist/web-client/assets/index-B76ZKfT_.js +79 -0
- package/dist/web-client/assets/{index-CpEl9LTS.css → index-CqLKj8Xu.css} +1 -1
- package/dist/web-client/index.html +2 -2
- package/dist/web-client/sw.js +1 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/web-client/assets/index-CsuwzlcQ.js +0 -79
|
@@ -205,7 +205,7 @@ test('tmux installer remaps owned pairs and preserves occupied user bindings', (
|
|
|
205
205
|
const currentNode = defaultMenu.indexOf('-#[bold]Current node');
|
|
206
206
|
const lifecycle = defaultMenu.indexOf('-#[bold]Lifecycle');
|
|
207
207
|
const system = defaultMenu.indexOf('-#[bold]System');
|
|
208
|
-
for (const item of ['resume node', 'graph view', 'focus manager', 'show ›']) {
|
|
208
|
+
for (const item of ['resume node', 'graph view', 'focus manager', 'show ›', 'view settings ›']) {
|
|
209
209
|
assert.ok(defaultMenu.indexOf(item) > explore && defaultMenu.indexOf(item) < currentNode, `${item} belongs in Explore`);
|
|
210
210
|
}
|
|
211
211
|
assert.match(defaultMenu, /"show ›" s .*display-menu.*metadata.*context \+ reports/);
|
|
@@ -213,7 +213,10 @@ test('tmux installer remaps owned pairs and preserves occupied user bindings', (
|
|
|
213
213
|
assert.ok(defaultMenu.indexOf(item) > currentNode && defaultMenu.indexOf(item) < lifecycle, `${item} belongs to the current node`);
|
|
214
214
|
}
|
|
215
215
|
assert.ok(defaultMenu.indexOf('fork into idle root') > lifecycle && defaultMenu.indexOf('fork into idle root') < system, 'fork belongs in Lifecycle');
|
|
216
|
-
assert.
|
|
216
|
+
assert.match(defaultMenu, /"view settings ›" w .*display-menu.*fold finished tool calls/);
|
|
217
|
+
// Matched with its selector, so the Explore group's "view settings ›" item
|
|
218
|
+
// (selector `w`) cannot satisfy this assertion by substring.
|
|
219
|
+
assert.ok(defaultMenu.indexOf('settings p ') > system, 'settings belongs in System');
|
|
217
220
|
assert.ok(defaultMenu.indexOf('create ›') > system, 'create belongs in System');
|
|
218
221
|
assert.match(tmuxBinding(socket, 'root', 'M-v') ?? '', /switch-client/);
|
|
219
222
|
// The inbox toggle installs at its default key (alt+i → M-i) in the same sweep.
|
package/dist/core/bash-jobs.d.ts
CHANGED
|
@@ -5,9 +5,20 @@ export interface BashJobPaths {
|
|
|
5
5
|
jobLog: string;
|
|
6
6
|
jobExit: string;
|
|
7
7
|
jobBg: string;
|
|
8
|
+
/** Process-group id of the job's detached supervisor, written at spawn. It is
|
|
9
|
+
* what makes a job stoppable from outside the agent that started it (the
|
|
10
|
+
* Inspector's cancel) — without it on disk, only the agent's own handoff
|
|
11
|
+
* notice knows the group to signal. */
|
|
12
|
+
jobPgid: string;
|
|
8
13
|
}
|
|
9
14
|
export declare function bashJobsDir(contextDir: string): string;
|
|
10
15
|
export declare function bashJobPaths(contextDir: string, jobId: string): BashJobPaths;
|
|
16
|
+
/** The job's supervisor process group, or undefined for a job started before
|
|
17
|
+
* pgid was persisted (or a partially-written dir). */
|
|
18
|
+
export declare function readJobPgid(contextDir: string, jobId: string): number | undefined;
|
|
19
|
+
/** Last `count` lines of a job log, cheaply: read at most the trailing 64 KiB
|
|
20
|
+
* rather than the whole file, which for a long-running job can be enormous. */
|
|
21
|
+
export declare function tailJobLog(logPath: string, count: number): string[];
|
|
11
22
|
/** Mint a fresh job id — `${Date.now()}-${hex}`. The sole minting site; the
|
|
12
23
|
* valve calls this instead of building the literal inline so the epoch-ms
|
|
13
24
|
* prefix is a real contract shared with its parser (jobStartedAtMs), not an
|
package/dist/core/bash-jobs.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the prefix menu requests backgrounding by creating job.bg. Keeping that signal
|
|
4
4
|
// on disk means the command can outlive its pi process and still report its exit.
|
|
5
5
|
import { randomBytes } from 'node:crypto';
|
|
6
|
-
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
export function bashJobsDir(contextDir) {
|
|
9
9
|
return join(contextDir, 'jobs');
|
|
@@ -17,8 +17,48 @@ export function bashJobPaths(contextDir, jobId) {
|
|
|
17
17
|
jobLog: join(dir, 'job.log'),
|
|
18
18
|
jobExit: join(dir, 'job.exit'),
|
|
19
19
|
jobBg: join(dir, 'job.bg'),
|
|
20
|
+
jobPgid: join(dir, 'job.pgid'),
|
|
20
21
|
};
|
|
21
22
|
}
|
|
23
|
+
/** The job's supervisor process group, or undefined for a job started before
|
|
24
|
+
* pgid was persisted (or a partially-written dir). */
|
|
25
|
+
export function readJobPgid(contextDir, jobId) {
|
|
26
|
+
try {
|
|
27
|
+
const n = Number(readFileSync(bashJobPaths(contextDir, jobId).jobPgid, 'utf8').trim());
|
|
28
|
+
return Number.isInteger(n) && n > 1 ? n : undefined;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Last `count` lines of a job log, cheaply: read at most the trailing 64 KiB
|
|
35
|
+
* rather than the whole file, which for a long-running job can be enormous. */
|
|
36
|
+
export function tailJobLog(logPath, count) {
|
|
37
|
+
if (count <= 0)
|
|
38
|
+
return [];
|
|
39
|
+
const WINDOW = 64 * 1024;
|
|
40
|
+
let fd;
|
|
41
|
+
try {
|
|
42
|
+
const size = statSync(logPath).size;
|
|
43
|
+
const start = Math.max(0, size - WINDOW);
|
|
44
|
+
const buf = Buffer.alloc(Math.min(size, WINDOW));
|
|
45
|
+
fd = openSync(logPath, 'r');
|
|
46
|
+
readSync(fd, buf, 0, buf.length, start);
|
|
47
|
+
const text = buf.toString('utf8');
|
|
48
|
+
// A partial first line (we cut mid-line) is dropped, not shown truncated.
|
|
49
|
+
const lines = (start > 0 ? text.slice(text.indexOf('\n') + 1) : text).split('\n');
|
|
50
|
+
if (lines[lines.length - 1] === '')
|
|
51
|
+
lines.pop();
|
|
52
|
+
return lines.slice(-count);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
if (fd !== undefined)
|
|
59
|
+
closeSync(fd);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
22
62
|
/** Mint a fresh job id — `${Date.now()}-${hex}`. The sole minting site; the
|
|
23
63
|
* valve calls this instead of building the literal inline so the epoch-ms
|
|
24
64
|
* prefix is a real contract shared with its parser (jobStartedAtMs), not an
|
|
@@ -21,7 +21,7 @@ import { ensureHome, ensureNodeDirs, nodeMetaPath, nodeDir, nodesRoot, jobDir, }
|
|
|
21
21
|
/** The identity keys meta.json persists. Listed explicitly so no runtime field
|
|
22
22
|
* can ever leak onto disk even when a fully-hydrated NodeMeta is handed in. */
|
|
23
23
|
const IDENTITY_KEYS = [
|
|
24
|
-
'node_id', 'name', 'description', 'cycles', 'created', 'cwd', 'host_kind', 'profile_id', 'kind', 'mode',
|
|
24
|
+
'node_id', 'name', 'description', 'icon', 'cycles', 'created', 'cwd', 'host_kind', 'profile_id', 'kind', 'mode',
|
|
25
25
|
'lifecycle', 'persona_ack', 'parent', 'spawned_by', 'fork_from', 'human_work_target', 'private_fork_phase',
|
|
26
26
|
'private_fork_recipe', 'passive_default', 'pi_session_id', 'pi_session_file', 'cycle_pending', 'model_override',
|
|
27
27
|
'launch', 'managed_worktree',
|
|
@@ -9,6 +9,7 @@ export interface NamedNode {
|
|
|
9
9
|
name: string;
|
|
10
10
|
kind: string;
|
|
11
11
|
description?: string;
|
|
12
|
+
icon?: string;
|
|
12
13
|
}
|
|
13
14
|
/** A node's human label: its explicit handle plus the pi-generated description
|
|
14
15
|
* of its first task, e.g. `fix-auth refactor-login-flow`. The handle is
|
|
@@ -34,5 +34,7 @@ export function personaLabel(meta) {
|
|
|
34
34
|
* badge. Recomputed from meta on every revive and pushed live when naming
|
|
35
35
|
* generates the first task description. */
|
|
36
36
|
export function editorLabel(meta) {
|
|
37
|
-
|
|
37
|
+
const icon = (meta.icon ?? '').trim();
|
|
38
|
+
const name = fullName(meta);
|
|
39
|
+
return icon !== '' ? `${icon} ${name}` : name;
|
|
38
40
|
}
|
|
@@ -23,6 +23,12 @@ export declare const GRAY = "\u001B[90m";
|
|
|
23
23
|
* `isPidAlive` / `isBusy` reads entirely (a remote node id may collide with a
|
|
24
24
|
* local one) and renders the bare status glyph instead. */
|
|
25
25
|
export declare function nodeGlyph(node: NodeMeta | null, remote?: boolean): string;
|
|
26
|
+
/** Drop every SGR sequence, keeping the visible text. The selection bar uses
|
|
27
|
+
* this: reverse-video INVERTS a coloured cell into a coloured block, so a row
|
|
28
|
+
* built from tinted spans highlights as a patchwork of background swatches
|
|
29
|
+
* instead of one bar. Stripping colour first leaves a single clean bar — the
|
|
30
|
+
* glyphs (●/○/✓/✗/⚙/◷) still carry the signal, exactly as under NO_COLOR. */
|
|
31
|
+
export declare function stripSgr(s: string): string;
|
|
26
32
|
/** Visible width, ignoring ANSI escapes. */
|
|
27
33
|
export declare function visibleWidth(s: string): number;
|
|
28
34
|
/** Truncate to `max` VISIBLE columns: escape sequences are copied through
|
|
@@ -71,6 +71,14 @@ export function nodeGlyph(node, remote = false) {
|
|
|
71
71
|
return `${ESC}${v.color}m${v.glyph}${RESET}`;
|
|
72
72
|
}
|
|
73
73
|
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
74
|
+
/** Drop every SGR sequence, keeping the visible text. The selection bar uses
|
|
75
|
+
* this: reverse-video INVERTS a coloured cell into a coloured block, so a row
|
|
76
|
+
* built from tinted spans highlights as a patchwork of background swatches
|
|
77
|
+
* instead of one bar. Stripping colour first leaves a single clean bar — the
|
|
78
|
+
* glyphs (●/○/✓/✗/⚙/◷) still carry the signal, exactly as under NO_COLOR. */
|
|
79
|
+
export function stripSgr(s) {
|
|
80
|
+
return s.replace(ANSI_RE, '');
|
|
81
|
+
}
|
|
74
82
|
/** Visible width, ignoring ANSI escapes. */
|
|
75
83
|
export function visibleWidth(s) {
|
|
76
84
|
return s.replace(ANSI_RE, '').length;
|
|
@@ -87,6 +87,12 @@ export interface NodeIdentity {
|
|
|
87
87
|
/** A 2-4 word kebab-case handle derived from the node's first prompt (named
|
|
88
88
|
* headlessly by pi; see runtime/naming.ts). Shown in the editor label. */
|
|
89
89
|
description?: string;
|
|
90
|
+
/** A single Nerd Font glyph depicting the node's work, chosen by the same
|
|
91
|
+
* headless namer that produced `description` (see runtime/naming.ts). Kept as
|
|
92
|
+
* its own field rather than baked into the description so each surface decides
|
|
93
|
+
* whether to render it; absent on a node named before icons existed, or when
|
|
94
|
+
* the model returned nothing usable. */
|
|
95
|
+
icon?: string;
|
|
90
96
|
/** How many times this node has been (re)launched — born at 0, bumped on
|
|
91
97
|
* every revive. Exposed to the pi footer as CRTR_CYCLES so a refresh/crash
|
|
92
98
|
* cycle reads at a glance there. */
|
package/dist/core/config.js
CHANGED
|
@@ -359,12 +359,13 @@ function mergeConfig(partial) {
|
|
|
359
359
|
const condensed_history = CONDENSED_HISTORY_MODES.includes(partial.condensed_history)
|
|
360
360
|
? partial.condensed_history
|
|
361
361
|
: defaults.condensed_history;
|
|
362
|
+
const fold_finished_tools = typeof partial.fold_finished_tools === 'boolean' ? partial.fold_finished_tools : defaults.fold_finished_tools;
|
|
362
363
|
const keybindings = normalizeKeybindings(partial.keybindings);
|
|
363
364
|
const modelLadders = mergeModelLadders(partial.modelLadders);
|
|
364
365
|
const kinds = mergeKinds(partial.kinds);
|
|
365
366
|
const remoteCanvas = mergeRemoteCanvas(partial.remoteCanvas);
|
|
366
367
|
const spawnEnv = mergeSpawnEnv(partial.spawnEnv, defaults.spawnEnv?.allow);
|
|
367
|
-
return { schema_version, marketplaces, plugins, configuredClis, auto_update, max_panes_per_window, brokerThresholds, completion_bell, live_cycles, condensed_history, keybindings, modelLadders, kinds, remoteCanvas, spawnEnv };
|
|
368
|
+
return { schema_version, marketplaces, plugins, configuredClis, auto_update, max_panes_per_window, brokerThresholds, completion_bell, live_cycles, condensed_history, fold_finished_tools, keybindings, modelLadders, kinds, remoteCanvas, spawnEnv };
|
|
368
369
|
}
|
|
369
370
|
/** Raw (un-defaulted) partial config for one scope, or null if the scope has
|
|
370
371
|
* no root or no config.json. Used by `readMergedLaunchConfig` to layer
|
|
@@ -497,7 +498,7 @@ export function subKindsAvailableTo(kind) {
|
|
|
497
498
|
* an untouched key is never rewritten. */
|
|
498
499
|
const CONFIG_TOP_LEVEL_KEYS = [
|
|
499
500
|
'schema_version', 'marketplaces', 'plugins', 'configuredClis', 'auto_update',
|
|
500
|
-
'max_panes_per_window', 'brokerThresholds', 'completion_bell', 'live_cycles', 'condensed_history', 'keybindings', 'modelLadders', 'kinds',
|
|
501
|
+
'max_panes_per_window', 'brokerThresholds', 'completion_bell', 'live_cycles', 'condensed_history', 'fold_finished_tools', 'keybindings', 'modelLadders', 'kinds',
|
|
501
502
|
'remoteCanvas', 'spawnEnv',
|
|
502
503
|
];
|
|
503
504
|
/** The SPARSE `modelLadders` to persist after a config mutation: the raw
|
|
@@ -75,9 +75,9 @@ export declare function clipBody(body: string): {
|
|
|
75
75
|
*
|
|
76
76
|
* Format (per sender group):
|
|
77
77
|
* From <sender> — <N> update(s):
|
|
78
|
-
* [<kind>]
|
|
79
|
-
*
|
|
80
|
-
*
|
|
78
|
+
* [<kind>]
|
|
79
|
+
* <short report body>
|
|
80
|
+
* (report: <absolute report path>)
|
|
81
81
|
*
|
|
82
82
|
* Large reports remain canonical pointers and add one shared dereference hint.
|
|
83
83
|
*/
|
package/dist/core/feed/inbox.js
CHANGED
|
@@ -330,15 +330,19 @@ function inlineReport(e) {
|
|
|
330
330
|
/**
|
|
331
331
|
* Render one entry's digest line(s).
|
|
332
332
|
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
333
|
+
* A short pushed report is inlined as its own body, followed by a pointer to
|
|
334
|
+
* the durable artifact. The label is NOT repeated above it: a report's label is
|
|
335
|
+
* just its first line (feed.ts `firstLine`), so printing both reads as a
|
|
336
|
+
* truncated preview stacked on the full text it previews. Larger reports stay
|
|
337
|
+
* canonical history pointers. Direct messages retain their bounded
|
|
335
338
|
* inline-body/spill behavior.
|
|
336
339
|
*/
|
|
337
340
|
function renderEntry(e) {
|
|
338
341
|
const report = inlineReport(e);
|
|
339
342
|
if (report !== null) {
|
|
343
|
+
const indented = report.body.trimEnd().split('\n').map((l) => ` ${l}`).join('\n');
|
|
340
344
|
return {
|
|
341
|
-
text: ` [${e.kind}]
|
|
345
|
+
text: ` [${e.kind}]\n${indented}\n (report: ${report.path})`,
|
|
342
346
|
hasCanonicalRef: false,
|
|
343
347
|
};
|
|
344
348
|
}
|
|
@@ -364,9 +368,9 @@ function renderEntry(e) {
|
|
|
364
368
|
*
|
|
365
369
|
* Format (per sender group):
|
|
366
370
|
* From <sender> — <N> update(s):
|
|
367
|
-
* [<kind>]
|
|
368
|
-
*
|
|
369
|
-
*
|
|
371
|
+
* [<kind>]
|
|
372
|
+
* <short report body>
|
|
373
|
+
* (report: <absolute report path>)
|
|
370
374
|
*
|
|
371
375
|
* Large reports remain canonical pointers and add one shared dereference hint.
|
|
372
376
|
*/
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { CronDTO } from '../../api/dto/crons.js';
|
|
2
2
|
import type { SourceError, ViewCore } from '../view/contract.js';
|
|
3
|
-
import { scheduledItems, type InspectorSection } from './model.js';
|
|
3
|
+
import { scheduledItems, type BashJobRow, type InspectorSection } from './model.js';
|
|
4
|
+
/** What a `y` confirmation will act on. Both sections arm cancellation the same
|
|
5
|
+
* way, so the pending action carries its own kind rather than the view guessing
|
|
6
|
+
* from whichever section happens to be open when the key lands. */
|
|
7
|
+
export type PendingAction = {
|
|
8
|
+
kind: 'cron' | 'job';
|
|
9
|
+
id: string;
|
|
10
|
+
label: string;
|
|
11
|
+
};
|
|
4
12
|
export interface InspectorState {
|
|
5
13
|
target: string;
|
|
6
14
|
section: InspectorSection;
|
|
@@ -14,10 +22,17 @@ export interface InspectorState {
|
|
|
14
22
|
error: SourceError | null;
|
|
15
23
|
fetchedAt: number;
|
|
16
24
|
};
|
|
25
|
+
jobs: {
|
|
26
|
+
data: BashJobRow[] | null;
|
|
27
|
+
error: SourceError | null;
|
|
28
|
+
fetchedAt: number;
|
|
29
|
+
};
|
|
17
30
|
selectedCronId: string | null;
|
|
31
|
+
selectedJobId: string | null;
|
|
18
32
|
expanded: boolean;
|
|
19
|
-
pendingCancel:
|
|
33
|
+
pendingCancel: PendingAction | null;
|
|
20
34
|
scroll: Record<InspectorSection, number>;
|
|
21
35
|
}
|
|
22
36
|
export declare const inspectorCore: ViewCore<InspectorState>;
|
|
37
|
+
export declare function selectedJob(state: InspectorState): BashJobRow | undefined;
|
|
23
38
|
export { scheduledItems };
|
|
@@ -35,6 +35,25 @@ const scheduleSource = {
|
|
|
35
35
|
return { ok: true, data: crons };
|
|
36
36
|
},
|
|
37
37
|
};
|
|
38
|
+
/** Trailing log lines carried with every job row: enough to fill the expanded
|
|
39
|
+
* card, so watching a job needs no second surface. */
|
|
40
|
+
const LOG_TAIL_LINES = 14;
|
|
41
|
+
/** Live background bash jobs for the inspected node. Non-blocking, exactly like
|
|
42
|
+
* the schedule: a node with no jobs dir (or a remote target) degrades this one
|
|
43
|
+
* section, never the whole view. */
|
|
44
|
+
const jobsSource = {
|
|
45
|
+
id: 'jobs',
|
|
46
|
+
request: (target) => ({ kind: 'exec', bin: 'crtr', args: ['--json', 'node', 'bash', 'list', '--node', target, '--tail', String(LOG_TAIL_LINES)] }),
|
|
47
|
+
parse: (raw) => {
|
|
48
|
+
const result = parseJson(raw, 'node bash list', false);
|
|
49
|
+
if (!result.ok)
|
|
50
|
+
return result;
|
|
51
|
+
const jobs = result.data['jobs'];
|
|
52
|
+
if (!Array.isArray(jobs))
|
|
53
|
+
return { ok: false, error: error('parse', 'Jobs unavailable', 'The job list did not contain job rows.', false) };
|
|
54
|
+
return { ok: true, data: jobs };
|
|
55
|
+
},
|
|
56
|
+
};
|
|
38
57
|
const cancelCommand = {
|
|
39
58
|
id: 'cancel',
|
|
40
59
|
request: (id) => ({ kind: 'exec', bin: 'crtr', args: ['--json', 'cron', 'cancel', id] }),
|
|
@@ -45,8 +64,50 @@ const cancelCommand = {
|
|
|
45
64
|
return { ok: true, data: { canceled: String(result.data['canceled'] ?? '') } };
|
|
46
65
|
},
|
|
47
66
|
};
|
|
67
|
+
const killJobCommand = {
|
|
68
|
+
id: 'kill-job',
|
|
69
|
+
request: ({ target, job }) => ({ kind: 'exec', bin: 'crtr', args: ['--json', 'node', 'bash', 'kill', job, '--node', target] }),
|
|
70
|
+
parse: (raw) => {
|
|
71
|
+
const result = parseJson(raw, 'node bash kill', false);
|
|
72
|
+
if (!result.ok)
|
|
73
|
+
return result;
|
|
74
|
+
return { ok: true, data: { job: String(result.data['job_id'] ?? '') } };
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
/** Open the selected job's log in its own tmux window, following like `tail -f`.
|
|
78
|
+
* A new window rather than a nested popup: tmux allows one popup per client and
|
|
79
|
+
* the Inspector is usually already in one — and a window is where you actually
|
|
80
|
+
* want a log you intend to keep watching after the Inspector closes. */
|
|
81
|
+
const openLogCommand = {
|
|
82
|
+
id: 'open-log',
|
|
83
|
+
request: ({ path, title }) => ({
|
|
84
|
+
kind: 'exec',
|
|
85
|
+
bin: 'tmux',
|
|
86
|
+
args: ['new-window', '-n', title, `less +F -- ${quote(path)} 2>/dev/null || tail -f -- ${quote(path)}`],
|
|
87
|
+
}),
|
|
88
|
+
parse: (raw) => {
|
|
89
|
+
if (!raw.ok || (raw.exitCode ?? 0) !== 0) {
|
|
90
|
+
return { ok: false, error: error('no_tmux', 'Could not open the log', raw.stderr || 'tmux new-window failed — the Inspector must be running inside tmux.', false) };
|
|
91
|
+
}
|
|
92
|
+
return { ok: true, data: {} };
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
function quote(text) {
|
|
96
|
+
return `'${text.split("'").join(`'\\''`)}'`;
|
|
97
|
+
}
|
|
98
|
+
const SECTION_ORDER = ['overview', 'schedule', 'jobs', 'raw'];
|
|
48
99
|
function section(value) {
|
|
49
|
-
return value === 'schedule' || value === 'raw' ? value : 'overview';
|
|
100
|
+
return value === 'schedule' || value === 'jobs' || value === 'raw' ? value : 'overview';
|
|
101
|
+
}
|
|
102
|
+
/** The section-aware status line: what is pending, counted for what you see. */
|
|
103
|
+
function countsFor(state) {
|
|
104
|
+
const crons = `${scheduledItems(state.schedule.data ?? []).length} cron(s) armed`;
|
|
105
|
+
const jobs = `${(state.jobs.data ?? []).length} background job(s) running`;
|
|
106
|
+
if (state.section === 'schedule')
|
|
107
|
+
return crons;
|
|
108
|
+
if (state.section === 'jobs')
|
|
109
|
+
return jobs;
|
|
110
|
+
return `${crons} · ${jobs}`;
|
|
50
111
|
}
|
|
51
112
|
function reconcile(state, next) {
|
|
52
113
|
const items = scheduledItems(next);
|
|
@@ -58,21 +119,30 @@ function reconcile(state, next) {
|
|
|
58
119
|
return { ...state, schedule: { data: next, error: null, fetchedAt: Date.now() }, selectedCronId: replacement?.cron.cron_id ?? null, expanded: false, pendingCancel: null };
|
|
59
120
|
}
|
|
60
121
|
export const inspectorCore = {
|
|
61
|
-
manifest: { id: 'inspector', title: 'Inspector', description: 'Node overview and
|
|
122
|
+
manifest: { id: 'inspector', title: 'Inspector', description: 'Node overview, scheduled crons, and live background bash jobs', refreshMs: 5000 },
|
|
62
123
|
init(options) {
|
|
63
124
|
return {
|
|
64
125
|
target: options['target'] ?? '', section: section(options['section']),
|
|
65
126
|
dossier: { data: null, error: null, fetchedAt: 0 }, schedule: { data: null, error: null, fetchedAt: 0 },
|
|
66
|
-
|
|
67
|
-
|
|
127
|
+
jobs: { data: null, error: null, fetchedAt: 0 },
|
|
128
|
+
selectedCronId: null, selectedJobId: null, expanded: false, pendingCancel: null,
|
|
129
|
+
scroll: { overview: 0, schedule: 0, jobs: 0, raw: 0 },
|
|
68
130
|
};
|
|
69
131
|
},
|
|
70
|
-
sources: { dossier: dossierSource, schedule: scheduleSource },
|
|
71
|
-
commands: { cancel: cancelCommand },
|
|
132
|
+
sources: { dossier: dossierSource, schedule: scheduleSource, jobs: jobsSource },
|
|
133
|
+
commands: { cancel: cancelCommand, killJob: killJobCommand, openLog: openLogCommand },
|
|
72
134
|
intents: {
|
|
73
135
|
async refresh(ctx) {
|
|
74
136
|
ctx.signal.setStatus('Refreshing…');
|
|
75
|
-
|
|
137
|
+
let note;
|
|
138
|
+
// All three reads are independent subprocesses, so they run TOGETHER: the
|
|
139
|
+
// host drops keystrokes while an intent is in flight, and the auto-refresh
|
|
140
|
+
// fires every 5s — resolving them in series would make the view feel deaf.
|
|
141
|
+
const [dossier, schedule, jobs] = await Promise.all([
|
|
142
|
+
ctx.resolve(dossierSource, ctx.state.target),
|
|
143
|
+
ctx.resolve(scheduleSource, ctx.state.target),
|
|
144
|
+
ctx.resolve(jobsSource, ctx.state.target),
|
|
145
|
+
]);
|
|
76
146
|
if (!dossier.ok) {
|
|
77
147
|
ctx.set((state) => ({ ...state, dossier: { ...state.dossier, error: dossier.error } }));
|
|
78
148
|
ctx.signal.setBanner(`${dossier.error.display.headline}: ${dossier.error.display.explanation}`, 'error');
|
|
@@ -80,7 +150,6 @@ export const inspectorCore = {
|
|
|
80
150
|
return;
|
|
81
151
|
}
|
|
82
152
|
ctx.set((state) => ({ ...state, dossier: { data: dossier.data, error: null, fetchedAt: Date.now() } }));
|
|
83
|
-
const schedule = await ctx.resolve(scheduleSource, ctx.state.target);
|
|
84
153
|
if (!schedule.ok) {
|
|
85
154
|
ctx.set((state) => ({ ...state, schedule: { ...state.schedule, error: schedule.error } }));
|
|
86
155
|
ctx.signal.setBanner(schedule.error.display.explanation, 'error');
|
|
@@ -89,37 +158,90 @@ export const inspectorCore = {
|
|
|
89
158
|
const before = ctx.state.selectedCronId;
|
|
90
159
|
ctx.set((state) => reconcile(state, schedule.data));
|
|
91
160
|
if (before !== null && !schedule.data.some((cron) => cron.cron_id === before))
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
ctx.
|
|
161
|
+
note = 'Selected cron disappeared; moved to the next item.';
|
|
162
|
+
// Clear ONLY a banner this source raised. A background refresh must not
|
|
163
|
+
// wipe the user's own "press y to confirm" prompt or an action result.
|
|
164
|
+
if (ctx.state.schedule.error !== null)
|
|
165
|
+
ctx.signal.clearBanner();
|
|
166
|
+
}
|
|
167
|
+
if (!jobs.ok)
|
|
168
|
+
ctx.set((state) => ({ ...state, jobs: { ...state.jobs, error: jobs.error } }));
|
|
169
|
+
else {
|
|
170
|
+
const gone = ctx.state.selectedJobId !== null && !jobs.data.some((job) => job.job_id === ctx.state.selectedJobId);
|
|
171
|
+
ctx.set((state) => reconcileJobs(state, jobs.data));
|
|
172
|
+
if (gone)
|
|
173
|
+
note = 'The selected job finished; moved to the next one.';
|
|
96
174
|
}
|
|
175
|
+
// One status line, always about the section you are looking at: a count of
|
|
176
|
+
// crons in schedule, of jobs in jobs, of both in the whole-node sections.
|
|
177
|
+
ctx.signal.setStatus(note ?? countsFor(ctx.state));
|
|
97
178
|
const node = record(ctx.state.dossier.data?.['node']);
|
|
98
179
|
ctx.signal.setSubtitle(`${node['name'] ?? ctx.state.target} · ${ctx.state.section}`);
|
|
99
180
|
},
|
|
100
|
-
sectionNext(ctx) { const order =
|
|
101
|
-
sectionPrev(ctx) { const order =
|
|
181
|
+
sectionNext(ctx) { const order = SECTION_ORDER; const next = order[(order.indexOf(ctx.state.section) + 1) % order.length]; ctx.set((state) => ({ ...state, section: next, pendingCancel: null })); ctx.signal.clearBanner(); ctx.signal.setStatus(countsFor(ctx.state)); ctx.signal.setSubtitle(`${ctx.state.target} · ${next}`); },
|
|
182
|
+
sectionPrev(ctx) { const order = SECTION_ORDER; const next = order[(order.indexOf(ctx.state.section) + order.length - 1) % order.length]; ctx.set((state) => ({ ...state, section: next, pendingCancel: null })); ctx.signal.clearBanner(); ctx.signal.setStatus(countsFor(ctx.state)); ctx.signal.setSubtitle(`${ctx.state.target} · ${next}`); },
|
|
102
183
|
down(ctx) { ctx.set((state) => move(state, 1)); },
|
|
103
184
|
up(ctx) { ctx.set((state) => move(state, -1)); },
|
|
104
185
|
pageDown(ctx) { ctx.set((state) => ({ ...state, scroll: { ...state.scroll, [state.section]: state.scroll[state.section] + 8 } })); },
|
|
105
186
|
pageUp(ctx) { ctx.set((state) => ({ ...state, scroll: { ...state.scroll, [state.section]: Math.max(0, state.scroll[state.section] - 8) } })); },
|
|
106
|
-
toggleDetail(ctx) {
|
|
107
|
-
ctx.
|
|
108
|
-
|
|
109
|
-
|
|
187
|
+
toggleDetail(ctx) {
|
|
188
|
+
if (ctx.state.section === 'jobs' ? ctx.state.selectedJobId !== null : ctx.state.selectedCronId !== null) {
|
|
189
|
+
ctx.set((state) => ({ ...state, expanded: !state.expanded }));
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
cancelItem(ctx) {
|
|
193
|
+
const pending = ctx.state.section === 'jobs'
|
|
194
|
+
? selectedJob(ctx.state) && { kind: 'job', id: selectedJob(ctx.state).job_id, label: commandHead(selectedJob(ctx.state).command) }
|
|
195
|
+
: ctx.state.selectedCronId !== null && { kind: 'cron', id: ctx.state.selectedCronId, label: ctx.state.selectedCronId };
|
|
196
|
+
if (!pending)
|
|
197
|
+
return;
|
|
198
|
+
if (pending.kind === 'job' && selectedJob(ctx.state)?.pgid === null) {
|
|
199
|
+
ctx.signal.setBanner('This job predates recorded process groups — crtr cannot stop it. Open its log and stop it by hand.', 'error');
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
ctx.set((state) => ({ ...state, pendingCancel: pending }));
|
|
203
|
+
ctx.signal.setBanner(pending.kind === 'job'
|
|
204
|
+
? `Stop job “${pending.label}”? It dies now and the node is told. Press y to confirm.`
|
|
205
|
+
: `Cancel cron ${pending.label}? Press y to confirm.`, 'action');
|
|
206
|
+
},
|
|
207
|
+
async openLog(ctx) {
|
|
208
|
+
const job = selectedJob(ctx.state);
|
|
209
|
+
if (job === undefined)
|
|
210
|
+
return;
|
|
211
|
+
ctx.signal.setStatus('Opening the job log…');
|
|
212
|
+
const result = await ctx.execute(openLogCommand, { path: job.log_path, title: `job · ${commandHead(job.command, 18)}` });
|
|
213
|
+
if (!result.ok) {
|
|
214
|
+
ctx.signal.setBanner(result.error.display.explanation, 'error');
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
ctx.signal.setBanner('Log opened in a new tmux window, following live. Quit the Inspector to read it.', 'info');
|
|
218
|
+
},
|
|
110
219
|
async confirm(ctx) {
|
|
111
|
-
const
|
|
112
|
-
if (
|
|
220
|
+
const pending = ctx.state.pendingCancel;
|
|
221
|
+
if (pending === null)
|
|
113
222
|
return;
|
|
223
|
+
if (pending.kind === 'job') {
|
|
224
|
+
ctx.signal.setStatus('Stopping the job…');
|
|
225
|
+
const killed = await ctx.execute(killJobCommand, { target: ctx.state.target, job: pending.id });
|
|
226
|
+
ctx.set((state) => ({ ...state, pendingCancel: null }));
|
|
227
|
+
if (!killed.ok) {
|
|
228
|
+
ctx.signal.setBanner(killed.error.display.explanation, 'error');
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
await ctx.dispatch('refresh');
|
|
232
|
+
const stillRunning = (ctx.state.jobs.data ?? []).some((job) => job.job_id === pending.id);
|
|
233
|
+
ctx.signal.setBanner(stillRunning ? 'The job is still running; refresh and retry.' : 'Job stopped — the node was told it was canceled.', stillRunning ? 'error' : 'info');
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
114
236
|
ctx.signal.setStatus('Canceling cron…');
|
|
115
|
-
const result = await ctx.execute(cancelCommand, id);
|
|
237
|
+
const result = await ctx.execute(cancelCommand, pending.id);
|
|
116
238
|
ctx.set((state) => ({ ...state, pendingCancel: null }));
|
|
117
239
|
if (!result.ok) {
|
|
118
240
|
ctx.signal.setBanner(result.error.display.explanation, 'error');
|
|
119
241
|
return;
|
|
120
242
|
}
|
|
121
243
|
await ctx.dispatch('refresh');
|
|
122
|
-
const stillArmed = (ctx.state.schedule.data ?? []).some((cron) => cron.cron_id === id);
|
|
244
|
+
const stillArmed = (ctx.state.schedule.data ?? []).some((cron) => cron.cron_id === pending.id);
|
|
123
245
|
ctx.signal.setBanner(stillArmed ? 'Cron remains armed; refresh and retry.' : 'Cron canceled.', stillArmed ? 'error' : 'info');
|
|
124
246
|
},
|
|
125
247
|
dismiss(ctx) { ctx.set((state) => ({ ...state, pendingCancel: null })); ctx.signal.clearBanner(); },
|
|
@@ -128,14 +250,41 @@ export const inspectorCore = {
|
|
|
128
250
|
const next = cron?.anchor_node ?? cron?.created_by;
|
|
129
251
|
if (!next)
|
|
130
252
|
return;
|
|
131
|
-
ctx.set((state) => ({ ...state, target: next, selectedCronId: null, expanded: false, pendingCancel: null, dossier: { data: null, error: null, fetchedAt: 0 }, schedule: { data: null, error: null, fetchedAt: 0 } }));
|
|
253
|
+
ctx.set((state) => ({ ...state, target: next, selectedCronId: null, expanded: false, pendingCancel: null, dossier: { data: null, error: null, fetchedAt: 0 }, schedule: { data: null, error: null, fetchedAt: 0 }, jobs: { data: null, error: null, fetchedAt: 0 } }));
|
|
132
254
|
void ctx.dispatch('refresh');
|
|
133
255
|
},
|
|
134
256
|
quit(ctx) { ctx.signal.quit(); },
|
|
135
257
|
},
|
|
136
258
|
};
|
|
137
259
|
function selectedItem(state) { return scheduledItems(state.schedule.data ?? []).find((item) => item.cron.cron_id === state.selectedCronId); }
|
|
260
|
+
export function selectedJob(state) {
|
|
261
|
+
return (state.jobs.data ?? []).find((job) => job.job_id === state.selectedJobId);
|
|
262
|
+
}
|
|
263
|
+
/** First line of a command, for banners and window titles. */
|
|
264
|
+
function commandHead(command, cap = 40) {
|
|
265
|
+
const line = (command.split('\n')[0] ?? '').replace(/\s+/g, ' ').trim();
|
|
266
|
+
return line.length > cap ? `${line.slice(0, cap - 1)}…` : line;
|
|
267
|
+
}
|
|
268
|
+
/** Keep the job cursor on the same job across refreshes; when it finished, land
|
|
269
|
+
* on whatever took its place rather than losing the cursor entirely. */
|
|
270
|
+
function reconcileJobs(state, next) {
|
|
271
|
+
const fetched = { data: next, error: null, fetchedAt: Date.now() };
|
|
272
|
+
if (state.selectedJobId !== null && next.some((job) => job.job_id === state.selectedJobId))
|
|
273
|
+
return { ...state, jobs: fetched };
|
|
274
|
+
const previousIndex = (state.jobs.data ?? []).findIndex((job) => job.job_id === state.selectedJobId);
|
|
275
|
+
const replacement = next[Math.min(Math.max(0, previousIndex), Math.max(0, next.length - 1))];
|
|
276
|
+
const pendingCancel = state.pendingCancel?.kind === 'job' ? null : state.pendingCancel;
|
|
277
|
+
return { ...state, jobs: fetched, selectedJobId: replacement?.job_id ?? null, expanded: false, pendingCancel };
|
|
278
|
+
}
|
|
138
279
|
function move(state, delta) {
|
|
280
|
+
if (state.section === 'jobs') {
|
|
281
|
+
const jobs = state.jobs.data ?? [];
|
|
282
|
+
if (jobs.length === 0)
|
|
283
|
+
return state;
|
|
284
|
+
const current = Math.max(0, jobs.findIndex((job) => job.job_id === state.selectedJobId));
|
|
285
|
+
const next = Math.max(0, Math.min(jobs.length - 1, current + delta));
|
|
286
|
+
return { ...state, selectedJobId: jobs[next].job_id, expanded: false, pendingCancel: null };
|
|
287
|
+
}
|
|
139
288
|
if (state.section !== 'schedule')
|
|
140
289
|
return { ...state, scroll: { ...state.scroll, [state.section]: Math.max(0, state.scroll[state.section] + delta) } };
|
|
141
290
|
const items = scheduledItems(state.schedule.data ?? []);
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import type { CronDTO } from '../../api/dto/crons.js';
|
|
2
|
-
export type InspectorSection = 'overview' | 'schedule' | 'raw';
|
|
2
|
+
export type InspectorSection = 'overview' | 'schedule' | 'jobs' | 'raw';
|
|
3
|
+
/** One live background bash job, as `crtr --json node bash list` reports it. */
|
|
4
|
+
export interface BashJobRow {
|
|
5
|
+
job_id: string;
|
|
6
|
+
command: string;
|
|
7
|
+
started_at: string;
|
|
8
|
+
elapsed_ms: number;
|
|
9
|
+
log_path: string;
|
|
10
|
+
/** Supervisor process group — null for a job started before crtr persisted
|
|
11
|
+
* one, which is exactly the set of jobs the Inspector cannot stop. */
|
|
12
|
+
pgid: number | null;
|
|
13
|
+
log_tail: string[];
|
|
14
|
+
}
|
|
3
15
|
export interface ScheduledItem {
|
|
4
16
|
cron: CronDTO;
|
|
5
17
|
nextAt: string;
|
|
@@ -14,6 +26,8 @@ export declare const ICON: {
|
|
|
14
26
|
readonly artifacts: string;
|
|
15
27
|
readonly paths: string;
|
|
16
28
|
readonly cron: string;
|
|
29
|
+
readonly job: string;
|
|
30
|
+
readonly log: string;
|
|
17
31
|
readonly section: string;
|
|
18
32
|
readonly raw: string;
|
|
19
33
|
readonly empty: string;
|
|
@@ -38,6 +52,21 @@ export declare function statusVisual(status: string): {
|
|
|
38
52
|
tone: Tone;
|
|
39
53
|
};
|
|
40
54
|
export declare function scheduledItems(crons: readonly CronDTO[]): ScheduledItem[];
|
|
55
|
+
/** Elapsed duration in the shape every bash-handoff surface uses: `12s`,
|
|
56
|
+
* `3m 40s`, `1h 04m`. Duplicated from core/bash-jobs.ts on purpose — a view
|
|
57
|
+
* module stays a pure read and never pulls the node:fs job scanner in. */
|
|
58
|
+
export declare function elapsed(ms: number): string;
|
|
59
|
+
/** How a running job's age reads at a glance: an ordered ramp, not decoration.
|
|
60
|
+
* Fresh work is green, a job past 15m is amber, past an hour is red — the same
|
|
61
|
+
* good/warn/bad progression the node status glyphs use. */
|
|
62
|
+
export declare function jobTone(elapsedMs: number): Tone;
|
|
63
|
+
/** Milliseconds a job has been running, live from `started_at`. */
|
|
64
|
+
export declare function jobElapsedMs(job: BashJobRow, now?: number): number;
|
|
65
|
+
/** Live elapsed time for a job row — recomputed from `started_at` so the number
|
|
66
|
+
* keeps ticking between the view's 5s refreshes; `elapsed_ms` is the fallback. */
|
|
67
|
+
export declare function jobElapsed(job: BashJobRow, now?: number): string;
|
|
68
|
+
/** First line of a command, whitespace-collapsed and capped for a one-row cell. */
|
|
69
|
+
export declare function commandCell(command: string, cap?: number): string;
|
|
41
70
|
export declare function relativeTime(iso: string | null, now?: number): string;
|
|
42
71
|
/** How long ago an ISO timestamp was, e.g. `3d ago`. `—` when absent. */
|
|
43
72
|
export declare function ago(iso: unknown, now?: number): string;
|