@softov/ahpc 0.1.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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +296 -0
  3. package/dist/src/ahp/channels.d.ts +146 -0
  4. package/dist/src/ahp/channels.js +423 -0
  5. package/dist/src/ahp/connection.d.ts +656 -0
  6. package/dist/src/ahp/connection.js +1 -0
  7. package/dist/src/ahp/fake.d.ts +69 -0
  8. package/dist/src/ahp/fake.js +1996 -0
  9. package/dist/src/ahp/live.d.ts +160 -0
  10. package/dist/src/ahp/live.js +2641 -0
  11. package/dist/src/ahp/operate.d.ts +36 -0
  12. package/dist/src/ahp/operate.js +56 -0
  13. package/dist/src/ahp/publish.d.ts +39 -0
  14. package/dist/src/ahp/publish.js +370 -0
  15. package/dist/src/ahp/status.d.ts +23 -0
  16. package/dist/src/ahp/status.js +23 -0
  17. package/dist/src/ahp/types.d.ts +745 -0
  18. package/dist/src/ahp/types.js +27 -0
  19. package/dist/src/app.d.ts +54 -0
  20. package/dist/src/app.js +369 -0
  21. package/dist/src/blocks.d.ts +88 -0
  22. package/dist/src/blocks.js +102 -0
  23. package/dist/src/cli/main.d.ts +13 -0
  24. package/dist/src/cli/main.js +1537 -0
  25. package/dist/src/cli/render.d.ts +43 -0
  26. package/dist/src/cli/render.js +91 -0
  27. package/dist/src/config.d.ts +63 -0
  28. package/dist/src/config.js +45 -0
  29. package/dist/src/connect.d.ts +53 -0
  30. package/dist/src/connect.js +69 -0
  31. package/dist/src/control.d.ts +189 -0
  32. package/dist/src/control.js +2241 -0
  33. package/dist/src/diff.d.ts +44 -0
  34. package/dist/src/diff.js +111 -0
  35. package/dist/src/flags.d.ts +28 -0
  36. package/dist/src/flags.js +76 -0
  37. package/dist/src/main.d.ts +10 -0
  38. package/dist/src/main.js +54 -0
  39. package/dist/src/schedule.d.ts +46 -0
  40. package/dist/src/schedule.js +140 -0
  41. package/dist/src/screens.d.ts +79 -0
  42. package/dist/src/screens.js +1322 -0
  43. package/dist/src/state.d.ts +355 -0
  44. package/dist/src/state.js +531 -0
  45. package/dist/src/terminal.d.ts +44 -0
  46. package/dist/src/terminal.js +101 -0
  47. package/dist/src/tui.d.ts +92 -0
  48. package/dist/src/tui.js +372 -0
  49. package/dist/src/view/automations.d.ts +31 -0
  50. package/dist/src/view/automations.js +76 -0
  51. package/dist/src/view/bood/art.d.ts +90 -0
  52. package/dist/src/view/bood/art.js +152 -0
  53. package/dist/src/view/bood/beetle.d.ts +7 -0
  54. package/dist/src/view/bood/beetle.js +123 -0
  55. package/dist/src/view/bood/bunny.d.ts +8 -0
  56. package/dist/src/view/bood/bunny.js +228 -0
  57. package/dist/src/view/bood/cat.d.ts +9 -0
  58. package/dist/src/view/bood/cat.js +358 -0
  59. package/dist/src/view/bood/crab.d.ts +7 -0
  60. package/dist/src/view/bood/crab.js +121 -0
  61. package/dist/src/view/bood/index.d.ts +29 -0
  62. package/dist/src/view/bood/index.js +25 -0
  63. package/dist/src/view/bood/motion.d.ts +178 -0
  64. package/dist/src/view/bood/motion.js +298 -0
  65. package/dist/src/view/bood/owl.d.ts +9 -0
  66. package/dist/src/view/bood/owl.js +285 -0
  67. package/dist/src/view/bood/registry.d.ts +36 -0
  68. package/dist/src/view/bood/registry.js +218 -0
  69. package/dist/src/view/bood/render.d.ts +31 -0
  70. package/dist/src/view/bood/render.js +196 -0
  71. package/dist/src/view/bood/sprite.d.ts +18 -0
  72. package/dist/src/view/bood/sprite.js +130 -0
  73. package/dist/src/view/bood/sprout.d.ts +8 -0
  74. package/dist/src/view/bood/sprout.js +121 -0
  75. package/dist/src/view/bood/types.d.ts +158 -0
  76. package/dist/src/view/bood/types.js +57 -0
  77. package/dist/src/view/bubble.d.ts +75 -0
  78. package/dist/src/view/bubble.js +86 -0
  79. package/dist/src/view/changes.d.ts +35 -0
  80. package/dist/src/view/changes.js +22 -0
  81. package/dist/src/view/composer.d.ts +64 -0
  82. package/dist/src/view/composer.js +192 -0
  83. package/dist/src/view/controls.d.ts +44 -0
  84. package/dist/src/view/controls.js +49 -0
  85. package/dist/src/view/creature.d.ts +69 -0
  86. package/dist/src/view/creature.js +83 -0
  87. package/dist/src/view/customizations.d.ts +28 -0
  88. package/dist/src/view/customizations.js +53 -0
  89. package/dist/src/view/details.d.ts +65 -0
  90. package/dist/src/view/details.js +65 -0
  91. package/dist/src/view/filediff.d.ts +29 -0
  92. package/dist/src/view/filediff.js +24 -0
  93. package/dist/src/view/files.d.ts +31 -0
  94. package/dist/src/view/files.js +39 -0
  95. package/dist/src/view/hitl.d.ts +43 -0
  96. package/dist/src/view/hitl.js +171 -0
  97. package/dist/src/view/icons.d.ts +13 -0
  98. package/dist/src/view/icons.js +71 -0
  99. package/dist/src/view/picker.d.ts +42 -0
  100. package/dist/src/view/picker.js +71 -0
  101. package/dist/src/view/sessionhead.d.ts +41 -0
  102. package/dist/src/view/sessionhead.js +60 -0
  103. package/dist/src/view/sessions.d.ts +34 -0
  104. package/dist/src/view/sessions.js +61 -0
  105. package/dist/src/view/terminal.d.ts +30 -0
  106. package/dist/src/view/terminal.js +32 -0
  107. package/dist/src/view/toolcall.d.ts +27 -0
  108. package/dist/src/view/toolcall.js +48 -0
  109. package/dist/src/view/transcript.d.ts +50 -0
  110. package/dist/src/view/transcript.js +60 -0
  111. package/package.json +72 -0
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The protocol, as a client reads it.
3
+ *
4
+ * These are AHP's own names for AHP's own shapes - `SessionSummary.status` is
5
+ * the bitset the host sends, `responseParts` is one ordered stream, a pending
6
+ * input is either a tool confirmation or a question. Renaming them here would
7
+ * only mean translating twice, and the whole point of the example is to find
8
+ * out which components the *protocol's* shapes need.
9
+ *
10
+ * It is a subset: what a chat client has to render. The authority is the
11
+ * `@agent-host-protocol` package's own `src/types/`.
12
+ */
13
+ /**
14
+ * Status is activity and client flags in one number.
15
+ *
16
+ * `InputNeeded` carries `InProgress`, so it has to be tested first - a turn
17
+ * waiting on a confirmation otherwise reads as merely running, and nobody
18
+ * goes to answer it.
19
+ */
20
+ export const SessionFlag = {
21
+ Idle: 1,
22
+ Error: 2,
23
+ InProgress: 8,
24
+ InputNeeded: 24,
25
+ IsRead: 32,
26
+ IsArchived: 64,
27
+ };
@@ -0,0 +1,54 @@
1
+ import type { Disposable, TextUIApp } from '@textui/core';
2
+ import type { HostConnection } from './ahp/connection.js';
3
+ export interface ChatOptions {
4
+ builtins?: boolean;
5
+ /**
6
+ * Keys from the config file, over the ones this client ships with.
7
+ *
8
+ * A chord to a command id, or to `null` to take the chord away. Read once
9
+ * at startup: a binding is registered when the controller is, and this
10
+ * client has no reload.
11
+ */
12
+ keys?: Record<string, string | null>;
13
+ /**
14
+ * The header trades its own name for a seven-cell creature, on a session.
15
+ *
16
+ * Off unless asked for, and it is the config file that asks - `boodInline`
17
+ * in `~/.config/ahpc/config.json`.
18
+ */
19
+ boodInline?: boolean;
20
+ /**
21
+ * The creature roams the whole application, on the floating layer.
22
+ *
23
+ * Off unless asked for - `boodFloat` in the config file. A terminal has no
24
+ * per-cell transparency, so a figure that goes everywhere writes over what
25
+ * it stands on: it keeps off the composer and off anything opened on a
26
+ * layer, and a screen whose content runs to the bottom gets a cat on it.
27
+ */
28
+ boodFloat?: boolean;
29
+ /** The host. Omit for the scripted one, which is what the tests use. */
30
+ host?: HostConnection & {
31
+ pump?(): boolean;
32
+ };
33
+ /**
34
+ * Where a new session works, before anybody chooses otherwise.
35
+ *
36
+ * A path on the **host**, which is not this machine unless the host happens
37
+ * to be. Pass an empty string to say there is none and let the host decide;
38
+ * left out entirely it is the directory this process started in, which is
39
+ * the defensible guess only for a host that is this process.
40
+ */
41
+ workspace?: string;
42
+ /**
43
+ * How wide the terminal has to be before the catalogue shows both panes.
44
+ *
45
+ * Narrower than this and the detail pane starts hidden - left opens it,
46
+ * right puts it away - because a split that leaves both halves truncated is
47
+ * worse than either half whole. Settable here so it can be tuned without a
48
+ * rebuild; `140` is where a session list stops cutting its titles with a
49
+ * detail pane beside it.
50
+ */
51
+ splitAt?: number;
52
+ }
53
+ export declare function registerChat(app: TextUIApp, options?: ChatOptions): Disposable;
54
+ export { CONTROLLER } from './control.js';
@@ -0,0 +1,369 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "@textui/core/jsx-runtime";
2
+ import { createBag, defineComponent, useApp, useTheme, useStoreSubtree, useStoreValue, } from '@textui/core';
3
+ import { KeyHints, Row, registerBuiltins } from '@textui/widgets';
4
+ import { CONTROLLER, createController } from './control.js';
5
+ import { fakeHost } from './ahp/fake.js';
6
+ import { BOOD, BOOD_FLOAT, BOOD_FLOOR, BOOD_INLINE, FOCUS, HOST, HOST_ERROR, INPUT, INPUT_STATUS, OPEN, RUNNING, SCREEN, SESSIONS, SPLIT_AT, SPLIT_DEFAULT, STATUS, WORKSPACE, boodFloor, openSession, workspaceName, } from './state.js';
7
+ import { decodeStatus } from './ahp/status.js';
8
+ import { AutomationsScreen, ChangesScreen, ChatScreen, FilesScreen, HostsScreen, McpScreen, NewAutomationScreen, NewSessionScreen, SessionsScreen, TerminalScreen, SettingsScreen, SkillsScreen, } from './screens.js';
9
+ import { ChatBubble, ReasoningBlock, StreamingText } from './view/bubble.js';
10
+ import { ChatComposer } from './view/composer.js';
11
+ import { ChatHitl } from './view/hitl.js';
12
+ import { ChatTranscript } from './view/transcript.js';
13
+ import { BoodSprite, Creature, moodOf, pickBood } from './view/creature.js';
14
+ import { ChangesList } from './view/changes.js';
15
+ import { FileList } from './view/files.js';
16
+ import { AutomationList } from './view/automations.js';
17
+ import { ConnectionBadge, SessionList } from './view/sessions.js';
18
+ import { ToolCallRow } from './view/toolcall.js';
19
+ /**
20
+ * A chat client for an agent host.
21
+ *
22
+ * What it is here to find out is which components a chat application needs
23
+ * that a terminal UI catalog does not already have. The answer, by the end of
24
+ * writing it, is in `README.md` - and the shape of the answer is that the
25
+ * conversation is one component the catalog cannot stand in for, the composer
26
+ * is a second, and everything else is composition.
27
+ *
28
+ * The application takes a host rather than making one, so a test can mount it
29
+ * against a scripted one and drive time by hand.
30
+ */
31
+ /**
32
+ * The creature, over the whole application.
33
+ *
34
+ * Mounted on the `floating` layer rather than inside a screen, so navigating
35
+ * moves the screen out from under it instead of unmounting it: escape from a
36
+ * session leaves the figure exactly where it was standing. This is the only
37
+ * place that knows both the store and the bood, which is what keeps
38
+ * `view/bood/` from having an opinion about sessions.
39
+ *
40
+ * The floor is the top of whatever the screen keeps at the bottom. A screen
41
+ * with a composer keeps a lot more of it than one with only a status bar, so
42
+ * the inset follows the screen rather than being one number that is wrong on
43
+ * half of them.
44
+ */
45
+ /**
46
+ * What the bottom keeps when nothing down there has said otherwise.
47
+ *
48
+ * The hints row, the shell's own edge, and the closing border of whatever
49
+ * panel is there. Standing on that border put the figure through it and
50
+ * through the row above it; a row clear of it is the ground.
51
+ */
52
+ const BOTTOM_CHROME = 3;
53
+ const BoodOverlay = defineComponent('BoodOverlay', () => {
54
+ const app = useApp();
55
+ const name = useStoreValue(BOOD);
56
+ const roaming = useStoreValue(BOOD_FLOAT, false) ?? false;
57
+ useStoreSubtree(BOOD_FLOOR);
58
+ // Whatever is standing at the bottom says where it starts; the creature gets
59
+ // the row above the highest of them. Nothing down there and it takes the
60
+ // bottom of the terminal, less the chrome that is on every screen.
61
+ const standing = boodFloor(app.store);
62
+ const status = useStoreValue(STATUS, 1) ?? 1;
63
+ useStoreSubtree(SESSIONS);
64
+ const session = openSession(app.store);
65
+ if (!roaming || !name)
66
+ return null;
67
+ /*
68
+ * Out of the way of anything that was opened on purpose.
69
+ *
70
+ * A terminal has no per-cell transparency, so the figure writes over
71
+ * whatever it is standing on - which is a fair price for a mascot wandering
72
+ * across a list, and not a fair price for a menu. The completion popup is a
73
+ * layer, the palette is a layer, a dialog is a layer: if anything else is
74
+ * open, the creature is not on top of it.
75
+ */
76
+ if (app.layers.entries().some((entry) => entry.id !== 'bood'))
77
+ return null;
78
+ return (_jsx(BoodSprite, { name: name, mood: session ? moodOf(decodeStatus(status).activity) : 'happy', floor: standing !== undefined ? standing - 1 : undefined, inset: BOTTOM_CHROME }));
79
+ });
80
+ const Header = defineComponent('ChatHeader', () => {
81
+ const app = useApp();
82
+ const theme = useTheme();
83
+ const host = useStoreValue(HOST);
84
+ const status = useStoreValue(STATUS, 1) ?? 1;
85
+ // Subscribed, not asked. `screens.current()` is a method call in the middle
86
+ // of a render: it reads the right answer once and nothing tells this to look
87
+ // again, so a surface that navigating does not remount keeps the last
88
+ // screen's chrome for ever.
89
+ // Subscribed so the title follows what is open, without the header being
90
+ // remounted to notice.
91
+ useStoreValue(SCREEN, null);
92
+ useStoreValue(OPEN, null);
93
+ // And to the summaries themselves. `openSession` is a plain read, so a
94
+ // title or a status arriving from the host changed the store and left this
95
+ // row showing what it said when the session was opened - which is why the
96
+ // header only caught up when navigating away and back remounted it.
97
+ useStoreSubtree(SESSIONS);
98
+ const session = openSession(app.store);
99
+ const decoded = decodeStatus(status);
100
+ const bood = useStoreValue(BOOD);
101
+ const boodInline = useStoreValue(BOOD_INLINE, false) ?? false;
102
+ // Only on a session, because the seven cells are carrying that session's
103
+ // state - on the catalogue there is no one thing for them to be about, and
104
+ // a creature that means nothing where the name used to be is a worse trade.
105
+ // Truthiness, not a comparison with undefined: nothing open is `null` here,
106
+ // and the row beside this one has always tested it the same way.
107
+ const wearsBood = boodInline && Boolean(session);
108
+ return (
109
+ // Only the title gives way. Everything else on this row is fixed-width and
110
+ // says what the application *is* - a header that truncates its own name to
111
+ // "Assist…" in order to fit more of a session title has given up the one
112
+ // part that is the same on every screen. The workspace yields after the
113
+ // title, and the status glyph never does: it is one cell and it is the
114
+ // thing the row is scanned for.
115
+ _jsxs(Row, { gap: 1, children: [wearsBood
116
+ ? _jsx(Creature, { name: bood, form: "inline", mood: moodOf(decoded.activity), shrink: 0 })
117
+ : _jsx("text", { content: "Assistant", bold: true, fg: "accent", shrink: 0 }), _jsx("text", { content: theme.glyphs.separator, fg: "subtle", shrink: 0 }), session ? (_jsxs(_Fragment, { children: [_jsx("text", { content: theme.glyphs[decoded.glyph], fg: decoded.tone, shrink: 0 }), _jsx("text", { content: session.title, flex: 1, truncate: "end" }), _jsx("text", { content: workspaceName(session.workingDirectories[0]), fg: "muted", shrink: 4, truncate: "end" })] })) : (_jsx("text", { content: host?.url ?? 'no host', fg: "muted", flex: 1, truncate: "end" }))] }));
118
+ });
119
+ /**
120
+ * The keys, as the reader sees them.
121
+ *
122
+ * They change with where the focus is, because they *are* different there:
123
+ * while the composer has the keyboard, every letter is a letter.
124
+ */
125
+ const Hints = defineComponent('ChatHints', (props) => {
126
+ const theme = useTheme();
127
+ /**
128
+ * Which key the footer names for a newline.
129
+ *
130
+ * `alt+enter`, which is `ESC CR` and the one encoding every terminal can
131
+ * express. The field also takes `ctrl+enter`, and that is deliberately not
132
+ * named here: it has three encodings - a bare LF, xterm's `CSI 27;5;13~`,
133
+ * kitty's `CSI 13;5u` - and a terminal that sends plain CR for it is
134
+ * sending the enter key, which nothing downstream can tell apart. VS Code's
135
+ * terminal is one such, so naming it offers a key that does not exist.
136
+ *
137
+ * One key in the row rather than two: the row elides every hint on a narrow
138
+ * terminal, and a hint nobody can read costs the same as a wrong one.
139
+ */
140
+ const newline = 'alt+enter';
141
+ const waiting = useStoreValue(INPUT, null);
142
+ const upDown = `${theme.glyphs.arrowUp}${theme.glyphs.arrowDown}`;
143
+ const leftRight = `${theme.glyphs.arrowLeft}${theme.glyphs.arrowRight}`;
144
+ // Which keys exist is a property of where you are, not of what is open: a
145
+ // session stays open while its changes are on screen, and `i write` there
146
+ // is an offer nothing honours.
147
+ const screen = useStoreValue(SCREEN, 'sessions') ?? 'sessions';
148
+ // Not `status > 1`: the bitset carries "a client has read this" in the same
149
+ // number, so an idle session somebody looked at is 33 and every hint would
150
+ // read "stop".
151
+ const running = useStoreValue(RUNNING, false) ?? false;
152
+ // Where the keyboard is decides what the keys mean. While the composer has
153
+ // it, escape leaves the field; from the transcript, escape leaves the
154
+ // screen - and a hint row that said one of those in both places is wrong
155
+ // half the time.
156
+ const focused = useStoreValue(FOCUS, null);
157
+ const composing = focused === 'chat.composer';
158
+ // A question is not a confirmation, and the keys are not the same either.
159
+ // Offering "a approve" over an elicitation is the same mistake as rendering
160
+ // one as the other, made in the one row that is supposed to explain it.
161
+ if (screen === 'chat' && waiting?.kind === 'toolConfirmation') {
162
+ return (_jsx(KeyHints, { ...props, hints: [
163
+ { keys: 'a', label: 'approve' },
164
+ { keys: 'd', label: 'deny' },
165
+ { keys: '1-9', label: 'option' },
166
+ { keys: 'esc', label: 'read' },
167
+ ] }));
168
+ }
169
+ if (screen === 'chat' && waiting) {
170
+ return (_jsx(KeyHints, { ...props, hints: [
171
+ { keys: 'tab', label: 'next question' },
172
+ { keys: 'space', label: 'choose' },
173
+ { keys: 'enter', label: 'send answers' },
174
+ { keys: 'esc', label: 'read' },
175
+ ] }));
176
+ }
177
+ if (screen === 'chat') {
178
+ return (_jsx(KeyHints, { ...props, hints: composing
179
+ ? [
180
+ { keys: 'enter', label: 'send' },
181
+ { keys: newline, label: 'newline' },
182
+ { keys: 'esc', label: 'read' },
183
+ { keys: 'ctrl+c', label: running ? 'stop' : 'quit' },
184
+ ]
185
+ : [
186
+ { keys: upDown, label: 'move' },
187
+ { keys: 'enter', label: 'expand' },
188
+ { keys: 'i', label: 'write' },
189
+ { keys: 'G', label: 'follow' },
190
+ { keys: 'c', label: 'changes' },
191
+ { keys: 'k', label: 'skills' },
192
+ { keys: 'esc', label: 'back' },
193
+ // The same key, and it says which: while a turn is running it
194
+ // stops it, and when none is it leaves. A hint that always read
195
+ // "stop" is wrong most of the time.
196
+ { keys: 'ctrl+c', label: running ? 'stop' : 'quit' },
197
+ ] }));
198
+ }
199
+ if (screen === 'new') {
200
+ return (_jsx(KeyHints, { ...props, hints: [
201
+ { keys: 'enter', label: 'start' },
202
+ { keys: newline, label: 'newline' },
203
+ { keys: 'tab', label: 'options' },
204
+ { keys: 'esc', label: 'sessions' },
205
+ { keys: 'ctrl+c', label: 'quit' },
206
+ ] }));
207
+ }
208
+ // On the terminal, `ctrl+c` is the shell's - it is how a command is
209
+ // stopped, and saying "quit" there is how somebody closes the application
210
+ // trying to interrupt a `ping`.
211
+ if (screen === 'terminal') {
212
+ return (_jsx(KeyHints, { ...props, hints: [
213
+ { keys: 'enter', label: 'run' },
214
+ { keys: 'ctrl+c', label: 'interrupt' },
215
+ { keys: 'esc', label: 'back' },
216
+ { keys: 'ctrl+p', label: 'commands' },
217
+ ] }));
218
+ }
219
+ // The list screens. `tab move` is a form's answer and these are
220
+ // lists: what moves is the cursor, and enter is what a row is for.
221
+ if (screen === 'changes' || screen === 'skills' || screen === 'mcp' || screen === 'files'
222
+ || screen === 'automations') {
223
+ return (_jsx(KeyHints, { ...props, hints: [
224
+ { keys: upDown, label: 'move' },
225
+ { keys: 'enter', label: screen === 'changes' || screen === 'files' ? 'open' : screen === 'automations' ? 'run' : 'on / off' },
226
+ // Only where they do something. A hint for a key that is inert on
227
+ // this screen is worse than no hint.
228
+ ...(screen === 'changes'
229
+ ? [{ keys: ']', label: 'changeset' }, { keys: 'r', label: 'read' }, { keys: 'x', label: 'do' }]
230
+ : []),
231
+ // Named only where they do something. `enter` already runs one, so
232
+ // what is left is the switch and the one that does not come back.
233
+ ...(screen === 'automations'
234
+ ? [{ keys: 'n', label: 'new' }, { keys: 'e', label: 'on / off' }, { keys: 'd', label: 'forget' }]
235
+ : []),
236
+ { keys: 'esc', label: 'back' },
237
+ { keys: 'ctrl+p', label: 'commands' },
238
+ { keys: 'ctrl+c', label: 'quit' },
239
+ ] }));
240
+ }
241
+ if (screen !== 'sessions') {
242
+ return (_jsx(KeyHints, { ...props, hints: [
243
+ { keys: 'tab', label: 'move' },
244
+ { keys: 'esc', label: 'back' },
245
+ { keys: 'ctrl+p', label: 'commands' },
246
+ { keys: 'ctrl+c', label: 'quit' },
247
+ ] }));
248
+ }
249
+ return (_jsx(KeyHints, { ...props, hints: [
250
+ { keys: upDown, label: 'move' },
251
+ // Which one it is depends on where the detail pane is, and the pane is
252
+ // right there on the screen saying so. Naming both is what fits.
253
+ { keys: leftRight, label: 'detail' },
254
+ { keys: 'enter', label: 'open' },
255
+ { keys: 'n', label: 'new' },
256
+ // `a` archives and `delete` disposes, and neither is here: the row is
257
+ // one line and naming `ctrl+f` cost it the room. Both are a letter
258
+ // away in the palette, and the filter is the one nothing else offers.
259
+ { keys: 'ctrl+f', label: 'filter' },
260
+ { keys: 'ctrl+p', label: 'commands' },
261
+ { keys: 'ctrl+c', label: 'quit' },
262
+ ] }));
263
+ });
264
+ const Status = defineComponent('ChatStatus', () => {
265
+ const screen = useStoreValue(SCREEN, 'sessions');
266
+ // What the host last refused, where a person is already looking. A refusal
267
+ // that only reaches a log is a client that appears to have ignored the key
268
+ // you pressed.
269
+ //
270
+ // Unless the row above the composer is already saying it, which it does
271
+ // when the refusal was of an answer given to the block waiting there. Said
272
+ // twice on a narrow terminal it is one sentence in red on two of twenty
273
+ // rows, and the second one adds nothing to the first.
274
+ const said = useStoreValue(INPUT_STATUS, null) ?? null;
275
+ const refusal = useStoreValue(HOST_ERROR, null) ?? null;
276
+ const error = said?.state === 'failed' && said.text === refusal ? null : refusal;
277
+ return (_jsxs(Row, { gap: 2, children: [error
278
+ ? _jsx("text", { content: error, fg: "danger", flex: 1, truncate: "end" })
279
+ : _jsx(Hints, { flex: 1 }), _jsx("text", { content: screen ?? '-', fg: "muted" })] }));
280
+ });
281
+ export function registerChat(app, options = {}) {
282
+ const bag = createBag();
283
+ if (options.builtins !== false)
284
+ bag.add(registerBuiltins(app));
285
+ const host = options.host ?? fakeHost();
286
+ const controller = createController(app, host, options.keys);
287
+ app.store.set(WORKSPACE, options.workspace ?? process.cwd());
288
+ app.store.set(SPLIT_AT, options.splitAt ?? SPLIT_DEFAULT);
289
+ // Once, here, rather than per mount: two surfaces drawing two different
290
+ // animals would read as two mascots rather than one that gets about.
291
+ app.store.set(BOOD, pickBood());
292
+ app.store.set(BOOD_INLINE, options.boodInline ?? false);
293
+ app.store.set(BOOD_FLOAT, options.boodFloat ?? false);
294
+ bag.add(controller);
295
+ bag.add(app.services.provide(CONTROLLER, controller));
296
+ for (const [component, render] of [
297
+ ['ChatBubble', ChatBubble],
298
+ ['StreamingText', StreamingText],
299
+ ['ReasoningBlock', ReasoningBlock],
300
+ ['ToolCallRow', ToolCallRow],
301
+ ['ChatTranscript', ChatTranscript],
302
+ ['ChatComposer', ChatComposer],
303
+ ['ChatHitl', ChatHitl],
304
+ ['SessionList', SessionList],
305
+ ['ConnectionBadge', ConnectionBadge],
306
+ ['ChangesList', ChangesList],
307
+ ['FileList', FileList],
308
+ ['AutomationList', AutomationList],
309
+ ['SessionsScreen', SessionsScreen],
310
+ ['ChatScreen', ChatScreen],
311
+ ['NewSessionScreen', NewSessionScreen],
312
+ ['TerminalScreen', TerminalScreen],
313
+ ['ChangesScreen', ChangesScreen],
314
+ ['FilesScreen', FilesScreen],
315
+ ['AutomationsScreen', AutomationsScreen],
316
+ ['NewAutomationScreen', NewAutomationScreen],
317
+ ['SettingsScreen', SettingsScreen],
318
+ ['HostsScreen', HostsScreen],
319
+ ['SkillsScreen', SkillsScreen],
320
+ ['McpScreen', McpScreen],
321
+ ['BoodOverlay', BoodOverlay],
322
+ ['ChatHeader', Header],
323
+ ['ChatStatus', Status],
324
+ ['ChatHints', Hints],
325
+ ]) {
326
+ bag.add(app.components.register({
327
+ component,
328
+ category: 'template',
329
+ renderer: { kind: 'function', render: render },
330
+ }));
331
+ }
332
+ // Over every screen, for as long as the application is running.
333
+ bag.add(app.layers.open({
334
+ id: 'bood',
335
+ layer: 'floating',
336
+ node: { component: 'BoodOverlay' },
337
+ position: { kind: 'screen', rect: { x: 0, y: 0 } },
338
+ }));
339
+ bag.add(app.surfaces.open({ surface: 'header', key: 'title', target: { component: 'ChatHeader' } }));
340
+ bag.add(app.surfaces.open({ surface: 'status', key: 'status', target: { component: 'ChatStatus' } }));
341
+ for (const screen of [
342
+ { id: 'sessions', component: 'SessionsScreen' },
343
+ // Kept alive: coming back from the changes list to a conversation that
344
+ // had scrolled itself back to the top is losing your place in a document
345
+ // that is still being written.
346
+ { id: 'chat', component: 'ChatScreen', keepAlive: true },
347
+ { id: 'new', component: 'NewSessionScreen' },
348
+ { id: 'changes', component: 'ChangesScreen' },
349
+ { id: 'files', component: 'FilesScreen' },
350
+ { id: 'automations', component: 'AutomationsScreen' },
351
+ { id: 'automation.new', component: 'NewAutomationScreen' },
352
+ { id: 'settings', component: 'SettingsScreen' },
353
+ { id: 'hosts', component: 'HostsScreen' },
354
+ { id: 'skills', component: 'SkillsScreen' },
355
+ { id: 'mcp', component: 'McpScreen' },
356
+ // Kept alive: a shell you scrolled back through and then left is a shell
357
+ // that should still be where you left it.
358
+ { id: 'terminal', component: 'TerminalScreen', keepAlive: true },
359
+ ]) {
360
+ bag.add(app.screens.register(screen));
361
+ }
362
+ void controller.refresh();
363
+ // The composer, with nothing open. A client whose first screen is a
364
+ // catalogue makes "talk to an agent" a two-step errand; the first message is
365
+ // what creates the session, so the field is what the application opens on.
366
+ app.screens.reset('new');
367
+ return bag;
368
+ }
369
+ export { CONTROLLER } from './control.js';
@@ -0,0 +1,88 @@
1
+ import type { QueuedMessage, ToolCall, Turn } from './ahp/types.js';
2
+ /**
3
+ * A conversation, flattened into the rows a viewport scrolls.
4
+ *
5
+ * A turn is not a box. It is a run of rows - a header, some prose, a tool
6
+ * call, more prose - and the transcript has to be able to put its cursor on
7
+ * one of them, measure it, and scroll to it. Nesting each turn inside a
8
+ * container would mean the transcript could only ever scroll to a whole turn,
9
+ * and a turn can be four hundred rows long.
10
+ *
11
+ * The order is the host's order. `responseParts` interleaves prose and calls
12
+ * in one stream, and "let me search for those" means something before the
13
+ * searches and nothing after them.
14
+ */
15
+ export type Block = {
16
+ kind: 'said';
17
+ id: string;
18
+ turnId: string;
19
+ text: string;
20
+ } | {
21
+ kind: 'header';
22
+ id: string;
23
+ turnId: string;
24
+ model?: string;
25
+ /**
26
+ * What the turn was asked for besides the model, in the host's words.
27
+ *
28
+ * The values rather than the keys: `thinkingLevel` is one host's name for
29
+ * a property whose *answers* are what a person reads, and a header that
30
+ * spelled out the key would be twice as long and no clearer.
31
+ */
32
+ settings?: string;
33
+ meta: string;
34
+ state: Turn['state'];
35
+ } | {
36
+ kind: 'prose';
37
+ id: string;
38
+ turnId: string;
39
+ content: string;
40
+ streaming: boolean;
41
+ } | {
42
+ kind: 'reasoning';
43
+ id: string;
44
+ turnId: string;
45
+ content: string;
46
+ streaming: boolean;
47
+ } | {
48
+ kind: 'notice';
49
+ id: string;
50
+ turnId: string;
51
+ content: string;
52
+ } | {
53
+ kind: 'failure';
54
+ id: string;
55
+ turnId: string;
56
+ content: string;
57
+ resumable: boolean;
58
+ } | {
59
+ kind: 'tool';
60
+ id: string;
61
+ turnId: string;
62
+ call: ToolCall;
63
+ } | {
64
+ kind: 'queued';
65
+ id: string;
66
+ messageId: string;
67
+ text: string;
68
+ };
69
+ /**
70
+ * Everything in a block that a person could be looking for.
71
+ *
72
+ * A tool call is its name, its command and what came back, because all three
73
+ * are things somebody searches a transcript for - the file a command touched
74
+ * is in the output and nowhere else. A header is the model and the settings,
75
+ * which is how "where did I switch to opus" is answered.
76
+ */
77
+ export declare function blockText(block: Block): string;
78
+ /**
79
+ * Where in the conversation a query appears, as block indices in order.
80
+ *
81
+ * Case-insensitive, and a blank query matches nothing rather than everything:
82
+ * a find with no term is a find that has not been typed yet, and lighting up
83
+ * every block for it is the opposite of what the box is for.
84
+ */
85
+ export declare function findBlocks(blocks: Block[], query: string): number[];
86
+ /** Blocks the cursor stops on: the ones that do something when activated. */
87
+ export declare function selectable(block: Block): boolean;
88
+ export declare function toBlocks(turns: Turn[], queued?: QueuedMessage[]): Block[];
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Everything in a block that a person could be looking for.
3
+ *
4
+ * A tool call is its name, its command and what came back, because all three
5
+ * are things somebody searches a transcript for - the file a command touched
6
+ * is in the output and nowhere else. A header is the model and the settings,
7
+ * which is how "where did I switch to opus" is answered.
8
+ */
9
+ export function blockText(block) {
10
+ switch (block.kind) {
11
+ case 'said':
12
+ case 'queued':
13
+ return block.text;
14
+ case 'prose':
15
+ case 'reasoning':
16
+ case 'notice':
17
+ case 'failure':
18
+ return block.content;
19
+ case 'header':
20
+ return [block.model, block.settings, block.meta].filter(Boolean).join(' ');
21
+ case 'tool':
22
+ return [
23
+ block.call.name, block.call.toolName, block.call.input,
24
+ block.call.intention, block.call.outcome, block.call.output,
25
+ ...(block.call.files ?? []),
26
+ ].filter(Boolean).join(' ');
27
+ }
28
+ }
29
+ /**
30
+ * Where in the conversation a query appears, as block indices in order.
31
+ *
32
+ * Case-insensitive, and a blank query matches nothing rather than everything:
33
+ * a find with no term is a find that has not been typed yet, and lighting up
34
+ * every block for it is the opposite of what the box is for.
35
+ */
36
+ export function findBlocks(blocks, query) {
37
+ const needle = query.trim().toLowerCase();
38
+ if (needle === '')
39
+ return [];
40
+ const found = [];
41
+ blocks.forEach((block, index) => {
42
+ if (blockText(block).toLowerCase().includes(needle))
43
+ found.push(index);
44
+ });
45
+ return found;
46
+ }
47
+ /** Blocks the cursor stops on: the ones that do something when activated. */
48
+ export function selectable(block) {
49
+ // A queued message among them, because taking one back is something you do
50
+ // to it - and the cursor is how anything in the transcript is reached.
51
+ return block.kind === 'tool' || block.kind === 'reasoning' || block.kind === 'queued';
52
+ }
53
+ export function toBlocks(turns, queued = []) {
54
+ const blocks = [];
55
+ for (const turn of turns) {
56
+ if (turn.role === 'user') {
57
+ blocks.push({ kind: 'said', id: `${turn.id}:said`, turnId: turn.id, text: turn.message ?? '' });
58
+ continue;
59
+ }
60
+ const running = turn.state === 'running';
61
+ blocks.push({
62
+ kind: 'header',
63
+ id: `${turn.id}:head`,
64
+ turnId: turn.id,
65
+ ...(turn.model ? { model: turn.model.id } : {}),
66
+ ...(turn.model?.config ? { settings: Object.values(turn.model.config).join(' · ') } : {}),
67
+ meta: running ? 'running' : turn.elapsedMs ? `${(turn.elapsedMs / 1000).toFixed(1)}s` : '',
68
+ state: turn.state,
69
+ });
70
+ turn.parts.forEach((part, index) => {
71
+ const last = index === turn.parts.length - 1;
72
+ switch (part.kind) {
73
+ case 'markdown':
74
+ blocks.push({ kind: 'prose', id: part.id, turnId: turn.id, content: part.content, streaming: running && last });
75
+ break;
76
+ case 'reasoning':
77
+ blocks.push({ kind: 'reasoning', id: part.id, turnId: turn.id, content: part.content, streaming: running && last });
78
+ break;
79
+ case 'systemNotification':
80
+ blocks.push({ kind: 'notice', id: part.id, turnId: turn.id, content: part.content });
81
+ break;
82
+ case 'toolCall':
83
+ blocks.push({ kind: 'tool', id: part.id, turnId: turn.id, call: part.call });
84
+ break;
85
+ case 'error':
86
+ blocks.push({ kind: 'failure', id: part.id, turnId: turn.id, content: part.message, resumable: part.resumable });
87
+ break;
88
+ default:
89
+ break;
90
+ }
91
+ });
92
+ }
93
+ // What the person typed while the agent was busy. Below the conversation
94
+ // because that is when it will be said, and visibly not sent yet.
95
+ // Keyed by the host's own id, not by position: the queue is the host's, the
96
+ // head leaves it whenever the running turn ends, and an index would name a
97
+ // different message every time one did.
98
+ for (const message of queued) {
99
+ blocks.push({ kind: 'queued', id: `queued:${message.id}`, messageId: message.id, text: message.text });
100
+ }
101
+ return blocks;
102
+ }
@@ -0,0 +1,13 @@
1
+ /** Every command, and the argv reading that picks one. */
2
+ export declare const HELP = "ahpc - drive an agent host from a shell\n\n ahpc [--host ws://\u2026] <command> [args] the screen is 'ahpc' with no command\n\nSessions\n session list the catalogue, newest first [--archived] [--json]\n session show <uri> what the host says about one [--full] [--json]\n session new start one [--agent P] [--cwd DIR] [--set k=v]\u2026 [--json]\n session rm <uri> dispose it\n session history <uri> its turns [--all] [--full] [--json]\n session config <uri> the schema and what is in force [--json]\n session set <uri> <k> <v> change one config key\n session read <uri> mark read [--unread]\n session archive <uri> put it away [--undo]\n session customizations <uri> skills, prompts, agents, servers [--json]\n session export <uri> the whole session as one document\n [--json] [--markdown]\n session toggle <uri> <id> turn one on [--off]\n\nTurns\n prompt <uri> <text> say it and stream the answer [--model M] [--json]\n exec <text> a session, one turn, and dispose it\n [--agent P] [--cwd DIR] [--model M] [--json]\n cancel <uri> stop the running turn\n queue <uri> <text> say it after the one running [--model M]\n unqueue <uri> <id> take it back\n\nAnswering\n watch <uri> BLOCK until something wants a person, print, exit\n [--until turn|input|idle] [--timeout S] [--json]\n confirm <uri> <toolCallId> approve a tool call [--deny] [--option ID]\n answer <uri> <requestId> answer a question [--field k=v]\u2026 [--reject]\n\nChats\n chat list <uri> the conversations in a session [--json]\n chat new <uri> [text] another one beside it\n chat rm <chatUri> close one\n\nThe harness\n agents what it serves, and each one's models [--json]\n models every model, by harness [--json]\n commands what a slash offers [--json]\n customizations skills, prompts, agents and MCP servers,\n before any session exists [--kind k] [--json]\n completions <uri> <text> what the host would complete [--offset N] [--json]\n\nChanges and files\n changes <uri> the files a session touched [--json]\n [--list] [--scope s] [--<variable> v]\n [--reviewed f] [--unreviewed f]\n [--operations] what may be done to it\n [--run id] [--file f] [--yes] do one of them\n [--list] every changeset it offers\n [--scope <name>] one of them, e.g. turn\n [--turnId <id>] what a chosen scope still needs\n [--reviewed <file>] tick one off, repeatable\n [--unreviewed <file>] and clear one\n content <uri> <file> one of them, in full\n resource list <uri> a directory the host serves [--json]\n resource read <uri> a file on the host\n resource stat <uri> what it is, without reading it [--json]\n resource write <uri> [file] from a file, or from stdin [--create-only]\n guarded by the file's etag unless [--force]\n resource rm <uri> delete it [--recursive]\n resource mkdir <uri> make a directory\n resource mv <uri> <to> move it [--fail-if-exists]\n resource cp <uri> <to> copy it [--fail-if-exists]\n\nAutomations\n automation list what runs on its own [--json]\n automation show <uri> one of them [--json]\n automation triggers what this host can trigger on [--json]\n automation runs <uri> its history, every page [--json]\n automation run <uri> start it now\n automation enable <uri> switch it on\n automation disable <uri> switch it off\n automation rm <uri> forget it\n\nThe host's own log\n logs what the daemon is saying [--level L] [--follow]\n\nSigning in\n auth what this host protects [--json]\n auth <resource> push a token [--token T] [--expires-in S]\n or set AHPC_TOKEN_<RESOURCE>, or pipe one in\n\nTerminals\n terminal list what is running [--json]\n terminal new open a shell [--cwd DIR] [--name N]\n terminal rm <uri> kill it\n terminal send <uri> <text> type into it\n terminal watch <uri> follow its output [--timeout S]\n\nRecording\n AHPC_RECORD=<file> append every frame, both directions, for\n 'npm run wire' to check against the protocol\n\nAnything else\n dispatch <uri> <type> send one action verbatim [--field k=v]\u2026 [--chat]\n status what this client is connected to [--json]\n help this\n\nThe host\n --host <url> ws://host:port, or AHPC_HOST, or the config file\n --token <tkn> a bearer token for it, or AHPC_TOKEN, or the config file\n --config-file read this instead of the one below\n (none) the scripted host, which needs nothing installed\n\nConfiguration\n config where the file is, and what is in force [--json]\n\nOutput is for reading. --json is the same answer for a program.\n";
3
+ /** A message for the person, not a stack trace. */
4
+ export declare class Fault extends Error {
5
+ }
6
+ /**
7
+ * One command, and then the process is done.
8
+ *
9
+ * A switch rather than a registry: every branch is a few lines against
10
+ * `HostConnection`, and the shape of the whole surface being readable in one
11
+ * file is worth more than the indirection a registry would buy.
12
+ */
13
+ export declare function cli(command: string, rest: string[]): Promise<number>;