@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,531 @@
1
+ import { byUrgency, decodeStatus } from './ahp/status.js';
2
+ /**
3
+ * Where the conversation lives, and how the host's actions get there.
4
+ *
5
+ * The store is the only state. Everything the host says is folded in here, and
6
+ * every screen reads it back out - so the transcript, the sessions list, the
7
+ * status bar and the composer are all looking at one answer to "what is
8
+ * happening", and a turn that finishes updates all four without any of them
9
+ * knowing the others exist.
10
+ *
11
+ * Nothing in this file renders. That is the whole point of it being a file.
12
+ */
13
+ export const HOST = '$/chat/host';
14
+ export const SESSIONS = '$/chat/sessions';
15
+ /** The open conversation. One at a time, so it is not keyed by session. */
16
+ export const TURNS = '$/chat/conv/turns';
17
+ export const INPUT = '$/chat/conv/input';
18
+ export const CHANGES = '$/chat/conv/changes';
19
+ /**
20
+ * What the host handed the open session: plugins, skills, MCP servers.
21
+ *
22
+ * On the conversation rather than the catalogue, because that is where the
23
+ * protocol puts it - two sessions on the same host, opened in different
24
+ * directories, are given different skills, so there is no host-wide answer to
25
+ * cache. Read when a panel that shows them opens.
26
+ */
27
+ export const CUSTOMIZATIONS = '$/chat/conv/customizations';
28
+ /**
29
+ * Who else is in the open session.
30
+ *
31
+ * Host-kept membership from `SessionState.activeClients`, including this
32
+ * client, which adds itself on opening the view. A session nobody else is in
33
+ * is the ordinary case and shows nothing.
34
+ */
35
+ export const PRESENT = '$/chat/conv/present';
36
+ /** Which file of the changeset is open, by uri. Null is the list. */
37
+ export const OPEN_FILE = '$/chat/conv/file';
38
+ /**
39
+ * Which changesets this session offers, and which one is on screen.
40
+ *
41
+ * The session advertises several - what the conversation changed, what one
42
+ * turn changed, what the working tree has - and drawing the first that needs
43
+ * no filling in was drawing one and hiding the rest. `CHANGE_AT` is the
44
+ * chosen changeset's own URI, or empty for "whichever the host would pick".
45
+ */
46
+ export const CHANGE_SCOPES = '$/chat/conv/scopes';
47
+ export const CHANGE_AT = '$/chat/conv/scope';
48
+ /** The row the cursor is on, so a key can act on it. */
49
+ export const CHANGE_ROW = '$/chat/conv/row';
50
+ /**
51
+ * Where the file browser is looking, and what it found.
52
+ *
53
+ * The host's filesystem, not this machine's. `FILES_AT` is a `file://`
54
+ * directory URI on the host; empty means the session's own workspace, which is
55
+ * the only directory a client can name without having listed something first.
56
+ */
57
+ export const FILES_AT = '$/chat/files/at';
58
+ export const FILES_ENTRIES = '$/chat/files/entries';
59
+ export const FILES_OPEN = '$/chat/files/open';
60
+ /**
61
+ * The automations screen: what the host holds, and which row the cursor is on.
62
+ *
63
+ * The host's, not a session's - an automation outlives every session it
64
+ * starts, and belongs to the host the way the catalogue does.
65
+ */
66
+ export const AUTOMATIONS = '$/chat/automations/all';
67
+ export const AUTOMATION_ROW = '$/chat/automations/row';
68
+ export const STATUS = '$/chat/conv/status';
69
+ /**
70
+ * Is there a turn to stop, as a `when` clause can ask it.
71
+ *
72
+ * The status is a bitset carrying client flags as well as activity - an idle
73
+ * session that has been read is 33 - so "status > 1" is not "something is
74
+ * happening", it is "something is happening, or somebody looked at it". The
75
+ * decoding belongs in one place, and a clause cannot call it, so the answer is
76
+ * written beside the number.
77
+ */
78
+ export const RUNNING = '$/chat/conv/running';
79
+ /**
80
+ * What the next message will be sent as.
81
+ *
82
+ * Not a session's state, and deliberately not keyed by session: it is the
83
+ * composer's own row of choices, which exists before there is a session to
84
+ * hang them on. Sending is what turns them into one - a `createSession` with
85
+ * the harness and the workspace, and a `chat/turnStarted` carrying the model,
86
+ * because AHP hangs the model on the message rather than on the session.
87
+ */
88
+ export const PROVIDER = '$/chat/compose/provider';
89
+ export const MODEL = '$/chat/compose/model';
90
+ /**
91
+ * The answers to the chosen model's own questions.
92
+ *
93
+ * A model carries a `configSchema` and the protocol says a client presents it
94
+ * as a form and returns the resolved values in `ModelSelection.config`. Kept
95
+ * beside the model rather than in `SETTINGS`, because they are two different
96
+ * documents answered separately: the session's schema is the host's, this one
97
+ * is the model's, and a model that changes takes its answers with it.
98
+ */
99
+ export const MODEL_CONFIG = '$/chat/compose/modelConfig';
100
+ /**
101
+ * The chat the open session dispatches to.
102
+ *
103
+ * A session is not a conversation - it holds chats - and the uri of the one
104
+ * being read is what gets pasted into a shell or a bug report. Held because
105
+ * only `detail` knows it, and asking again to draw a caption is a round trip
106
+ * for something that does not change.
107
+ */
108
+ export const CHAT_URI = '$/chat/conv/chat';
109
+ /**
110
+ * The open session's chats.
111
+ *
112
+ * A session is a container: it holds chats, and which of them is being read
113
+ * is `CHAT_URI`. One chat is the ordinary case and the row that shows this is
114
+ * hidden then - a list of one is a list nobody needs.
115
+ */
116
+ export const CHATS = '$/chat/conv/chats';
117
+ /**
118
+ * Whether the open session's agent can hold another chat.
119
+ *
120
+ * A path rather than a check, because that is what gates a command: `when`
121
+ * reads the store, and a host that does not advertise `multipleChats` is one
122
+ * where `createChat` MUST NOT be called - so the command is not offered.
123
+ */
124
+ /** The terminals the host is running. The host's list, not any session's. */
125
+ export const TERMINALS = '$/chat/term/all';
126
+ /** Which of them is being read. */
127
+ export const OPEN_TERMINAL = '$/chat/term/open';
128
+ /** Its state. Null while nothing has been read yet, which is not the same as empty. */
129
+ export const TERMINAL = '$/chat/term/state';
130
+ export const CAN_ADD_CHAT = '$/chat/conv/canAddChat';
131
+ /**
132
+ * Whether this session's agent can fork a chat, and whether it can hold a side
133
+ * chat.
134
+ *
135
+ * Two paths rather than one, because `capabilities.multipleChats` carries the
136
+ * two separately and a host may advertise one without the other. A command
137
+ * offered where the host will refuse it is worse than one that is not there.
138
+ */
139
+ export const CAN_FORK = '$/chat/conv/canFork';
140
+ export const CAN_SIDE_CHAT = '$/chat/conv/canSideChat';
141
+ /** Whether the open session has more than one chat to move between. */
142
+ export const HAS_CHATS = '$/chat/conv/hasChats';
143
+ export const WORKSPACE = '$/chat/compose/workspace';
144
+ /**
145
+ * Everything else the host asks about, keyed by the host's own keys.
146
+ *
147
+ * Not one path per setting: the settings are whatever the harness advertises,
148
+ * and this client had a `permissions` path because the *fixture* called its
149
+ * key `permissionMode`. A real host's keys are `isolation`, `autoApprove` and
150
+ * `mode`, so the row of chips read every one of them as absent - a control
151
+ * naming a key is a control that works against one host.
152
+ */
153
+ export const SETTINGS = '$/chat/compose/settings';
154
+ /** What the host last refused, in the host's own words. Cleared by success. */
155
+ export const HOST_ERROR = '$/chat/host/error';
156
+ /**
157
+ * What became of the answer just given, on the row above the composer.
158
+ *
159
+ * Not `HOST_ERROR`, which is the application's last refusal and lives in the
160
+ * footer: this is about the block that is waiting on a person, and it only
161
+ * ever says anything between pressing a button on that block and the host
162
+ * letting go of the question. Both, for a refusal of *this* - the footer is
163
+ * twenty rows from the button that was pressed, and a person who pressed
164
+ * Approve and was told nothing has no reason to look down there.
165
+ */
166
+ export const INPUT_STATUS = '$/chat/conv/inputStatus';
167
+ export const OPEN = '$/chat/ui/open';
168
+ /** Which of the bood this run got. One animal, wherever one is drawn. */
169
+ export const BOOD = '$/chat/ui/bood';
170
+ /**
171
+ * How many rows the bottom of the screen is keeping, which is the creature's
172
+ * floor.
173
+ *
174
+ * Published by whatever is down there rather than assumed, because it moves:
175
+ * the composer grows a slash menu upward, grows again with a multi-line draft,
176
+ * and is not on every screen at all. A constant was wrong the moment a menu
177
+ * opened - the figure stood at the height the composer used to be, which is
178
+ * inside the menu.
179
+ */
180
+ export const BOOD_FLOOR = '$/chat/ui/boodFloor';
181
+ /**
182
+ * The top row of one thing standing at the bottom, under its own name.
183
+ *
184
+ * A row and not a height, because a height has to be right and a row only has
185
+ * to be where the thing is. Adding up heights meant trusting each measurement
186
+ * and a guess at the chrome underneath them - and the block that asks about a
187
+ * tool reported seven rows while drawing ten, which put the creature inside
188
+ * the question it was supposed to be standing on.
189
+ *
190
+ * A subtree because there is more than one of them: the composer is down
191
+ * there, and the block that asks is a sibling above it rather than a part of
192
+ * it, so neither one knows the whole answer.
193
+ */
194
+ export const boodFloorFor = (key) => `${BOOD_FLOOR}/${key}`;
195
+ /** The highest thing standing at the bottom, or nothing standing there. */
196
+ export function boodFloor(store) {
197
+ const rows = Object.values(store.get(BOOD_FLOOR) ?? {})
198
+ .filter((row) => typeof row === 'number' && row > 0);
199
+ return rows.length > 0 ? Math.min(...rows) : undefined;
200
+ }
201
+ /** Whether the creature roams the whole application rather than one screen. */
202
+ export const BOOD_FLOAT = '$/chat/ui/boodFloat';
203
+ /** Whether the header trades its own name for a seven-cell creature. */
204
+ export const BOOD_INLINE = '$/chat/ui/boodInline';
205
+ /** The catalogue's highlight. What a session command acts on when none is open. */
206
+ export const SELECTED = '$/chat/ui/selected';
207
+ export const DRAFT = '$/chat/ui/draft';
208
+ export const QUEUE = '$/chat/ui/queue';
209
+ export const HISTORY = '$/chat/ui/history';
210
+ export const FILTER = '$/chat/ui/filter';
211
+ export const ARCHIVED = '$/chat/ui/archived';
212
+ export const EXPANDED = '$/chat/ui/expanded';
213
+ /** What is being looked for in the open conversation. Empty means nothing is. */
214
+ export const FIND = '$/chat/ui/find';
215
+ /** Whether the find box is up. It stays up on an empty query, which is how it is typed into. */
216
+ export const FINDING = '$/chat/ui/finding';
217
+ /** Which of the matches the cursor is on, counted from zero. */
218
+ export const FIND_AT = '$/chat/ui/findAt';
219
+ /**
220
+ * Which block the transcript cursor is on.
221
+ *
222
+ * In the chat screen's own scope, so it survives a trip to the changes list
223
+ * and dies with the screen. Named here because the find commands move it and
224
+ * they do not live in the screen.
225
+ */
226
+ export const CURSOR = '$/screen.chat/cursor';
227
+ /**
228
+ * Whether what the agent said is drawn as markdown, or as what it typed.
229
+ *
230
+ * On, because an agent writes markdown and reading `**this**` is reading the
231
+ * punctuation instead of the sentence. Off is for the times the punctuation is
232
+ * the point: copying a fenced block out with the fence, seeing whether a table
233
+ * is a table or four lines that happen to have pipes in them, and reading a
234
+ * link's target rather than its label.
235
+ */
236
+ export const MARKDOWN = '$/chat/ui/markdown';
237
+ /**
238
+ * Whether the catalogue's detail pane is out, or `null` for "whatever the
239
+ * terminal is wide enough for".
240
+ *
241
+ * Three states rather than two, because "nobody has said" and "somebody said
242
+ * no" are different: the first still follows the window as it is resized, and
243
+ * the second has to survive a resize or the key that closed the pane would be
244
+ * undone by dragging the corner.
245
+ */
246
+ export const SIDEBAR = '$/chat/ui/sidebar';
247
+ /**
248
+ * The width at which the detail pane is out to begin with.
249
+ *
250
+ * Under it the two panes are each other's problem: forty cells of detail take
251
+ * the session list down to a column that cuts every title, and the detail
252
+ * pane they were taken for is itself too narrow to hold the URIs it exists to
253
+ * show. So below this the catalogue is one pane, and the detail is something
254
+ * you open.
255
+ */
256
+ export const SPLIT_AT = '$/chat/ui/splitAt';
257
+ export const SPLIT_DEFAULT = 140;
258
+ /**
259
+ * The runtime's own state, read rather than asked for.
260
+ *
261
+ * `screens.current()` and `focus.focused()` are method calls: correct at the
262
+ * moment they run and attached to nothing. A surface that survives navigating -
263
+ * which is the whole point of a surface - has to subscribe instead, or it
264
+ * keeps the chrome of the screen you left.
265
+ */
266
+ export const SCREEN = '$/layout/screen/current';
267
+ export const FOCUS = '$/focus/id';
268
+ // -------------------------------------------------------------------- writing
269
+ /**
270
+ * Fold one thing the host said into the store.
271
+ *
272
+ * A delta is a word, so this runs per word during a turn. It writes the whole
273
+ * turn list because the store is addressed by path and a mutation in place is
274
+ * a change nothing can see - a real client debounces the write, which is a
275
+ * change here and nowhere above.
276
+ */
277
+ export function applyEvent(store, event, model) {
278
+ switch (event.type) {
279
+ case 'snapshot': {
280
+ const next = [...event.turns, ...(event.active ? [event.active] : [])];
281
+ writeTurns(store, next);
282
+ store.set(INPUT, event.input ?? null);
283
+ store.set(QUEUE, event.queued);
284
+ writeStatus(store, event.status);
285
+ /*
286
+ * The host's draft, taken only when this client has nothing typed.
287
+ *
288
+ * `chat-channel.md` says a client SHOULD use any `draft` to initialise
289
+ * its input state - initialise, not follow: every keystroke here
290
+ * produces a debounced dispatch, and every dispatch comes back as a
291
+ * snapshot a moment later, so taking it always would put the cursor
292
+ * back where the round trip started. What is empty here has nothing to
293
+ * lose, which is the case the specification is describing.
294
+ */
295
+ if (event.draft !== '' && !(store.get(DRAFT) ?? ''))
296
+ store.set(DRAFT, event.draft);
297
+ // A snapshot arrived, so whatever the host last refused is not what is
298
+ // on screen any more.
299
+ store.set(HOST_ERROR, null);
300
+ store.set(INPUT_STATUS, null);
301
+ return next;
302
+ }
303
+ case 'turnStarted': {
304
+ const next = [...model.filter((t) => t.id !== event.turn.id), event.turn];
305
+ writeTurns(store, next);
306
+ return next;
307
+ }
308
+ case 'delta':
309
+ case 'toolCall':
310
+ // The part was mutated on the turn the model already holds; the write is
311
+ // what makes it visible. Rebuilding the list is what changes identity.
312
+ writeTurns(store, model);
313
+ return model;
314
+ case 'turnComplete': {
315
+ const next = model.map((turn) => (turn.id === event.turn.id ? event.turn : turn));
316
+ writeTurns(store, next);
317
+ return next;
318
+ }
319
+ case 'queued':
320
+ // The host's list, replacing whatever this client thought it was. Two
321
+ // clients queueing into the same chat is the ordinary case, not the
322
+ // exotic one, and the only list that is right is the one the host has.
323
+ store.set(QUEUE, event.messages);
324
+ return model;
325
+ case 'inputNeeded':
326
+ store.set(INPUT, event.input);
327
+ // A new question, about which nothing has been said yet. Left alone it
328
+ // would arrive under the last one's "Approving..." - or, worse, under
329
+ // the refusal that is the reason this one is being asked again.
330
+ store.set(INPUT_STATUS, null);
331
+ return model;
332
+ case 'inputResolved':
333
+ store.set(INPUT, null);
334
+ // The host has let go of the question, which is the answer to whatever
335
+ // this row was waiting to hear.
336
+ store.set(INPUT_STATUS, null);
337
+ return model;
338
+ case 'chats':
339
+ store.set(CHATS, event.items);
340
+ // Switching and closing need somewhere to go, which one chat is not.
341
+ store.set(HAS_CHATS, event.items.length > 1);
342
+ return model;
343
+ case 'customizations':
344
+ // The host's list, replacing whatever this client last read. It is the
345
+ // authority: a switch is answered there, and a server signs in there.
346
+ store.set(CUSTOMIZATIONS, event.items);
347
+ return model;
348
+ case 'present':
349
+ store.set(PRESENT, event.clients);
350
+ return model;
351
+ case 'status':
352
+ writeStatus(store, event.status);
353
+ return model;
354
+ case 'error':
355
+ // Through `reportHostError`, not straight at the path: when this is the
356
+ // host's answer to an answer just given, the row above the composer is
357
+ // where the person who gave it is looking. The footer alone left
358
+ // "Approving..." sitting there over a session that had stopped
359
+ // listening, with the reason twenty rows below it.
360
+ reportHostError(store, event.message);
361
+ return model;
362
+ case 'changes':
363
+ store.set(CHANGES, event.changes);
364
+ return model;
365
+ default:
366
+ return model;
367
+ }
368
+ }
369
+ /** The answer has gone to the host, which has not said anything about it yet. */
370
+ export function sendingInput(store, text) {
371
+ store.set(INPUT_STATUS, { state: 'sending', text });
372
+ }
373
+ /** It is not going, and this is why. */
374
+ export function inputRefused(store, text) {
375
+ store.set(INPUT_STATUS, { state: 'failed', text });
376
+ }
377
+ /**
378
+ * The host refused something, wherever it was asked from.
379
+ *
380
+ * The footer says so always. The block waiting on a person says so too when
381
+ * it was that block's answer being refused, and only then - a refusal earned
382
+ * by some other command has no business turning the row above the composer
383
+ * red, which is exactly what reading `HOST_ERROR` from there would do.
384
+ */
385
+ export function reportHostError(store, message) {
386
+ store.set(HOST_ERROR, message);
387
+ if (store.get(INPUT_STATUS)?.state === 'sending')
388
+ inputRefused(store, message);
389
+ }
390
+ /** The number, and the one question everything else asks of it. */
391
+ export function writeStatus(store, status) {
392
+ const activity = decodeStatus(status).activity;
393
+ store.set(STATUS, status);
394
+ store.set(RUNNING, activity === 'running' || activity === 'input');
395
+ }
396
+ /** A new array every time, and new part arrays inside it, or nothing redraws. */
397
+ function writeTurns(store, turns) {
398
+ store.set(TURNS, turns.map((turn) => ({ ...turn, parts: [...turn.parts] })));
399
+ }
400
+ export function writeSessions(store, sessions) {
401
+ const byUri = {};
402
+ for (const session of sessions)
403
+ byUri[session.resource] = session;
404
+ store.set(SESSIONS, byUri);
405
+ }
406
+ // -------------------------------------------------------------------- reading
407
+ export function sessions(store) {
408
+ const byUri = store.get(SESSIONS) ?? {};
409
+ return Object.values(byUri);
410
+ }
411
+ /**
412
+ * The catalogue as the list shows it.
413
+ *
414
+ * Archived is hidden rather than filtered out of existence: a session somebody
415
+ * put away is still a session, and the count of what is hidden is what tells
416
+ * the reader the list is not everything.
417
+ */
418
+ export function visibleSessions(store) {
419
+ const query = (store.get(FILTER) ?? '').trim().toLowerCase();
420
+ const showArchived = store.get(ARCHIVED) ?? false;
421
+ return sessions(store)
422
+ .filter((session) => showArchived || !decodeStatus(session.status).archived)
423
+ .filter((session) => query === ''
424
+ || session.title.toLowerCase().includes(query)
425
+ || session.provider.includes(query)
426
+ || session.workingDirectories.some((dir) => dir.toLowerCase().includes(query)))
427
+ .sort(byUrgency);
428
+ }
429
+ /**
430
+ * How many sessions the archived switch is keeping out of the list.
431
+ *
432
+ * Zero while the switch is on, because then it is keeping none out. What it
433
+ * counts is the difference the switch would make: a row offering to show
434
+ * archived sessions where there are none to show is a row that does nothing.
435
+ */
436
+ export function hiddenSessions(store) {
437
+ if (store.get(ARCHIVED) ?? false)
438
+ return 0;
439
+ return sessions(store).filter((session) => decodeStatus(session.status).archived).length;
440
+ }
441
+ export function openSession(store) {
442
+ const uri = store.get(OPEN);
443
+ if (!uri)
444
+ return null;
445
+ return (store.get(SESSIONS) ?? {})[uri] ?? null;
446
+ }
447
+ export function turns(store) {
448
+ return store.get(TURNS) ?? [];
449
+ }
450
+ export function pendingInput(store) {
451
+ return store.get(INPUT) ?? null;
452
+ }
453
+ export function changes(store) {
454
+ return store.get(CHANGES) ?? { status: 'complete', files: [] };
455
+ }
456
+ /** The running turn, if there is one. Never in the history until it finishes. */
457
+ export function activeTurn(store) {
458
+ return turns(store).find((turn) => turn.state === 'running') ?? null;
459
+ }
460
+ export function queue(store) {
461
+ return store.get(QUEUE) ?? [];
462
+ }
463
+ /** A short name for a `file://` working directory. */
464
+ export function workspaceName(uri) {
465
+ if (!uri)
466
+ return 'no workspace';
467
+ return uri.replace(/^file:\/\//, '').split('/').filter(Boolean).pop() ?? '/';
468
+ }
469
+ /**
470
+ * What to call the project a session is in.
471
+ *
472
+ * The host's own name when it gives one, and the last segment of the working
473
+ * directory when it does not - which is the same answer for most hosts and the
474
+ * right one for a host that names projects itself.
475
+ */
476
+ export function projectName(session) {
477
+ return session.project?.displayName || workspaceName(session.workingDirectories[0]);
478
+ }
479
+ /**
480
+ * What a host said about git, under the well-known key in `_meta`.
481
+ *
482
+ * `_meta` is an open map, so every step down is checked: a host may put
483
+ * anything here, including a `git` that is not an object.
484
+ */
485
+ function git(session) {
486
+ const found = session._meta?.git;
487
+ return typeof found === 'object' && found !== null && !Array.isArray(found)
488
+ ? found
489
+ : {};
490
+ }
491
+ function count(value) {
492
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
493
+ }
494
+ /**
495
+ * The branch a session's directory is on, if its host says.
496
+ *
497
+ * Two spellings, because there are two vocabularies and only one of them is
498
+ * written down anywhere. `branchName` is what the reference host sends and is
499
+ * the one to prefer; `branch` is what one other host sent until it adopted the
500
+ * reference's names. Neither is in the specification - `git` is a well-known
501
+ * key in an open map and its contents are convention - so reading only the
502
+ * name this client happened to meet first is how the branch row came to say
503
+ * "the host does not say" against a host that was saying it all along.
504
+ */
505
+ export function branchName(session) {
506
+ const found = git(session);
507
+ for (const key of ['branchName', 'branch']) {
508
+ const name = found[key];
509
+ if (typeof name === 'string' && name !== '')
510
+ return name;
511
+ }
512
+ return undefined;
513
+ }
514
+ /**
515
+ * How far the branch has drifted, where the host counted.
516
+ *
517
+ * Ahead, behind and uncommitted, in the arrows a person reads without a
518
+ * legend. Zeroes are left out rather than drawn as zeroes: the interesting
519
+ * state is the one that is not clean, and three noughts beside every branch is
520
+ * three columns of nothing.
521
+ */
522
+ export function branchDrift(session) {
523
+ const found = git(session);
524
+ const marks = [
525
+ ['\u2191', count(found.outgoingChanges)],
526
+ ['\u2193', count(found.incomingChanges)],
527
+ ['\u2022', count(found.uncommittedChanges)],
528
+ ];
529
+ const said = marks.filter(([, n]) => n > 0).map(([mark, n]) => `${mark}${n}`);
530
+ return said.length > 0 ? said.join(' ') : undefined;
531
+ }
@@ -0,0 +1,44 @@
1
+ import type { TextUIApp } from '@textui/core';
2
+ import type { HostConnection } from './ahp/connection.js';
3
+ /**
4
+ * Terminals, as operations rather than as a screen.
5
+ *
6
+ * The split is doop's: what a terminal *is* - which ones exist, which is
7
+ * open, what it has said, what happens when you type - lives here, and the
8
+ * view only draws it. The two change for different reasons, and a view that
9
+ * held the subscription would lose the terminal every time it was unmounted.
10
+ *
11
+ * A terminal belongs to the *host*, not to a session: it outlives the turn
12
+ * that opened it, several clients watch one, and the protocol lists them on
13
+ * the root channel. So this is the host's list and not any session's.
14
+ */
15
+ export interface Terminals {
16
+ /** Re-read the host's list. */
17
+ refresh(): Promise<void>;
18
+ /** Open one, and read it. */
19
+ open(options?: {
20
+ cwd?: string;
21
+ }): Promise<void>;
22
+ /** Read one that already exists. */
23
+ read(uri: string): void;
24
+ /** Send what was typed. Nothing comes back but what the shell says. */
25
+ write(data: string): void;
26
+ /**
27
+ * Say how big the terminal is being drawn.
28
+ *
29
+ * Sent on opening one and whenever the size changes. A host that is never
30
+ * told wraps its output at its own default, so a wide terminal shows lines
31
+ * broken at eighty columns and a narrow one shows them running off.
32
+ * Repeats are dropped: a resize event per frame is a dispatch per frame.
33
+ */
34
+ resize(cols: number, rows: number): void;
35
+ /** Empty the scrollback of the open one. */
36
+ clear(): void;
37
+ /** Rename the open one. */
38
+ rename(title: string): void;
39
+ /** Kill the open one and read whatever is left. */
40
+ close(): Promise<void>;
41
+ /** Let go of the subscription. */
42
+ dispose(): void;
43
+ }
44
+ export declare function createTerminals(app: TextUIApp, host: HostConnection, report: (error: unknown) => void): Terminals;
@@ -0,0 +1,101 @@
1
+ import { OPEN_TERMINAL, TERMINAL, TERMINALS } from './state.js';
2
+ export function createTerminals(app, host, report) {
3
+ /** The one being read. Closed and replaced, never two at once. */
4
+ let watching;
5
+ /** The last size reported, so an unchanged one is not reported again. */
6
+ let sized = '';
7
+ const list = async () => {
8
+ try {
9
+ const found = await host.terminals();
10
+ app.store.set(TERMINALS, found);
11
+ return found;
12
+ }
13
+ catch (error) {
14
+ report(error);
15
+ return [];
16
+ }
17
+ };
18
+ const terminals = {
19
+ refresh: async () => { await list(); },
20
+ read: (uri) => {
21
+ if (watching?.uri === uri)
22
+ return;
23
+ watching?.close();
24
+ app.store.set(OPEN_TERMINAL, uri);
25
+ // Null rather than an empty terminal: nothing has been read yet, and a
26
+ // blank screen that says "no output" is a claim rather than a wait.
27
+ app.store.set(TERMINAL, null);
28
+ const held = host.watchTerminal(uri, (state) => {
29
+ if (app.store.get(OPEN_TERMINAL) !== uri)
30
+ return;
31
+ app.store.set(TERMINAL, state);
32
+ });
33
+ watching = { uri, close: held.close };
34
+ },
35
+ open: async (options) => {
36
+ try {
37
+ const uri = await host.createTerminal(options?.cwd ? { cwd: options.cwd } : {});
38
+ await list();
39
+ terminals.read(uri);
40
+ }
41
+ catch (error) {
42
+ report(error);
43
+ }
44
+ },
45
+ write: (data) => {
46
+ const uri = app.store.get(OPEN_TERMINAL);
47
+ if (!uri)
48
+ return;
49
+ host.writeTerminal(uri, data);
50
+ },
51
+ resize: (cols, rows) => {
52
+ const uri = app.store.get(OPEN_TERMINAL);
53
+ if (!uri)
54
+ return;
55
+ // The same size again is not a resize. A screen re-renders for reasons
56
+ // that have nothing to do with its width, and every one of them would
57
+ // otherwise be a dispatch.
58
+ const at = `${uri} ${String(cols)}x${String(rows)}`;
59
+ if (at === sized)
60
+ return;
61
+ sized = at;
62
+ host.resizeTerminal(uri, cols, rows);
63
+ },
64
+ clear: () => {
65
+ const uri = app.store.get(OPEN_TERMINAL);
66
+ if (!uri)
67
+ return;
68
+ host.clearTerminal(uri);
69
+ },
70
+ rename: (title) => {
71
+ const uri = app.store.get(OPEN_TERMINAL);
72
+ if (!uri)
73
+ return;
74
+ host.renameTerminal(uri, title);
75
+ },
76
+ close: async () => {
77
+ const uri = app.store.get(OPEN_TERMINAL);
78
+ if (!uri)
79
+ return;
80
+ watching?.close();
81
+ watching = undefined;
82
+ app.store.set(OPEN_TERMINAL, null);
83
+ app.store.set(TERMINAL, null);
84
+ try {
85
+ await host.disposeTerminal(uri);
86
+ }
87
+ catch (error) {
88
+ report(error);
89
+ }
90
+ const left = await list();
91
+ const next = left[0];
92
+ if (next)
93
+ terminals.read(next.resource);
94
+ },
95
+ dispose: () => {
96
+ watching?.close();
97
+ watching = undefined;
98
+ },
99
+ };
100
+ return terminals;
101
+ }