@softov/ahpc 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +296 -0
- package/dist/src/ahp/channels.d.ts +146 -0
- package/dist/src/ahp/channels.js +423 -0
- package/dist/src/ahp/connection.d.ts +656 -0
- package/dist/src/ahp/connection.js +1 -0
- package/dist/src/ahp/fake.d.ts +69 -0
- package/dist/src/ahp/fake.js +1996 -0
- package/dist/src/ahp/live.d.ts +160 -0
- package/dist/src/ahp/live.js +2641 -0
- package/dist/src/ahp/operate.d.ts +36 -0
- package/dist/src/ahp/operate.js +56 -0
- package/dist/src/ahp/publish.d.ts +39 -0
- package/dist/src/ahp/publish.js +370 -0
- package/dist/src/ahp/status.d.ts +23 -0
- package/dist/src/ahp/status.js +23 -0
- package/dist/src/ahp/types.d.ts +745 -0
- package/dist/src/ahp/types.js +27 -0
- package/dist/src/app.d.ts +54 -0
- package/dist/src/app.js +369 -0
- package/dist/src/blocks.d.ts +88 -0
- package/dist/src/blocks.js +102 -0
- package/dist/src/cli/main.d.ts +13 -0
- package/dist/src/cli/main.js +1537 -0
- package/dist/src/cli/render.d.ts +43 -0
- package/dist/src/cli/render.js +91 -0
- package/dist/src/config.d.ts +63 -0
- package/dist/src/config.js +45 -0
- package/dist/src/connect.d.ts +53 -0
- package/dist/src/connect.js +69 -0
- package/dist/src/control.d.ts +189 -0
- package/dist/src/control.js +2241 -0
- package/dist/src/diff.d.ts +44 -0
- package/dist/src/diff.js +111 -0
- package/dist/src/flags.d.ts +28 -0
- package/dist/src/flags.js +76 -0
- package/dist/src/main.d.ts +10 -0
- package/dist/src/main.js +54 -0
- package/dist/src/schedule.d.ts +46 -0
- package/dist/src/schedule.js +140 -0
- package/dist/src/screens.d.ts +79 -0
- package/dist/src/screens.js +1322 -0
- package/dist/src/state.d.ts +355 -0
- package/dist/src/state.js +531 -0
- package/dist/src/terminal.d.ts +44 -0
- package/dist/src/terminal.js +101 -0
- package/dist/src/tui.d.ts +92 -0
- package/dist/src/tui.js +372 -0
- package/dist/src/view/automations.d.ts +31 -0
- package/dist/src/view/automations.js +76 -0
- package/dist/src/view/bood/art.d.ts +90 -0
- package/dist/src/view/bood/art.js +152 -0
- package/dist/src/view/bood/beetle.d.ts +7 -0
- package/dist/src/view/bood/beetle.js +123 -0
- package/dist/src/view/bood/bunny.d.ts +8 -0
- package/dist/src/view/bood/bunny.js +228 -0
- package/dist/src/view/bood/cat.d.ts +9 -0
- package/dist/src/view/bood/cat.js +358 -0
- package/dist/src/view/bood/crab.d.ts +7 -0
- package/dist/src/view/bood/crab.js +121 -0
- package/dist/src/view/bood/index.d.ts +29 -0
- package/dist/src/view/bood/index.js +25 -0
- package/dist/src/view/bood/motion.d.ts +178 -0
- package/dist/src/view/bood/motion.js +298 -0
- package/dist/src/view/bood/owl.d.ts +9 -0
- package/dist/src/view/bood/owl.js +285 -0
- package/dist/src/view/bood/registry.d.ts +36 -0
- package/dist/src/view/bood/registry.js +218 -0
- package/dist/src/view/bood/render.d.ts +31 -0
- package/dist/src/view/bood/render.js +196 -0
- package/dist/src/view/bood/sprite.d.ts +18 -0
- package/dist/src/view/bood/sprite.js +130 -0
- package/dist/src/view/bood/sprout.d.ts +8 -0
- package/dist/src/view/bood/sprout.js +121 -0
- package/dist/src/view/bood/types.d.ts +158 -0
- package/dist/src/view/bood/types.js +57 -0
- package/dist/src/view/bubble.d.ts +75 -0
- package/dist/src/view/bubble.js +86 -0
- package/dist/src/view/changes.d.ts +35 -0
- package/dist/src/view/changes.js +22 -0
- package/dist/src/view/composer.d.ts +64 -0
- package/dist/src/view/composer.js +192 -0
- package/dist/src/view/controls.d.ts +44 -0
- package/dist/src/view/controls.js +49 -0
- package/dist/src/view/creature.d.ts +69 -0
- package/dist/src/view/creature.js +83 -0
- package/dist/src/view/customizations.d.ts +28 -0
- package/dist/src/view/customizations.js +53 -0
- package/dist/src/view/details.d.ts +65 -0
- package/dist/src/view/details.js +65 -0
- package/dist/src/view/filediff.d.ts +29 -0
- package/dist/src/view/filediff.js +24 -0
- package/dist/src/view/files.d.ts +31 -0
- package/dist/src/view/files.js +39 -0
- package/dist/src/view/hitl.d.ts +43 -0
- package/dist/src/view/hitl.js +171 -0
- package/dist/src/view/icons.d.ts +13 -0
- package/dist/src/view/icons.js +71 -0
- package/dist/src/view/picker.d.ts +42 -0
- package/dist/src/view/picker.js +71 -0
- package/dist/src/view/sessionhead.d.ts +41 -0
- package/dist/src/view/sessionhead.js +60 -0
- package/dist/src/view/sessions.d.ts +34 -0
- package/dist/src/view/sessions.js +61 -0
- package/dist/src/view/terminal.d.ts +30 -0
- package/dist/src/view/terminal.js +32 -0
- package/dist/src/view/toolcall.d.ts +27 -0
- package/dist/src/view/toolcall.js +48 -0
- package/dist/src/view/transcript.d.ts +50 -0
- package/dist/src/view/transcript.js +60 -0
- package/package.json +72 -0
|
@@ -0,0 +1,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
|
+
}
|