@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,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
|
+
});
|