@softov/ahpc 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +296 -0
  3. package/dist/src/ahp/channels.d.ts +146 -0
  4. package/dist/src/ahp/channels.js +423 -0
  5. package/dist/src/ahp/connection.d.ts +656 -0
  6. package/dist/src/ahp/connection.js +1 -0
  7. package/dist/src/ahp/fake.d.ts +69 -0
  8. package/dist/src/ahp/fake.js +1996 -0
  9. package/dist/src/ahp/live.d.ts +160 -0
  10. package/dist/src/ahp/live.js +2641 -0
  11. package/dist/src/ahp/operate.d.ts +36 -0
  12. package/dist/src/ahp/operate.js +56 -0
  13. package/dist/src/ahp/publish.d.ts +39 -0
  14. package/dist/src/ahp/publish.js +370 -0
  15. package/dist/src/ahp/status.d.ts +23 -0
  16. package/dist/src/ahp/status.js +23 -0
  17. package/dist/src/ahp/types.d.ts +745 -0
  18. package/dist/src/ahp/types.js +27 -0
  19. package/dist/src/app.d.ts +54 -0
  20. package/dist/src/app.js +369 -0
  21. package/dist/src/blocks.d.ts +88 -0
  22. package/dist/src/blocks.js +102 -0
  23. package/dist/src/cli/main.d.ts +13 -0
  24. package/dist/src/cli/main.js +1537 -0
  25. package/dist/src/cli/render.d.ts +43 -0
  26. package/dist/src/cli/render.js +91 -0
  27. package/dist/src/config.d.ts +63 -0
  28. package/dist/src/config.js +45 -0
  29. package/dist/src/connect.d.ts +53 -0
  30. package/dist/src/connect.js +69 -0
  31. package/dist/src/control.d.ts +189 -0
  32. package/dist/src/control.js +2241 -0
  33. package/dist/src/diff.d.ts +44 -0
  34. package/dist/src/diff.js +111 -0
  35. package/dist/src/flags.d.ts +28 -0
  36. package/dist/src/flags.js +76 -0
  37. package/dist/src/main.d.ts +10 -0
  38. package/dist/src/main.js +54 -0
  39. package/dist/src/schedule.d.ts +46 -0
  40. package/dist/src/schedule.js +140 -0
  41. package/dist/src/screens.d.ts +79 -0
  42. package/dist/src/screens.js +1322 -0
  43. package/dist/src/state.d.ts +355 -0
  44. package/dist/src/state.js +531 -0
  45. package/dist/src/terminal.d.ts +44 -0
  46. package/dist/src/terminal.js +101 -0
  47. package/dist/src/tui.d.ts +92 -0
  48. package/dist/src/tui.js +372 -0
  49. package/dist/src/view/automations.d.ts +31 -0
  50. package/dist/src/view/automations.js +76 -0
  51. package/dist/src/view/bood/art.d.ts +90 -0
  52. package/dist/src/view/bood/art.js +152 -0
  53. package/dist/src/view/bood/beetle.d.ts +7 -0
  54. package/dist/src/view/bood/beetle.js +123 -0
  55. package/dist/src/view/bood/bunny.d.ts +8 -0
  56. package/dist/src/view/bood/bunny.js +228 -0
  57. package/dist/src/view/bood/cat.d.ts +9 -0
  58. package/dist/src/view/bood/cat.js +358 -0
  59. package/dist/src/view/bood/crab.d.ts +7 -0
  60. package/dist/src/view/bood/crab.js +121 -0
  61. package/dist/src/view/bood/index.d.ts +29 -0
  62. package/dist/src/view/bood/index.js +25 -0
  63. package/dist/src/view/bood/motion.d.ts +178 -0
  64. package/dist/src/view/bood/motion.js +298 -0
  65. package/dist/src/view/bood/owl.d.ts +9 -0
  66. package/dist/src/view/bood/owl.js +285 -0
  67. package/dist/src/view/bood/registry.d.ts +36 -0
  68. package/dist/src/view/bood/registry.js +218 -0
  69. package/dist/src/view/bood/render.d.ts +31 -0
  70. package/dist/src/view/bood/render.js +196 -0
  71. package/dist/src/view/bood/sprite.d.ts +18 -0
  72. package/dist/src/view/bood/sprite.js +130 -0
  73. package/dist/src/view/bood/sprout.d.ts +8 -0
  74. package/dist/src/view/bood/sprout.js +121 -0
  75. package/dist/src/view/bood/types.d.ts +158 -0
  76. package/dist/src/view/bood/types.js +57 -0
  77. package/dist/src/view/bubble.d.ts +75 -0
  78. package/dist/src/view/bubble.js +86 -0
  79. package/dist/src/view/changes.d.ts +35 -0
  80. package/dist/src/view/changes.js +22 -0
  81. package/dist/src/view/composer.d.ts +64 -0
  82. package/dist/src/view/composer.js +192 -0
  83. package/dist/src/view/controls.d.ts +44 -0
  84. package/dist/src/view/controls.js +49 -0
  85. package/dist/src/view/creature.d.ts +69 -0
  86. package/dist/src/view/creature.js +83 -0
  87. package/dist/src/view/customizations.d.ts +28 -0
  88. package/dist/src/view/customizations.js +53 -0
  89. package/dist/src/view/details.d.ts +65 -0
  90. package/dist/src/view/details.js +65 -0
  91. package/dist/src/view/filediff.d.ts +29 -0
  92. package/dist/src/view/filediff.js +24 -0
  93. package/dist/src/view/files.d.ts +31 -0
  94. package/dist/src/view/files.js +39 -0
  95. package/dist/src/view/hitl.d.ts +43 -0
  96. package/dist/src/view/hitl.js +171 -0
  97. package/dist/src/view/icons.d.ts +13 -0
  98. package/dist/src/view/icons.js +71 -0
  99. package/dist/src/view/picker.d.ts +42 -0
  100. package/dist/src/view/picker.js +71 -0
  101. package/dist/src/view/sessionhead.d.ts +41 -0
  102. package/dist/src/view/sessionhead.js +60 -0
  103. package/dist/src/view/sessions.d.ts +34 -0
  104. package/dist/src/view/sessions.js +61 -0
  105. package/dist/src/view/terminal.d.ts +30 -0
  106. package/dist/src/view/terminal.js +32 -0
  107. package/dist/src/view/toolcall.d.ts +27 -0
  108. package/dist/src/view/toolcall.js +48 -0
  109. package/dist/src/view/transcript.d.ts +50 -0
  110. package/dist/src/view/transcript.js +60 -0
  111. package/package.json +72 -0
