@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.
- package/LICENSE +21 -0
- package/README.md +296 -0
- package/dist/src/ahp/channels.d.ts +146 -0
- package/dist/src/ahp/channels.js +423 -0
- package/dist/src/ahp/connection.d.ts +656 -0
- package/dist/src/ahp/connection.js +1 -0
- package/dist/src/ahp/fake.d.ts +69 -0
- package/dist/src/ahp/fake.js +1996 -0
- package/dist/src/ahp/live.d.ts +160 -0
- package/dist/src/ahp/live.js +2641 -0
- package/dist/src/ahp/operate.d.ts +36 -0
- package/dist/src/ahp/operate.js +56 -0
- package/dist/src/ahp/publish.d.ts +39 -0
- package/dist/src/ahp/publish.js +370 -0
- package/dist/src/ahp/status.d.ts +23 -0
- package/dist/src/ahp/status.js +23 -0
- package/dist/src/ahp/types.d.ts +745 -0
- package/dist/src/ahp/types.js +27 -0
- package/dist/src/app.d.ts +54 -0
- package/dist/src/app.js +369 -0
- package/dist/src/blocks.d.ts +88 -0
- package/dist/src/blocks.js +102 -0
- package/dist/src/cli/main.d.ts +13 -0
- package/dist/src/cli/main.js +1537 -0
- package/dist/src/cli/render.d.ts +43 -0
- package/dist/src/cli/render.js +91 -0
- package/dist/src/config.d.ts +63 -0
- package/dist/src/config.js +45 -0
- package/dist/src/connect.d.ts +53 -0
- package/dist/src/connect.js +69 -0
- package/dist/src/control.d.ts +189 -0
- package/dist/src/control.js +2241 -0
- package/dist/src/diff.d.ts +44 -0
- package/dist/src/diff.js +111 -0
- package/dist/src/flags.d.ts +28 -0
- package/dist/src/flags.js +76 -0
- package/dist/src/main.d.ts +10 -0
- package/dist/src/main.js +54 -0
- package/dist/src/schedule.d.ts +46 -0
- package/dist/src/schedule.js +140 -0
- package/dist/src/screens.d.ts +79 -0
- package/dist/src/screens.js +1322 -0
- package/dist/src/state.d.ts +355 -0
- package/dist/src/state.js +531 -0
- package/dist/src/terminal.d.ts +44 -0
- package/dist/src/terminal.js +101 -0
- package/dist/src/tui.d.ts +92 -0
- package/dist/src/tui.js +372 -0
- package/dist/src/view/automations.d.ts +31 -0
- package/dist/src/view/automations.js +76 -0
- package/dist/src/view/bood/art.d.ts +90 -0
- package/dist/src/view/bood/art.js +152 -0
- package/dist/src/view/bood/beetle.d.ts +7 -0
- package/dist/src/view/bood/beetle.js +123 -0
- package/dist/src/view/bood/bunny.d.ts +8 -0
- package/dist/src/view/bood/bunny.js +228 -0
- package/dist/src/view/bood/cat.d.ts +9 -0
- package/dist/src/view/bood/cat.js +358 -0
- package/dist/src/view/bood/crab.d.ts +7 -0
- package/dist/src/view/bood/crab.js +121 -0
- package/dist/src/view/bood/index.d.ts +29 -0
- package/dist/src/view/bood/index.js +25 -0
- package/dist/src/view/bood/motion.d.ts +178 -0
- package/dist/src/view/bood/motion.js +298 -0
- package/dist/src/view/bood/owl.d.ts +9 -0
- package/dist/src/view/bood/owl.js +285 -0
- package/dist/src/view/bood/registry.d.ts +36 -0
- package/dist/src/view/bood/registry.js +218 -0
- package/dist/src/view/bood/render.d.ts +31 -0
- package/dist/src/view/bood/render.js +196 -0
- package/dist/src/view/bood/sprite.d.ts +18 -0
- package/dist/src/view/bood/sprite.js +130 -0
- package/dist/src/view/bood/sprout.d.ts +8 -0
- package/dist/src/view/bood/sprout.js +121 -0
- package/dist/src/view/bood/types.d.ts +158 -0
- package/dist/src/view/bood/types.js +57 -0
- package/dist/src/view/bubble.d.ts +75 -0
- package/dist/src/view/bubble.js +86 -0
- package/dist/src/view/changes.d.ts +35 -0
- package/dist/src/view/changes.js +22 -0
- package/dist/src/view/composer.d.ts +64 -0
- package/dist/src/view/composer.js +192 -0
- package/dist/src/view/controls.d.ts +44 -0
- package/dist/src/view/controls.js +49 -0
- package/dist/src/view/creature.d.ts +69 -0
- package/dist/src/view/creature.js +83 -0
- package/dist/src/view/customizations.d.ts +28 -0
- package/dist/src/view/customizations.js +53 -0
- package/dist/src/view/details.d.ts +65 -0
- package/dist/src/view/details.js +65 -0
- package/dist/src/view/filediff.d.ts +29 -0
- package/dist/src/view/filediff.js +24 -0
- package/dist/src/view/files.d.ts +31 -0
- package/dist/src/view/files.js +39 -0
- package/dist/src/view/hitl.d.ts +43 -0
- package/dist/src/view/hitl.js +171 -0
- package/dist/src/view/icons.d.ts +13 -0
- package/dist/src/view/icons.js +71 -0
- package/dist/src/view/picker.d.ts +42 -0
- package/dist/src/view/picker.js +71 -0
- package/dist/src/view/sessionhead.d.ts +41 -0
- package/dist/src/view/sessionhead.js +60 -0
- package/dist/src/view/sessions.d.ts +34 -0
- package/dist/src/view/sessions.js +61 -0
- package/dist/src/view/terminal.d.ts +30 -0
- package/dist/src/view/terminal.js +32 -0
- package/dist/src/view/toolcall.d.ts +27 -0
- package/dist/src/view/toolcall.js +48 -0
- package/dist/src/view/transcript.d.ts +50 -0
- package/dist/src/view/transcript.js +60 -0
- 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
|
+
}
|