@softov/ahpc 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/dist/src/ahp/fake.js +61 -14
- package/dist/src/ahp/live.d.ts +7 -0
- package/dist/src/ahp/live.js +93 -37
- package/dist/src/ahp/publish.js +13 -0
- package/dist/src/ahp/types.d.ts +20 -0
- package/dist/src/app.js +34 -7
- package/dist/src/blocks.d.ts +4 -74
- package/dist/src/blocks.js +10 -48
- package/dist/src/cli/main.d.ts +1 -1
- package/dist/src/cli/main.js +75 -2
- package/dist/src/connect.d.ts +2 -0
- package/dist/src/connect.js +1 -0
- package/dist/src/control.d.ts +6 -0
- package/dist/src/control.js +140 -10
- package/dist/src/flags.js +1 -1
- package/dist/src/links.d.ts +54 -0
- package/dist/src/links.js +120 -0
- package/dist/src/resources.d.ts +13 -0
- package/dist/src/resources.js +46 -0
- package/dist/src/screens.js +95 -50
- package/dist/src/state.d.ts +42 -0
- package/dist/src/state.js +80 -1
- package/dist/src/tui.d.ts +3 -1
- package/dist/src/tui.js +28 -4
- package/dist/src/view/creature.d.ts +0 -12
- package/dist/src/view/creature.js +0 -20
- package/dist/src/view/wire.d.ts +36 -0
- package/dist/src/view/wire.js +196 -0
- package/dist/src/wire.d.ts +70 -0
- package/dist/src/wire.js +194 -0
- package/dist/src/wiretui.d.ts +22 -0
- package/dist/src/wiretui.js +69 -0
- package/package.json +6 -5
- package/dist/src/diff.d.ts +0 -44
- package/dist/src/diff.js +0 -111
- package/dist/src/view/bubble.d.ts +0 -75
- package/dist/src/view/bubble.js +0 -86
- package/dist/src/view/composer.d.ts +0 -64
- package/dist/src/view/composer.js +0 -192
- package/dist/src/view/controls.d.ts +0 -44
- package/dist/src/view/controls.js +0 -49
- package/dist/src/view/details.d.ts +0 -65
- package/dist/src/view/details.js +0 -65
- package/dist/src/view/filediff.d.ts +0 -29
- package/dist/src/view/filediff.js +0 -24
- package/dist/src/view/hitl.d.ts +0 -43
- package/dist/src/view/hitl.js +0 -171
- package/dist/src/view/icons.d.ts +0 -13
- package/dist/src/view/icons.js +0 -71
- package/dist/src/view/picker.d.ts +0 -42
- package/dist/src/view/picker.js +0 -71
- package/dist/src/view/sessionhead.d.ts +0 -41
- package/dist/src/view/sessionhead.js +0 -60
- package/dist/src/view/sessions.d.ts +0 -34
- package/dist/src/view/sessions.js +0 -61
- package/dist/src/view/toolcall.d.ts +0 -27
- package/dist/src/view/toolcall.js +0 -48
- package/dist/src/view/transcript.d.ts +0 -50
- package/dist/src/view/transcript.js +0 -60
package/dist/src/tui.js
CHANGED
|
@@ -2,6 +2,7 @@ import { WRITER_KEY, createApp } from '@textui/core';
|
|
|
2
2
|
import { writeFile } from 'node:fs/promises';
|
|
3
3
|
import { bufferToSvg, createNodeTerminal, createWriter, renderStill, } from '@textui/terminal';
|
|
4
4
|
import { registerChat } from './app.js';
|
|
5
|
+
import { parseSessionLink } from './links.js';
|
|
5
6
|
import { CONTROLLER } from './control.js';
|
|
6
7
|
import { connect, sink } from './connect.js';
|
|
7
8
|
import { loadConfig } from './config.js';
|
|
@@ -30,6 +31,11 @@ What this client serves back
|
|
|
30
31
|
terminal attached this prints one frame and exits,
|
|
31
32
|
so a background shell publishes nothing.
|
|
32
33
|
|
|
34
|
+
Seeing the wire
|
|
35
|
+
--wire <file> Append every frame, both directions, as JSON lines:
|
|
36
|
+
{ at, from, peer, frame }. AHPC_RECORD=<file> is
|
|
37
|
+
the same thing from a shell.
|
|
38
|
+
|
|
33
39
|
Appearance
|
|
34
40
|
--theme <name> workbench, paper-light, ...
|
|
35
41
|
--shell <name> The shell layout
|
|
@@ -37,7 +43,8 @@ Appearance
|
|
|
37
43
|
--bood Let the creature loose on the whole screen. It
|
|
38
44
|
keeps off the composer and off anything asking
|
|
39
45
|
a question, and alt+g turns it off again.
|
|
40
|
-
--session <uri> Open this session
|
|
46
|
+
--session <uri> Open this session, by its URI or by an
|
|
47
|
+
agent-host-session:// link
|
|
41
48
|
|
|
42
49
|
Stills, for a README or a test
|
|
43
50
|
--static, -s One frame to stdout instead of running
|
|
@@ -148,6 +155,9 @@ export function parse(argv) {
|
|
|
148
155
|
case '--publish-writable':
|
|
149
156
|
options.publishWritable = true;
|
|
150
157
|
break;
|
|
158
|
+
case '--wire':
|
|
159
|
+
options.wire = String(argv[++i]);
|
|
160
|
+
break;
|
|
151
161
|
case '--help':
|
|
152
162
|
case '-h':
|
|
153
163
|
options.help = true;
|
|
@@ -198,10 +208,15 @@ async function still(options) {
|
|
|
198
208
|
// of scripted words rather than all of them, and the caret is wherever the
|
|
199
209
|
// agent had got to. `--settled` runs until the script has nothing left it
|
|
200
210
|
// can do without being answered, which is how the confirmation is reached.
|
|
201
|
-
before: (app) => {
|
|
211
|
+
before: async (app) => {
|
|
202
212
|
const controller = app.services.require(CONTROLLER);
|
|
203
|
-
|
|
204
|
-
|
|
213
|
+
// A URI, or the link the reference host's tools answer with.
|
|
214
|
+
if (options.session) {
|
|
215
|
+
if (parseSessionLink(options.session))
|
|
216
|
+
await controller.openLink(options.session);
|
|
217
|
+
else
|
|
218
|
+
controller.open(options.session);
|
|
219
|
+
}
|
|
205
220
|
/*
|
|
206
221
|
* Pushed when asked for, and not otherwise.
|
|
207
222
|
*
|
|
@@ -346,6 +361,15 @@ export async function tui(argv) {
|
|
|
346
361
|
app.services.provide(WRITER_KEY, createWriter(terminal.capabilities()));
|
|
347
362
|
sink.report = (message) => reportHostError(app.store, message);
|
|
348
363
|
await app.start();
|
|
364
|
+
// `--session`, on the screen as well as in a still: the conversation named,
|
|
365
|
+
// by its URI or by an `agent-host-session://` link, opened on arrival.
|
|
366
|
+
if (options.session) {
|
|
367
|
+
const controller = app.services.require(CONTROLLER);
|
|
368
|
+
const named = options.session;
|
|
369
|
+
const opened = parseSessionLink(named) ? controller.openLink(named) : (controller.open(named), Promise.resolve(true));
|
|
370
|
+
void opened.then((ok) => { if (ok)
|
|
371
|
+
app.screens.push('chat'); });
|
|
372
|
+
}
|
|
349
373
|
/**
|
|
350
374
|
* The last resort, and the reason it exists at all.
|
|
351
375
|
*
|
|
@@ -43,18 +43,6 @@ export type { CreatureProps, Form, Mood } from './bood/index.js';
|
|
|
43
43
|
* that matches the status row above it.
|
|
44
44
|
*/
|
|
45
45
|
export declare function moodOf(activity: Activity): Mood;
|
|
46
|
-
/**
|
|
47
|
-
* Say which row this component starts at, so nothing stands on top of it.
|
|
48
|
-
*
|
|
49
|
-
* Its top row rather than its height: the row is where the thing actually is,
|
|
50
|
-
* and a height has to be added to a guess about everything below it to mean
|
|
51
|
-
* anything. Published rather than assumed because it moves - the composer
|
|
52
|
-
* grows a slash menu upward and grows again with a wrapped draft, and the
|
|
53
|
-
* block that asks about a tool is only there while something is waiting.
|
|
54
|
-
* Cleared on the way out, or a screen that had one would keep making room for
|
|
55
|
-
* it after it had gone.
|
|
56
|
-
*/
|
|
57
|
-
export declare function useFloorTop(key: string, row: number): void;
|
|
58
46
|
/**
|
|
59
47
|
* One animal per run, rather than one per component that draws one.
|
|
60
48
|
*
|
|
@@ -25,8 +25,6 @@
|
|
|
25
25
|
* instead of being the big one shrunk.
|
|
26
26
|
*/
|
|
27
27
|
import { BOOD, boodHeight, livelyNames } from './bood/index.js';
|
|
28
|
-
import { useApp, useEffect } from '@textui/core';
|
|
29
|
-
import { boodFloorFor } from '../state.js';
|
|
30
28
|
export { BoodSprite, Creature, MOODS, FORMS, creatureFrames, creatureMotion, creatureSize, drawCreature, livelyNames, } from './bood/index.js';
|
|
31
29
|
/**
|
|
32
30
|
* What the figure is doing, from what the session is doing.
|
|
@@ -48,24 +46,6 @@ export function moodOf(activity) {
|
|
|
48
46
|
: activity === 'input' ? 'thinking'
|
|
49
47
|
: 'happy';
|
|
50
48
|
}
|
|
51
|
-
/**
|
|
52
|
-
* Say which row this component starts at, so nothing stands on top of it.
|
|
53
|
-
*
|
|
54
|
-
* Its top row rather than its height: the row is where the thing actually is,
|
|
55
|
-
* and a height has to be added to a guess about everything below it to mean
|
|
56
|
-
* anything. Published rather than assumed because it moves - the composer
|
|
57
|
-
* grows a slash menu upward and grows again with a wrapped draft, and the
|
|
58
|
-
* block that asks about a tool is only there while something is waiting.
|
|
59
|
-
* Cleared on the way out, or a screen that had one would keep making room for
|
|
60
|
-
* it after it had gone.
|
|
61
|
-
*/
|
|
62
|
-
export function useFloorTop(key, row) {
|
|
63
|
-
const app = useApp();
|
|
64
|
-
useEffect(() => {
|
|
65
|
-
app.store.set(boodFloorFor(key), row);
|
|
66
|
-
return () => app.store.set(boodFloorFor(key), 0);
|
|
67
|
-
}, [key, row]);
|
|
68
|
-
}
|
|
69
49
|
/**
|
|
70
50
|
* One animal per run, rather than one per component that draws one.
|
|
71
51
|
*
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { BindingPath, BoxProps, Disposable, RenderOutput, TextUIApp } from '@textui/core';
|
|
2
|
+
import type { WireRow } from '../wire.js';
|
|
3
|
+
export declare const WIRE_ROWS: BindingPath;
|
|
4
|
+
export declare const WIRE_FILTER: BindingPath;
|
|
5
|
+
export declare const WIRE_SELECTED: BindingPath;
|
|
6
|
+
export declare const WIRE_FOLLOW: BindingPath;
|
|
7
|
+
export declare const WIRE_FILE: BindingPath;
|
|
8
|
+
export declare const WIRE_FRAME: BindingPath;
|
|
9
|
+
export declare const WIRE_SCOPE = "wire.rows";
|
|
10
|
+
/** The rows the filter keeps, from what the store holds. */
|
|
11
|
+
export declare const visibleRows: (rows: WireRow[], filter: string) => WireRow[];
|
|
12
|
+
export interface WireListProps extends BoxProps {
|
|
13
|
+
rows: WireRow[];
|
|
14
|
+
selectedId?: string | null;
|
|
15
|
+
onSelect?(id: string): void;
|
|
16
|
+
onOpen?(id: string): void;
|
|
17
|
+
emptyMessage?: string;
|
|
18
|
+
focusId?: string;
|
|
19
|
+
autoFocus?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export declare const WireList: (props: WireListProps) => RenderOutput;
|
|
22
|
+
export declare const WireScreen: (props: Record<string, never>) => RenderOutput;
|
|
23
|
+
export interface WireOptions {
|
|
24
|
+
/** The capture to read. */
|
|
25
|
+
file: string;
|
|
26
|
+
/** How often to look for more of it, in milliseconds. */
|
|
27
|
+
interval?: number;
|
|
28
|
+
builtins?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The wire screen on an application, reading one file.
|
|
32
|
+
*
|
|
33
|
+
* The same shape as `registerChat`, and nothing shared with it: no host, no
|
|
34
|
+
* controller, no sessions. What comes back takes the reader down with it.
|
|
35
|
+
*/
|
|
36
|
+
export declare function registerWire(app: TextUIApp, options: WireOptions): Disposable;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "@textui/core/jsx-runtime";
|
|
2
|
+
import { createBag, defineComponent, useApp, useEffect, useFocusScope, useSize, useStoreValue, useTheme } from '@textui/core';
|
|
3
|
+
import { Column, KeyHints, List, Panel, Row, ScrollView, SearchBox, registerBuiltins } from '@textui/widgets';
|
|
4
|
+
import { follow, matches } from '../wire.js';
|
|
5
|
+
/*
|
|
6
|
+
* The wire, as a screen.
|
|
7
|
+
*
|
|
8
|
+
* One row per frame and nothing else on it: the time, which way it went, the
|
|
9
|
+
* method or the action type, the channel. A row opens to the frame itself.
|
|
10
|
+
* The file is read as it grows, so a capture `ahpd --wire` or this client is
|
|
11
|
+
* still writing is watched live - which is the point: every question about
|
|
12
|
+
* the wire had been answered by reading source, and this answers it by
|
|
13
|
+
* looking.
|
|
14
|
+
*
|
|
15
|
+
* Its own screen rather than one of the conversation's, because it needs no
|
|
16
|
+
* host. A capture is a file, and the file may be the other end's.
|
|
17
|
+
*/
|
|
18
|
+
export const WIRE_ROWS = '$/wire/rows';
|
|
19
|
+
export const WIRE_FILTER = '$/wire/filter';
|
|
20
|
+
export const WIRE_SELECTED = '$/wire/selected';
|
|
21
|
+
export const WIRE_FOLLOW = '$/wire/follow';
|
|
22
|
+
export const WIRE_FILE = '$/wire/file';
|
|
23
|
+
export const WIRE_FRAME = '$/wire/frame';
|
|
24
|
+
export const WIRE_SCOPE = 'wire.rows';
|
|
25
|
+
/** The most frames held. A capture of a long session is more than a screen can list, and the newest are what is being watched. */
|
|
26
|
+
const KEPT = 20_000;
|
|
27
|
+
/** Below this width the frame is a drawer over the list rather than a pane beside it. */
|
|
28
|
+
const SPLIT = 100;
|
|
29
|
+
const TONE = {
|
|
30
|
+
request: 'default',
|
|
31
|
+
notification: 'default',
|
|
32
|
+
response: 'muted',
|
|
33
|
+
error: 'danger',
|
|
34
|
+
text: 'warning',
|
|
35
|
+
};
|
|
36
|
+
/** The rows the filter keeps, from what the store holds. */
|
|
37
|
+
export const visibleRows = (rows, filter) => (filter.trim() === '' ? rows : rows.filter((row) => matches(row, filter)));
|
|
38
|
+
/** The frame, as lines. Capped: a snapshot can run to thousands, and the top of it is what says what it is. */
|
|
39
|
+
const linesOf = (frame) => {
|
|
40
|
+
const text = typeof frame === 'string' ? frame : JSON.stringify(frame, null, 2) ?? '';
|
|
41
|
+
const lines = text.split('\n');
|
|
42
|
+
return lines.length > 3000 ? [...lines.slice(0, 3000), `… ${lines.length - 3000} more lines`] : lines;
|
|
43
|
+
};
|
|
44
|
+
export const WireList = defineComponent('WireList', (props) => {
|
|
45
|
+
const { rows, selectedId, onSelect, onOpen, emptyMessage, focusId, autoFocus, ...rest } = props;
|
|
46
|
+
const theme = useTheme();
|
|
47
|
+
const byId = new Map(rows.map((row) => [String(row.seq), row]));
|
|
48
|
+
const items = rows.map((row) => ({ id: String(row.seq), label: row.method || row.kind, tone: TONE[row.kind] }));
|
|
49
|
+
return (_jsx(List, { items: items, ...(selectedId ? { selectedId } : {}), ...(focusId ? { focusId } : {}), ...(autoFocus ? { autoFocus } : {}), ...(onSelect ? { onSelect: (id) => onSelect(id) } : {}), ...(onOpen ? { onActivate: (id) => onOpen(id) } : {}), emptyMessage: emptyMessage ?? 'Nothing on the wire yet', renderItem: (item, state) => {
|
|
50
|
+
const row = byId.get(item.id);
|
|
51
|
+
if (row === undefined)
|
|
52
|
+
return _jsx("text", { content: item.label });
|
|
53
|
+
const time = row.at.length >= 23 ? row.at.slice(11, 23) : row.at;
|
|
54
|
+
// Which way it went, drawn as an arrow between the two ends: the
|
|
55
|
+
// client is on the left, the host on the right, on every row.
|
|
56
|
+
const arrow = row.from === 'client' ? theme.glyphs.arrowRight : theme.glyphs.arrowLeft;
|
|
57
|
+
const what = row.kind === 'response' ? `${row.method} ${theme.glyphs.check}` : row.kind === 'error' ? `${row.method} ${theme.glyphs.cross}` : row.method || row.kind;
|
|
58
|
+
const dim = state.selected ? {} : { fg: 'subtle' };
|
|
59
|
+
return (_jsxs(Row, { gap: 1, children: [_jsx("text", { content: time, shrink: 0, ...dim }), _jsx("text", { content: arrow, shrink: 0, ...(state.selected ? {} : { fg: (row.from === 'client' ? 'accent' : 'info') }) }), _jsx("text", { content: what, shrink: 0, bold: row.kind === 'request' || row.kind === 'notification', ...(state.selected ? {} : { fg: TONE[row.kind] }) }), row.type ? _jsx("text", { content: row.type, shrink: 0, ...(state.selected ? {} : { fg: 'accent' }) }) : null, row.channel ? _jsx("text", { content: row.channel, shrink: 2, truncate: "start", ...dim }) : null, _jsx("text", { content: row.detail, flex: 1, truncate: "end", ...dim })] }));
|
|
60
|
+
}, ...rest }));
|
|
61
|
+
});
|
|
62
|
+
export const WireScreen = defineComponent('WireScreen', () => {
|
|
63
|
+
const app = useApp();
|
|
64
|
+
const theme = useTheme();
|
|
65
|
+
useFocusScope({ id: WIRE_SCOPE });
|
|
66
|
+
const rows = useStoreValue(WIRE_ROWS, []) ?? [];
|
|
67
|
+
const filter = useStoreValue(WIRE_FILTER, '') ?? '';
|
|
68
|
+
const selected = useStoreValue(WIRE_SELECTED, null) ?? null;
|
|
69
|
+
const following = useStoreValue(WIRE_FOLLOW, true) ?? true;
|
|
70
|
+
const file = useStoreValue(WIRE_FILE, '') ?? '';
|
|
71
|
+
const drawer = useStoreValue(WIRE_FRAME, false) ?? false;
|
|
72
|
+
const shown = visibleRows(rows, filter);
|
|
73
|
+
const width = useSize().width;
|
|
74
|
+
const wide = width >= SPLIT;
|
|
75
|
+
const open = wide || drawer;
|
|
76
|
+
// Following: the highlight stays on the newest row the filter keeps, so
|
|
77
|
+
// a live capture reads like a tail. Stopped by moving the highlight.
|
|
78
|
+
const last = shown.at(-1);
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (shown.length === 0)
|
|
81
|
+
return;
|
|
82
|
+
if (following) {
|
|
83
|
+
app.store.set(WIRE_SELECTED, String(last?.seq ?? ''));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (selected !== null && shown.some((row) => String(row.seq) === selected))
|
|
87
|
+
return;
|
|
88
|
+
app.store.set(WIRE_SELECTED, String(last?.seq ?? ''));
|
|
89
|
+
}, [last?.seq ?? 0, following, filter]);
|
|
90
|
+
const current = shown.find((row) => String(row.seq) === selected) ?? null;
|
|
91
|
+
const aside = Math.max(40, Math.min(80, Math.round(width * 0.45)));
|
|
92
|
+
const list = (_jsxs(Panel, { title: "Wire", ...(open && wide ? { width: width - aside - 1 } : { flex: 1 }), meta: `${shown.length}${shown.length === rows.length ? '' : ` of ${rows.length}`} frames${following ? ` ${theme.glyphs.bulletFilled} following` : ''}`, children: [_jsx(SearchBox, { value: filter, placeholder: "method, action type, channel, id", focusId: "wire.filter", onChange: (value) => app.store.set(WIRE_FILTER, value) }), _jsx(WireList, { rows: shown, selectedId: selected, focusId: "wire.list", autoFocus: true, flex: 1, onSelect: (id) => {
|
|
93
|
+
if (id !== selected && id !== String(last?.seq ?? ''))
|
|
94
|
+
app.store.set(WIRE_FOLLOW, false);
|
|
95
|
+
app.store.set(WIRE_SELECTED, id);
|
|
96
|
+
}, onOpen: () => { app.store.set(WIRE_FRAME, true); app.focus.focus('wire.frame'); }, emptyMessage: rows.length === 0 ? `Nothing in ${file || 'the capture'} yet` : 'Nothing matches' }), _jsx("text", { content: file, fg: "subtle", truncate: "start" })] }));
|
|
97
|
+
const frame = current ? (_jsxs(Panel, { title: `${current.from === 'client' ? 'client' : 'host'} ${current.from === 'client' ? theme.glyphs.arrowRight : theme.glyphs.arrowLeft} ${current.method || current.kind}`, ...(wide ? { width: aside } : { flex: 1 }), meta: current.id !== undefined ? `id ${current.id}` : current.kind, children: [_jsxs(Row, { gap: 1, children: [_jsx("text", { content: current.at, fg: "subtle", shrink: 0 }), current.peer ? _jsx("text", { content: current.peer, fg: "muted", flex: 1, truncate: "start" }) : null] }), _jsx(ScrollView, { flex: 1, focusId: "wire.frame", children: _jsx(Column, { children: linesOf(current.frame).map((line, index) => (_jsx("text", { content: line, wrap: "none", truncate: "end", ...(current.kind === 'error' && index === 0 ? { fg: 'danger' } : {}) }, index))) }) })] })) : (_jsx(Panel, { title: "Frame", ...(wide ? { width: aside } : { flex: 1 }), children: _jsx("text", { content: "No frame under the highlight.", fg: "subtle" }) }));
|
|
98
|
+
if (!wide)
|
|
99
|
+
return open ? frame : list;
|
|
100
|
+
return (_jsxs(Row, { flex: 1, gap: 1, children: [list, open ? frame : null] }));
|
|
101
|
+
});
|
|
102
|
+
const WireHeader = defineComponent('WireHeader', () => {
|
|
103
|
+
const theme = useTheme();
|
|
104
|
+
const file = useStoreValue(WIRE_FILE, '') ?? '';
|
|
105
|
+
return (_jsxs(Row, { gap: 1, children: [_jsx("text", { content: "Wire", bold: true, fg: "accent", shrink: 0 }), _jsx("text", { content: theme.glyphs.separator, fg: "subtle", shrink: 0 }), _jsx("text", { content: file, fg: "muted", flex: 1, truncate: "start" })] }));
|
|
106
|
+
});
|
|
107
|
+
const WireHints = defineComponent('WireHints', (props) => {
|
|
108
|
+
const theme = useTheme();
|
|
109
|
+
const following = useStoreValue(WIRE_FOLLOW, true) ?? true;
|
|
110
|
+
return (_jsx(KeyHints, { ...props, hints: [
|
|
111
|
+
{ keys: `${theme.glyphs.arrowUp}${theme.glyphs.arrowDown}`, label: 'frame' },
|
|
112
|
+
{ keys: 'enter', label: 'open' },
|
|
113
|
+
{ keys: 'esc', label: 'back' },
|
|
114
|
+
{ keys: 'f', label: following ? 'stop following' : 'follow' },
|
|
115
|
+
{ keys: 'ctrl+f', label: 'filter' },
|
|
116
|
+
{ keys: 'ctrl+c', label: 'quit' },
|
|
117
|
+
] }));
|
|
118
|
+
});
|
|
119
|
+
/**
|
|
120
|
+
* The wire screen on an application, reading one file.
|
|
121
|
+
*
|
|
122
|
+
* The same shape as `registerChat`, and nothing shared with it: no host, no
|
|
123
|
+
* controller, no sessions. What comes back takes the reader down with it.
|
|
124
|
+
*/
|
|
125
|
+
export function registerWire(app, options) {
|
|
126
|
+
const bag = createBag();
|
|
127
|
+
if (options.builtins !== false)
|
|
128
|
+
bag.add(registerBuiltins(app));
|
|
129
|
+
app.store.set(WIRE_FILE, options.file);
|
|
130
|
+
app.store.set(WIRE_ROWS, []);
|
|
131
|
+
app.store.set(WIRE_FOLLOW, true);
|
|
132
|
+
app.store.set(WIRE_FRAME, false);
|
|
133
|
+
for (const [component, render] of [
|
|
134
|
+
['WireList', WireList],
|
|
135
|
+
['WireScreen', WireScreen],
|
|
136
|
+
['WireHeader', WireHeader],
|
|
137
|
+
['WireHints', WireHints],
|
|
138
|
+
]) {
|
|
139
|
+
bag.add(app.components.register({ component, category: 'template', renderer: { kind: 'function', render: render } }));
|
|
140
|
+
}
|
|
141
|
+
bag.add(app.surfaces.open({ surface: 'header', key: 'title', target: { component: 'WireHeader' } }));
|
|
142
|
+
bag.add(app.surfaces.open({ surface: 'status', key: 'status', target: { component: 'WireHints' } }));
|
|
143
|
+
bag.add(app.screens.register({ id: 'wire', component: 'WireScreen' }));
|
|
144
|
+
bag.add(app.commands.register({
|
|
145
|
+
id: 'wire.follow',
|
|
146
|
+
title: 'Follow the wire',
|
|
147
|
+
category: 'Wire',
|
|
148
|
+
description: 'Keep the highlight on the newest frame',
|
|
149
|
+
slots: ['palette'],
|
|
150
|
+
keepOpen: true,
|
|
151
|
+
checked: WIRE_FOLLOW,
|
|
152
|
+
run: () => app.store.set(WIRE_FOLLOW, !(app.store.get(WIRE_FOLLOW) ?? true)),
|
|
153
|
+
}));
|
|
154
|
+
bag.add(app.commands.register({
|
|
155
|
+
id: 'wire.filter',
|
|
156
|
+
title: 'Filter the wire',
|
|
157
|
+
category: 'Wire',
|
|
158
|
+
description: 'Keep the frames whose method, action type or channel match',
|
|
159
|
+
slots: ['palette'],
|
|
160
|
+
run: () => app.focus.focus('wire.filter'),
|
|
161
|
+
}));
|
|
162
|
+
bag.add(app.commands.register({
|
|
163
|
+
id: 'wire.openFrame',
|
|
164
|
+
title: 'Open the frame',
|
|
165
|
+
category: 'Wire',
|
|
166
|
+
description: 'Read the frame under the highlight',
|
|
167
|
+
slots: ['palette'],
|
|
168
|
+
run: () => { app.store.set(WIRE_FRAME, true); app.focus.focus('wire.frame'); },
|
|
169
|
+
}));
|
|
170
|
+
bag.add(app.commands.register({
|
|
171
|
+
id: 'wire.closeFrame',
|
|
172
|
+
title: 'Back to the list',
|
|
173
|
+
category: 'Wire',
|
|
174
|
+
description: 'Put the frame away',
|
|
175
|
+
slots: ['palette'],
|
|
176
|
+
run: () => { app.store.set(WIRE_FRAME, false); app.focus.focus('wire.list'); },
|
|
177
|
+
}));
|
|
178
|
+
for (const binding of [
|
|
179
|
+
{ keys: 'f', commandId: 'wire.follow', scopeId: WIRE_SCOPE },
|
|
180
|
+
{ keys: 'ctrl+f', commandId: 'wire.filter' },
|
|
181
|
+
{ keys: 'right', commandId: 'wire.openFrame', scopeId: WIRE_SCOPE },
|
|
182
|
+
{ keys: 'left', commandId: 'wire.closeFrame', scopeId: WIRE_SCOPE },
|
|
183
|
+
{ keys: 'escape', commandId: 'wire.closeFrame', scopeId: WIRE_SCOPE },
|
|
184
|
+
])
|
|
185
|
+
bag.add(app.keybindings.register(binding));
|
|
186
|
+
// The file, folded in as it grows. Kept to the newest `KEPT`, and the
|
|
187
|
+
// sequence numbers keep counting so a row's name never changes under it.
|
|
188
|
+
const reader = follow(options.file, (rows) => {
|
|
189
|
+
const held = app.store.get(WIRE_ROWS) ?? [];
|
|
190
|
+
const next = [...held, ...rows];
|
|
191
|
+
app.store.set(WIRE_ROWS, next.length > KEPT ? next.slice(next.length - KEPT) : next);
|
|
192
|
+
}, options.interval === undefined ? {} : { interval: options.interval });
|
|
193
|
+
bag.add({ dispose: () => reader.close() });
|
|
194
|
+
app.screens.reset('wire');
|
|
195
|
+
return bag;
|
|
196
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A wire capture, read back.
|
|
3
|
+
*
|
|
4
|
+
* `--wire <file>` on either end writes one line per frame, `{ at, from, peer,
|
|
5
|
+
* frame }`, and this is the other half: the lines as rows a screen can list,
|
|
6
|
+
* and a reader that keeps up with a file still being written. Nothing here
|
|
7
|
+
* renders, and nothing here needs a host - a capture is a file, and the two
|
|
8
|
+
* ends of a conversation can be read from the same shell.
|
|
9
|
+
*/
|
|
10
|
+
/** One line of a capture, as written by `ahpd --wire` and `ahpc --wire`. */
|
|
11
|
+
export interface WireLine {
|
|
12
|
+
at: string;
|
|
13
|
+
from: 'client' | 'host';
|
|
14
|
+
peer?: string;
|
|
15
|
+
frame: unknown;
|
|
16
|
+
}
|
|
17
|
+
/** One frame, described. Everything a row shows is decided here, once. */
|
|
18
|
+
export interface WireRow {
|
|
19
|
+
/** Its line number in the file, which is the only name a frame has. */
|
|
20
|
+
seq: number;
|
|
21
|
+
at: string;
|
|
22
|
+
from: 'client' | 'host';
|
|
23
|
+
peer: string;
|
|
24
|
+
/** What kind of JSON-RPC message the frame is. */
|
|
25
|
+
kind: 'request' | 'response' | 'error' | 'notification' | 'text';
|
|
26
|
+
/** The method, or for a response the method of the request it answers. */
|
|
27
|
+
method: string;
|
|
28
|
+
/** The request id, where there is one. */
|
|
29
|
+
id?: string;
|
|
30
|
+
/** The channel the frame names, where it names one. */
|
|
31
|
+
channel?: string;
|
|
32
|
+
/** The action type, for a dispatch or an action. */
|
|
33
|
+
type?: string;
|
|
34
|
+
/** One line about the payload: the error message, the action, the text. */
|
|
35
|
+
detail: string;
|
|
36
|
+
frame: unknown;
|
|
37
|
+
}
|
|
38
|
+
/** One line parsed, or undefined for one that is not a capture line. */
|
|
39
|
+
export declare function parseWireLine(text: string): WireLine | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* The frames described, in order.
|
|
42
|
+
*
|
|
43
|
+
* A response says nothing about what it answers, so the requests are held by
|
|
44
|
+
* id and direction until their answer goes by - which is what puts a method
|
|
45
|
+
* on a response row, and is why this is a reader with state rather than a
|
|
46
|
+
* function of one line.
|
|
47
|
+
*/
|
|
48
|
+
export declare function describer(): (line: WireLine, seq: number) => WireRow;
|
|
49
|
+
/** Whether a row is one the filter keeps. A substring of anything a row shows, the way the catalogue filters. */
|
|
50
|
+
export declare const matches: (row: WireRow, query: string) => boolean;
|
|
51
|
+
export interface Follower {
|
|
52
|
+
/** Stop reading. */
|
|
53
|
+
close(): void;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The file, as it grows.
|
|
57
|
+
*
|
|
58
|
+
* Read from the top, then from wherever the last read stopped whenever the
|
|
59
|
+
* file changes - `fs.watch` where the platform has it, and a poll beside it
|
|
60
|
+
* because a watcher on a file being appended to by another process is not
|
|
61
|
+
* something every platform delivers. A line still being written is kept
|
|
62
|
+
* until its newline arrives, so a frame is never handed over in halves. A
|
|
63
|
+
* file cut back to nothing, which is what a new `--wire` run does, starts
|
|
64
|
+
* over rather than reading past its end.
|
|
65
|
+
*/
|
|
66
|
+
export declare function follow(path: string, onRows: (rows: WireRow[]) => void, options?: {
|
|
67
|
+
interval?: number;
|
|
68
|
+
}): Follower;
|
|
69
|
+
/** A row as one line of text, for a shell that is not a terminal. */
|
|
70
|
+
export declare const rowText: (row: WireRow) => string;
|
package/dist/src/wire.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A wire capture, read back.
|
|
3
|
+
*
|
|
4
|
+
* `--wire <file>` on either end writes one line per frame, `{ at, from, peer,
|
|
5
|
+
* frame }`, and this is the other half: the lines as rows a screen can list,
|
|
6
|
+
* and a reader that keeps up with a file still being written. Nothing here
|
|
7
|
+
* renders, and nothing here needs a host - a capture is a file, and the two
|
|
8
|
+
* ends of a conversation can be read from the same shell.
|
|
9
|
+
*/
|
|
10
|
+
import { closeSync, openSync, readSync, statSync, watch } from 'node:fs';
|
|
11
|
+
const bag = (value) => (typeof value === 'object' && value !== null ? value : {});
|
|
12
|
+
const str = (value) => (typeof value === 'string' ? value : undefined);
|
|
13
|
+
/** One line parsed, or undefined for one that is not a capture line. */
|
|
14
|
+
export function parseWireLine(text) {
|
|
15
|
+
let held;
|
|
16
|
+
try {
|
|
17
|
+
held = JSON.parse(text);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const line = bag(held);
|
|
23
|
+
if (typeof line.at !== 'string' || (line.from !== 'client' && line.from !== 'host') || !('frame' in line))
|
|
24
|
+
return undefined;
|
|
25
|
+
return { at: line.at, from: line.from, ...(typeof line.peer === 'string' ? { peer: line.peer } : {}), frame: line.frame };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The frames described, in order.
|
|
29
|
+
*
|
|
30
|
+
* A response says nothing about what it answers, so the requests are held by
|
|
31
|
+
* id and direction until their answer goes by - which is what puts a method
|
|
32
|
+
* on a response row, and is why this is a reader with state rather than a
|
|
33
|
+
* function of one line.
|
|
34
|
+
*/
|
|
35
|
+
export function describer() {
|
|
36
|
+
/** Requests waiting for an answer, by the direction they went and their id. */
|
|
37
|
+
const pending = new Map();
|
|
38
|
+
return (line, seq) => {
|
|
39
|
+
const frame = bag(line.frame);
|
|
40
|
+
const peer = line.peer ?? '';
|
|
41
|
+
if (typeof line.frame !== 'object' || line.frame === null) {
|
|
42
|
+
return { seq, at: line.at, from: line.from, peer, kind: 'text', method: '', detail: String(line.frame), frame: line.frame };
|
|
43
|
+
}
|
|
44
|
+
const id = frame.id === undefined || frame.id === null ? undefined : String(frame.id);
|
|
45
|
+
const params = bag(frame.params);
|
|
46
|
+
const channel = str(params.channel);
|
|
47
|
+
const action = bag(params.action);
|
|
48
|
+
const type = str(action.type);
|
|
49
|
+
if (typeof frame.method === 'string') {
|
|
50
|
+
const method = frame.method;
|
|
51
|
+
if (id !== undefined)
|
|
52
|
+
pending.set(`${line.from}:${id}`, method);
|
|
53
|
+
return {
|
|
54
|
+
seq, at: line.at, from: line.from, peer,
|
|
55
|
+
kind: id === undefined ? 'notification' : 'request',
|
|
56
|
+
method, ...(id === undefined ? {} : { id }), ...(channel === undefined ? {} : { channel }), ...(type === undefined ? {} : { type }),
|
|
57
|
+
detail: detailOf(method, params, action),
|
|
58
|
+
frame: line.frame,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
// An answer travels the other way from the question it answers.
|
|
62
|
+
const asked = line.from === 'client' ? 'host' : 'client';
|
|
63
|
+
const method = id === undefined ? '' : pending.get(`${asked}:${id}`) ?? '';
|
|
64
|
+
if (id !== undefined)
|
|
65
|
+
pending.delete(`${asked}:${id}`);
|
|
66
|
+
if ('error' in frame) {
|
|
67
|
+
const error = bag(frame.error);
|
|
68
|
+
return { seq, at: line.at, from: line.from, peer, kind: 'error', method, ...(id === undefined ? {} : { id }), detail: `${str(error.code) ?? String(error.code ?? '')} ${str(error.message) ?? ''}`.trim(), frame: line.frame };
|
|
69
|
+
}
|
|
70
|
+
return { seq, at: line.at, from: line.from, peer, kind: 'response', method, ...(id === undefined ? {} : { id }), detail: summaryOf(frame.result), frame: line.frame };
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** What a request or a notification is about, in a few words. */
|
|
74
|
+
const detailOf = (method, params, action) => {
|
|
75
|
+
// The action type is the row's own field; what is left is what the action
|
|
76
|
+
// carried, where it carried words.
|
|
77
|
+
if (method === 'dispatchAction' || method === 'action') {
|
|
78
|
+
const text = str(bag(action.message).text) ?? str(action.text) ?? str(action.title);
|
|
79
|
+
return text === undefined ? '' : text.replace(/\s+/g, ' ').slice(0, 80);
|
|
80
|
+
}
|
|
81
|
+
if (method === 'initialize')
|
|
82
|
+
return str(params.clientId) ?? '';
|
|
83
|
+
if (method === 'createSession')
|
|
84
|
+
return `${str(params.provider) ?? ''} ${str(params.channel) ?? ''}`.trim();
|
|
85
|
+
// The channel is the row's own field too; what else the request names.
|
|
86
|
+
return str(params.uri) ?? str(params.session) ?? str(params.resource) ?? str(params.provider) ?? '';
|
|
87
|
+
};
|
|
88
|
+
/** What a result is, in a few words: its keys, or the value when it is one. */
|
|
89
|
+
const summaryOf = (result) => {
|
|
90
|
+
if (result === undefined || result === null)
|
|
91
|
+
return '';
|
|
92
|
+
if (typeof result !== 'object')
|
|
93
|
+
return String(result);
|
|
94
|
+
if (Array.isArray(result))
|
|
95
|
+
return `${result.length} item${result.length === 1 ? '' : 's'}`;
|
|
96
|
+
const keys = Object.keys(result);
|
|
97
|
+
return keys.length === 0 ? '{}' : keys.slice(0, 6).join(', ') + (keys.length > 6 ? ', …' : '');
|
|
98
|
+
};
|
|
99
|
+
/** Whether a row is one the filter keeps. A substring of anything a row shows, the way the catalogue filters. */
|
|
100
|
+
export const matches = (row, query) => {
|
|
101
|
+
const wanted = query.trim().toLowerCase();
|
|
102
|
+
if (wanted === '')
|
|
103
|
+
return true;
|
|
104
|
+
return [row.method, row.channel ?? '', row.type ?? '', row.kind, row.from, row.id ?? '', row.detail, row.peer]
|
|
105
|
+
.some((field) => field.toLowerCase().includes(wanted));
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* The file, as it grows.
|
|
109
|
+
*
|
|
110
|
+
* Read from the top, then from wherever the last read stopped whenever the
|
|
111
|
+
* file changes - `fs.watch` where the platform has it, and a poll beside it
|
|
112
|
+
* because a watcher on a file being appended to by another process is not
|
|
113
|
+
* something every platform delivers. A line still being written is kept
|
|
114
|
+
* until its newline arrives, so a frame is never handed over in halves. A
|
|
115
|
+
* file cut back to nothing, which is what a new `--wire` run does, starts
|
|
116
|
+
* over rather than reading past its end.
|
|
117
|
+
*/
|
|
118
|
+
export function follow(path, onRows, options = {}) {
|
|
119
|
+
const describe = describer();
|
|
120
|
+
let offset = 0;
|
|
121
|
+
let seq = 0;
|
|
122
|
+
let partial = '';
|
|
123
|
+
let closed = false;
|
|
124
|
+
const read = () => {
|
|
125
|
+
if (closed)
|
|
126
|
+
return;
|
|
127
|
+
let size;
|
|
128
|
+
try {
|
|
129
|
+
size = statSync(path).size;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (size < offset) {
|
|
135
|
+
offset = 0;
|
|
136
|
+
partial = '';
|
|
137
|
+
}
|
|
138
|
+
if (size === offset)
|
|
139
|
+
return;
|
|
140
|
+
const fd = openSync(path, 'r');
|
|
141
|
+
let chunk;
|
|
142
|
+
try {
|
|
143
|
+
chunk = Buffer.alloc(size - offset);
|
|
144
|
+
readSync(fd, chunk, 0, chunk.length, offset);
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
closeSync(fd);
|
|
148
|
+
}
|
|
149
|
+
offset = size;
|
|
150
|
+
const text = partial + chunk.toString('utf8');
|
|
151
|
+
const lines = text.split('\n');
|
|
152
|
+
partial = lines.pop() ?? '';
|
|
153
|
+
const rows = [];
|
|
154
|
+
for (const line of lines) {
|
|
155
|
+
if (line.trim() === '')
|
|
156
|
+
continue;
|
|
157
|
+
const parsed = parseWireLine(line);
|
|
158
|
+
seq += 1;
|
|
159
|
+
if (parsed === undefined) {
|
|
160
|
+
rows.push({ seq, at: '', from: 'client', peer: '', kind: 'text', method: '', detail: line.slice(0, 120), frame: line });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
rows.push(describe(parsed, seq));
|
|
164
|
+
}
|
|
165
|
+
if (rows.length > 0)
|
|
166
|
+
onRows(rows);
|
|
167
|
+
};
|
|
168
|
+
read();
|
|
169
|
+
const timer = setInterval(read, options.interval ?? 250);
|
|
170
|
+
timer.unref?.();
|
|
171
|
+
let watcher;
|
|
172
|
+
try {
|
|
173
|
+
watcher = watch(path, () => { read(); });
|
|
174
|
+
watcher.unref?.();
|
|
175
|
+
watcher.on('error', () => { watcher = undefined; });
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
watcher = undefined;
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
close: () => {
|
|
182
|
+
closed = true;
|
|
183
|
+
clearInterval(timer);
|
|
184
|
+
watcher?.close();
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/** A row as one line of text, for a shell that is not a terminal. */
|
|
189
|
+
export const rowText = (row) => {
|
|
190
|
+
const time = row.at.length >= 23 ? row.at.slice(11, 23) : row.at;
|
|
191
|
+
const arrow = row.from === 'client' ? '->' : '<-';
|
|
192
|
+
const what = row.kind === 'response' ? `${row.method} ok` : row.kind === 'error' ? `${row.method} error` : row.method;
|
|
193
|
+
return [time, arrow, what || row.kind, row.type ?? '', row.channel ?? '', row.detail].filter((one) => one !== '').join(' ');
|
|
194
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ahpc wire <file>`: a capture, watched.
|
|
3
|
+
*
|
|
4
|
+
* A screen with no host behind it. On a terminal it is the wire screen,
|
|
5
|
+
* reading the file as it grows; anywhere else it is one line per frame to
|
|
6
|
+
* stdout, which is what a shell wants from it. Loaded only when asked for,
|
|
7
|
+
* for the same reason the conversation screen is: it pulls in a renderer,
|
|
8
|
+
* and `ahpc session list` should not pay for one.
|
|
9
|
+
*/
|
|
10
|
+
export interface WireTuiOptions {
|
|
11
|
+
file: string;
|
|
12
|
+
theme?: string;
|
|
13
|
+
shell?: string;
|
|
14
|
+
/** Keep printing as the file grows, off a terminal. */
|
|
15
|
+
follow?: boolean;
|
|
16
|
+
/** One JSON object per row rather than a line for reading. */
|
|
17
|
+
json?: boolean;
|
|
18
|
+
/** Only the rows this matches, off a terminal; on one the filter box is the same thing. */
|
|
19
|
+
filter?: string;
|
|
20
|
+
}
|
|
21
|
+
/** The screen, or the lines. */
|
|
22
|
+
export declare function wireTui(options: WireTuiOptions): Promise<void>;
|