@@ -0,0 +1,1322 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@textui/core/jsx-runtime";
2
+ import { defineComponent, useApp, useCapabilities, useEffect, useFocusScope, useMemo, useRequiredService, useSize, useState, useStore, useStoreSubtree, useStoreValue, useTheme, } from '@textui/core';
3
+ import { Badge, Column, Divider, EmptyState, Field, Form, FormActions, Marquee, Panel, RadioGroup, Row, SearchBox, Select, TextInput, argumentOf, useForm } from '@textui/widgets';
4
+ import { PRESETS, presetFor, scheduleProblem, zoneIsKnownHere } from './schedule.js';
5
+ import { AUTOMATIONS_SCOPE, CHANGES_SCOPE, CHAT_SCOPE, CONTROLLER, MCP_SCOPE, SESSIONS_SCOPE, SKILLS_SCOPE, modelCommand, settingCommand, } from './control.js';
6
+ import { branchName, branchDrift, ARCHIVED, AUTOMATIONS, AUTOMATION_ROW, CHANGES, CUSTOMIZATIONS, DRAFT, EXPANDED, FILTER, FOCUS, HISTORY, HOST, INPUT, CHANGE_AT, CHANGE_ROW, CHANGE_SCOPES, FILES_AT, FILES_ENTRIES, FILES_OPEN, MODEL, MODEL_CONFIG, OPEN, OPEN_FILE, CHAT_URI, PROVIDER, QUEUE, SELECTED, SESSIONS, SETTINGS, SIDEBAR, CHATS, CURSOR, FIND, FINDING, FIND_AT, OPEN_TERMINAL, PRESENT, SPLIT_AT, SPLIT_DEFAULT, TERMINAL, TERMINALS, TURNS, WORKSPACE, hiddenSessions, openSession, visibleSessions, workspaceName, } from './state.js';
7
+ import { findBlocks, toBlocks } from './blocks.js';
8
+ import { decodeStatus } from './ahp/status.js';
9
+ import { ChatTranscript } from './view/transcript.js';
10
+ import { ChatComposer } from './view/composer.js';
11
+ import { ChatSessionHead } from './view/sessionhead.js';
12
+ import { settingIcon, valueIcon } from './view/icons.js';
13
+ import { ChatHitl, ChatInputStatus } from './view/hitl.js';
14
+ import { ChangesList } from './view/changes.js';
15
+ import { FileList } from './view/files.js';
16
+ import { AutomationList } from './view/automations.js';
17
+ import { CustomizationList } from './view/customizations.js';
18
+ import { TerminalView } from './view/terminal.js';
19
+ import { FileDiff } from './view/filediff.js';
20
+ import { diffLines } from './diff.js';
21
+ import { ConnectionBadge, SessionList } from './view/sessions.js';
22
+ import { SessionDetails } from './view/details.js';
23
+ import { openPicker } from './view/picker.js';
24
+ /**
25
+ * The screens.
26
+ *
27
+ * Eight, and each is a different question: which conversation, the
28
+ * conversation itself, starting one, what it changed, how it is configured,
29
+ * which host any of it is on, what the host handed it, and which of its tools
30
+ * are answering. Anything else that came up - a tool call's full output, the
31
+ * command palette, a confirm - is a layer or an expansion inside one of these,
32
+ * because none of them is a place you navigate *to*.
33
+ *
34
+ * Every screen is composition. The parts are in `view/`, the actions are in
35
+ * `control.ts`, and what is left here is which part goes where.
36
+ */
37
+ /**
38
+ * Everything the catalogue knows about one session, as rows.
39
+ *
40
+ * Two sources, and they are not interchangeable: the summary is what
41
+ * `listSessions` returns and the detail is the session channel's own state -
42
+ * the chat URI, the lifecycle and the settings in force are only ever on the
43
+ * second. A pane that showed the summary alone could not answer "which chat is
44
+ * this" or "what may it do without asking", which are the two questions
45
+ * somebody reading a catalogue of agents actually has.
46
+ */
47
+ function describe(session, detail) {
48
+ const status = decodeStatus(session.status);
49
+ const setting = (key) => {
50
+ const value = detail?.config.values[key];
51
+ if (value === undefined)
52
+ return '';
53
+ // The host's own wording. `acceptEdits` is an id, "Accept edits" is what
54
+ // the schema calls it, and the provider is the authority on both.
55
+ const property = detail?.config.properties.find((found) => found.key === key);
56
+ return property?.values.find((choice) => choice.value === value)?.label ?? value;
57
+ };
58
+ const changes = session.changes;
59
+ return [
60
+ { id: 'status', label: 'Status', value: status.label, tone: status.tone },
61
+ { id: 'activity', label: 'Doing', value: session.activity ?? detail?.activity ?? '', absent: 'nothing it says' },
62
+ { id: 'flags', label: 'Flags', value: [status.read ? 'read' : 'unread', status.archived ? 'archived' : ''].filter(Boolean).join(', ') },
63
+ { id: 'provider', label: 'Harness', value: session.provider },
64
+ { id: 'model', label: 'Model', value: detail?.model?.displayName ?? '', absent: 'nothing said yet' },
65
+ // What this model takes, which is not what the harness takes. Three of
66
+ // Claude's models accept five thinking levels, some accept one and some
67
+ // accept none, so the session-wide setting below can be a choice this
68
+ // model will not honour - and the only way to know was to try it.
69
+ ...(detail?.model?.options ?? []).map((option) => ({
70
+ id: `model.${option.key}`,
71
+ label: option.title,
72
+ // The host's words, positionally. Three implementations spell these
73
+ // five values three ways, and a client with its own list is one that
74
+ // disagrees with whichever host it is talking to.
75
+ value: option.values
76
+ .map((one) => (one.value === option.default ? `${one.label} (default)` : one.label))
77
+ .join(', '),
78
+ })),
79
+ // The host's own questions, in the host's own order. Naming them here is
80
+ // how the pane came to show a blank "Permissions" against a host whose key
81
+ // for it is `autoApprove`.
82
+ ...(detail?.config.properties ?? [])
83
+ .filter((property) => property.values.length > 0)
84
+ .map((property) => ({
85
+ id: `config.${property.key}`,
86
+ label: property.title,
87
+ value: setting(property.key),
88
+ })),
89
+ { id: 'workspace', label: 'Workspace', value: session.workingDirectories.map((dir) => dir.replace(/^file:\/\//, '')).join(', '), absent: 'the host\'s own directory' },
90
+ {
91
+ id: 'branch',
92
+ label: 'Branch',
93
+ value: [branchName(session), branchDrift(session)].filter(Boolean).join(' '),
94
+ absent: 'not a repository, or the host does not say',
95
+ },
96
+ // The identifiers, in full and copyable. A URI you can read half of is
97
+ // worse than one you cannot see at all: it looks like the whole thing.
98
+ { id: 'session', label: 'Session', value: session.resource },
99
+ { id: 'chat', label: 'Chat', value: detail?.chat ?? '', absent: 'no chat yet' },
100
+ // Only when there is more than one. A session with a single chat is a
101
+ // session where the two are the same thing, and a row saying "1 of 1" is
102
+ // a row that tells nobody anything.
103
+ ...((detail?.chats.length ?? 0) > 1
104
+ ? [{ id: 'chats', label: 'Chats', value: String(detail?.chats.length ?? 0) }]
105
+ : []),
106
+ // What the host said when it would not answer. `-32001 No agent for
107
+ // session` is a live catalogue listing something whose agent has exited:
108
+ // the row is real, and everything on the session channel is not there.
109
+ { id: 'lifecycle', label: 'Lifecycle', value: detail?.refusal ?? detail?.lifecycle ?? '', ...(detail?.refusal ? { tone: 'danger' } : {}) },
110
+ { id: 'created', label: 'Started', value: session.createdAt.slice(0, 16).replace('T', ' ') },
111
+ { id: 'modified', label: 'Updated', value: session.modifiedAt.slice(0, 16).replace('T', ' ') },
112
+ {
113
+ id: 'changes',
114
+ label: 'Changes',
115
+ value: changes?.files
116
+ ? `${changes.files} files +${changes.additions ?? 0} -${changes.deletions ?? 0}`
117
+ : '',
118
+ absent: 'nothing yet',
119
+ },
120
+ ];
121
+ }
122
+ // ---------------------------------------------------------------- 1. sessions
123
+ export const SessionsScreen = defineComponent('SessionsScreen', () => {
124
+ const app = useApp();
125
+ const theme = useTheme();
126
+ const controller = useRequiredService(CONTROLLER);
127
+ // While this is mounted, `n` `r` `a` `x` `d` mean what the catalogue means
128
+ // by them. On the conversation screen they do not exist.
129
+ useFocusScope({ id: SESSIONS_SCOPE });
130
+ useStoreSubtree(SESSIONS);
131
+ const filter = useStoreValue(FILTER, '') ?? '';
132
+ const archived = useStoreValue(ARCHIVED, false) ?? false;
133
+ const selected = useStoreValue(SELECTED, null) ?? null;
134
+ const sessions = visibleSessions(app.store);
135
+ const hidden = hiddenSessions(app.store);
136
+ const host = useStoreValue(HOST);
137
+ // A list with a highlight and nothing selected is a detail panel that is
138
+ // empty until a key is pressed.
139
+ const ids = sessions.map((session) => session.resource).join(',');
140
+ useEffect(() => {
141
+ if (sessions.length === 0)
142
+ return;
143
+ if (selected && sessions.some((session) => session.resource === selected))
144
+ return;
145
+ app.store.set(SELECTED, sessions[0]?.resource ?? null);
146
+ }, [ids]);
147
+ /**
148
+ * The pane with the keyboard is the wide one.
149
+ *
150
+ * A fixed split has to be wrong somewhere: forty cells for the detail pane
151
+ * left a session list too narrow to read a title in, and widening the list
152
+ * would truncate the URIs the detail pane exists to let you copy. Neither
153
+ * of those is a problem while you are looking at the *other* one, so the
154
+ * space follows the reader.
155
+ *
156
+ * Below `splitAt` there is not enough of it to divide at all: forty cells
157
+ * of detail take the list down to a column that cuts every title, and the
158
+ * detail pane they were taken for is still too narrow to hold the URIs it
159
+ * exists to show. Two truncated halves are worse than one whole one, so
160
+ * under that width the catalogue is one pane and the detail is a drawer.
161
+ *
162
+ * Right opens it and left puts it away: the key points at the pane, which
163
+ * is on the right of the screen. Above the split both panes are always
164
+ * drawn and the same two keys only move the keyboard between them.
165
+ */
166
+ const width = useSize().width;
167
+ const splitAt = useStoreValue(SPLIT_AT, SPLIT_DEFAULT) ?? SPLIT_DEFAULT;
168
+ // Three states: out, away, and nobody has said. The last one follows the
169
+ // window, so a terminal being dragged wider opens the pane - and a person
170
+ // who put it away keeps it away, which a plain boolean defaulted from the
171
+ // width could not do.
172
+ const asked = useStoreValue(SIDEBAR, null);
173
+ const open = asked ?? width > splitAt;
174
+ const focused = useStoreValue(FOCUS, null);
175
+ const reading = open && focused === 'chat.details';
176
+ const aside = Math.max(34, Math.min(56, Math.round(width * 0.4)));
177
+ // What the pane without the keyboard keeps: two fifths, and never less
178
+ // than a session title fits in. Capped as the terminal grows, because the
179
+ // pane being read has a use for the rest and this one does not.
180
+ const current = sessions.find((session) => session.resource === selected);
181
+ const status = current ? decodeStatus(current.status) : null;
182
+ const waiting = sessions.filter((session) => decodeStatus(session.status).activity === 'input').length;
183
+ // The channel's own state, asked for one session at a time. Asking for
184
+ // every row would be a round trip per row on a screen that already has
185
+ // everything a row needs.
186
+ const [detail, setDetail] = useState(null);
187
+ useEffect(() => {
188
+ setDetail(null);
189
+ if (!selected)
190
+ return;
191
+ let live = true;
192
+ // Caught, not `void`ed. A live catalogue lists sessions whose agent has
193
+ // gone, the host refuses to talk about them, and an unhandled rejection
194
+ // from a highlight moving is an application that exits when you press a
195
+ // down arrow.
196
+ void controller.detail(selected)
197
+ .then((found) => { if (live)
198
+ setDetail(found); })
199
+ .catch((error) => controller.report(error));
200
+ return () => { live = false; };
201
+ }, [selected ?? '']);
202
+ return (_jsxs(Row, { flex: 1, gap: 1, children: [_jsxs(Panel, { title: "Sessions", ...(reading ? { width: aside } : { flex: 1 }), meta: waiting > 0 ? `${theme.glyphs.warning} ${waiting} waiting on you` : `${sessions.length} shown`, children: [_jsx(SearchBox, { value: filter, placeholder: "title, provider or workspace",
203
+ // Named, so `ctrl+f` has something to focus. A control whose id
204
+ // comes from its instance cannot be the target of a command.
205
+ focusId: "chat.filter", onChange: (value) => app.store.set(FILTER, value) }), _jsx(SessionList, { sessions: sessions, selectedId: selected, focusId: "chat.sessions",
206
+ // The list, not the filter. Whatever registers first would
207
+ // otherwise hold the keyboard on arrival, and the filter is drawn
208
+ // above the list - which made every single-letter command a letter
209
+ // typed into a text field. `n`, `a` and `d` did nothing at all,
210
+ // which is not what a missing key looks like from the outside.
211
+ autoFocus: true, flex: 1, onSelect: (uri) => app.store.set(SELECTED, uri), onOpen: (uri) => { controller.open(uri); app.screens.push('chat'); }, emptyMessage: filter ? 'Nothing matches' : 'No sessions on this host' }), archived
212
+ ? _jsx("text", { content: "x hide archived", fg: "subtle" })
213
+ : hidden > 0
214
+ ? _jsx("text", { content: `x show archived (${hidden})`, fg: "subtle" })
215
+ : null] }), open ? (_jsx(Panel, { title: "Session", ...(reading ? { flex: 1 } : { width: aside }), meta: current ? 'enter copies' : '', children: current && status ? (_jsxs(Column, { gap: 1, flex: 1, children: [_jsxs(Row, { gap: 1, children: [_jsx("text", { content: theme.glyphs[status.glyph], fg: status.tone }), _jsx("text", { content: current.title, bold: true, wrap: "word", flex: 1 }), status.archived ? _jsx(Badge, { label: "archived", tone: "muted" }) : null] }), _jsx(SessionDetails, { fields: describe(current, detail), focusId: "chat.details", claim: asked === true,
216
+ // Whole, not just the row under the cursor. The values here
217
+ // are the answer - a workspace path, a branch, two URIs - and
218
+ // a pane that shows you the first half of the one you are
219
+ // looking for has made you walk to it to find out it was not
220
+ // the one.
221
+ values: "all" }), _jsx("text", { content: "", flex: 1 }), _jsx(ConnectionBadge, { url: host?.url ?? '', state: host?.state ?? 'offline', sessions: sessions.length })] })) : (_jsx(EmptyState, { title: "Nothing selected", message: "Choose a session on the left." })) })) : null] }));
222
+ });
223
+ /**
224
+ * The composer's control row, for whichever session it is above.
225
+ *
226
+ * Two of these are the client's own questions - which harness, which model -
227
+ * because the protocol asks them itself. Everything after them is the host's:
228
+ * one chip per property its schema offers, in its order, with its titles. The
229
+ * row used to name `permissionMode`, which is what the *fixture* calls its
230
+ * key, so against a real host - whose keys are `isolation`, `autoApprove` and
231
+ * `mode` - it showed one chip that could answer nothing.
232
+ *
233
+ * Labels, never ids. `acceptEdits` is what the host stores and "Accept edits"
234
+ * is what it calls it, and the schema is the authority on both - so a chip
235
+ * reads as a sentence about what will happen rather than as a config value.
236
+ */
237
+ /**
238
+ * What a slash offers with no session open.
239
+ *
240
+ * Asked once and filtered locally as the draft is typed, the same way the
241
+ * chat screen filters a session's own list - a request per keystroke would
242
+ * ask the host the same question forty times to narrow one menu.
243
+ */
244
+ /**
245
+ * What the host offers to complete the word the caret is in.
246
+ *
247
+ * Asked of the host on every change to the draft, because that is whose
248
+ * question it is - a path is a path on *its* filesystem, and which of them
249
+ * match what has been typed changes with every keystroke. Only when there is
250
+ * something to complete: a draft with no at-sign in its last word asks
251
+ * nothing, which is most keystrokes.
252
+ *
253
+ * Latest wins. Two answers can be in flight after two quick keystrokes, and
254
+ * the older one arriving second would put the wrong list under the caret.
255
+ */
256
+ /**
257
+ * The draft with a completion's range replaced by its text.
258
+ *
259
+ * The range and not an append: what is being completed is a fragment, so
260
+ * `@src/ho` becomes `@src/host.ts` by replacing from the at-sign. Appending
261
+ * would produce `@src/ho@src/host.ts`.
262
+ */
263
+ function splice(draft, path) {
264
+ return `${draft.slice(0, path.rangeStart)}${path.insertText}${draft.slice(path.rangeEnd)}`;
265
+ }
266
+ function usePathCompletions(draft, channel) {
267
+ const controller = useRequiredService(CONTROLLER);
268
+ const [found, setFound] = useState([]);
269
+ const asking = /(?:^|\s)@\S*$/.test(draft) ? draft : '';
270
+ useEffect(() => {
271
+ if (asking === '' || !channel) {
272
+ setFound([]);
273
+ return;
274
+ }
275
+ let mine = true;
276
+ void controller.completions(channel, asking)
277
+ .then((items) => { if (mine)
278
+ setFound(items); })
279
+ .catch(() => { if (mine)
280
+ setFound([]); });
281
+ return () => { mine = false; };
282
+ }, [asking, channel]);
283
+ return asking === '' ? [] : found;
284
+ }
285
+ /**
286
+ * The terminal screen.
287
+ *
288
+ * Nothing is decided here: which terminals exist, which is open and what
289
+ * happens when a line is sent all live in `terminal.ts`, and this puts them on
290
+ * screen. Unmounting it therefore does not lose the shell, which is the
291
+ * reason for the split.
292
+ */
293
+ export const TerminalScreen = defineComponent('TerminalScreen', () => {
294
+ const app = useApp();
295
+ const controller = useRequiredService(CONTROLLER);
296
+ useFocusScope({ id: 'chat.terminal' });
297
+ const rows = useStoreValue(TERMINALS, []) ?? [];
298
+ const open = useStoreValue(OPEN_TERMINAL, null) ?? null;
299
+ const state = useStoreValue(TERMINAL, null);
300
+ const [draft, setDraft] = useState('');
301
+ const size = useSize();
302
+ /*
303
+ * Tell the host how wide this is being drawn.
304
+ *
305
+ * `terminal-channel.md` has `terminal/resized` among the actions a client
306
+ * dispatches, and its reducer sets `cols` and `rows`. A host never told
307
+ * wraps at its own default - eighty columns on a terminal twice that,
308
+ * which looks like the shell being wrong rather than like nobody having
309
+ * said. Sent whenever the size or the open terminal changes; the
310
+ * controller drops a repeat, so a re-render for any other reason costs
311
+ * nothing.
312
+ */
313
+ useEffect(() => {
314
+ if (open === null)
315
+ return;
316
+ controller.terminals.resize(size.width, size.height);
317
+ }, [open, size.width, size.height]);
318
+ // Asked for on arrival, and only then: a host with no terminals is the
319
+ // ordinary case, and a list polled behind a screen nobody is looking at is
320
+ // a request per second for an answer nobody reads.
321
+ useEffect(() => {
322
+ void controller.terminals.refresh().then(() => {
323
+ if (app.store.get(OPEN_TERMINAL))
324
+ return;
325
+ const first = (app.store.get(TERMINALS) ?? [])[0];
326
+ if (first)
327
+ controller.terminals.read(first.resource);
328
+ });
329
+ }, []);
330
+ if (rows.length === 0 && open === null) {
331
+ return (_jsx(EmptyState, { title: "No terminals", message: "ctrl+p, then Open a terminal. The shell runs on the host, in a directory it serves.", flex: 1 }));
332
+ }
333
+ return (_jsx(TerminalView, { rows: rows, open: open, state: state ?? null, draft: draft, onDraft: setDraft, onSend: (line) => { controller.terminals.write(line); setDraft(''); }, onSelect: (uri) => { controller.terminals.read(uri); } }));
334
+ });
335
+ function useHarnessCommands() {
336
+ const controller = useRequiredService(CONTROLLER);
337
+ const [items, setItems] = useState([]);
338
+ useEffect(() => {
339
+ void controller.harnessCommands().then(setItems)
340
+ .catch((error) => controller.report(error));
341
+ }, []);
342
+ return items;
343
+ }
344
+ function useComposerOptions() {
345
+ const unicode = useCapabilities().unicode;
346
+ const controller = useRequiredService(CONTROLLER);
347
+ const open = useStoreValue(OPEN, null) ?? null;
348
+ const provider = useStoreValue(PROVIDER, 'claude') ?? 'claude';
349
+ const model = useStoreValue(MODEL, '') ?? '';
350
+ const modelConfig = useStoreValue(MODEL_CONFIG, {}) ?? {};
351
+ const settings = useStoreValue(SETTINGS, {}) ?? {};
352
+ const workspace = useStoreValue(WORKSPACE, '') ?? '';
353
+ const [agents, setAgents] = useState([]);
354
+ const [config, setConfig] = useState(null);
355
+ useEffect(() => {
356
+ void controller.agents().then(setAgents).catch((error) => controller.report(error));
357
+ }, []);
358
+ // Asking is also what registers a command per property, so the chips below
359
+ // have something to open and the palette has the same questions in it.
360
+ useEffect(() => {
361
+ void controller.settings().then(setConfig)
362
+ .catch((error) => controller.report(error));
363
+ }, [provider, open]);
364
+ const agent = agents.find((found) => found.provider === provider);
365
+ // A harness with no models is the ordinary answer for one nobody has signed
366
+ // into, so the chip says so rather than opening on an empty list. Until the
367
+ // catalogue has arrived there is no harness to say it about.
368
+ const models = agent ? agent.models : null;
369
+ return [
370
+ {
371
+ id: 'harness',
372
+ // A mark on every chip, and it is not decoration. The row truncates
373
+ // labels from the right as the terminal narrows, and a chip that is only
374
+ // a label truncates to nothing you can identify; one that leads with a
375
+ // mark still says which question it is at four cells wide.
376
+ icon: settingIcon(unicode, 'harness'),
377
+ label: agent?.displayName ?? provider,
378
+ // Fixed once the session exists: it is the process the conversation is
379
+ // running in.
380
+ ...(open ? {} : { commandId: 'compose.harness' }),
381
+ },
382
+ {
383
+ id: 'model',
384
+ icon: settingIcon(unicode, 'model'),
385
+ label: models?.find((found) => found.id === model)?.displayName
386
+ ?? (model || (models !== null && models.length === 0 ? 'no models' : 'default')),
387
+ ...(models !== null && models.length === 0 ? {} : { commandId: 'compose.model' }),
388
+ },
389
+ /*
390
+ * The chosen model's own questions.
391
+ *
392
+ * The protocol says a client presents a model's `configSchema` as a form
393
+ * and returns the answers in `ModelSelection.config`. These are that form:
394
+ * they sit beside the session's questions because a person reads one row,
395
+ * and they are answered separately because they go out on the message
396
+ * rather than at the session.
397
+ */
398
+ ...(models?.find((found) => found.id === model)?.options ?? [])
399
+ .filter((property) => property.values.length > 0)
400
+ .map((property) => {
401
+ // What the host said it opens with, until somebody chooses.
402
+ const value = modelConfig[property.key] ?? property.default;
403
+ const chosen = property.values.find((found) => found.value === value);
404
+ return {
405
+ id: `model.${property.key}`,
406
+ icon: (value !== undefined
407
+ ? valueIcon(unicode, value, chosen?.label)
408
+ : undefined)
409
+ ?? settingIcon(unicode, property.key, property.title),
410
+ label: chosen?.label ?? value ?? property.title,
411
+ title: property.title,
412
+ commandId: modelCommand(property.key),
413
+ };
414
+ }),
415
+ ...(config?.properties ?? [])
416
+ .filter((property) => property.values.length > 0)
417
+ // A key the model also asks about is the model's to answer: the session
418
+ // key is the whole harness's default and the model's is what this
419
+ // message runs at, and drawing both put two controls on one row
420
+ // disagreeing about the same setting.
421
+ .filter((property) => !(models?.find((found) => found.id === model)?.options ?? [])
422
+ .some((one) => one.key === property.key))
423
+ .map((property) => {
424
+ const value = settings[property.key];
425
+ const chosen = property.values.find((found) => found.value === value);
426
+ return {
427
+ id: property.key,
428
+ // The value's own mark where it has one - which of five approval
429
+ // modes is in force is the thing worth reading from the row itself.
430
+ // The question's mark otherwise, so a branch chip is still a branch.
431
+ icon: (value !== undefined
432
+ ? valueIcon(unicode, value, chosen?.label)
433
+ : undefined)
434
+ ?? settingIcon(unicode, property.key, property.title),
435
+ label: chosen?.label ?? value ?? property.title,
436
+ // The question, for anything showing these with room for the pair.
437
+ title: property.title,
438
+ // Shown but not asked where the host says it cannot be changed on a
439
+ // running session: offering it produces a refusal, not an edit.
440
+ ...(open && !property.sessionMutable ? {} : { commandId: settingCommand(property.key) }),
441
+ };
442
+ }),
443
+ {
444
+ id: 'workspace',
445
+ icon: settingIcon(unicode, 'workspace'),
446
+ label: workspaceName(workspace ? `file://${workspace}` : undefined),
447
+ ...(open ? {} : { commandId: 'compose.workspace' }),
448
+ },
449
+ ];
450
+ }
451
+ // -------------------------------------------------------------------- 2. chat
452
+ /**
453
+ * What goes after a slash, from both places it can come from.
454
+ *
455
+ * The client's own commands are the palette's, so a slash is a second way into
456
+ * the same list rather than a list of its own. The session's are the skills
457
+ * and prompts the host contributed - filtered to the ones a person may invoke,
458
+ * because a skill can be marked as the agent's alone and offering one is
459
+ * offering something the host will refuse.
460
+ *
461
+ * Disabled customizations are left out entirely rather than shown greyed. A
462
+ * menu is a list of what can be done, and a row that cannot be chosen is a row
463
+ * that has to be read to find that out.
464
+ */
465
+ /**
466
+ * A command's arguments, written the way they would be typed.
467
+ *
468
+ * Required in angle brackets and optional in square ones, which is how every
469
+ * usage line since man(1) has said the same thing.
470
+ *
471
+ * An argument with fixed choices is left out: it is asked for with a picker,
472
+ * so nothing is typed after the name and a mask saying otherwise would be
473
+ * wrong. A command whose arguments are all pickers has no mask at all.
474
+ */
475
+ function mask(args) {
476
+ const typed = (args ?? []).filter((arg) => arg.choices === undefined);
477
+ if (typed.length === 0)
478
+ return undefined;
479
+ return typed.map((arg) => (arg.required ? `<${arg.name}>` : `[${arg.name}]`)).join(' ');
480
+ }
481
+ function slashCommands(app, contributed) {
482
+ const session = contributed
483
+ .filter((item) => (item.kind === 'skill' || item.kind === 'prompt')
484
+ && item.enabled
485
+ && item.userInvocable !== false)
486
+ .map((item) => ({
487
+ id: item.name,
488
+ kind: 'session',
489
+ title: item.name,
490
+ ...(item.description ? { description: item.description } : {}),
491
+ ...(item.from ? { from: item.from } : {}),
492
+ }));
493
+ const client = app.commands.list({ slot: 'palette', enabledOnly: true })
494
+ .map((command) => ({
495
+ id: command.id,
496
+ kind: 'client',
497
+ title: command.title,
498
+ ...(command.description ? { description: command.description } : {}),
499
+ ...(mask(command.args) ? { hint: mask(command.args) } : {}),
500
+ }));
501
+ return [...session, ...client];
502
+ }
503
+ const NO_TURNS = [];
504
+ const NO_QUEUE = [];
505
+ const NONE_EXPANDED = {};
506
+ export const ChatScreen = defineComponent('ChatScreen', () => {
507
+ const app = useApp();
508
+ const controller = useRequiredService(CONTROLLER);
509
+ useFocusScope({ id: CHAT_SCOPE });
510
+ // The empties are shared rather than written out, and it matters: a
511
+ // fallback literal is a new object every render, so a path nothing has
512
+ // written yet reads as *changed* every time it is read. Everything
513
+ // derived from one then changes too, and the memos below never hold.
514
+ const turns = useStoreValue(TURNS, NO_TURNS) ?? NO_TURNS;
515
+ const input = useStoreValue(INPUT, null) ?? null;
516
+ const queued = useStoreValue(QUEUE, NO_QUEUE) ?? NO_QUEUE;
517
+ const draft = useStoreValue(DRAFT, '') ?? '';
518
+ const expanded = useStoreValue(EXPANDED, NONE_EXPANDED) ?? NONE_EXPANDED;
519
+ const history = useStoreValue(HISTORY, []) ?? [];
520
+ const [recall, setRecall] = useState(history.length);
521
+ // The cursor is state like everything else, and it lives in the screen's
522
+ // own scope - so it survives a trip to the changes list, which is
523
+ // `keepAlive`, and dies with the screen, which is what a scope is for.
524
+ const [cursor, setCursor] = useStore(CURSOR, 0);
525
+ // Subscribed, because `openSession` is a plain read. The caption carries
526
+ // the session's own state, and a state arriving from the host while the
527
+ // conversation is open changed the store and left this screen showing
528
+ // what it said when the session was opened - so it only caught up when
529
+ // leaving and coming back remounted it.
530
+ useStoreSubtree(SESSIONS);
531
+ const session = openSession(app.store);
532
+ const model = useStoreValue(MODEL, '') ?? '';
533
+ const present = useStoreValue(PRESENT, []) ?? [];
534
+ const chat = useStoreValue(CHAT_URI, null) ?? null;
535
+ const running = turns.some((turn) => turn.state === 'running');
536
+ /*
537
+ * Memoised, and the three below it with it, because of what the
538
+ * transcript does with them.
539
+ *
540
+ * A component whose props are all unchanged is not re-run, and neither is
541
+ * anything under it - so stable props here are what keep a keystroke in
542
+ * the composer from rebuilding every block of the conversation and
543
+ * reconciling the whole feed. Derived fresh they were new objects every
544
+ * time, which is a change as far as anything can tell, and a screen that
545
+ * re-renders for the draft dragged four hundred blocks along with it.
546
+ */
547
+ const blocks = useMemo(() => toBlocks(turns, queued), [turns, queued]);
548
+ /*
549
+ * Find, over the conversation on screen.
550
+ *
551
+ * The matches are block indices and the cursor is a block index, so
552
+ * moving between them is moving the cursor - the feed then scrolls to it
553
+ * and draws it selected, which is the same thing that happens when the
554
+ * arrow keys walk the transcript. There is no second notion of "where the
555
+ * search is".
556
+ *
557
+ * `at` is clamped rather than reset, so a match list that shrinks as more
558
+ * is typed keeps the reader near where they were instead of throwing them
559
+ * back to the top of the conversation on every keystroke.
560
+ */
561
+ const finding = useStoreValue(FINDING, false) ?? false;
562
+ const query = useStoreValue(FIND, '') ?? '';
563
+ const found = useMemo(() => findBlocks(blocks, query), [blocks, query]);
564
+ const at = Math.min(useStoreValue(FIND_AT, 0) ?? 0, Math.max(0, found.length - 1));
565
+ /*
566
+ * The keyboard goes to the box when it opens.
567
+ *
568
+ * Not from the command that opens it, and not from this effect directly:
569
+ * the field is a child, its focus id is registered by the child's own
570
+ * mount effect, and a parent's effect runs first - so both of those ask
571
+ * for an id that does not exist yet and silently focus nothing. A
572
+ * microtask is after the whole flush, which is when the field is there.
573
+ */
574
+ useEffect(() => {
575
+ if (!finding)
576
+ return;
577
+ queueMicrotask(() => app.focus.focus('chat.find'));
578
+ }, [finding]);
579
+ const options = useComposerOptions();
580
+ // Read here as well as on the panel, because the slash menu is the other
581
+ // place a skill is reached from and this is the screen it is reached on.
582
+ // The store is shared, so a session already looked at costs nothing.
583
+ const { items: skills } = useCustomizations();
584
+ const paths = usePathCompletions(draft, useStoreValue(CHAT_URI, null) ?? null);
585
+ // Which conversation is being read, when there is more than one to read.
586
+ const chats = useStoreValue(CHATS, []) ?? [];
587
+ const reading = chats.findIndex((entry) => entry.resource === chat);
588
+ // The same answers the chips are showing, with the question beside each -
589
+ // read off one source rather than asked for a second time.
590
+ const settingRows = options
591
+ .filter((option) => option.title !== undefined)
592
+ .map((option) => ({ label: option.title, value: option.label }));
593
+ // What the caption is made of, as one value that can be compared. The
594
+ // parts are rebuilt every render and the caption they describe is not.
595
+ const headSignature = JSON.stringify([session, model, chat, settingRows, chats.length, reading]);
596
+ const head = useMemo(() => (_jsxs(Column, { padding: [0, 0, 1, 0], children: [session ? (_jsx(ChatSessionHead, { session: session, present: present, ...(model ? { model } : {}), ...(chat ? { chat } : {}), settings: chats.length > 1
597
+ // Where a session holds several, which one is being read is the
598
+ // first thing somebody needs from the header - a transcript
599
+ // that changed under the same title is otherwise unexplained.
600
+ ? [{ label: 'Chat', value: `${String(reading + 1)} of ${String(chats.length)}` }, ...settingRows]
601
+ : settingRows })) : null, _jsx(Divider, { dim: true })] })), [headSignature]);
602
+ const onToggle = useMemo(() => (id) => {
603
+ // A queued message is not something to expand. Enter on one takes it
604
+ // back, which is the only thing there is to do to a message that has
605
+ // not been sent yet.
606
+ const waiting = id.startsWith('queued:') ? id.slice('queued:'.length) : null;
607
+ if (waiting !== null) {
608
+ controller.unqueue(waiting);
609
+ return;
610
+ }
611
+ app.store.set(EXPANDED, { ...expanded, [id]: !expanded[id] });
612
+ }, [expanded, controller, app]);
613
+ /*
614
+ * Reaching the top of the transcript asks for what is above it.
615
+ *
616
+ * The cursor arriving at the first block is the only "scrolled to the
617
+ * beginning" signal there is here - `Feed` owns the viewport and reports
618
+ * the selection, not the offset - and it is a good enough one: a person
619
+ * at the oldest turn on screen is a person looking for the one before it.
620
+ *
621
+ * `busy` keeps a second request from going out while the first is in
622
+ * flight, and `done` stops asking once the host has said there is nothing
623
+ * behind what is loaded. Both are per open session, which is what the
624
+ * dependency is for.
625
+ */
626
+ const uri = session?.resource ?? null;
627
+ const paging = useMemo(() => ({ busy: false, done: false }), [uri]);
628
+ // `useStore` hands back a fresh setter every render, which is a changed
629
+ // prop every render.
630
+ const onCursor = useMemo(() => (next) => {
631
+ setCursor(next);
632
+ if (next !== 0 || uri === null || paging.busy || paging.done)
633
+ return;
634
+ paging.busy = true;
635
+ void controller.loadOlderTurns(uri)
636
+ .then((more) => { paging.done = !more; })
637
+ .catch(() => { paging.done = true; })
638
+ .finally(() => { paging.busy = false; });
639
+ }, [uri, paging, controller]);
640
+ if (!session) {
641
+ return _jsx(EmptyState, { title: "No session open", message: "Open one from the catalogue.", flex: 1 });
642
+ }
643
+ return (_jsxs(Column, { flex: 1, gap: 1, children: [finding ? (
644
+ /*
645
+ * The find box, in the top right corner.
646
+ *
647
+ * A row of its own above the transcript rather than floating over
648
+ * it: a box drawn on top of the conversation hides the lines it is
649
+ * there to help you read, and the one row it costs is only spent
650
+ * while a search is open.
651
+ */
652
+ _jsxs(Row, { justify: "end", gap: 1, children: [_jsx(SearchBox, { value: query, placeholder: "find in this conversation", width: 40, focusId: "chat.find", onChange: (next) => {
653
+ app.store.set(FIND, next);
654
+ // A new term is a new search, so it starts at the first match
655
+ // rather than at whatever number the last one had reached.
656
+ app.store.set(FIND_AT, 0);
657
+ const hits = findBlocks(blocks, next);
658
+ if (hits.length > 0)
659
+ setCursor(hits[0]);
660
+ },
661
+ // Enter is the next one, which is what every find box in a
662
+ // terminal does. Up, down and escape are keybindings rather
663
+ // than handlers here: the field does not claim them, so they
664
+ // reach the bindings, and that is where every other key in this
665
+ // application is declared.
666
+ onSubmit: () => { void app.execute('chat.find.next'); } }), _jsx("text", { content: query.trim() === '' ? '' : found.length === 0
667
+ ? 'no match'
668
+ : `${String(at + 1)} of ${String(found.length)}`, fg: query.trim() !== '' && found.length === 0 ? 'danger' : 'accent' })] })) : null, _jsx(ChatTranscript
669
+ // The top of the conversation, inside it. See `head`.
670
+ , {
671
+ // The top of the conversation, inside it. See `head`.
672
+ head: head, flex: 1, blocks: blocks, expanded: expanded, ...(finding && query.trim() !== '' ? { match: query.trim(), pinCursor: true } : {}), cursor: cursor ?? 0, onCursor: onCursor, onToggle: onToggle }), input ? (_jsx(ChatHitl, { input: input, onApprove: (option) => controller.approve(option), onDeny: () => controller.deny(), onAnswer: (answers, accepted) => controller.answer(answers, accepted),
673
+ // The block takes keys globally while it is up, escape included -
674
+ // which is right for `a` and `d` and wrong for the one key that is
675
+ // how you leave. Sent back to the transcript unconditionally it
676
+ // read as "escape does nothing", and a session blocked on a
677
+ // confirmation could not be left without answering it. So escape
678
+ // leaves whatever it is in: the find box, then the block, then
679
+ // the screen. The find box is named here because a global handler
680
+ // runs before any keybinding, so the box's own escape never gets
681
+ // the key while a session is waiting on an answer.
682
+ onEscape: () => {
683
+ if (finding) {
684
+ void app.execute('chat.find.close');
685
+ return;
686
+ }
687
+ if (app.focus.focused() === 'chat.transcript')
688
+ app.screens.pop();
689
+ else
690
+ app.focus.focus('chat.transcript');
691
+ } })) : null, _jsx(ChatInputStatus, {}), _jsx(ChatComposer, { value: draft, running: running, queued: queued.length, options: options, onOption: (option, anchorId) => {
692
+ if (option.commandId)
693
+ openPicker(app, { commandId: option.commandId, anchorId });
694
+ }, commands: slashCommands(app, skills), paths: paths, onPath: (path) => {
695
+ const next = splice(draft, path);
696
+ app.store.set(DRAFT, next);
697
+ controller.draft(next);
698
+ }, onChange: (value) => { app.store.set(DRAFT, value); controller.draft(value); }, onCommand: (picked) => {
699
+ /*
700
+ * A skill is typed, not run.
701
+ *
702
+ * There is no "invoke this skill" in the protocol: a host
703
+ * contributes skills as customizations and a person invokes one by
704
+ * sending its name as the message, exactly as they would have
705
+ * typed it. So choosing one completes the draft rather than
706
+ * dispatching anything - and leaves the trailing space, because
707
+ * most of them take an argument and the alternative is a person
708
+ * pressing enter on a bare `/review` to find out.
709
+ */
710
+ if (picked.kind === 'session') {
711
+ app.store.set(DRAFT, `/${picked.id} `);
712
+ app.focus.focus('chat.composer');
713
+ return;
714
+ }
715
+ app.store.set(DRAFT, '');
716
+ const command = app.commands.get(picked.id);
717
+ // A command that still has a question to ask cannot just be run -
718
+ // `execute` refuses a missing required argument, loudly - so it
719
+ // gets its picker, the same one the chip above would have opened.
720
+ if (command && argumentOf(command)) {
721
+ openPicker(app, { commandId: picked.id, anchorId: 'chat.composer' });
722
+ return;
723
+ }
724
+ void app.execute(picked.id, undefined, 'palette');
725
+ }, onSubmit: (value) => { controller.send(value); setRecall(history.length + 1); }, onCancel: () => app.focus.focus('chat.transcript'), onHistory: (direction) => {
726
+ // Up at the top of an empty draft is the last thing you sent. It
727
+ // is the fastest correction there is, and every other client has it.
728
+ const next = Math.max(0, Math.min(history.length, recall + direction));
729
+ setRecall(next);
730
+ app.store.set(DRAFT, history[next] ?? '');
731
+ }, onLeave: () => app.focus.focus('chat.transcript'), autoFocus: !input })] }));
732
+ });
733
+ // --------------------------------------------------------------- 3. new session
734
+ export const NewSessionScreen = defineComponent('NewSessionScreen', () => {
735
+ const app = useApp();
736
+ const controller = useRequiredService(CONTROLLER);
737
+ const theme = useTheme();
738
+ const draft = useStoreValue(DRAFT, '') ?? '';
739
+ const history = useStoreValue(HISTORY, []) ?? [];
740
+ const [recall, setRecall] = useState(history.length);
741
+ const options = useComposerOptions();
742
+ const harnessSkills = useHarnessCommands();
743
+ const newPaths = usePathCompletions(draft, 'ahp-root://');
744
+ return (_jsxs(Column, { flex: 1, gap: 1, children: [_jsxs(Column, { flex: 1, justify: "center", align: "center", gap: 0, children: [_jsx("text", { content: "A new session", fg: "muted" }), _jsx("text", { content: "The first message is what starts it.", fg: "subtle" }), _jsx("text", { content: `${theme.glyphs.chevronLeft} esc for the sessions you already have`, fg: "subtle" })] }), _jsx(ChatComposer, { value: draft, options: options, onOption: (option, anchorId) => {
745
+ if (option.commandId)
746
+ openPicker(app, { commandId: option.commandId, anchorId });
747
+ },
748
+ // The harness's as well as ours. A skill chosen here completes the
749
+ // draft to `/name `, and the message that creates the session is
750
+ // then the one that invokes it - which is where somebody most often
751
+ // wants a skill, and the only place it used to be unreachable.
752
+ commands: slashCommands(app, harnessSkills),
753
+ // The root channel, since there is no session yet: a host answers
754
+ // for its own default directory, which is where this one would go.
755
+ paths: newPaths, onPath: (path) => app.store.set(DRAFT, splice(draft, path)), onChange: (value) => app.store.set(DRAFT, value),
756
+ // Chosen here rather than sent. Without this the menu listed the
757
+ // client's own commands and pressing enter on one created a session
758
+ // whose first message was the literal text `/theme`.
759
+ onCommand: (picked) => {
760
+ // A skill is typed, not run - the same as on an open session.
761
+ // There is no "invoke this skill" in the protocol: a person
762
+ // invokes one by sending its name, and here that name is also
763
+ // what creates the session.
764
+ if (picked.kind === 'session') {
765
+ app.store.set(DRAFT, `/${picked.id} `);
766
+ app.focus.focus('chat.composer');
767
+ return;
768
+ }
769
+ app.store.set(DRAFT, '');
770
+ const command = app.commands.get(picked.id);
771
+ if (command && argumentOf(command)) {
772
+ openPicker(app, { commandId: picked.id, anchorId: 'chat.composer' });
773
+ return;
774
+ }
775
+ void app.execute(picked.id, undefined, 'palette');
776
+ },
777
+ // The message *is* the session. Nothing is created until there is
778
+ // something to say, which is why there is no Start button to leave
779
+ // pressed by mistake - and why the provider being lazy costs nothing:
780
+ // it does not attach until there is a turn to run, and this is one.
781
+ onSubmit: (value) => {
782
+ const first = value.trim();
783
+ if (!first)
784
+ return;
785
+ setRecall(history.length + 1);
786
+ const workspace = app.store.get(WORKSPACE) ?? '';
787
+ void controller.create({
788
+ provider: app.store.get(PROVIDER) ?? 'claude',
789
+ ...(workspace ? { workingDirectory: workspace } : {}),
790
+ first,
791
+ })
792
+ .then(() => app.screens.push('chat'))
793
+ // Stay here. A host that refused to create the session has left
794
+ // nothing to navigate to, and the draft is still in the field.
795
+ .catch((error) => controller.report(error));
796
+ }, onCancel: () => app.execute('go.sessions'),
797
+ // Left off the front of the field, twice over, is the same thought as
798
+ // escape: out of here, back to what already exists.
799
+ onLeave: () => app.execute('go.sessions'), onHistory: (direction) => {
800
+ const next = Math.max(0, Math.min(history.length, recall + direction));
801
+ setRecall(next);
802
+ app.store.set(DRAFT, history[next] ?? '');
803
+ }, autoFocus: true })] }));
804
+ });
805
+ // ----------------------------------------------------------------- 4. changes
806
+ /**
807
+ * The changeset, and one file out of it.
808
+ *
809
+ * Two states of one screen rather than two screens, because opening a file is
810
+ * not somewhere you navigate to: escape goes back to the list and then out,
811
+ * the way it does everywhere else here. Which file is in the store so that
812
+ * leaving for the conversation and coming back arrives where it was.
813
+ *
814
+ * The content is fetched when a row opens and never before. A changeset is a
815
+ * list of names the host sends up front and a pile of bytes it does not, and
816
+ * a screen that read both together would download a session's whole diff to
817
+ * draw a list of filenames.
818
+ */
819
+ export const ChangesScreen = defineComponent('ChangesScreen', () => {
820
+ const app = useApp();
821
+ const controller = useRequiredService(CONTROLLER);
822
+ const changes = useStoreValue(CHANGES, { status: 'complete', files: [] })
823
+ ?? { status: 'complete', files: [] };
824
+ const open = useStoreValue(OPEN_FILE, null) ?? null;
825
+ const session = openSession(app.store);
826
+ const file = changes.files.find((found) => found.uri === open) ?? null;
827
+ // Letters mean things here, so the scope is on while this screen is.
828
+ useFocusScope({ id: CHANGES_SCOPE });
829
+ /*
830
+ * Which changesets this session offers, and which of them is on screen.
831
+ *
832
+ * Asked of the host rather than assumed: a session advertises what it
833
+ * advertises, and a client that offered the four the protocol names would
834
+ * have three that are refused. Nothing is chosen here at first - an empty
835
+ * `CHANGE_AT` means whichever the host would pick, which is what this
836
+ * screen did before there was any way to ask for another.
837
+ */
838
+ const scopes = useStoreValue(CHANGE_SCOPES, []) ?? [];
839
+ const at = useStoreValue(CHANGE_AT, '') ?? '';
840
+ const uri = session?.resource ?? '';
841
+ useEffect(() => {
842
+ if (!uri)
843
+ return;
844
+ void controller.changesets(uri)
845
+ .then((found) => {
846
+ app.store.set(CHANGE_SCOPES, found);
847
+ /*
848
+ * Settle on one, rather than leaving "whichever the host would pick".
849
+ *
850
+ * The screen can draw the default without naming it, but nothing
851
+ * else can act on it: ticking a file off needs the changeset's own
852
+ * URI, and "the one you would have chosen" is not a URI. So the
853
+ * first reachable scope becomes the chosen one the moment the list
854
+ * arrives, and every key after that has something concrete to work
855
+ * with.
856
+ */
857
+ if ((app.store.get(CHANGE_AT) ?? '') !== '')
858
+ return;
859
+ const first = found.find((scope) => scope.variables.length === 0);
860
+ if (first)
861
+ app.store.set(CHANGE_AT, first.uriTemplate);
862
+ })
863
+ .catch((error) => controller.report(error));
864
+ }, [uri]);
865
+ // The chosen one, re-read when the choice moves. The default arrives on
866
+ // the session channel already, so only a named scope is fetched here.
867
+ useEffect(() => {
868
+ if (!uri || !at)
869
+ return;
870
+ void controller.changesAt(uri, at)
871
+ .then((found) => app.store.set(CHANGES, found))
872
+ .catch((error) => controller.report(error));
873
+ }, [uri, at]);
874
+ const chosen = scopes.find((scope) => scope.uriTemplate === at)
875
+ ?? scopes.find((scope) => scope.variables.length === 0);
876
+ /*
877
+ * Which row the cursor is on, kept in step with the list.
878
+ *
879
+ * `onSelect` fires when the selection *moves*, and the list arrives with
880
+ * its first row already selected - so without this, a key acting on "this
881
+ * file" does nothing until somebody has pressed an arrow, which reads as a
882
+ * key that works the second time.
883
+ */
884
+ const row = useStoreValue(CHANGE_ROW, '') ?? '';
885
+ useEffect(() => {
886
+ if (changes.files.some((one) => one.uri === row))
887
+ return;
888
+ app.store.set(CHANGE_ROW, changes.files[0]?.uri ?? '');
889
+ }, [changes.files.map((one) => one.uri).join('\u0000')]);
890
+ const [loaded, setLoaded] = useState(null);
891
+ const [failure, setFailure] = useState(null);
892
+ useEffect(() => {
893
+ if (!file) {
894
+ setLoaded(null);
895
+ setFailure(null);
896
+ return;
897
+ }
898
+ // Both sides at once. They are two fetches and one answer, and rendering
899
+ // half a diff while the other half is in flight makes every line of a
900
+ // file look added for as long as the second request takes.
901
+ let live = true;
902
+ setLoaded(null);
903
+ setFailure(null);
904
+ const side = async (ref) => (ref ? controller.content(ref) : { text: '' });
905
+ void Promise.all([side(file.content?.before), side(file.content?.after)])
906
+ .then(([before, after]) => {
907
+ if (!live)
908
+ return;
909
+ setLoaded({
910
+ uri: file.uri,
911
+ before: before.text,
912
+ after: after.text,
913
+ ...(before.binary ?? after.binary
914
+ ? { binary: (before.binary ?? after.binary) }
915
+ : {}),
916
+ });
917
+ })
918
+ .catch((error) => {
919
+ if (!live)
920
+ return;
921
+ // The host's own words. A blank pane over a ref that expired is the
922
+ // client looking broken for something the host said plainly.
923
+ setFailure(error instanceof Error ? error.message : String(error));
924
+ });
925
+ return () => { live = false; };
926
+ }, [file?.uri ?? '']);
927
+ const title = `Changes ${session ? `- ${session.title}` : ''}`;
928
+ if (file) {
929
+ const kind = !file.before ? 'new' : !file.after ? 'deleted' : 'edited';
930
+ return (_jsx(Panel, { title: title, flex: 1, children: failure !== null ? (_jsx(EmptyState, { title: "The host would not send it", message: failure, flex: 1 })) : !loaded ? (_jsx(EmptyState, { title: "Reading the file", flex: 1 })) : (_jsx(FileDiff, { path: file.uri.replace(/^file:\/\//, ''), kind: kind, diff: diffLines(loaded.before, loaded.after), ...(loaded.binary ? { binary: loaded.binary } : {}), flex: 1 })) }));
931
+ }
932
+ return (_jsxs(Panel, { title: title, flex: 1, children: [scopes.length > 1 ? (_jsx(Row, { gap: 2, children: scopes.map((scope) => {
933
+ const here = scope.uriTemplate === (chosen?.uriTemplate ?? '');
934
+ // A template with variables left in it is a question about a
935
+ // turn, and there is nothing on this screen to answer it from.
936
+ // Drawn anyway, dimmed: a scope the host offers and this screen
937
+ // cannot reach is worth saying, and silently dropping it is how
938
+ // somebody concludes their host does not have per-turn diffs.
939
+ const reachable = scope.variables.length === 0;
940
+ return (_jsx("text", { content: reachable ? scope.label : `${scope.label} (needs a turn)`, ...(here ? { fg: 'accent', bold: true } : {}), ...(reachable ? {} : { fg: 'muted' }) }, scope.uriTemplate));
941
+ }) })) : null, (changes.operations ?? []).length > 0 ? (_jsx(Row, { gap: 2, children: (changes.operations ?? []).map((operation) => (_jsx("text", { content: operation.status === 'idle'
942
+ ? operation.label
943
+ : `${operation.label} (${operation.status})`, fg: operation.status === 'error' ? 'danger' : operation.status === 'idle' ? 'muted' : 'warning' }, operation.id))) })) : null, _jsx(ChangesList, { changes: changes, reviewable: chosen?.reviewable === true, onOpen: (found) => app.store.set(OPEN_FILE, found), onSelect: (found) => app.store.set(CHANGE_ROW, found), autoFocus: true, flex: 1 })] }));
944
+ });
945
+ // ---------------------------------------------------- 6d. a new automation
946
+ /**
947
+ * Writing one down.
948
+ *
949
+ * The half that was missing: this client could list, run and switch off an
950
+ * automation and could not make one, so the only way to have any was to write
951
+ * the daemon's own file by hand and restart it. That is not a workflow, it is
952
+ * what a person does when a client has a hole in it.
953
+ *
954
+ * The expression is checked here and *understood* on the host. `scheduleProblem`
955
+ * reads the protocol's grammar and stops there; when it next comes round is the
956
+ * host's answer, and it arrives as `nextRunAt` on the row this screen leaves
957
+ * behind. So the confirmation that a schedule is real is the list saying when it
958
+ * will fire, not this form saying it looks right.
959
+ *
960
+ * Manual-only is a real choice and not an empty box. The protocol says an
961
+ * automation with no triggers is one nothing fires, so leaving the schedule
962
+ * blank writes a definition with an empty trigger list rather than a broken one.
963
+ */
964
+ export const NewAutomationScreen = defineComponent('NewAutomationScreen', () => {
965
+ const app = useApp();
966
+ const controller = useRequiredService(CONTROLLER);
967
+ const session = openSession(app.store);
968
+ const [failure, setFailure] = useState(null);
969
+ const form = useForm({
970
+ initialValues: {
971
+ title: '',
972
+ message: '',
973
+ // Where the open session is working, when there is one. It is the only
974
+ // directory this client can name without having listed something.
975
+ directory: (session?.workingDirectories[0] ?? '').replace(/^file:\/\//, ''),
976
+ expression: '',
977
+ // The machine's own, because a schedule written without thinking about
978
+ // the zone means the one the person writing it is in.
979
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
980
+ },
981
+ validate: (values) => {
982
+ const errors = {};
983
+ if (values.title.trim() === '')
984
+ errors.title = 'An automation needs a name to be found by';
985
+ // The first message is what the automation is *for*: a session created
986
+ // and never spoken to does nothing at all.
987
+ if (values.message.trim() === '')
988
+ errors.message = 'This is what it will say, so it cannot be empty';
989
+ // Blank is manual-only, which is a choice. Anything else has to parse.
990
+ if (values.expression.trim() !== '') {
991
+ const problem = scheduleProblem(values.expression);
992
+ if (problem !== undefined)
993
+ errors.expression = problem;
994
+ else if (!zoneIsKnownHere(values.timeZone)) {
995
+ errors.timeZone = `This machine does not know ${values.timeZone}`;
996
+ }
997
+ }
998
+ return errors;
999
+ },
1000
+ onSubmit: async (values) => {
1001
+ const scheduled = values.expression.trim() !== '';
1002
+ try {
1003
+ await controller.createAutomation({
1004
+ title: values.title.trim(),
1005
+ enabled: true,
1006
+ message: { text: values.message.trim() },
1007
+ session: {
1008
+ ...(session?.provider ? { provider: session.provider } : {}),
1009
+ ...(values.directory.trim() !== ''
1010
+ ? { workingDirectories: [`file://${values.directory.trim()}`] }
1011
+ : {}),
1012
+ },
1013
+ // An empty list, not an absent key: the protocol says an empty
1014
+ // trigger list is what manual-only means, and a definition with no
1015
+ // `triggers` at all is one a host has to guess about.
1016
+ triggers: scheduled
1017
+ ? [{
1018
+ id: 't1',
1019
+ kind: 'schedule',
1020
+ schedule: { expression: values.expression.trim(), timeZone: values.timeZone.trim() },
1021
+ }]
1022
+ : [],
1023
+ });
1024
+ app.screens.pop();
1025
+ }
1026
+ catch (error) {
1027
+ setFailure(error instanceof Error ? error.message : String(error));
1028
+ }
1029
+ },
1030
+ });
1031
+ return (_jsx(Panel, { title: "A new automation", flex: 1, children: _jsx(Form, { form: form, flex: 1, children: _jsxs(Column, { gap: 0, flex: 1, children: [_jsxs(Row, { gap: 1, children: [_jsx(Field, { name: "title", label: "Name", labelWidth: 10, required: true, flex: 2, children: _jsx(TextInput, { value: form.values.title, autoFocus: true, placeholder: "Nightly framework build", onChange: (value) => { form.setValue('title', value); form.touch('title'); } }) }), _jsx(Field, { name: "directory", label: "in", labelWidth: 3, flex: 2, children: _jsx(TextInput, { value: form.values.directory, placeholder: "the host's default directory", onChange: (value) => { form.setValue('directory', value); form.touch('directory'); } }) })] }), _jsx(Field, { name: "message", label: "Says", labelWidth: 10, required: true, children: _jsx(TextInput, { value: form.values.message, placeholder: "review what changed today", onChange: (value) => { form.setValue('message', value); form.touch('message'); } }) }), _jsx(Divider, {}), _jsx(Field, { name: "preset", label: "Runs", labelWidth: 10, children: _jsx(Select, { options: [
1032
+ ...PRESETS.map((one) => ({ value: one.id, label: one.label })),
1033
+ // Only reachable by typing. Offering it as a choice would be
1034
+ // offering to clear the field somebody just filled in.
1035
+ ...(presetFor(form.values.expression) === undefined
1036
+ ? [{ value: 'custom', label: 'Something else, written below' }]
1037
+ : []),
1038
+ ], value: presetFor(form.values.expression)?.id ?? 'custom', mode: "floating", onChange: (value) => {
1039
+ const chosen = PRESETS.find((one) => one.id === value);
1040
+ if (!chosen)
1041
+ return;
1042
+ form.setValue('expression', chosen.expression);
1043
+ form.touch('expression');
1044
+ } }) }), _jsxs(Row, { gap: 1, children: [_jsx(Field, { name: "expression", label: "Schedule", labelWidth: 10, hint: "minute hour day month weekday", flex: 2, children: _jsx(TextInput, { value: form.values.expression, placeholder: "0 9 * * 1-5", onChange: (value) => { form.setValue('expression', value); form.touch('expression'); } }) }), _jsx(Field, { name: "timeZone", label: "Zone", labelWidth: 5, flex: 1, children: _jsx(TextInput, { value: form.values.timeZone, placeholder: "UTC", onChange: (value) => { form.setValue('timeZone', value); form.touch('timeZone'); } }) })] }), _jsx("text", { content: form.values.expression.trim() === ''
1045
+ ? 'No schedule: it runs when somebody presses Run.'
1046
+ : presetFor(form.values.expression)
1047
+ ? `${presetFor(form.values.expression)?.label}. The host works out the next one.`
1048
+ : 'The host works out when this comes round, and the list will say.', fg: "subtle" }), failure !== null ? _jsx("text", { content: failure, fg: "danger", wrap: "word" }) : null, _jsx(FormActions, { submitLabel: "Create", cancelLabel: "Cancel", onCancel: () => app.screens.pop() })] }) }) }));
1049
+ });
1050
+ // -------------------------------------------------------- 6c. the automations
1051
+ /**
1052
+ * What the host does without being asked.
1053
+ *
1054
+ * The catalogue answers "what has been said"; this answers "what will happen".
1055
+ * They are both the host's and only one of them was drawn, so a session that
1056
+ * appeared at nine this morning was a session with no account of itself - which
1057
+ * is what protocol 0.9.0's `origin` is for, and it is read on the catalogue
1058
+ * rather than here.
1059
+ *
1060
+ * Read whole on every change rather than patched. The channel says an
1061
+ * automation moved and the list is a dozen rows; a client that reduced its own
1062
+ * copy would be a second answer to what the host holds, for no gain at this
1063
+ * size.
1064
+ */
1065
+ export const AutomationsScreen = defineComponent('AutomationsScreen', () => {
1066
+ const app = useApp();
1067
+ const controller = useRequiredService(CONTROLLER);
1068
+ useFocusScope({ id: AUTOMATIONS_SCOPE });
1069
+ const automations = useStoreValue(AUTOMATIONS, []) ?? [];
1070
+ const [failure, setFailure] = useState(null);
1071
+ const [loading, setLoading] = useState(true);
1072
+ useEffect(() => {
1073
+ let live = true;
1074
+ const read = () => {
1075
+ void controller.automations()
1076
+ .then((found) => {
1077
+ if (!live)
1078
+ return;
1079
+ app.store.set(AUTOMATIONS, found);
1080
+ setFailure(null);
1081
+ setLoading(false);
1082
+ })
1083
+ .catch((error) => {
1084
+ if (!live)
1085
+ return;
1086
+ setLoading(false);
1087
+ // The host's own words. "Serves no automations" and "the daemon
1088
+ // has gone" want opposite things from a person.
1089
+ setFailure(error instanceof Error ? error.message : String(error));
1090
+ });
1091
+ };
1092
+ read();
1093
+ // The change worth hearing about is the one nobody made.
1094
+ const watch = controller.onAutomations(() => read());
1095
+ return () => { live = false; watch.close(); };
1096
+ }, []);
1097
+ if (failure !== null) {
1098
+ return (_jsx(Panel, { title: "Automations", flex: 1, children: _jsx(EmptyState, { title: "Nothing to schedule here", message: failure, flex: 1 }) }));
1099
+ }
1100
+ if (loading && automations.length === 0) {
1101
+ return (_jsx(Panel, { title: "Automations", flex: 1, children: _jsx(EmptyState, { title: "Asking the host", message: "Reading what it holds.", flex: 1 }) }));
1102
+ }
1103
+ return (_jsx(Panel, { title: "Automations", flex: 1, children: _jsx(AutomationList, { automations: automations, focusId: "chat.automations", autoFocus: true, flex: 1, onSelect: (uri) => app.store.set(AUTOMATION_ROW, uri),
1104
+ // Enter runs it, which is the verb this screen is for. The others
1105
+ // are keys, and all three are named in the hints.
1106
+ onOpen: (uri) => { app.store.set(AUTOMATION_ROW, uri); void app.execute('automation.run'); } }) }));
1107
+ });
1108
+ // ------------------------------------------------------------- 6b. the files
1109
+ /**
1110
+ * The host's filesystem, browsed.
1111
+ *
1112
+ * `resourceList` and `resourceRead` were on the connection and behind
1113
+ * `ahpc resource`, and the only place a host's files were *drawn* was the `@`
1114
+ * completion in the composer - so a host that serves a project made people
1115
+ * type paths they could not see.
1116
+ *
1117
+ * One directory at a time rather than a tree: `resourceList` answers about one
1118
+ * directory, and an expanding tree would be a request per node with nothing on
1119
+ * screen meanwhile. Opening a file reads it and shows it; nothing here writes,
1120
+ * though the host would now allow it - see the roadmap.
1121
+ */
1122
+ export const FilesScreen = defineComponent('FilesScreen', () => {
1123
+ const app = useApp();
1124
+ const controller = useRequiredService(CONTROLLER);
1125
+ const session = openSession(app.store);
1126
+ /*
1127
+ * The session's own directory, until somebody moves.
1128
+ *
1129
+ * The only directory a client can name without having listed something
1130
+ * first: everything else it knows about the host's filesystem it learned
1131
+ * from an answer.
1132
+ */
1133
+ const root = session?.workingDirectories[0] ?? '';
1134
+ const at = (useStoreValue(FILES_AT, '') ?? '') || root;
1135
+ const entries = useStoreValue(FILES_ENTRIES, []) ?? [];
1136
+ const open = useStoreValue(FILES_OPEN, '') ?? '';
1137
+ const [loading, setLoading] = useState(false);
1138
+ const [failure, setFailure] = useState(null);
1139
+ const [body, setBody] = useState(null);
1140
+ useEffect(() => {
1141
+ if (!at)
1142
+ return;
1143
+ let live = true;
1144
+ const read = () => {
1145
+ setLoading(true);
1146
+ setFailure(null);
1147
+ void controller.files(at)
1148
+ .then((found) => { if (live) {
1149
+ app.store.set(FILES_ENTRIES, found);
1150
+ setLoading(false);
1151
+ } })
1152
+ .catch((error) => {
1153
+ if (!live)
1154
+ return;
1155
+ setLoading(false);
1156
+ setFailure(error instanceof Error ? error.message : String(error));
1157
+ });
1158
+ };
1159
+ read();
1160
+ /*
1161
+ * And again when the host says it changed.
1162
+ *
1163
+ * A watch rather than a timer: the protocol has one, and a screen that
1164
+ * re-read on a schedule was asking a question the host had already
1165
+ * offered to answer. Released on leaving, which is the only way a watch
1166
+ * closes - the specification has no dispose command and the receiver
1167
+ * lets the watcher go when the last subscriber unsubscribes.
1168
+ */
1169
+ let watching;
1170
+ void controller.watchFiles(at, () => { if (live)
1171
+ read(); })
1172
+ .then((held) => { if (live)
1173
+ watching = held;
1174
+ else
1175
+ held.close(); });
1176
+ return () => { live = false; watching?.close(); };
1177
+ }, [at]);
1178
+ useEffect(() => {
1179
+ if (!open) {
1180
+ setBody(null);
1181
+ return;
1182
+ }
1183
+ let live = true;
1184
+ setBody(null);
1185
+ void controller.file(open)
1186
+ .then((found) => {
1187
+ if (!live)
1188
+ return;
1189
+ // The encoding is *reported* by the host rather than assumed here: a
1190
+ // pane that rendered base64 as text would print a PNG to a terminal.
1191
+ setBody(found.encoding === 'base64'
1192
+ ? { uri: open, text: '', binary: true }
1193
+ : { uri: open, text: found.data });
1194
+ })
1195
+ .catch((error) => {
1196
+ if (live)
1197
+ setFailure(error instanceof Error ? error.message : String(error));
1198
+ });
1199
+ return () => { live = false; };
1200
+ }, [open]);
1201
+ const title = `Files ${session ? `- ${session.title}` : ''}`;
1202
+ if (open) {
1203
+ const name = open.replace(/^file:\/\//, '');
1204
+ return (_jsx(Panel, { title: title, flex: 1, children: body === null ? (_jsx(EmptyState, { title: "Reading the file", message: name, flex: 1 })) : body.binary === true ? (_jsx(EmptyState, { title: "Not text", message: `${name} came back as bytes, so there is nothing to show.`, flex: 1 })) : (_jsxs(Column, { flex: 1, children: [_jsx(Marquee, { content: name, truncate: "start", fg: "muted" }), _jsx("text", { content: body.text, flex: 1 })] })) }));
1205
+ }
1206
+ return (_jsx(Panel, { title: title, flex: 1, children: _jsx(FileList, { at: at, entries: entries, loading: loading, ...(failure !== null ? { failure } : {}), ...(at !== root && at.length > root.length
1207
+ ? { onUp: () => app.store.set(FILES_AT, at.slice(0, at.lastIndexOf('/'))) }
1208
+ : {}), onOpen: (entry) => {
1209
+ if (entry.kind === 'directory')
1210
+ app.store.set(FILES_AT, entry.uri);
1211
+ else
1212
+ app.store.set(FILES_OPEN, entry.uri);
1213
+ }, autoFocus: true, flex: 1 }) }));
1214
+ });
1215
+ // ------------------------------------------------------------------ 7. skills
1216
+ /**
1217
+ * What the host handed this session, in two panels.
1218
+ *
1219
+ * Two rather than one with a filter, because they answer different questions.
1220
+ * "What can I ask for" is a list of skills and prompts and it is read while
1221
+ * composing; "why is that tool missing" is a list of MCP servers and their
1222
+ * states and it is read when something did not work. A single list sorted by
1223
+ * kind makes each of those a scroll past the other.
1224
+ */
1225
+ function useCustomizations() {
1226
+ const app = useApp();
1227
+ const controller = useRequiredService(CONTROLLER);
1228
+ const uri = useStoreValue(OPEN, '') ?? '';
1229
+ const items = useStoreValue(CUSTOMIZATIONS, null);
1230
+ useEffect(() => {
1231
+ if (!uri)
1232
+ return;
1233
+ void controller.customizations(uri)
1234
+ .then((found) => app.store.set(CUSTOMIZATIONS, found))
1235
+ .catch((error) => controller.report(error));
1236
+ }, [uri]);
1237
+ return { items: items ?? [], loading: items === null };
1238
+ }
1239
+ /** A panel over one slice of the list, with the switch on each row live. */
1240
+ function CustomizationPanel(props) {
1241
+ const app = useApp();
1242
+ const controller = useRequiredService(CONTROLLER);
1243
+ const uri = useStoreValue(OPEN, '') ?? '';
1244
+ const { items, loading } = useCustomizations();
1245
+ const shown = items.filter(props.keep);
1246
+ if (!uri) {
1247
+ return _jsx(EmptyState, { title: "No session open", message: "Open one from the catalogue.", flex: 1 });
1248
+ }
1249
+ if (loading)
1250
+ return _jsx(EmptyState, { title: "Asking the host", flex: 1 });
1251
+ if (shown.length === 0) {
1252
+ return _jsx(EmptyState, { title: props.empty.title, message: props.empty.message, flex: 1 });
1253
+ }
1254
+ return (_jsx(Panel, { title: props.title, flex: 1, children: _jsx(CustomizationList, { items: shown, autoFocus: true, focusId: "customizations.list", onToggle: (item) => {
1255
+ controller.setCustomizationEnabled(uri, item.id, !item.enabled);
1256
+ // Answered locally as well as dispatched. The host tells every
1257
+ // client when it has decided, but the row under the cursor should
1258
+ // not sit on the old answer while that arrives.
1259
+ app.store.set(CUSTOMIZATIONS, items.map((found) => (found.id === item.id ? { ...found, enabled: !item.enabled }
1260
+ : found.from === item.name ? { ...found, enabled: !item.enabled }
1261
+ : found)));
1262
+ }, flex: 1 }) }));
1263
+ }
1264
+ export const SkillsScreen = defineComponent('SkillsScreen', () => {
1265
+ useFocusScope({ id: SKILLS_SCOPE });
1266
+ return CustomizationPanel({
1267
+ title: 'Skills and commands',
1268
+ scopeId: SKILLS_SCOPE,
1269
+ empty: {
1270
+ title: 'Nothing contributed',
1271
+ message: 'No plugin or directory gave this session a skill, a prompt or an agent.',
1272
+ },
1273
+ // The containers too, because turning a plugin off is how you turn off
1274
+ // the six skills it brought, and a list of only the leaves offers six
1275
+ // switches where the host has one.
1276
+ keep: (item) => item.kind === 'skill' || item.kind === 'prompt' || item.kind === 'agent'
1277
+ || item.kind === 'plugin' || item.kind === 'directory',
1278
+ });
1279
+ });
1280
+ // --------------------------------------------------------------------- 8. mcp
1281
+ export const McpScreen = defineComponent('McpScreen', () => {
1282
+ useFocusScope({ id: MCP_SCOPE });
1283
+ return CustomizationPanel({
1284
+ title: 'MCP servers',
1285
+ scopeId: MCP_SCOPE,
1286
+ empty: {
1287
+ title: 'No MCP servers',
1288
+ message: 'This session was given none, by the host or by a plugin.',
1289
+ },
1290
+ keep: (item) => item.kind === 'mcpServer',
1291
+ });
1292
+ });
1293
+ // ---------------------------------------------------------------- 5. settings
1294
+ export const SettingsScreen = defineComponent('SettingsScreen', () => {
1295
+ const controller = useRequiredService(CONTROLLER);
1296
+ const uri = useStoreValue(OPEN, '');
1297
+ const [config, setConfig] = useState(null);
1298
+ useEffect(() => {
1299
+ if (!uri)
1300
+ return;
1301
+ void controller.config(uri).then(setConfig)
1302
+ .catch((error) => controller.report(error));
1303
+ }, [uri ?? '']);
1304
+ if (!config)
1305
+ return _jsx(EmptyState, { title: "Reading the session", flex: 1 });
1306
+ return (_jsx(Panel, { title: "Session settings", flex: 1, children: _jsxs(Column, { gap: 1, flex: 1, children: [config.properties.map((property) => (_jsxs(Column, { gap: 0, children: [_jsxs(Row, { gap: 1, children: [_jsx("text", { content: property.title, bold: true }), !property.sessionMutable ? _jsx("text", { content: "fixed for this session", fg: "subtle" }) : null] }), property.description ? _jsx("text", { content: property.description, fg: "muted", wrap: "word" }) : null, _jsx(RadioGroup, { options: property.values.map((value) => ({ value: value.value, label: value.label })), value: config.values[property.key] ?? '', disabled: !property.sessionMutable, onChange: (value) => {
1307
+ if (!uri)
1308
+ return;
1309
+ controller.setConfig(uri, property.key, value);
1310
+ setConfig({ ...config, values: { ...config.values, [property.key]: value } });
1311
+ } })] }, property.key))), _jsx("text", { content: "", flex: 1 }), _jsx("text", { content: "Only what the schema marks changeable on a running session is offered. The action merges one key - sending the whole object writes back what another client just changed.", fg: "subtle", wrap: "word" })] }) }));
1312
+ });
1313
+ // ------------------------------------------------------------------- 6. hosts
1314
+ export const HostsScreen = defineComponent('HostsScreen', () => {
1315
+ const app = useApp();
1316
+ const host = useStoreValue(HOST);
1317
+ const [agents, setAgents] = useState([]);
1318
+ useEffect(() => {
1319
+ void app.services.require(CONTROLLER).agents().then(setAgents);
1320
+ }, []);
1321
+ return (_jsx(Panel, { title: "Hosts", flex: 1, children: _jsxs(Column, { gap: 1, flex: 1, children: [_jsx(ConnectionBadge, { url: host?.url ?? '', state: host?.state ?? 'offline' }), _jsx("text", { content: "An agent host is a sessions server. Several clients watch and drive the same sessions; none of them owns the process running the agent.", fg: "muted", wrap: "word" }), host?.id === 'fake' ? (_jsx("text", { content: "This is the scripted host: five seeded sessions and an agent that answers four ways. Start with --host ws://\u2026 to drive a real one instead.", fg: "subtle", wrap: "word" })) : (_jsx("text", { content: "A live host. What it says is what it sent.", fg: "subtle", wrap: "word" })), agents.map((agent) => (_jsxs(Column, { gap: 0, children: [_jsxs(Row, { gap: 1, children: [_jsx("text", { content: agent.displayName, bold: true }), _jsx(Badge, { label: agent.provider, tone: "muted" })] }), agent.description ? _jsx("text", { content: agent.description, fg: "muted" }) : null, agent.models.map((model) => (_jsx("text", { content: ` ${model.displayName} (${model.id})`, fg: "subtle" }, model.id)))] }, agent.provider)))] }) }));
1322
+ });