@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,2241 @@
1
+ import { createBag, serviceKey } from '@textui/core';
2
+ import { spawn } from 'node:child_process';
3
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { confirm } from '@textui/widgets';
7
+ import { findBlocks, toBlocks } from './blocks.js';
8
+ import { operate } from './ahp/operate.js';
9
+ import { createTerminals } from './terminal.js';
10
+ import { SessionFlag } from './ahp/types.js';
11
+ import { valueIcon } from './view/icons.js';
12
+ import { ARCHIVED, BOOD_FLOAT, CAN_ADD_CHAT, CAN_FORK, CAN_SIDE_CHAT, CHAT_URI, CHATS, CUSTOMIZATIONS, CURSOR, DRAFT, EXPANDED, FILTER, FIND, FINDING, FIND_AT, HAS_CHATS, HOST, HOST_ERROR, INPUT, MODEL, MODEL_CONFIG, OPEN_TERMINAL, AUTOMATIONS, AUTOMATION_ROW, CHANGES as CHANGES_AT_PATH, CHANGE_AT, CHANGE_ROW, CHANGE_SCOPES, FILES_AT, FILES_OPEN, OPEN, OPEN_FILE, PROVIDER, MARKDOWN, QUEUE, RUNNING, SCREEN, SELECTED, SETTINGS, SIDEBAR, SPLIT_AT, SPLIT_DEFAULT, TURNS, WORKSPACE, applyEvent, inputRefused, pendingInput, queue, reportHostError, sendingInput, sessions, turns, writeSessions, writeStatus, } from './state.js';
13
+ export const CONTROLLER = serviceKey('chat.controller');
14
+ /** The command that asks about one config key. Registered when a host offers it. */
15
+ /**
16
+ * How long to wait before telling the host what is being typed.
17
+ *
18
+ * Long enough that a sentence is one dispatch rather than forty, short enough
19
+ * that leaving the screen mid-word does not lose the word.
20
+ */
21
+ const DRAFT_DEBOUNCE_MS = 600;
22
+ export const settingCommand = (key) => `compose.set.${key}`;
23
+ /** One per property of the chosen model's own schema. */
24
+ export const modelCommand = (key) => `compose.model.${key}`;
25
+ /**
26
+ * The two focus scopes, and why single-letter keys need them.
27
+ *
28
+ * The runtime already offers a key to the focused node before any keybinding,
29
+ * so `q` typed into the composer is a letter and not a quit. What a scope adds
30
+ * is *where a key exists at all*: `d` disposes a session on the catalogue and
31
+ * means nothing in a conversation, and a binding that exists on both screens
32
+ * is one that fires on the wrong one.
33
+ *
34
+ * A scope is active while the component that declares it is mounted, so the
35
+ * screens are what turn these on and off.
36
+ */
37
+ export const SESSIONS_SCOPE = 'chat.sessions';
38
+ export const CHAT_SCOPE = 'chat.conversation';
39
+ export const SKILLS_SCOPE = 'chat.skills';
40
+ export const MCP_SCOPE = 'chat.mcp';
41
+ export const CHANGES_SCOPE = 'chat.changes';
42
+ export const AUTOMATIONS_SCOPE = 'chat.automations';
43
+ export function createController(app, host, bindings) {
44
+ const bag = createBag();
45
+ // The client's own copy of the conversation, kept so a delta can be applied
46
+ // to the part it names rather than the store being read back and rewritten.
47
+ let model = [];
48
+ let subscription = null;
49
+ app.store.set(HOST, { id: host.id, url: host.url, state: host.state() });
50
+ app.store.set(FILTER, '');
51
+ app.store.set(ARCHIVED, false);
52
+ app.store.set(EXPANDED, {});
53
+ app.store.set(QUEUE, []);
54
+ app.store.set(DRAFT, '');
55
+ // What the first message will be sent as, before there is a session to ask.
56
+ app.store.set(PROVIDER, 'claude');
57
+ app.store.set(MODEL, '');
58
+ app.store.set(SETTINGS, {});
59
+ /**
60
+ * What to do when the host says no.
61
+ *
62
+ * Everything below is either fire-and-forget or driven from an effect, so a
63
+ * rejection has nowhere to be caught by the caller - and an unhandled
64
+ * rejection ends the process, from a terminal sitting in its alternate
65
+ * screen. A refusal is information: it goes where the screens can read it.
66
+ */
67
+ /**
68
+ * One command per question the host says it will answer.
69
+ *
70
+ * Registered rather than written, because the questions are the harness's
71
+ * and not this client's: `permissionMode` is what the fixture calls its key
72
+ * and a real host's are `isolation`, `autoApprove` and `mode`. A command
73
+ * naming a key works against one host, so nothing here names one - the
74
+ * schema arrives, and the palette and the control row have it.
75
+ *
76
+ * Late binding is the whole point: a harness that grows a new setting grows
77
+ * a new chip and a new palette entry, and this file does not change.
78
+ */
79
+ const offered = new Map();
80
+ /** The same, for the chosen model's own schema, which is a separate document. */
81
+ const modelOffered = new Map();
82
+ const unicode = app.capabilities.unicode;
83
+ /**
84
+ * One command per question the chosen model asks.
85
+ *
86
+ * The protocol says a client presents a model's `configSchema` as a form and
87
+ * returns the answers in `ModelSelection.config`, so these write to
88
+ * `MODEL_CONFIG` and ride out on the next message rather than being
89
+ * dispatched at the session. The two documents are kept apart on purpose:
90
+ * the session's schema is the host's and outlives any model, this one goes
91
+ * when the model does.
92
+ */
93
+ const offerModel = (properties) => {
94
+ for (const [id, disposable] of modelOffered) {
95
+ disposable.dispose();
96
+ modelOffered.delete(id);
97
+ }
98
+ for (const property of properties) {
99
+ if (property.values.length === 0)
100
+ continue;
101
+ const id = modelCommand(property.key);
102
+ modelOffered.set(id, app.commands.register({
103
+ id,
104
+ title: property.title,
105
+ category: 'Compose',
106
+ slots: ['palette'],
107
+ args: [{
108
+ name: 'value',
109
+ type: 'string',
110
+ required: true,
111
+ description: property.description ?? `Choose ${property.title.toLowerCase()}`,
112
+ get default() {
113
+ const answers = app.store.get(MODEL_CONFIG) ?? {};
114
+ // What the host said it opens with, where nothing has been chosen.
115
+ return answers[property.key] ?? property.default;
116
+ },
117
+ ...(property.values.some((value) => value.description)
118
+ ? { descriptions: 'below' }
119
+ : {}),
120
+ choices: () => property.values.map((value) => ({
121
+ value: value.value,
122
+ label: value.label,
123
+ ...(value.description ? { description: value.description } : {}),
124
+ })),
125
+ }],
126
+ run: (args) => {
127
+ const chosen = property.values.find((value) => value.value === String(args.value));
128
+ if (!chosen)
129
+ return;
130
+ const answers = app.store.get(MODEL_CONFIG) ?? {};
131
+ app.store.set(MODEL_CONFIG, { ...answers, [property.key]: chosen.value });
132
+ },
133
+ }));
134
+ }
135
+ };
136
+ const offer = (config, uri) => {
137
+ const keep = new Set();
138
+ for (const property of config.properties) {
139
+ // Nothing to choose from is nothing to ask: a host's `permissions` key
140
+ // is an object the agent maintains, not a question with answers. Unless
141
+ // the host said to ask - `enumDynamic` is a schema whose values are a
142
+ // query, and an empty `enum` beside it means "not listed here", not
143
+ // "none".
144
+ if (property.values.length === 0 && property.enumDynamic !== true)
145
+ continue;
146
+ // A session that exists can only be changed where the host says so, and
147
+ // offering the rest produces a refusal instead of an edit.
148
+ if (uri && !property.sessionMutable)
149
+ continue;
150
+ const id = settingCommand(property.key);
151
+ keep.add(id);
152
+ offered.get(id)?.dispose();
153
+ // Whether the values are worth a mark at all. Five approval modes named
154
+ // in the same two words need one; a list of branch names does not, and a
155
+ // column of identical dots beside them is noise with a shape.
156
+ const marked = property.values.some((value) => valueIcon(unicode, value.value, value.label));
157
+ offered.set(id, app.commands.register({
158
+ id,
159
+ title: property.title,
160
+ category: 'Compose',
161
+ slots: ['palette'],
162
+ args: [{
163
+ name: 'value',
164
+ type: 'string',
165
+ required: true,
166
+ description: property.description ?? `Choose ${property.title.toLowerCase()}`,
167
+ /*
168
+ * What it is set to now, so the picker opens on that row.
169
+ *
170
+ * Read through a getter rather than captured: `offer` runs when the
171
+ * schema arrives and the value changes every time one of these is
172
+ * answered, so a value read at registration time is the answer that
173
+ * was in force when the host first replied.
174
+ */
175
+ get default() {
176
+ const values = app.store.get(SETTINGS) ?? {};
177
+ return values[property.key];
178
+ },
179
+ // The host's own words, all three of them. "Auto Mode" and "Plan
180
+ // Mode" are two words apart and mean entirely different things; the
181
+ // sentence under each is what tells them apart, and it is the
182
+ // difference between picking and guessing.
183
+ //
184
+ // Which is why the sentence gets a line of its own as soon as one of
185
+ // the values has one. Beside the label it shares the width with it,
186
+ // and a column of "Every tool call is c…" / "File edits run; com…"
187
+ // truncates away the exact part the reader is choosing on.
188
+ ...(property.values.some((value) => value.description)
189
+ ? { descriptions: 'below' }
190
+ : {}),
191
+ /*
192
+ * Asked for where the host said to ask, listed where it did not.
193
+ *
194
+ * `enumDynamic` means the values are a query rather than a schema -
195
+ * a branch list on a large repository is exactly that - so this
196
+ * calls `sessionConfigCompletions` and falls back to whatever the
197
+ * schema did carry if the host will not answer.
198
+ */
199
+ choices: property.enumDynamic === true && host.configCompletions
200
+ ? async () => {
201
+ try {
202
+ const found = await host.configCompletions?.({
203
+ property: property.key,
204
+ provider: app.store.get(PROVIDER) ?? '',
205
+ ...(app.store.get(WORKSPACE) ? { workingDirectory: app.store.get(WORKSPACE) } : {}),
206
+ values: app.store.get(SETTINGS) ?? {},
207
+ });
208
+ return (found ?? []).map((one) => ({
209
+ value: one.value,
210
+ label: one.label,
211
+ ...(one.description ? { description: one.description } : {}),
212
+ }));
213
+ }
214
+ catch {
215
+ return property.values.map((value) => ({ value: value.value, label: value.label }));
216
+ }
217
+ }
218
+ : () => property.values.map((value) => ({
219
+ value: value.value,
220
+ label: value.label,
221
+ ...(marked ? { icon: valueIcon(unicode, value.value, value.label, { fallback: true }) } : {}),
222
+ ...(value.description ? { description: value.description } : {}),
223
+ })),
224
+ }],
225
+ run: (args) => {
226
+ // A dynamic property's values were never in the schema, so there is
227
+ // nothing here to check one against: what the host offered is what
228
+ // came back, and rejecting it would reject every answer.
229
+ const value = String(args.value);
230
+ const chosen = property.enumDynamic === true
231
+ ? { value }
232
+ : property.values.find((one) => one.value === value);
233
+ if (!chosen)
234
+ return;
235
+ const current = app.store.get(SETTINGS) ?? {};
236
+ app.store.set(SETTINGS, { ...current, [property.key]: chosen.value });
237
+ const open = app.store.get(OPEN);
238
+ // One key at a time: the action merges, so sending the object
239
+ // writes back whatever this client happened to be holding.
240
+ if (open)
241
+ host.setConfig(open, property.key, chosen.value);
242
+ },
243
+ }));
244
+ }
245
+ // A harness the composer just switched to does not answer the last one's
246
+ // questions, and a chip pointing at a command nobody unregistered is a
247
+ // panel offering another harness's values.
248
+ for (const [id, disposable] of offered) {
249
+ if (keep.has(id))
250
+ continue;
251
+ disposable.dispose();
252
+ offered.delete(id);
253
+ }
254
+ };
255
+ bag.add({
256
+ dispose: () => {
257
+ for (const disposable of offered.values())
258
+ disposable.dispose();
259
+ offered.clear();
260
+ },
261
+ });
262
+ const failed = (error) => {
263
+ const rpc = error;
264
+ const message = rpc?.message ?? String(error);
265
+ reportHostError(app.store, typeof rpc?.code === 'number' ? `${message} (${rpc.code})` : message);
266
+ };
267
+ /**
268
+ * Read the catalogue again, at most once per turn of the loop.
269
+ *
270
+ * The host says "something moved" per action, and a turn finishing is
271
+ * several of them - so the unguarded version is one `listSessions` per
272
+ * delta. Coalescing makes a burst one read, and the read is what the list
273
+ * is drawn from either way.
274
+ */
275
+ let pending = null;
276
+ const refreshSoon = () => {
277
+ if (pending)
278
+ return;
279
+ pending = setTimeout(() => { pending = null; void reread(); }, 120);
280
+ // Nothing here should hold a process open: this is a repaint, not work.
281
+ pending.unref?.();
282
+ };
283
+ bag.add({ dispose: () => { if (pending)
284
+ clearTimeout(pending); pending = null; } });
285
+ /**
286
+ * Read the catalogue, and say nothing about anything else.
287
+ *
288
+ * `refresh` clears the last refusal, because a person pressing `r` is asking
289
+ * whether the host is answering again and a fresh list is the answer. This
290
+ * one is not that: it runs whenever the host says something moved, and a
291
+ * catalogue that answered says nothing about a session channel that refused
292
+ * - so clearing it here wiped "Authentication is required" off the status
293
+ * bar the moment any other session ticked.
294
+ */
295
+ const reread = async () => {
296
+ try {
297
+ writeSessions(app.store, await host.listSessions());
298
+ }
299
+ catch (error) {
300
+ failed(error);
301
+ }
302
+ };
303
+ /**
304
+ * What each provider says it can do, by provider id.
305
+ *
306
+ * Asked once at boot rather than when a palette happens to open: the chat
307
+ * commands are gated on it, and a gate that answers "no" until somebody
308
+ * opens an unrelated picker is a command missing for no visible reason.
309
+ */
310
+ const capable = new Map();
311
+ /**
312
+ * Whether one session's agent can hold another chat.
313
+ *
314
+ * Read from the row's own provider rather than the composer's: a client can
315
+ * have several hosts' sessions in one list, and only some of their agents
316
+ * can hold more than one.
317
+ */
318
+ const canAddChat = (uri) => {
319
+ const found = sessions(app.store).find((row) => row.resource === uri)?.provider;
320
+ return found !== undefined && capable.get(found) === true;
321
+ };
322
+ /** The two chat sources, each gated on the agent advertising that one. */
323
+ const sources = new Map();
324
+ const canSource = (uri, kind) => {
325
+ const found = sessions(app.store).find((row) => row.resource === uri)?.provider;
326
+ return found !== undefined && sources.get(found)?.[kind] === true;
327
+ };
328
+ void host.agents().then((found) => {
329
+ for (const entry of found)
330
+ capable.set(entry.provider, entry.multipleChats === true);
331
+ for (const entry of found)
332
+ sources.set(entry.provider, entry.chatSources ?? {});
333
+ // A session may already be open by the time this lands.
334
+ const uri = app.store.get(OPEN);
335
+ if (uri)
336
+ app.store.set(CAN_ADD_CHAT, canAddChat(uri));
337
+ if (uri) {
338
+ app.store.set(CAN_FORK, canSource(uri, 'fork'));
339
+ app.store.set(CAN_SIDE_CHAT, canSource(uri, 'sideChat'));
340
+ }
341
+ }).catch(() => undefined);
342
+ const terminals = createTerminals(app, host, failed);
343
+ /**
344
+ * The draft, on its way to the host, debounced.
345
+ *
346
+ * `chat-channel.md`: clients SHOULD debounce and MAY sync only at
347
+ * convenient points - eager syncing is explicitly not required. A dispatch
348
+ * per keystroke would be one round trip per character and a snapshot back
349
+ * for each.
350
+ */
351
+ let draftTimer;
352
+ bag.add({ dispose: () => clearTimeout(draftTimer) });
353
+ const controller = {
354
+ offerModel,
355
+ draft(text) {
356
+ const uri = app.store.get(OPEN);
357
+ if (!uri)
358
+ return;
359
+ clearTimeout(draftTimer);
360
+ draftTimer = setTimeout(() => { host.setDraft(uri, text); }, DRAFT_DEBOUNCE_MS);
361
+ },
362
+ async refresh() {
363
+ try {
364
+ writeSessions(app.store, await host.listSessions());
365
+ app.store.set(HOST_ERROR, null);
366
+ }
367
+ catch (error) {
368
+ failed(error);
369
+ }
370
+ },
371
+ report: failed,
372
+ /**
373
+ * Read a different chat in the session already open.
374
+ *
375
+ * A whole re-subscribe rather than a swap: the transcript, the queue and
376
+ * what is waiting all belong to the chat, so keeping any of them across
377
+ * the change would show one conversation's state under another's name.
378
+ */
379
+ terminals,
380
+ completions: (channel, text, offset) => host.completions({
381
+ channel,
382
+ text,
383
+ ...(offset !== undefined ? { offset } : {}),
384
+ }),
385
+ openChat(chat) {
386
+ const uri = app.store.get(OPEN);
387
+ if (!uri || app.store.get(CHAT_URI) === chat)
388
+ return;
389
+ subscription?.close();
390
+ model = [];
391
+ app.store.set(TURNS, []);
392
+ app.store.set(INPUT, null);
393
+ app.store.set(QUEUE, []);
394
+ app.store.set(CHAT_URI, chat);
395
+ subscription = host.subscribe(uri, (event) => {
396
+ model = applyEvent(app.store, event, model);
397
+ if (event.type === 'status')
398
+ refreshSoon();
399
+ }, chat);
400
+ },
401
+ async createChat(first, source) {
402
+ const uri = app.store.get(OPEN);
403
+ if (!uri)
404
+ return;
405
+ try {
406
+ const chat = await host.createChat(uri, first, source);
407
+ controller.openChat(chat);
408
+ }
409
+ catch (error) {
410
+ failed(error);
411
+ }
412
+ },
413
+ async disposeChat(chat) {
414
+ try {
415
+ await host.disposeChat(chat);
416
+ }
417
+ catch (error) {
418
+ failed(error);
419
+ return;
420
+ }
421
+ // Whatever is left. The host moves its own default; this client only
422
+ // has to stop reading a chat that is gone.
423
+ const left = (app.store.get(CHATS) ?? [])
424
+ .map((entry) => entry.resource)
425
+ .filter((resource) => resource !== chat);
426
+ const next = left[0];
427
+ if (next)
428
+ controller.openChat(next);
429
+ },
430
+ open(uri) {
431
+ // Closing drops this consumer only. Unsubscribing the channel to shed a
432
+ // duplicate is what silently kills the stream everything else reads.
433
+ subscription?.close();
434
+ model = [];
435
+ // Reading it is what makes it read, and the host tells every other
436
+ // client so. Nobody marks their own mail by hand.
437
+ host.setRead(uri, true);
438
+ app.store.set(OPEN, uri);
439
+ app.store.set(TURNS, []);
440
+ app.store.set(INPUT, null);
441
+ // Null rather than empty: the skills are this session's, and two
442
+ // sessions on the same host in different directories are handed
443
+ // different ones. An empty list would say "the host gave it none",
444
+ // which is an answer, and this is "nobody has asked yet".
445
+ app.store.set(CUSTOMIZATIONS, null);
446
+ app.store.set(CHATS, []);
447
+ app.store.set(HAS_CHATS, false);
448
+ app.store.set(CAN_ADD_CHAT, canAddChat(uri));
449
+ app.store.set(CAN_FORK, canSource(uri, 'fork'));
450
+ app.store.set(CAN_SIDE_CHAT, canSource(uri, 'sideChat'));
451
+ app.store.set(OPEN_FILE, null);
452
+ subscription = host.subscribe(uri, (event) => {
453
+ model = applyEvent(app.store, event, model);
454
+ // The open session's own status still refreshes the list here: the
455
+ // catalogue watch below covers what the *host* says moved, and this
456
+ // covers what this client is watching happen.
457
+ if (event.type === 'status')
458
+ refreshSoon();
459
+ });
460
+ // The composer's row is about the next message, so on an open session it
461
+ // has to describe *that* session: its harness, its workspace, the
462
+ // permission mode in force and what its last turn ran on. Left alone it
463
+ // would keep describing whatever was chosen on the new-session screen,
464
+ // which is a row of confident, wrong answers.
465
+ const summary = sessions(app.store).find((found) => found.resource === uri);
466
+ if (summary) {
467
+ app.store.set(PROVIDER, summary.provider);
468
+ app.store.set(WORKSPACE, (summary.workingDirectories[0] ?? '').replace(/^file:\/\//, ''));
469
+ }
470
+ app.store.set(CHAT_URI, null);
471
+ void host.detail(uri).then((detail) => {
472
+ if (app.store.get(OPEN) !== uri)
473
+ return;
474
+ app.store.set(SETTINGS, detail.config.values);
475
+ app.store.set(CHAT_URI, detail.chat);
476
+ // The id, which is what a turn rides on and what the picker matches
477
+ // against. The name beside it is the catalogue's, and resolving it is
478
+ // the chip's job rather than something to store a second copy of.
479
+ if (detail.model)
480
+ app.store.set(MODEL, detail.model.id);
481
+ offer(detail.config, uri);
482
+ }).catch(failed);
483
+ },
484
+ close() {
485
+ subscription?.close();
486
+ subscription = null;
487
+ app.store.set(OPEN, null);
488
+ app.store.set(TURNS, []);
489
+ app.store.set(INPUT, null);
490
+ app.store.set(CUSTOMIZATIONS, null);
491
+ app.store.set(CHATS, []);
492
+ app.store.set(HAS_CHATS, false);
493
+ app.store.set(CAN_ADD_CHAT, false);
494
+ app.store.set(CAN_FORK, false);
495
+ app.store.set(CAN_SIDE_CHAT, false);
496
+ app.store.set(OPEN_FILE, null);
497
+ // Idle, because nothing is open. A status that outlived the conversation
498
+ // it described is a header saying "running" over an empty screen.
499
+ writeStatus(app.store, 1);
500
+ },
501
+ send(text) {
502
+ const uri = app.store.get(OPEN);
503
+ const trimmed = text.trim();
504
+ if (!uri || trimmed === '')
505
+ return;
506
+ /*
507
+ * The draft is cleared here and at the host, and the pending sync is
508
+ * dropped.
509
+ *
510
+ * `draft()` debounces, so at the moment a message is sent there is
511
+ * usually a timer holding the text that was just sent. Left to fire it
512
+ * tells the host that is the draft; the host stores it and echoes
513
+ * `chat/draftChanged` back, and the message reappears in the composer a
514
+ * moment after it was sent - which reads as the send having failed.
515
+ */
516
+ clearTimeout(draftTimer);
517
+ app.store.set(DRAFT, '');
518
+ host.setDraft(uri, '');
519
+ const history = app.store.get('$/chat/ui/history') ?? [];
520
+ app.store.set('$/chat/ui/history', [...history, trimmed]);
521
+ // The model rides on the turn, not on the session: AHP hangs it on the
522
+ // message, so the composer's choice is applied here rather than being
523
+ // set on the session once.
524
+ const chosen = app.store.get(MODEL);
525
+ // The model's own answers go with it, in `ModelSelection.config` - the
526
+ // field the schema says a client returns a `configSchema` form in.
527
+ const answers = app.store.get(MODEL_CONFIG) ?? {};
528
+ const selection = chosen
529
+ ? { id: chosen, ...(Object.keys(answers).length > 0 ? { config: answers } : {}) }
530
+ : undefined;
531
+ // A turn is already running: this is a queued message, not a second
532
+ // turn. Sending it anyway is how two turns end up interleaved in one
533
+ // chat, and the host is not the thing that stops you.
534
+ //
535
+ // Queued *on the host*, which is the whole difference between a queue
536
+ // and a list. Held here it was never sent - nothing in this client was
537
+ // watching for the turn to end, so a message typed while the agent was
538
+ // working sat under the transcript saying `queued` until the session was
539
+ // closed. The host starts the next turn from the head as soon as it goes
540
+ // idle, and every client watching this chat sees the same queue.
541
+ if (turns(app.store).some((turn) => turn.state === 'running')) {
542
+ host.queue(uri, trimmed, selection);
543
+ return;
544
+ }
545
+ host.say(uri, trimmed, selection);
546
+ },
547
+ unqueue(id) {
548
+ const uri = app.store.get(OPEN);
549
+ if (!uri)
550
+ return;
551
+ host.unqueue(uri, id);
552
+ },
553
+ stop() {
554
+ const uri = app.store.get(OPEN);
555
+ if (uri)
556
+ host.stopTurn(uri);
557
+ },
558
+ /*
559
+ * The three that answer the block, and the one thing they have in common.
560
+ *
561
+ * Each of them can decline to do anything at all - there is no session
562
+ * open, the block up is the other kind, a required question is unanswered
563
+ * - and returning on that quietly is how a button comes to do nothing
564
+ * when pressed. Every path out of here now says which one it took, on the
565
+ * row above the composer: gone to the host, or not going and why.
566
+ */
567
+ approve(optionId) {
568
+ const uri = app.store.get(OPEN);
569
+ const input = pendingInput(app.store);
570
+ if (!uri || input?.kind !== 'toolConfirmation') {
571
+ inputRefused(app.store, 'Nothing is waiting to be approved');
572
+ return;
573
+ }
574
+ // The option's label, not its id: a live host's option ids are whole
575
+ // sentences with punctuation in them, and one of those in a status row
576
+ // is the row's whole width spent on something nobody reads.
577
+ const chosen = input.call.options?.find((option) => option.id === optionId);
578
+ sendingInput(app.store, chosen ? `Approving - ${chosen.label}...` : 'Approving...');
579
+ host.confirmToolCall(uri, input.call.id, true, optionId);
580
+ },
581
+ deny() {
582
+ const uri = app.store.get(OPEN);
583
+ const input = pendingInput(app.store);
584
+ if (!uri || input?.kind !== 'toolConfirmation') {
585
+ inputRefused(app.store, 'Nothing is waiting to be denied');
586
+ return;
587
+ }
588
+ sendingInput(app.store, 'Denying...');
589
+ host.confirmToolCall(uri, input.call.id, false);
590
+ },
591
+ answer(answers, accepted = true) {
592
+ const uri = app.store.get(OPEN);
593
+ const input = pendingInput(app.store);
594
+ if (!uri || input?.kind !== 'chatInput') {
595
+ inputRefused(app.store, 'No question is waiting to be answered');
596
+ return;
597
+ }
598
+ // An accept with no answers resumes the agent on the answers it already
599
+ // had, which for a question it has just asked is none.
600
+ const missing = input.questions.filter((q) => q.required && !answers[q.id]);
601
+ if (accepted && missing.length > 0) {
602
+ inputRefused(app.store, `${String(missing.length)} still to answer`);
603
+ return;
604
+ }
605
+ sendingInput(app.store, accepted ? 'Sending your answer...' : 'Declining...');
606
+ host.completeInput(uri, input.id, accepted, answers);
607
+ },
608
+ setArchived(uri, archived) {
609
+ host.setArchived(uri, archived);
610
+ void controller.refresh();
611
+ },
612
+ setRead(uri, read) {
613
+ host.setRead(uri, read);
614
+ void controller.refresh();
615
+ },
616
+ async disposeSession(uri) {
617
+ try {
618
+ await host.disposeSession(uri);
619
+ }
620
+ catch (error) {
621
+ failed(error);
622
+ }
623
+ if (app.store.get(OPEN) === uri)
624
+ controller.close();
625
+ await controller.refresh();
626
+ },
627
+ async create({ provider, workingDirectory, first }) {
628
+ // Not caught here: the caller is a screen that navigates on success, and
629
+ // navigating into a session the host refused to create is worse than the
630
+ // failure. It catches, and reports through `report`.
631
+ // What the control row was set to rides on the creation. Most of what a
632
+ // schema offers is not `sessionMutable`, so a session created without it
633
+ // is one that can never be told.
634
+ const config = app.store.get(SETTINGS) ?? {};
635
+ const uri = await host.createSession({
636
+ provider,
637
+ ...(workingDirectory ? { workingDirectory } : {}),
638
+ ...(Object.keys(config).length > 0 ? { config } : {}),
639
+ });
640
+ await controller.refresh();
641
+ controller.open(uri);
642
+ // The provider is lazy: a session sits in `creating` and emits nothing
643
+ // until there is a turn to run, so waiting for `ready` before the first
644
+ // dispatch is a deadlock - the dispatch is what causes it.
645
+ if (first)
646
+ controller.send(first);
647
+ return uri;
648
+ },
649
+ agents: () => host.agents(),
650
+ detail: (uri) => host.detail(uri),
651
+ config: (uri) => host.config(uri),
652
+ customizations: (uri) => host.customizations(uri),
653
+ harnessCommands: () => host.harnessCommands(),
654
+ setCustomizationEnabled: (uri, id, enabled) => host.setCustomizationEnabled(uri, id, enabled),
655
+ content: (ref) => host.content(ref),
656
+ loadOlderTurns: (uri) => host.loadOlderTurns(uri),
657
+ files: async (uri) => (await host.resourceList?.(uri)) ?? [],
658
+ /*
659
+ * Be told when a directory changes, instead of asking again.
660
+ *
661
+ * `resource-watch-channel.md` is explicit that there is no dispose
662
+ * command: releasing the last subscriber is what makes the host let the
663
+ * watcher go, so the returned handle is the whole of closing one. A host
664
+ * that will not serve a watch is not a failure - it answers `-32601` or
665
+ * refuses with `-32009`, and the screen goes on reading when it opens.
666
+ */
667
+ watchFiles: async (uri, changed) => {
668
+ if (!host.watchResource)
669
+ return { close: () => undefined };
670
+ try {
671
+ return await host.watchResource(uri, changed, { recursive: false });
672
+ }
673
+ catch {
674
+ return { close: () => undefined };
675
+ }
676
+ },
677
+ file: async (uri) => {
678
+ if (!host.resourceRead)
679
+ throw new Error('This host serves no files.');
680
+ return await host.resourceRead(uri);
681
+ },
682
+ automations: async () => {
683
+ if (!host.automations)
684
+ throw new Error('This host serves no automations.');
685
+ return await host.automations();
686
+ },
687
+ onAutomations: (observer) => host.onAutomations?.(observer) ?? { close: () => { } },
688
+ createAutomation: async (definition) => {
689
+ if (!host.createAutomation)
690
+ throw new Error('This host serves no automations.');
691
+ return await host.createAutomation(definition);
692
+ },
693
+ runAutomation: async (uri) => { await host.runAutomation?.(uri); },
694
+ setAutomationEnabled: async (uri, enabled) => { await host.setAutomationEnabled?.(uri, enabled); },
695
+ removeAutomation: async (uri) => { await host.removeAutomation?.(uri); },
696
+ changesets: async (uri) => (await host.changesets?.(uri)) ?? [],
697
+ changesAt: (uri, changeset) => host.changes(uri, changeset),
698
+ review: (changeset, files, isReviewed) => { host.review?.(changeset, files, isReviewed); },
699
+ async settings() {
700
+ const uri = app.store.get(OPEN) ?? null;
701
+ const config = uri
702
+ ? await host.config(uri)
703
+ : await host.resolveConfig({
704
+ provider: app.store.get(PROVIDER) ?? 'claude',
705
+ ...(app.store.get(WORKSPACE) ? { workingDirectory: app.store.get(WORKSPACE) } : {}),
706
+ values: app.store.get(SETTINGS) ?? {},
707
+ });
708
+ // The host had the last word: `resolveSessionConfig` echoes what it was
709
+ // given with its own defaults filled in, so this is what is in force
710
+ // rather than what was asked for.
711
+ app.store.set(SETTINGS, config.values);
712
+ offer(config, uri);
713
+ return config;
714
+ },
715
+ setConfig: (uri, key, value) => host.setConfig(uri, key, value),
716
+ pump: () => host.pump?.() ?? false,
717
+ };
718
+ bag.add({ dispose: () => subscription?.close() });
719
+ // The terminal's subscription is the controller's, not a screen's - which is
720
+ // the point of the split, and means it has to be let go here.
721
+ bag.add({ dispose: () => { terminals.dispose(); } });
722
+ // What the host says about sessions this client is not watching: one
723
+ // appearing, one finishing, one starting to wait. Without it the catalogue
724
+ // is only ever as fresh as the last time somebody navigated to it.
725
+ const watching = host.onSessions(refreshSoon);
726
+ bag.add({ dispose: () => watching.close() });
727
+ for (const command of commands(app, controller, host))
728
+ bag.add(app.commands.register(command));
729
+ for (const binding of keys(bindings))
730
+ bag.add(app.keybindings.register(binding));
731
+ return Object.assign(controller, { dispose: () => bag.dispose() });
732
+ }
733
+ // ------------------------------------------------------------------- commands
734
+ /** What to put back when a preview is abandoned. Only this knows what it changed. */
735
+ const previous = { theme: null, shell: null };
736
+ function commands(app, controller,
737
+ /*
738
+ * The connection, for the one command that needs it directly.
739
+ *
740
+ * Everything else here goes through the controller, and should: it is what
741
+ * keeps the commands from knowing there is a protocol. `changes.run` is the
742
+ * exception because `operate` is the negotiation - a refusal, a question,
743
+ * a retry - and wrapping that in a controller method would be hiding the
744
+ * question, which is the part a person has to answer.
745
+ */
746
+ connection) {
747
+ const selected = () => app.store.get(SELECTED) ?? null;
748
+ const openUri = () => app.store.get(OPEN) ?? null;
749
+ /**
750
+ * Which session a command acts on.
751
+ *
752
+ * The one being read, when a conversation is on screen; otherwise the one
753
+ * selected in the catalogue. A command that only ever read the catalogue's
754
+ * selection would archive the row you opened this from rather than the
755
+ * session you are looking at.
756
+ */
757
+ const target = () => (app.screens.current()?.id === 'chat' ? openUri() : null) ?? selected() ?? openUri();
758
+ const running = () => turns(app.store).some((turn) => turn.state === 'running');
759
+ /**
760
+ * Move the transcript cursor to the match `by` away from the one it is on.
761
+ *
762
+ * The blocks are rebuilt here rather than read from the screen: a command
763
+ * has the store and nothing else, and the store holds the turns that the
764
+ * screen builds the same blocks from. Doing it once per keypress on a
765
+ * conversation is not the cost that matters.
766
+ */
767
+ const step = (by) => {
768
+ const query = app.store.get(FIND) ?? '';
769
+ const blocks = toBlocks(turns(app.store), app.store.get(QUEUE) ?? []);
770
+ const found = findBlocks(blocks, query);
771
+ if (found.length === 0)
772
+ return;
773
+ const from = Math.min(app.store.get(FIND_AT) ?? 0, found.length - 1);
774
+ const next = (from + by + found.length) % found.length;
775
+ app.store.set(FIND_AT, next);
776
+ app.store.set(CURSOR, found[next] ?? 0);
777
+ };
778
+ /**
779
+ * The catalogue, one screen above the composer.
780
+ *
781
+ * Reset-then-push rather than push: the catalogue is reachable from the
782
+ * composer, from a conversation and from the palette, and pushing from each
783
+ * of those would stack three copies of it that escape then walks back
784
+ * through one at a time.
785
+ */
786
+ const toSessions = () => {
787
+ app.screens.reset('new');
788
+ app.screens.push('sessions');
789
+ void controller.refresh();
790
+ };
791
+ /**
792
+ * What the host last said it offers.
793
+ *
794
+ * Filled by the `choices` functions, which the palette calls when it opens
795
+ * one of these - so the labels a person is choosing from and the list a
796
+ * choice is resolved against are always the same fetch.
797
+ */
798
+ const known = { agents: [] };
799
+ /*
800
+ * Asked once at boot, not only when a palette opens one of the pickers.
801
+ *
802
+ * The chat commands are gated on what the agent says it can do, and a gate
803
+ * that answers "no" until somebody happens to open an unrelated picker is a
804
+ * command that is missing for no reason anybody can see.
805
+ */
806
+ const provider = () => app.store.get(PROVIDER) ?? 'claude';
807
+ const agent = () => known.agents.find((found) => found.provider === provider());
808
+ const listAgents = async () => {
809
+ try {
810
+ known.agents = await controller.agents();
811
+ }
812
+ catch (error) {
813
+ controller.report(error);
814
+ }
815
+ // The provider id is what the command is answered with, and the harness's
816
+ // own description is what tells two of them apart - a person choosing
817
+ // between "Copilot" and "Claude" is choosing between two sentences.
818
+ return known.agents.map((found) => ({
819
+ value: found.provider,
820
+ label: found.displayName,
821
+ ...(found.description ? { description: found.description } : {}),
822
+ }));
823
+ };
824
+ const listModels = async () => {
825
+ if (known.agents.length === 0)
826
+ await listAgents();
827
+ // The id under the name: `claude-sonnet-4-5-20250929` is what a person
828
+ // recognises from a config file, and what they would search for.
829
+ return (agent()?.models ?? []).map((model) => ({
830
+ value: model.id,
831
+ label: model.displayName,
832
+ ...(model.displayName === model.id ? {} : { description: model.id }),
833
+ }));
834
+ };
835
+ return [
836
+ {
837
+ /**
838
+ * The creature, on and off, without editing a file.
839
+ *
840
+ * `boodFloat` in the config says what it is on startup; this says what
841
+ * it is now. A mascot that can only be turned off by quitting, editing
842
+ * JSON and starting again is one somebody keeps off.
843
+ */
844
+ id: 'bood.toggle',
845
+ title: 'Show the creature',
846
+ category: 'View',
847
+ slots: ['palette', 'config'],
848
+ run: () => {
849
+ const showing = app.store.get(BOOD_FLOAT) ?? false;
850
+ app.store.set(BOOD_FLOAT, !showing);
851
+ },
852
+ },
853
+ {
854
+ id: 'app.palette',
855
+ title: 'Command Palette',
856
+ category: 'Navigation',
857
+ slots: [],
858
+ run: () => {
859
+ app.layers.open({
860
+ id: 'palette',
861
+ layer: 'modal',
862
+ scrim: true,
863
+ trapFocus: true,
864
+ dismissOnEscape: true,
865
+ node: {
866
+ component: 'CommandPalette',
867
+ width: 62,
868
+ commands: app.commands.list({ slot: 'palette', enabledOnly: true }),
869
+ onClose: { handler: () => app.layers.close('palette') },
870
+ },
871
+ });
872
+ },
873
+ },
874
+ /*
875
+ * The palette, with only the commands that configure this client in it.
876
+ *
877
+ * A slot rather than a screen of its own. Every one of these already
878
+ * answers a question about how the client looks or behaves, and a second
879
+ * place to ask them would be a second place to keep in step - so `config`
880
+ * is a second slot on the same commands, and this opens the same palette
881
+ * over a narrower list. A command joins it by naming the slot.
882
+ *
883
+ * `/config` finds it by id, the way every other client command in the
884
+ * slash menu is found.
885
+ */
886
+ {
887
+ id: 'app.config',
888
+ title: 'Configuration',
889
+ category: 'Navigation',
890
+ description: 'Theme, layout and the rest of what this client decides',
891
+ slots: ['palette'],
892
+ run: () => {
893
+ app.layers.open({
894
+ id: 'palette',
895
+ layer: 'modal',
896
+ scrim: true,
897
+ trapFocus: true,
898
+ dismissOnEscape: true,
899
+ node: {
900
+ component: 'CommandPalette',
901
+ width: 62,
902
+ placeholder: 'Configure',
903
+ commands: app.commands.list({ slot: 'config', enabledOnly: true }),
904
+ onClose: { handler: () => app.layers.close('palette') },
905
+ },
906
+ });
907
+ },
908
+ },
909
+ {
910
+ /*
911
+ * Every key that works right here, and what it does.
912
+ *
913
+ * The palette on `ctrl+p` is the list of *commands* and answers "what
914
+ * can I do"; this answers "what does this key do", which is the other
915
+ * question and the one a person asks with the keyboard in front of
916
+ * them. So it is the commands that have a chord, and only the ones
917
+ * whose `when` passes where the reader is standing - a keymap listing
918
+ * keys that do nothing here is a keymap that has to be second-guessed.
919
+ */
920
+ id: 'help.keys',
921
+ title: 'Keys',
922
+ category: 'Help',
923
+ description: 'Every key that does something here',
924
+ slots: ['palette'],
925
+ run: () => {
926
+ app.layers.open({
927
+ id: 'palette',
928
+ layer: 'modal',
929
+ scrim: true,
930
+ trapFocus: true,
931
+ dismissOnEscape: true,
932
+ node: {
933
+ component: 'CommandPalette',
934
+ width: 62,
935
+ placeholder: 'Keys',
936
+ commands: app.commands.list({ enabledOnly: true })
937
+ .filter((command) => app.keybindings.forCommand(command.id).length > 0),
938
+ onClose: { handler: () => app.layers.close('palette') },
939
+ },
940
+ });
941
+ },
942
+ },
943
+ {
944
+ id: 'go.back',
945
+ title: 'Back',
946
+ category: 'Navigation',
947
+ description: 'Return to the previous screen',
948
+ slots: ['palette'],
949
+ run: () => {
950
+ // The composer is the root, so there is nothing under it to pop to -
951
+ // and escape on the one screen that has no way back would do nothing
952
+ // at all. From there it means "show me what already exists".
953
+ if (app.screens.current()?.id === 'new') {
954
+ toSessions();
955
+ return;
956
+ }
957
+ app.screens.pop();
958
+ },
959
+ },
960
+ {
961
+ /*
962
+ * The other half of what a host holds.
963
+ *
964
+ * Not gated on a session being open, unlike `go.changes`: an automation
965
+ * belongs to the host and outlives every session it starts, so it is
966
+ * reachable from anywhere - including from a client that has opened
967
+ * nothing at all.
968
+ */
969
+ id: 'go.automations',
970
+ title: 'What the host runs on its own',
971
+ category: 'Screens',
972
+ description: 'Show the automations',
973
+ slots: ['palette'],
974
+ run: () => { app.screens.push('automations'); },
975
+ },
976
+ {
977
+ id: 'automation.new',
978
+ title: 'Write a new automation',
979
+ category: 'Automations',
980
+ description: 'A session the host starts without being asked',
981
+ slots: ['palette'],
982
+ run: () => { app.screens.push('automation.new'); },
983
+ },
984
+ {
985
+ id: 'automation.run',
986
+ title: 'Run this automation now',
987
+ category: 'Automations',
988
+ description: 'Start a run, whatever the schedule says',
989
+ slots: ['palette'],
990
+ when: `${AUTOMATION_ROW}`,
991
+ run: async () => {
992
+ const uri = app.store.get(AUTOMATION_ROW) ?? '';
993
+ const found = (app.store.get(AUTOMATIONS) ?? []).find((one) => one.resource === uri);
994
+ // The host says which verbs it will accept, and a client that pressed
995
+ // one it did not offer would be asking to be refused. A switched-off
996
+ // automation offers no `run`.
997
+ if (!found?.operations.includes('run'))
998
+ return;
999
+ try {
1000
+ await controller.runAutomation(uri);
1001
+ }
1002
+ catch (error) {
1003
+ controller.report(error);
1004
+ }
1005
+ },
1006
+ },
1007
+ {
1008
+ id: 'automation.toggle',
1009
+ title: 'Switch this automation on or off',
1010
+ category: 'Automations',
1011
+ description: 'Stop it firing, or let it fire again',
1012
+ slots: ['palette'],
1013
+ when: `${AUTOMATION_ROW}`,
1014
+ run: async () => {
1015
+ const uri = app.store.get(AUTOMATION_ROW) ?? '';
1016
+ const found = (app.store.get(AUTOMATIONS) ?? []).find((one) => one.resource === uri);
1017
+ if (!found?.operations.includes('update'))
1018
+ return;
1019
+ try {
1020
+ await controller.setAutomationEnabled(uri, !found.enabled);
1021
+ }
1022
+ catch (error) {
1023
+ controller.report(error);
1024
+ }
1025
+ },
1026
+ },
1027
+ {
1028
+ id: 'automation.remove',
1029
+ title: 'Forget this automation',
1030
+ category: 'Automations',
1031
+ description: 'Delete it and everything it has done',
1032
+ slots: ['palette'],
1033
+ when: `${AUTOMATION_ROW}`,
1034
+ run: async () => {
1035
+ const uri = app.store.get(AUTOMATION_ROW) ?? '';
1036
+ const found = (app.store.get(AUTOMATIONS) ?? []).find((one) => one.resource === uri);
1037
+ if (!found?.operations.includes('remove'))
1038
+ return;
1039
+ /*
1040
+ * Asked, because nothing gives it back.
1041
+ *
1042
+ * The protocol has no confirmation on this the way a changeset
1043
+ * operation carries one, so the question is this client's own - which
1044
+ * is the right way round: the host cannot know that a person is about
1045
+ * to lose the only copy of a schedule they wrote.
1046
+ */
1047
+ const yes = await confirm(app.layers, {
1048
+ title: 'Forget this automation?',
1049
+ message: `${found.title} and its history go, and nothing here brings them back.`,
1050
+ confirmLabel: 'Forget it',
1051
+ cancelLabel: 'Keep it',
1052
+ tone: 'danger',
1053
+ });
1054
+ if (!yes)
1055
+ return;
1056
+ try {
1057
+ await controller.removeAutomation(uri);
1058
+ app.store.set(AUTOMATION_ROW, '');
1059
+ }
1060
+ catch (error) {
1061
+ controller.report(error);
1062
+ }
1063
+ },
1064
+ },
1065
+ {
1066
+ id: 'go.sessions',
1067
+ title: 'Sessions',
1068
+ category: 'Screens',
1069
+ description: 'List all sessions',
1070
+ slots: ['palette'],
1071
+ run: () => toSessions(),
1072
+ },
1073
+ {
1074
+ id: 'go.new',
1075
+ title: 'New session',
1076
+ category: 'Screens',
1077
+ description: 'Start a new conversation',
1078
+ slots: ['palette'],
1079
+ run: () => { app.screens.reset('new'); app.focus.focus('chat.composer'); },
1080
+ },
1081
+ {
1082
+ id: 'go.changes',
1083
+ title: 'What this session changed',
1084
+ category: 'Screens',
1085
+ description: 'Show the files',
1086
+ slots: ['palette'],
1087
+ when: `${OPEN}`,
1088
+ // Always the list, never wherever it was left. A screen that reopens on
1089
+ // the one file somebody read an hour ago hides the other nineteen.
1090
+ run: () => { app.store.set(OPEN_FILE, null); app.screens.push('changes'); },
1091
+ },
1092
+ {
1093
+ /*
1094
+ * The host's filesystem, which is where the project actually is.
1095
+ *
1096
+ * Beside the changes screen rather than under it: what changed and what
1097
+ * is there are different questions, and a browser that only ever showed
1098
+ * the changed files would be a changeset with a worse name.
1099
+ */
1100
+ id: 'go.files',
1101
+ title: 'Browse the host\'s files',
1102
+ category: 'Screens',
1103
+ description: 'The project, as the host sees it',
1104
+ slots: ['palette'],
1105
+ when: `${OPEN}`,
1106
+ run: () => {
1107
+ // Always back at the top. A browser that reopens six directories deep
1108
+ // is one nobody can tell from a broken one.
1109
+ app.store.set(FILES_OPEN, '');
1110
+ app.store.set(FILES_AT, '');
1111
+ app.screens.push('files');
1112
+ },
1113
+ },
1114
+ {
1115
+ id: 'go.skills',
1116
+ title: 'Skills and commands',
1117
+ category: 'Screens',
1118
+ description: 'What plugins and directories gave this session',
1119
+ slots: ['palette'],
1120
+ when: `${OPEN}`,
1121
+ run: () => app.screens.push('skills'),
1122
+ },
1123
+ {
1124
+ id: 'go.mcp',
1125
+ title: 'MCP servers',
1126
+ category: 'Screens',
1127
+ description: 'Which servers this session has, and whether they answered',
1128
+ slots: ['palette'],
1129
+ when: `${OPEN}`,
1130
+ run: () => app.screens.push('mcp'),
1131
+ },
1132
+ {
1133
+ /*
1134
+ * The next changeset this session offers.
1135
+ *
1136
+ * One key rather than a picker widget, because the list is short and
1137
+ * every entry is a whole screen: cycling is what a person does to see
1138
+ * the other three, and a menu to choose between four things you are
1139
+ * about to look at anyway is a step in the way.
1140
+ *
1141
+ * Only the ones that are already URIs. A template with `{turnId}` still
1142
+ * in it is a question about a turn, and there is nothing on this screen
1143
+ * to answer it from - which is why the greyed rows say so rather than
1144
+ * being silently dropped.
1145
+ */
1146
+ id: 'changes.scope',
1147
+ title: 'Next changeset',
1148
+ category: 'Changes',
1149
+ run: () => {
1150
+ const offered = (app.store.get(CHANGE_SCOPES) ?? [])
1151
+ .filter((scope) => scope.variables.length === 0);
1152
+ if (offered.length === 0)
1153
+ return;
1154
+ const at = app.store.get(CHANGE_AT) ?? '';
1155
+ const seen = offered.findIndex((scope) => scope.uriTemplate === at);
1156
+ const next = offered[(seen + 1) % offered.length];
1157
+ app.store.set(CHANGE_AT, next?.uriTemplate ?? '');
1158
+ // The open file belongs to the changeset it came from.
1159
+ app.store.set(OPEN_FILE, null);
1160
+ },
1161
+ },
1162
+ {
1163
+ /*
1164
+ * Ticking the row off, which is a note about reading rather than a change.
1165
+ *
1166
+ * The host keeps it and tells every client, so nothing is written here -
1167
+ * what redraws the tick is the changeset coming back. That is the whole
1168
+ * reason it is worth having: two people reading one diff can see which
1169
+ * files the other has been through.
1170
+ */
1171
+ id: 'changes.review',
1172
+ title: 'Mark this file read',
1173
+ category: 'Changes',
1174
+ run: () => {
1175
+ const at = app.store.get(CHANGE_AT) ?? '';
1176
+ const row = app.store.get(CHANGE_ROW) ?? '';
1177
+ if (!at || !row)
1178
+ return;
1179
+ const held = app.store.get(CHANGES_AT_PATH);
1180
+ const file = held?.files.find((one) => one.uri === row);
1181
+ controller.review(at, [row], file?.reviewed !== true);
1182
+ },
1183
+ },
1184
+ {
1185
+ /*
1186
+ * Run the verb the changeset offers, asking first where the host said to.
1187
+ *
1188
+ * One command rather than one per verb, with the id as an argument, for
1189
+ * the reason the config settings are registered the other way round: the
1190
+ * *settings* are a fixed schema a host publishes once, and these change
1191
+ * with every changeset - a command per operation would be registering
1192
+ * and disposing three of them each time the scope moves.
1193
+ *
1194
+ * The confirmation is not optional. The protocol says a client MUST
1195
+ * display it before invoking, and its presence is also how the host says
1196
+ * the operation is destructive - so this is the screen's half of what
1197
+ * `--yes` is in the shell.
1198
+ */
1199
+ id: 'changes.run',
1200
+ title: 'Do something with these changes',
1201
+ category: 'Changes',
1202
+ when: `${SCREEN} == 'changes'`,
1203
+ args: [{
1204
+ name: 'operation',
1205
+ type: 'string',
1206
+ required: true,
1207
+ description: 'Which of the verbs this changeset offers',
1208
+ // What the changeset advertises, now. An id that was not offered is
1209
+ // one the host will refuse, so there is nothing to gain by listing
1210
+ // more than it says.
1211
+ choices: () => {
1212
+ const held = app.store.get(CHANGES_AT_PATH);
1213
+ return (held?.operations ?? [])
1214
+ .filter((one) => one.status !== 'disabled')
1215
+ .map((one) => ({ value: one.id, label: one.label, description: one.description ?? '' }));
1216
+ },
1217
+ }],
1218
+ run: async (given) => {
1219
+ const at = app.store.get(CHANGE_AT) ?? '';
1220
+ const held = app.store.get(CHANGES_AT_PATH);
1221
+ const wanted = String(given?.operation ?? '');
1222
+ const operation = (held?.operations ?? []).find((one) => one.id === wanted);
1223
+ if (!at || !operation)
1224
+ return;
1225
+ const row = app.store.get(CHANGE_ROW) ?? '';
1226
+ const onFile = !operation.scopes.includes('changeset');
1227
+ if (onFile && !row)
1228
+ return;
1229
+ if (operation.confirmation !== undefined) {
1230
+ const yes = await confirm(app.layers, {
1231
+ title: operation.label,
1232
+ message: operation.confirmation,
1233
+ confirmLabel: operation.label,
1234
+ cancelLabel: 'Leave it',
1235
+ // The presence of a confirmation is the host calling this
1236
+ // destructive, so the button is styled as one.
1237
+ tone: 'danger',
1238
+ });
1239
+ if (!yes)
1240
+ return;
1241
+ }
1242
+ try {
1243
+ const done = await operate(connection, at, wanted, {
1244
+ ...(onFile ? { target: { kind: 'resource', resource: row } } : {}),
1245
+ /*
1246
+ * Asked here as well, and separately.
1247
+ *
1248
+ * "Discard this file" and "let this host write to your repository"
1249
+ * are two different questions, and answering the first is not
1250
+ * answering the second. A client that took the grant silently
1251
+ * would be one where saying yes to a diff quietly hands over the
1252
+ * working tree.
1253
+ */
1254
+ ask: (request) => confirm(app.layers, {
1255
+ title: 'Let the host write?',
1256
+ message: `${operation.label} needs write access to ${request.uri.replace(/^file:\/\//, '')}.`,
1257
+ confirmLabel: 'Allow',
1258
+ cancelLabel: 'No',
1259
+ tone: 'danger',
1260
+ }),
1261
+ });
1262
+ // The host's own sentence about what it did, shown where a refusal
1263
+ // would be. A silent success on a destructive verb reads as one that
1264
+ // did not happen.
1265
+ if (done.message !== undefined)
1266
+ app.store.set(HOST_ERROR, done.message);
1267
+ }
1268
+ catch (error) {
1269
+ controller.report(error);
1270
+ }
1271
+ },
1272
+ },
1273
+ {
1274
+ id: 'changes.close',
1275
+ title: 'Back to the file list',
1276
+ category: 'Screens',
1277
+ description: 'Close the open file',
1278
+ // Not in the palette: it is what escape does on one screen, and a
1279
+ // palette entry for it would be offering "go back" as a command.
1280
+ slots: [],
1281
+ when: `${OPEN_FILE}`,
1282
+ run: () => app.store.set(OPEN_FILE, null),
1283
+ },
1284
+ {
1285
+ id: 'go.settings',
1286
+ title: 'Session settings',
1287
+ category: 'Screens',
1288
+ description: 'Settings for this session',
1289
+ slots: ['palette', 'config'],
1290
+ when: `${OPEN}`,
1291
+ run: () => app.screens.push('settings'),
1292
+ },
1293
+ {
1294
+ id: 'go.hosts',
1295
+ title: 'Hosts',
1296
+ category: 'Screens',
1297
+ description: 'Manage all hosts',
1298
+ slots: ['palette'],
1299
+ run: () => app.screens.push('hosts')
1300
+ },
1301
+ /**
1302
+ * Markdown, or the characters the agent typed.
1303
+ *
1304
+ * An agent writes markdown, so drawing it is the default - reading
1305
+ * `**this**` is reading the punctuation instead of the sentence. Off is
1306
+ * for when the punctuation *is* what you are after: copying a fenced
1307
+ * block out with its fence, checking whether a table is a table or four
1308
+ * lines with pipes in them, reading a link's target rather than its
1309
+ * label.
1310
+ *
1311
+ * A toggle rather than two commands, because there are two states and the
1312
+ * screen shows you which one you are in.
1313
+ */
1314
+ {
1315
+ id: 'view.markdown',
1316
+ title: 'Markdown or raw text',
1317
+ category: 'View',
1318
+ description: 'Draw what the agent said as markdown, or as it typed it',
1319
+ slots: ['palette', 'config'],
1320
+ run: () => {
1321
+ const on = app.store.get(MARKDOWN) ?? true;
1322
+ app.store.set(MARKDOWN, !on);
1323
+ },
1324
+ },
1325
+ // Appearance is a registration, not a rewrite. The same graph is mounted
1326
+ // under whichever theme and shell are chosen, which is the claim the
1327
+ // runtime makes and the one an example is meant to be evidence for.
1328
+ /**
1329
+ * The message, in whatever editor the shell says.
1330
+ *
1331
+ * `$VISUAL` before `$EDITOR`, which is the order every program that opens
1332
+ * one uses: `EDITOR` is historically the line editor and `VISUAL` the
1333
+ * full-screen one, and a terminal application wants the second.
1334
+ *
1335
+ * The file is `.md` because that is what a message is - an editor that
1336
+ * highlights by extension gets it right, and one that does not is
1337
+ * unaffected.
1338
+ */
1339
+ {
1340
+ id: 'editor.open',
1341
+ title: 'Edit in $EDITOR',
1342
+ category: 'Compose',
1343
+ description: 'Open the message in an external editor',
1344
+ slots: ['palette'],
1345
+ // The draft it edits belongs to the composer, and only two screens have one.
1346
+ when: `${SCREEN} == 'chat' || ${SCREEN} == 'new'`,
1347
+ run: async () => {
1348
+ const editor = process.env.VISUAL ?? process.env.EDITOR;
1349
+ if (!editor) {
1350
+ controller.report(new Error('No $VISUAL or $EDITOR is set, so there is no editor to open.'));
1351
+ return;
1352
+ }
1353
+ // A directory of its own, so the name is not guessable and nothing
1354
+ // else in the temp directory is touched when it is removed.
1355
+ const where = await mkdtemp(join(tmpdir(), 'ahpc-'));
1356
+ const file = join(where, 'message.md');
1357
+ try {
1358
+ await writeFile(file, app.store.get(DRAFT) ?? '');
1359
+ await app.suspend(async () => {
1360
+ await new Promise((resolve) => {
1361
+ /*
1362
+ * One string, through the shell.
1363
+ *
1364
+ * `$EDITOR` is a command line rather than a path - `code -w` and
1365
+ * `nvim -c startinsert` are both ordinary values - so it cannot
1366
+ * be spawned as a program with arguments. And the file has to be
1367
+ * part of that string: Node's `shell` option joins the argument
1368
+ * array onto the command rather than passing it to the shell, so
1369
+ * a `"$1"` placeholder is never substituted and the editor opens
1370
+ * with no file at all.
1371
+ */
1372
+ const quoted = `"${file.replace(/(["\\$`])/g, '\\$1')}"`;
1373
+ const child = spawn(`${editor} ${quoted}`, { stdio: 'inherit', shell: true });
1374
+ child.on('close', () => { resolve(); });
1375
+ child.on('error', () => { resolve(); });
1376
+ });
1377
+ });
1378
+ const text = await readFile(file, 'utf8');
1379
+ // One trailing newline is the editor's, not the message's - every
1380
+ // editor that respects POSIX adds it and nobody typed it.
1381
+ const next = text.replace(/\n$/, '');
1382
+ app.store.set(DRAFT, next);
1383
+ controller.draft(next);
1384
+ }
1385
+ catch (error) {
1386
+ controller.report(error);
1387
+ }
1388
+ finally {
1389
+ await rm(where, { recursive: true, force: true });
1390
+ }
1391
+ },
1392
+ },
1393
+ {
1394
+ id: 'view.theme',
1395
+ title: 'Theme',
1396
+ category: 'View',
1397
+ description: 'Change the colors and shapes',
1398
+ slots: ['palette', 'config'],
1399
+ // The command says what it needs and the palette asks. Wearing it while
1400
+ // the highlight moves is what makes a theme choosable at all: the names
1401
+ // mean nothing until the screen is in one.
1402
+ args: [{
1403
+ name: 'id',
1404
+ type: 'string',
1405
+ required: true,
1406
+ choices: app.themes.list().map((theme) => theme.id),
1407
+ default: app.theme.id,
1408
+ preview: (value) => {
1409
+ previous.theme ??= app.theme.id;
1410
+ if (value === null) {
1411
+ if (previous.theme)
1412
+ app.setTheme(previous.theme);
1413
+ previous.theme = null;
1414
+ return;
1415
+ }
1416
+ app.setTheme(value);
1417
+ },
1418
+ }],
1419
+ run: (args) => {
1420
+ previous.theme = null;
1421
+ if (args.id)
1422
+ app.setTheme(String(args.id));
1423
+ },
1424
+ },
1425
+ {
1426
+ id: 'view.shell',
1427
+ title: 'Layout',
1428
+ category: 'View',
1429
+ description: 'Change the layout and controls',
1430
+ slots: ['palette', 'config'],
1431
+ args: [{
1432
+ name: 'id',
1433
+ type: 'string',
1434
+ required: true,
1435
+ choices: app.shells.list().map((shell) => shell.id),
1436
+ default: app.activeShell(),
1437
+ preview: (value) => {
1438
+ previous.shell ??= app.activeShell();
1439
+ if (value === null) {
1440
+ if (previous.shell)
1441
+ app.setShell(previous.shell);
1442
+ previous.shell = null;
1443
+ return;
1444
+ }
1445
+ app.setShell(value);
1446
+ },
1447
+ }],
1448
+ run: (args) => {
1449
+ previous.shell = null;
1450
+ if (args.id)
1451
+ app.setShell(String(args.id));
1452
+ },
1453
+ },
1454
+ // The composer's control row. Four questions about what the next message
1455
+ // will be sent as, each asked by the palette, anchored above its chip.
1456
+ {
1457
+ id: 'compose.harness',
1458
+ title: 'Harness',
1459
+ category: 'Compose',
1460
+ description: 'Select the agent harness',
1461
+ slots: ['palette'],
1462
+ // Fixed once a session exists: it is the process the conversation is
1463
+ // running in, and a chip offering to change it would be offering a lie.
1464
+ when: `!${OPEN}`,
1465
+ args: [{
1466
+ name: 'id',
1467
+ type: 'string',
1468
+ required: true,
1469
+ description: 'Which agent runs this',
1470
+ choices: listAgents,
1471
+ }],
1472
+ run: (args) => {
1473
+ const chosen = known.agents.find((found) => found.provider === String(args.id));
1474
+ if (!chosen)
1475
+ return;
1476
+ app.store.set(PROVIDER, chosen.provider);
1477
+ // A model belongs to a harness. Kept across a change it names one the
1478
+ // new harness has never heard of, and the host refuses the turn.
1479
+ app.store.set(MODEL, '');
1480
+ },
1481
+ },
1482
+ {
1483
+ id: 'compose.model',
1484
+ title: 'Model',
1485
+ category: 'Compose',
1486
+ description: 'Select the model',
1487
+ slots: ['palette'],
1488
+ // The model rides on the message being composed, so it needs a composer.
1489
+ when: `${SCREEN} == 'chat' || ${SCREEN} == 'new'`,
1490
+ args: [{
1491
+ name: 'id',
1492
+ type: 'string',
1493
+ required: true,
1494
+ description: 'What the next message runs on',
1495
+ choices: listModels,
1496
+ }],
1497
+ run: (args) => {
1498
+ // The id, not the label. AHP hangs the model on the message, so this
1499
+ // is what rides on the next `chat/turnStarted`.
1500
+ const chosen = agent()?.models.find((model) => model.id === String(args.id));
1501
+ if (!chosen)
1502
+ return;
1503
+ app.store.set(MODEL, chosen.id);
1504
+ // A model's answers belong to that model. Carrying them across would
1505
+ // send a level the new model may not accept, in a field the host
1506
+ // reads without asking whether it fits.
1507
+ app.store.set(MODEL_CONFIG, {});
1508
+ controller.offerModel(chosen.options ?? []);
1509
+ },
1510
+ },
1511
+ /*
1512
+ * A session holds chats, and these are how a person moves between them.
1513
+ *
1514
+ * Gated on the agent advertising `multipleChats`: a host that does not is
1515
+ * one where `createChat` MUST NOT be called, so the command is not
1516
+ * offered rather than offered and refused.
1517
+ */
1518
+ {
1519
+ id: 'chat.new',
1520
+ title: 'New chat here',
1521
+ category: 'Session',
1522
+ description: 'Open another conversation in this session',
1523
+ slots: ['palette'],
1524
+ when: CAN_ADD_CHAT,
1525
+ run: () => void controller.createChat(),
1526
+ },
1527
+ /*
1528
+ * The two ways a chat comes from another one.
1529
+ *
1530
+ * Offered only where the agent advertises each: `capabilities.multipleChats`
1531
+ * carries `fork` and `sideChat` separately and a host may have one and not
1532
+ * the other, so this is two commands rather than one with a mode.
1533
+ */
1534
+ {
1535
+ id: 'chat.fork',
1536
+ title: 'Fork from this turn',
1537
+ category: 'Session',
1538
+ description: 'A new chat carrying this conversation up to the turn under the cursor',
1539
+ slots: ['palette'],
1540
+ when: CAN_FORK,
1541
+ run: () => {
1542
+ const from = app.store.get(CHAT_URI);
1543
+ const turn = [...turns(app.store)].reverse().find((one) => one.state !== 'running');
1544
+ if (!from || !turn)
1545
+ return;
1546
+ void controller.createChat(undefined, { kind: 'fork', chat: from, turnId: turn.id });
1547
+ },
1548
+ },
1549
+ {
1550
+ id: 'chat.side',
1551
+ title: 'Side chat from this turn',
1552
+ category: 'Session',
1553
+ description: 'A new chat with this turn as context, without copying it in',
1554
+ slots: ['palette'],
1555
+ when: CAN_SIDE_CHAT,
1556
+ run: () => {
1557
+ const from = app.store.get(CHAT_URI);
1558
+ const turn = [...turns(app.store)].reverse().find((one) => one.state !== 'running');
1559
+ if (!from || !turn)
1560
+ return;
1561
+ void controller.createChat(undefined, { kind: 'sideChat', chat: from, turnId: turn.id });
1562
+ },
1563
+ },
1564
+ {
1565
+ id: 'chat.switch',
1566
+ title: 'Chat',
1567
+ category: 'Session',
1568
+ description: 'Read a different conversation in this session',
1569
+ slots: ['palette'],
1570
+ when: HAS_CHATS,
1571
+ args: [{
1572
+ name: 'chat',
1573
+ type: 'string',
1574
+ required: true,
1575
+ description: 'Which conversation to read',
1576
+ get default() {
1577
+ return app.store.get(CHAT_URI) ?? undefined;
1578
+ },
1579
+ choices: () => (app.store.get(CHATS) ?? [])
1580
+ .map((entry, index) => ({
1581
+ value: entry.resource,
1582
+ // The title, and its place in the session: two chats begun from
1583
+ // the same first message are two rows reading the same thing.
1584
+ label: `${String(index + 1)}. ${entry.title}`,
1585
+ })),
1586
+ }],
1587
+ run: (args) => { controller.openChat(String(args.chat)); },
1588
+ },
1589
+ {
1590
+ id: 'chat.close',
1591
+ title: 'Close this chat',
1592
+ category: 'Session',
1593
+ description: 'Dispose the conversation being read',
1594
+ slots: ['palette'],
1595
+ when: HAS_CHATS,
1596
+ run: () => {
1597
+ const chat = app.store.get(CHAT_URI);
1598
+ if (chat)
1599
+ void controller.disposeChat(chat);
1600
+ },
1601
+ },
1602
+ /*
1603
+ * A shell on the host machine.
1604
+ *
1605
+ * Not gated: a host that runs none answers an empty list and refuses to
1606
+ * open one, and the screen says so - which is a better answer than a
1607
+ * command that is missing for a reason nobody can see.
1608
+ */
1609
+ {
1610
+ id: 'terminal.new',
1611
+ title: 'Open a terminal',
1612
+ category: 'Terminal',
1613
+ description: 'Start a shell on the host, in a directory it serves',
1614
+ slots: ['palette'],
1615
+ run: () => {
1616
+ const where = app.store.get(WORKSPACE);
1617
+ void controller.terminals.open(where ? { cwd: where } : {}).then(() => {
1618
+ app.screens.push('terminal');
1619
+ app.focus.focus('terminal.input');
1620
+ });
1621
+ },
1622
+ },
1623
+ {
1624
+ id: 'terminal.interrupt',
1625
+ title: 'Interrupt',
1626
+ category: 'Terminal',
1627
+ description: 'Send ctrl+c to the shell',
1628
+ slots: ['palette'],
1629
+ when: OPEN_TERMINAL,
1630
+ // `\u0003` is what a terminal sends for ctrl+c, and the shell is what
1631
+ // decides what to do with it - which is the point: stopping a command
1632
+ // is the shell's job, not this client's.
1633
+ run: () => { controller.terminals.write('\u0003'); },
1634
+ },
1635
+ {
1636
+ id: 'terminal.clear',
1637
+ title: 'Clear terminal',
1638
+ category: 'Terminal',
1639
+ description: 'Empty the scrollback of the open terminal',
1640
+ slots: ['palette'],
1641
+ when: OPEN_TERMINAL,
1642
+ // The host's own action, not a screen wiping what it drew: every client
1643
+ // watching this terminal sees it emptied, which is what `terminal/cleared`
1644
+ // resetting `content` means.
1645
+ run: () => { controller.terminals.clear(); },
1646
+ },
1647
+ {
1648
+ id: 'terminal.rename',
1649
+ title: 'Rename terminal',
1650
+ category: 'Terminal',
1651
+ description: 'Give the open terminal a name',
1652
+ slots: ['palette'],
1653
+ when: OPEN_TERMINAL,
1654
+ args: [{
1655
+ name: 'title',
1656
+ type: 'string',
1657
+ required: true,
1658
+ description: 'What to call it',
1659
+ }],
1660
+ run: (args) => {
1661
+ const title = String(args.title ?? '').trim();
1662
+ if (title)
1663
+ controller.terminals.rename(title);
1664
+ },
1665
+ },
1666
+ {
1667
+ id: 'terminal.close',
1668
+ title: 'Close this terminal',
1669
+ category: 'Terminal',
1670
+ description: 'Kill the shell being read',
1671
+ slots: ['palette'],
1672
+ when: OPEN_TERMINAL,
1673
+ run: () => void controller.terminals.close(),
1674
+ },
1675
+ {
1676
+ id: 'go.terminal',
1677
+ title: 'Terminals',
1678
+ category: 'Go',
1679
+ description: 'The shells running on the host',
1680
+ slots: ['palette'],
1681
+ run: () => { app.screens.push('terminal'); },
1682
+ },
1683
+ {
1684
+ id: 'compose.workspace',
1685
+ title: 'Workspace',
1686
+ category: 'Compose',
1687
+ description: 'Select the workspace',
1688
+ slots: ['palette'],
1689
+ when: `!${OPEN}`,
1690
+ // No `choices`, so the palette asks for it as text - the same overlay,
1691
+ // with its field as the answer rather than as a filter. Give it a
1692
+ // `choices` function later and the same chip becomes a list of
1693
+ // workspaces without anything else changing.
1694
+ args: [{
1695
+ name: 'path',
1696
+ type: 'string',
1697
+ required: true,
1698
+ description: 'Where the agent works. A path on the host, not on this machine.',
1699
+ }],
1700
+ run: (args) => {
1701
+ const path = String(args.path ?? '').trim();
1702
+ if (path)
1703
+ app.store.set(WORKSPACE, path);
1704
+ },
1705
+ },
1706
+ {
1707
+ id: 'compose.start',
1708
+ title: 'Start a session with what has been typed',
1709
+ category: 'Compose',
1710
+ slots: ['palette'],
1711
+ when: `!${OPEN}`,
1712
+ run: () => {
1713
+ const first = (app.store.get(DRAFT) ?? '').trim();
1714
+ if (!first)
1715
+ return;
1716
+ void controller.create({
1717
+ provider: provider(),
1718
+ ...(app.store.get(WORKSPACE) ? { workingDirectory: app.store.get(WORKSPACE) } : {}),
1719
+ first,
1720
+ })
1721
+ .then(() => app.screens.push('chat'))
1722
+ .catch((error) => controller.report(error));
1723
+ },
1724
+ },
1725
+ {
1726
+ id: 'session.open',
1727
+ title: 'Open session',
1728
+ category: 'Session',
1729
+ description: 'Show the conversation',
1730
+ slots: ['palette'],
1731
+ // It opens the row under the cursor, so it needs one.
1732
+ when: `${SELECTED} || ${OPEN}`,
1733
+ run: (args) => {
1734
+ const uri = (typeof args.uri === 'string' ? args.uri : null) ?? selected();
1735
+ if (!uri)
1736
+ return;
1737
+ controller.open(uri);
1738
+ app.screens.push('chat');
1739
+ },
1740
+ args: [{ name: 'uri', type: 'string' }],
1741
+ },
1742
+ {
1743
+ id: 'session.new',
1744
+ title: 'New session',
1745
+ category: 'Session',
1746
+ description: 'Start a new conversation',
1747
+ slots: ['palette'],
1748
+ run: () => {
1749
+ // Nothing open, so the control row describes a session that does not
1750
+ // exist yet rather than the one that was on screen a moment ago.
1751
+ controller.close();
1752
+ app.screens.reset('new');
1753
+ app.focus.focus('chat.composer');
1754
+ },
1755
+ },
1756
+ {
1757
+ id: 'session.refresh',
1758
+ title: 'Refresh the catalogue',
1759
+ category: 'Session',
1760
+ description: 'Reload list from the host',
1761
+ slots: ['palette'],
1762
+ keepOpen: true,
1763
+ run: () => void controller.refresh(),
1764
+ },
1765
+ {
1766
+ id: 'session.archive',
1767
+ title: 'Archive / unarchive',
1768
+ category: 'Session',
1769
+ description: 'Hide or show this session',
1770
+ slots: ['palette'],
1771
+ // It acts on the session being read, or the one selected in the catalogue.
1772
+ when: `${OPEN} || ${SELECTED}`,
1773
+ run: () => {
1774
+ const uri = target();
1775
+ // Every session, not the visible ones. An archived session is hidden
1776
+ // by default, so reading the flag off the filtered list found nothing,
1777
+ // fell back to a status of zero, and archived it a second time - which
1778
+ // is a toggle that only ever goes one way.
1779
+ const session = sessions(app.store).find((found) => found.resource === uri);
1780
+ if (!uri || !session)
1781
+ return;
1782
+ controller.setArchived(uri, (session.status & SessionFlag.IsArchived) === 0);
1783
+ },
1784
+ },
1785
+ {
1786
+ id: 'session.read',
1787
+ title: 'Mark read / unread',
1788
+ category: 'Session',
1789
+ description: 'Mark this session read or unread',
1790
+ slots: ['palette'],
1791
+ // It acts on the session being read, or the one selected in the catalogue.
1792
+ when: `${OPEN} || ${SELECTED}`,
1793
+ run: () => {
1794
+ const uri = target();
1795
+ const session = sessions(app.store).find((found) => found.resource === uri);
1796
+ if (!uri || !session)
1797
+ return;
1798
+ controller.setRead(uri, (session.status & SessionFlag.IsRead) === 0);
1799
+ },
1800
+ },
1801
+ {
1802
+ id: 'session.dispose',
1803
+ title: 'Dispose session',
1804
+ category: 'Session',
1805
+ description: 'Delete this session',
1806
+ slots: ['palette'],
1807
+ // It acts on the session being read, or the one selected in the catalogue.
1808
+ when: `${OPEN} || ${SELECTED}`,
1809
+ run: async () => {
1810
+ const uri = target();
1811
+ if (!uri)
1812
+ return;
1813
+ // The host frees the session and tells every other client. Ending
1814
+ // somebody else's conversation is not an undo, so it is asked for.
1815
+ const yes = await confirm(app.layers, {
1816
+ title: 'Dispose session',
1817
+ message: 'The host ends this session for every client watching it. The record of what happened stays.',
1818
+ confirmLabel: 'Dispose',
1819
+ cancelLabel: 'Keep',
1820
+ tone: 'danger',
1821
+ });
1822
+ if (yes)
1823
+ await controller.disposeSession(uri);
1824
+ },
1825
+ },
1826
+ {
1827
+ id: 'session.toggleArchived',
1828
+ title: 'Archived sessions',
1829
+ category: 'Session',
1830
+ description: 'List the sessions that have been put away',
1831
+ slots: ['palette'],
1832
+ keepOpen: true,
1833
+ // A switch, and the palette says which way it is set. The title used to
1834
+ // read `Show archived sessions`, which is one direction of a thing that
1835
+ // has two and says nothing about where it stands.
1836
+ checked: ARCHIVED,
1837
+ run: () => app.store.set(ARCHIVED, !(app.store.get(ARCHIVED) ?? false)),
1838
+ },
1839
+ {
1840
+ id: 'chat.stop',
1841
+ title: 'Stop the turn',
1842
+ category: 'Chat',
1843
+ description: 'Force session to stop running',
1844
+ slots: ['palette'],
1845
+ // On the screen that is showing the turn. A session left open behind
1846
+ // you keeps its status - a blocked one reads 24 for ever - so a clause
1847
+ // that only asked "is something running" swallowed `ctrl+c` on every
1848
+ // other screen from the moment a session was first opened, and the
1849
+ // application could never be closed again.
1850
+ when: `${SCREEN} == 'chat' && ${RUNNING}`,
1851
+ run: () => controller.stop(),
1852
+ },
1853
+ {
1854
+ id: 'chat.approve',
1855
+ title: 'Approve what the agent is waiting on',
1856
+ category: 'Chat',
1857
+ description: 'Approve the tool call ',
1858
+ slots: ['palette'],
1859
+ // There is nothing to approve until the agent has asked.
1860
+ when: `${INPUT}`,
1861
+ run: (args) => controller.approve(typeof args.option === 'string' ? args.option : undefined),
1862
+ args: [{ name: 'option', type: 'string' }],
1863
+ },
1864
+ {
1865
+ id: 'chat.deny',
1866
+ title: 'Deny it',
1867
+ category: 'Chat',
1868
+ description: 'Deny the tool call',
1869
+ slots: ['palette'],
1870
+ // There is nothing to deny until the agent has asked.
1871
+ when: `${INPUT}`,
1872
+ run: () => controller.deny()
1873
+ },
1874
+ {
1875
+ id: 'chat.send',
1876
+ title: 'Send a message',
1877
+ category: 'Chat',
1878
+ description: 'Send a message',
1879
+ slots: ['palette'],
1880
+ // `send` returns without a session, so offering it without one is offering nothing.
1881
+ when: `${OPEN}`,
1882
+ args: [{ name: 'text', type: 'string', required: true, description: 'What to say' }],
1883
+ run: (args) => controller.send(String(args.text ?? '')),
1884
+ },
1885
+ {
1886
+ id: 'chat.focusComposer',
1887
+ title: 'Write a message',
1888
+ category: 'Chat',
1889
+ description: 'Focus the composer',
1890
+ slots: ['palette'],
1891
+ // Focus goes to a field that is only mounted on these two screens.
1892
+ when: `${SCREEN} == 'chat' || ${SCREEN} == 'new'`,
1893
+ run: () => app.focus.focus('chat.composer'),
1894
+ },
1895
+ {
1896
+ /*
1897
+ * Find, in the conversation that is open.
1898
+ *
1899
+ * The same key as the catalogue's filter, on the screen where the
1900
+ * catalogue is not: `ctrl+f` means "look for something in what is in
1901
+ * front of me", and which of the two that is depends on where you are.
1902
+ */
1903
+ id: 'chat.find',
1904
+ title: 'Find in the conversation',
1905
+ category: 'Chat',
1906
+ description: 'Search what has been said in this session',
1907
+ slots: ['palette'],
1908
+ when: `${SCREEN} == 'chat'`,
1909
+ run: () => {
1910
+ app.store.set(FINDING, true);
1911
+ app.focus.focus('chat.find');
1912
+ },
1913
+ },
1914
+ {
1915
+ /*
1916
+ * The next match, and the one before it.
1917
+ *
1918
+ * The matches are block indices and the transcript cursor is a block
1919
+ * index, so going to one is moving the cursor - the feed scrolls to it
1920
+ * and draws it selected, which is what it already does when the arrow
1921
+ * keys walk the conversation. Nothing here has a second idea of where
1922
+ * the search is.
1923
+ *
1924
+ * Wrapping at both ends, because a reader pressing enter at the last
1925
+ * match means "keep going": a find that falls silent at the end of the
1926
+ * conversation reads as broken rather than as finished.
1927
+ */
1928
+ id: 'chat.find.next',
1929
+ title: 'Next match',
1930
+ category: 'Chat',
1931
+ when: `${SCREEN} == 'chat' && ${FINDING}`,
1932
+ run: () => step(1),
1933
+ },
1934
+ {
1935
+ id: 'chat.find.previous',
1936
+ title: 'Previous match',
1937
+ category: 'Chat',
1938
+ when: `${SCREEN} == 'chat' && ${FINDING}`,
1939
+ run: () => step(-1),
1940
+ },
1941
+ {
1942
+ id: 'chat.find.close',
1943
+ title: 'Close the find box',
1944
+ category: 'Chat',
1945
+ // Not in the palette: escape closes it and it is only reachable while
1946
+ // it is open, so a row for it is a row that never applies.
1947
+ when: `${SCREEN} == 'chat' && ${FINDING}`,
1948
+ run: () => {
1949
+ app.store.set(FINDING, false);
1950
+ app.store.set(FIND, '');
1951
+ app.focus.focus('chat.transcript');
1952
+ },
1953
+ },
1954
+ {
1955
+ id: 'session.filter',
1956
+ title: 'Filter the catalogue',
1957
+ category: 'Session',
1958
+ description: 'Filter the catalogue',
1959
+ slots: ['palette'],
1960
+ // The filter box belongs to the catalogue.
1961
+ when: `${SCREEN} == 'sessions'`,
1962
+ run: () => app.focus.focus('chat.filter'),
1963
+ },
1964
+ /**
1965
+ * The detail pane, as a drawer.
1966
+ *
1967
+ * Right opens it and left puts it away: the key points at the pane. It is
1968
+ * on the right of the screen, so right is where you go to read it and
1969
+ * left is the way back to the list - which is also what makes the pair
1970
+ * work above `splitAt`, where the pane is always out and the same two
1971
+ * keys just move the keyboard between the halves.
1972
+ *
1973
+ * Opening is going there. There is no state where the pane is out and
1974
+ * nobody is reading it, because the width follows the focus and a wide
1975
+ * pane nobody asked for is the session list being narrow for nothing.
1976
+ *
1977
+ * Tab still walks the screen. It is the way *out* of a text field, where
1978
+ * left and right are letters, and taking it away would strand anyone who
1979
+ * had reached the filter box.
1980
+ */
1981
+ {
1982
+ id: 'session.openDetails',
1983
+ title: 'Open the session detail',
1984
+ category: 'Session',
1985
+ description: 'Show the detail pane, and read it',
1986
+ slots: ['palette'],
1987
+ // The pane is the catalogue's, so the command is too. Without this it
1988
+ // was offered on every screen and did nothing on all but one.
1989
+ when: `${SCREEN} == 'sessions'`,
1990
+ run: () => {
1991
+ app.store.set(SIDEBAR, true);
1992
+ app.focus.focus('chat.details');
1993
+ },
1994
+ },
1995
+ {
1996
+ id: 'session.closeDetails',
1997
+ title: 'Put the session detail away',
1998
+ category: 'Session',
1999
+ description: 'Hide the detail pane, and give the list the width',
2000
+ slots: ['palette'],
2001
+ when: `${SCREEN} == 'sessions'`,
2002
+ run: () => {
2003
+ // The focus first. Unmounting the pane the keyboard is in leaves the
2004
+ // focus on a node that is no longer there, and the next key goes
2005
+ // nowhere at all.
2006
+ app.focus.focus('chat.sessions');
2007
+ // Only below the split, where the pane is a drawer. Above it, both
2008
+ // panes are always drawn and this key is the way back to the list.
2009
+ const width = app.store.get(SPLIT_AT) ?? SPLIT_DEFAULT;
2010
+ if (app.size.width <= width)
2011
+ app.store.set(SIDEBAR, false);
2012
+ },
2013
+ },
2014
+ {
2015
+ id: 'chat.focusTranscript',
2016
+ title: 'Read the transcript',
2017
+ category: 'Chat',
2018
+ description: 'Focus the transcript',
2019
+ slots: ['palette'],
2020
+ // The transcript is the conversation's, and nothing else mounts it.
2021
+ when: `${SCREEN} == 'chat'`,
2022
+ run: () => app.focus.focus('chat.transcript'),
2023
+ },
2024
+ {
2025
+ id: 'chat.clearQueue',
2026
+ title: 'Drop queued messages',
2027
+ category: 'Chat',
2028
+ description: 'Drop queued messages',
2029
+ slots: ['palette'],
2030
+ when: `${QUEUE}`,
2031
+ // One dispatch each, because that is what the protocol offers - and the
2032
+ // list comes back from the host rather than being emptied here, so a
2033
+ // message another client queued in the meantime is not silently dropped
2034
+ // by a client that never knew about it.
2035
+ run: () => {
2036
+ for (const message of queue(app.store))
2037
+ controller.unqueue(message.id);
2038
+ },
2039
+ },
2040
+ {
2041
+ id: 'chat.expand',
2042
+ title: 'Expand / collapse the selected block',
2043
+ category: 'Chat',
2044
+ description: 'Expand / collapse the selected block',
2045
+ slots: [],
2046
+ run: (args) => {
2047
+ const id = String(args.id ?? '');
2048
+ if (!id)
2049
+ return;
2050
+ const expanded = app.store.get(EXPANDED) ?? {};
2051
+ app.store.set(EXPANDED, { ...expanded, [id]: !expanded[id] });
2052
+ },
2053
+ args: [{ name: 'id', type: 'string' }],
2054
+ },
2055
+ {
2056
+ id: 'chat.running',
2057
+ title: 'Is a turn running',
2058
+ category: 'Chat',
2059
+ description: 'Is a turn running',
2060
+ slots: [],
2061
+ run: () => running(),
2062
+ },
2063
+ ];
2064
+ }
2065
+ /**
2066
+ * The defaults, with the config file's own bindings over them.
2067
+ *
2068
+ * Naming a chord replaces every default on it rather than adding to them: a
2069
+ * chord that meant one thing globally and another on one screen would still
2070
+ * mean the second after somebody rebound the first, which is not what
2071
+ * rebinding a key means. `null` takes the chord away and binds nothing.
2072
+ */
2073
+ function keys(over) {
2074
+ const defaults = shipped();
2075
+ if (!over || Object.keys(over).length === 0)
2076
+ return defaults;
2077
+ const named = new Set(Object.keys(over));
2078
+ return [
2079
+ ...defaults.filter((binding) => !named.has(binding.keys)),
2080
+ ...Object.entries(over)
2081
+ .filter((entry) => entry[1] !== null)
2082
+ .map(([chord, commandId]) => ({ keys: chord, commandId })),
2083
+ ];
2084
+ }
2085
+ /** What this client ships with. */
2086
+ function shipped() {
2087
+ return [
2088
+ // Global: nothing types these, so they are safe wherever focus is.
2089
+ { keys: 'ctrl+p', commandId: 'app.palette' },
2090
+ // Help, where every terminal application has put it.
2091
+ { keys: 'f1', commandId: 'help.keys' },
2092
+ // `alt+g` rather than `ctrl+g`, which is the external editor's key in
2093
+ // every other agent CLI and is kept free for it here.
2094
+ { keys: 'alt+g', commandId: 'bood.toggle' },
2095
+ // The key every other agent CLI opens an editor with, which is why the
2096
+ // creature moved off it.
2097
+ { keys: 'ctrl+g', commandId: 'editor.open' },
2098
+ // The clause is on the *binding*, not only on the command. A binding that
2099
+ // matches has handled the key - whether or not the command it names then
2100
+ // declines to run - so a `when` that lives only on the command swallows
2101
+ // `ctrl+c` and it never reaches the one below that closes the application.
2102
+ { keys: 'ctrl+c', commandId: 'chat.stop', when: `${SCREEN} == 'chat' && ${RUNNING}` },
2103
+ /*
2104
+ * On the terminal screen, `ctrl+c` belongs to the shell.
2105
+ *
2106
+ * It is how a person stops a command, and it reached the binding below
2107
+ * instead - so interrupting a `ping` closed the whole application. The
2108
+ * clause is on the binding for the same reason as the one above: a
2109
+ * binding that matches has handled the key, and a `when` only on the
2110
+ * command swallows it without passing it on.
2111
+ */
2112
+ { keys: 'ctrl+c', commandId: 'terminal.interrupt', when: `${SCREEN} == 'terminal' && ${OPEN_TERMINAL}` },
2113
+ { keys: 'ctrl+n', commandId: 'session.new' },
2114
+ { keys: 'ctrl+r', commandId: 'session.refresh' },
2115
+ /*
2116
+ * `alt+t`, for the reason `alt+m` is the markdown key: an alt chord
2117
+ * arrives as ESC then the letter, which survives SSH, tmux and a console
2118
+ * that has never heard of the kitty protocol. It also leaves `ctrl+t`
2119
+ * unclaimed, which is what the other agent CLIs spend on a todo list.
2120
+ */
2121
+ { keys: 'alt+t', commandId: 'view.theme' },
2122
+ /*
2123
+ * Three, and `alt+m` is the one to reach for.
2124
+ *
2125
+ * Ctrl+M and Return are the same byte. In raw mode the Return key sends
2126
+ * CR, `0x0d`, and this decoder names that `enter` - deliberately, and
2127
+ * with a comment saying so. A terminal speaking the kitty protocol or
2128
+ * xterm's `modifyOtherKeys` sends the two apart and `ctrl+m` arrives as
2129
+ * itself; everywhere else it cannot, and no binding here can change that.
2130
+ * It is kept for the terminals that can, and it is not the one to
2131
+ * document.
2132
+ *
2133
+ * `alt+m` has no such collision: it arrives as ESC then `m`, which this
2134
+ * decoder reads as alt+the key - so it works over SSH, in tmux and on a
2135
+ * console that has never heard of the kitty protocol. Global, because
2136
+ * nothing types it.
2137
+ *
2138
+ * And `m` on its own in the chat scope, where letters are letters only
2139
+ * while the composer holds them - the same bargain `c`, `s` and `t`
2140
+ * already made on this screen.
2141
+ */
2142
+ { keys: 'alt+m', commandId: 'view.markdown' },
2143
+ { keys: 'ctrl+m', commandId: 'view.markdown' },
2144
+ { keys: 'm', commandId: 'view.markdown', scopeId: CHAT_SCOPE },
2145
+ { keys: 'escape', commandId: 'go.back' },
2146
+ // The catalogue.
2147
+ { keys: 'n', commandId: 'session.new', scopeId: SESSIONS_SCOPE },
2148
+ { keys: 'r', commandId: 'session.refresh', scopeId: SESSIONS_SCOPE },
2149
+ { keys: 'a', commandId: 'session.archive', scopeId: SESSIONS_SCOPE },
2150
+ { keys: 'u', commandId: 'session.read', scopeId: SESSIONS_SCOPE },
2151
+ { keys: 'x', commandId: 'session.toggleArchived', scopeId: SESSIONS_SCOPE },
2152
+ { keys: 'd', commandId: 'session.dispose', scopeId: SESSIONS_SCOPE },
2153
+ // The key somebody reaches for without being told, beside the letter they
2154
+ // had to be. Both, because `delete` is the guess and `d` is what the
2155
+ // footer has room to name. It confirms either way - ending somebody
2156
+ // else's conversation is not an undo.
2157
+ { keys: 'delete', commandId: 'session.dispose', scopeId: SESSIONS_SCOPE },
2158
+ // `ctrl+f` rather than `/`, which is what a person reaches for to search
2159
+ // and what the field itself leaves unclaimed.
2160
+ /*
2161
+ * The catalogue's filter and the conversation's find, on the one key a
2162
+ * person reaches for to search.
2163
+ *
2164
+ * The clauses are on the *bindings* and not only on the commands. A
2165
+ * binding that matches has handled the key whether or not the command it
2166
+ * names then declines to run, so the first of these two swallowed
2167
+ * `ctrl+f` on every screen and the second was never reached.
2168
+ */
2169
+ { keys: 'ctrl+f', commandId: 'session.filter', when: `${SCREEN} == 'sessions'` },
2170
+ { keys: 'ctrl+f', commandId: 'chat.find', when: `${SCREEN} == 'chat'` },
2171
+ /*
2172
+ * Walking the matches, while the find box is up.
2173
+ *
2174
+ * The field does not claim up and down, so they reach the bindings. Not
2175
+ * scoped to the conversation: the keyboard is in the field, and a scope
2176
+ * is about which screen a *letter* belongs to. The clause is what keeps
2177
+ * these three off every other key press - unguarded they are `up`, `down`
2178
+ * and `escape` taken away from the whole application.
2179
+ *
2180
+ * `priority` and not position, because escape here has to beat the escape
2181
+ * that leaves the screen and that one is declared above. Order decides a
2182
+ * tie and this is not a tie to settle by which line came first: the find
2183
+ * box is the thing in front of the reader, so it answers first for as
2184
+ * long as it is open.
2185
+ */
2186
+ { keys: 'down', commandId: 'chat.find.next', when: FINDING, priority: 10 },
2187
+ { keys: 'up', commandId: 'chat.find.previous', when: FINDING, priority: 10 },
2188
+ { keys: 'escape', commandId: 'chat.find.close', when: FINDING, priority: 10 },
2189
+ // Scoped, not global, and after the focused node has had its turn: while
2190
+ // the filter box has the keyboard these two are caret movement, and the
2191
+ // runtime offers the key there first.
2192
+ { keys: 'right', commandId: 'session.openDetails', scopeId: SESSIONS_SCOPE },
2193
+ { keys: 'left', commandId: 'session.closeDetails', scopeId: SESSIONS_SCOPE },
2194
+ // The conversation. `i` is the one that gets you into the composer, and
2195
+ // out of it is escape - the pair that makes every other letter reachable.
2196
+ { keys: 'c', commandId: 'go.changes', scopeId: CHAT_SCOPE },
2197
+ { keys: 'f', commandId: 'go.files', scopeId: CHAT_SCOPE },
2198
+ /*
2199
+ * On the changes screen, and nowhere else.
2200
+ *
2201
+ * `]` rather than `tab`, which was the obvious choice and does not work:
2202
+ * tab is how focus moves and the runtime has already spent it before any
2203
+ * binding sees it. `]` is typed by nothing here and reads as "the next
2204
+ * one", which is what it does.
2205
+ *
2206
+ * `r` is a letter, so it lives in the changes scope for the same reason
2207
+ * `c` lives in the chat one - it must not fire while something is being
2208
+ * typed on another screen.
2209
+ */
2210
+ { keys: ']', commandId: 'changes.scope', scopeId: CHANGES_SCOPE },
2211
+ { keys: 'r', commandId: 'changes.review', scopeId: CHANGES_SCOPE },
2212
+ // `x` for "do something to this". The command takes which one as an
2213
+ // argument, so the key opens the picker rather than committing to a verb -
2214
+ // which is right, because the verbs differ per changeset and per host.
2215
+ { keys: 'x', commandId: 'changes.run', scopeId: CHANGES_SCOPE },
2216
+ // Scoped to the screen, so a letter is a letter everywhere else. `enter`
2217
+ // runs one and is the list's own, which leaves the switch and the
2218
+ // one that does not come back.
2219
+ { keys: 'n', commandId: 'automation.new', scopeId: AUTOMATIONS_SCOPE },
2220
+ { keys: 'e', commandId: 'automation.toggle', scopeId: AUTOMATIONS_SCOPE },
2221
+ { keys: 'd', commandId: 'automation.remove', scopeId: AUTOMATIONS_SCOPE },
2222
+ { keys: 's', commandId: 'go.settings', scopeId: CHAT_SCOPE },
2223
+ { keys: 't', commandId: 'chat.stop', scopeId: CHAT_SCOPE },
2224
+ { keys: 'k', commandId: 'go.skills', scopeId: CHAT_SCOPE },
2225
+ { keys: 'p', commandId: 'go.mcp', scopeId: CHAT_SCOPE },
2226
+ /**
2227
+ * An open file closes before the screen does.
2228
+ *
2229
+ * Two bindings on `escape`, and the priority is what decides between
2230
+ * them rather than the order they happen to be written in - the registry
2231
+ * sorts by priority and leaves ties in insertion order, which is a rule
2232
+ * about this array that nothing in this array says.
2233
+ *
2234
+ * The clause is on the binding as well as on the command, for the reason
2235
+ * given above `ctrl+c`: a binding that matches has handled the key even
2236
+ * when the command declines, so an escape with no file open would be
2237
+ * swallowed here and never reach `go.back`.
2238
+ */
2239
+ { keys: 'escape', commandId: 'changes.close', when: `${OPEN_FILE}`, priority: 10 },
2240
+ ];
2241
+ }