@softov/ahpc 0.2.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.
Files changed (73) hide show
  1. package/README.md +37 -6
  2. package/dist/src/ahp/fake.js +61 -14
  3. package/dist/src/ahp/live.d.ts +7 -0
  4. package/dist/src/ahp/live.js +156 -43
  5. package/dist/src/ahp/publish.js +13 -0
  6. package/dist/src/ahp/types.d.ts +20 -0
  7. package/dist/src/app.js +34 -7
  8. package/dist/src/blocks.d.ts +4 -74
  9. package/dist/src/blocks.js +10 -48
  10. package/dist/src/cli/main.d.ts +1 -1
  11. package/dist/src/cli/main.js +109 -6
  12. package/dist/src/connect.d.ts +2 -0
  13. package/dist/src/connect.js +1 -0
  14. package/dist/src/control.d.ts +6 -0
  15. package/dist/src/control.js +140 -10
  16. package/dist/src/flags.js +4 -1
  17. package/dist/src/links.d.ts +54 -0
  18. package/dist/src/links.js +120 -0
  19. package/dist/src/main.js +14 -2
  20. package/dist/src/mcp/http.d.ts +10 -0
  21. package/dist/src/mcp/http.js +110 -3
  22. package/dist/src/mcp/serve.d.ts +47 -9
  23. package/dist/src/mcp/serve.js +65 -14
  24. package/dist/src/mcp/stdio.d.ts +1 -0
  25. package/dist/src/mcp/stdio.js +7 -1
  26. package/dist/src/mcp/tools.d.ts +37 -2
  27. package/dist/src/mcp/tools.js +439 -2
  28. package/dist/src/resources.d.ts +13 -0
  29. package/dist/src/resources.js +46 -0
  30. package/dist/src/screens.js +95 -50
  31. package/dist/src/state.d.ts +42 -0
  32. package/dist/src/state.js +80 -1
  33. package/dist/src/tui.d.ts +3 -1
  34. package/dist/src/tui.js +29 -4
  35. package/dist/src/version.d.ts +2 -0
  36. package/dist/src/version.js +38 -0
  37. package/dist/src/view/creature.d.ts +0 -12
  38. package/dist/src/view/creature.js +0 -20
  39. package/dist/src/view/wire.d.ts +36 -0
  40. package/dist/src/view/wire.js +196 -0
  41. package/dist/src/wait.d.ts +11 -0
  42. package/dist/src/wait.js +15 -2
  43. package/dist/src/wire.d.ts +70 -0
  44. package/dist/src/wire.js +194 -0
  45. package/dist/src/wiretui.d.ts +22 -0
  46. package/dist/src/wiretui.js +69 -0
  47. package/package.json +6 -5
  48. package/dist/src/diff.d.ts +0 -44
  49. package/dist/src/diff.js +0 -111
  50. package/dist/src/view/bubble.d.ts +0 -75
  51. package/dist/src/view/bubble.js +0 -86
  52. package/dist/src/view/composer.d.ts +0 -64
  53. package/dist/src/view/composer.js +0 -192
  54. package/dist/src/view/controls.d.ts +0 -44
  55. package/dist/src/view/controls.js +0 -49
  56. package/dist/src/view/details.d.ts +0 -65
  57. package/dist/src/view/details.js +0 -65
  58. package/dist/src/view/filediff.d.ts +0 -29
  59. package/dist/src/view/filediff.js +0 -24
  60. package/dist/src/view/hitl.d.ts +0 -43
  61. package/dist/src/view/hitl.js +0 -171
  62. package/dist/src/view/icons.d.ts +0 -13
  63. package/dist/src/view/icons.js +0 -71
  64. package/dist/src/view/picker.d.ts +0 -42
  65. package/dist/src/view/picker.js +0 -71
  66. package/dist/src/view/sessionhead.d.ts +0 -41
  67. package/dist/src/view/sessionhead.js +0 -60
  68. package/dist/src/view/sessions.d.ts +0 -34
  69. package/dist/src/view/sessions.js +0 -61
  70. package/dist/src/view/toolcall.d.ts +0 -27
  71. package/dist/src/view/toolcall.js +0 -48
  72. package/dist/src/view/transcript.d.ts +0 -50
  73. package/dist/src/view/transcript.js +0 -60
@@ -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
+ }
@@ -24,6 +24,17 @@ export interface TurnOptions {
24
24
  id: string;
25
25
  name: string;
26
26
  }): void;
27
+ /**
28
+ * A tool the agent has started using, said once per call.
29
+ *
30
+ * Every tool call rather than only the ones that stop for a person, because
31
+ * this is what a caller watching a long turn has to go on: `onWaiting` fires
32
+ * on an approval and most turns never ask for one.
33
+ */
34
+ onStep?(call: {
35
+ id: string;
36
+ name: string;
37
+ }): void;
27
38
  }
28
39
  /**
29
40
  * Say something, and block until the turn it starts has finished.
package/dist/src/wait.js CHANGED
@@ -79,6 +79,14 @@ export async function turn(host, uri, text, options = {}) {
79
79
  let first = true;
80
80
  let noted;
81
81
  let answer;
82
+ /** Tool calls already reported, so a snapshot rebuilt per token says each once. */
83
+ const stepped = new Set();
84
+ const step = (call) => {
85
+ if (stepped.has(call.id))
86
+ return;
87
+ stepped.add(call.id);
88
+ options.onStep?.(call);
89
+ };
82
90
  const finished = until(host, uri, (event) => {
83
91
  /*
84
92
  * Two vocabularies, because a host may speak either.
@@ -98,8 +106,9 @@ export async function turn(host, uri, text, options = {}) {
98
106
  options.onDelta?.(event.text);
99
107
  return false;
100
108
  }
101
- if (event.type === 'toolCall' && event.call.status === 'pending-confirmation') {
102
- if (noted !== event.call.id) {
109
+ if (event.type === 'toolCall') {
110
+ step({ id: event.call.id, name: event.call.name });
111
+ if (event.call.status === 'pending-confirmation' && noted !== event.call.id) {
103
112
  noted = event.call.id;
104
113
  options.onWaiting?.({ id: event.call.id, name: event.call.name });
105
114
  }
@@ -124,6 +133,10 @@ export async function turn(host, uri, text, options = {}) {
124
133
  options.onDelta?.(now.slice(given));
125
134
  given = now.length;
126
135
  }
136
+ for (const part of event.active.parts) {
137
+ if (part.kind === 'toolCall')
138
+ step({ id: part.call.id, name: part.call.name });
139
+ }
127
140
  const call = event.active.parts.find((part) => part.kind === 'toolCall'
128
141
  && part.call.status === 'pending-confirmation');
129
142
  if (call?.kind === 'toolCall' && noted !== call.call.id) {
@@ -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;
@@ -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>;
@@ -0,0 +1,69 @@
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
+ import { WRITER_KEY, createApp } from '@textui/core';
11
+ import { createNodeTerminal, createWriter } from '@textui/terminal';
12
+ import { registerWire } from './view/wire.js';
13
+ import { follow, matches, rowText } from './wire.js';
14
+ /** The screen, or the lines. */
15
+ export async function wireTui(options) {
16
+ if (!process.stdout.isTTY || options.json) {
17
+ await new Promise((resolve) => {
18
+ let quiet;
19
+ const done = () => { reader.close(); resolve(); };
20
+ const reader = follow(options.file, (rows) => {
21
+ for (const row of rows) {
22
+ if (options.filter !== undefined && !matches(row, options.filter))
23
+ continue;
24
+ process.stdout.write(options.json ? `${JSON.stringify(row)}\n` : `${rowText(row)}\n`);
25
+ }
26
+ if (!options.follow) {
27
+ if (quiet)
28
+ clearTimeout(quiet);
29
+ quiet = setTimeout(done, 50);
30
+ }
31
+ });
32
+ // Without `--follow`, what is there now and then out: the first read
33
+ // is synchronous, so nothing arriving in the next moment means the
34
+ // file has been read to its end.
35
+ if (!options.follow && quiet === undefined)
36
+ quiet = setTimeout(done, 50);
37
+ });
38
+ return;
39
+ }
40
+ const terminal = createNodeTerminal();
41
+ const app = createApp({
42
+ terminal,
43
+ ...(options.theme ? { theme: options.theme } : {}),
44
+ ...(options.shell ? { shell: options.shell } : {}),
45
+ session: { managed: true, altScreen: true, mouse: true, title: 'wire' },
46
+ onBoot: (booted) => {
47
+ registerWire(booted, { file: options.file });
48
+ booted.commands.register({
49
+ id: 'app.quit',
50
+ title: 'Quit',
51
+ slots: ['palette'],
52
+ run: () => void app.stop().then(() => process.exit(0)),
53
+ });
54
+ booted.keybindings.register({ keys: 'ctrl+c', commandId: 'app.quit' });
55
+ booted.keybindings.register({ keys: 'ctrl+q', commandId: 'app.quit' });
56
+ booted.keybindings.register({ keys: 'q', commandId: 'app.quit', scopeId: 'wire.rows' });
57
+ },
58
+ });
59
+ app.services.provide(WRITER_KEY, createWriter(terminal.capabilities()));
60
+ await app.start();
61
+ const bail = (label) => (error) => {
62
+ void app.stop().finally(() => {
63
+ process.stderr.write(`${label}: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
64
+ process.exit(1);
65
+ });
66
+ };
67
+ process.on('unhandledRejection', bail('Unhandled rejection'));
68
+ process.on('uncaughtException', bail('Uncaught exception'));
69
+ }