@sigrun/tui 0.1.1 → 0.2.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/CHANGELOG.md +6 -0
- package/package.json +2 -2
- package/dist/actions.d.ts +0 -119
- package/dist/actions.js +0 -12
- package/dist/api.d.ts +0 -152
- package/dist/api.js +0 -189
- package/dist/blocks/braille.d.ts +0 -41
- package/dist/blocks/braille.js +0 -131
- package/dist/blocks/chart.d.ts +0 -20
- package/dist/blocks/chart.js +0 -397
- package/dist/blocks/collection/calendar.d.ts +0 -28
- package/dist/blocks/collection/calendar.js +0 -232
- package/dist/blocks/collection/card.d.ts +0 -36
- package/dist/blocks/collection/card.js +0 -128
- package/dist/blocks/collection/collection.d.ts +0 -9
- package/dist/blocks/collection/collection.js +0 -26
- package/dist/blocks/collection/grid.d.ts +0 -18
- package/dist/blocks/collection/grid.js +0 -72
- package/dist/blocks/collection/lanes.d.ts +0 -32
- package/dist/blocks/collection/lanes.js +0 -320
- package/dist/blocks/collection/list.d.ts +0 -30
- package/dist/blocks/collection/list.js +0 -273
- package/dist/blocks/collection/move.d.ts +0 -67
- package/dist/blocks/collection/move.js +0 -96
- package/dist/blocks/collection/shared.d.ts +0 -87
- package/dist/blocks/collection/shared.js +0 -177
- package/dist/blocks/collection/table.d.ts +0 -26
- package/dist/blocks/collection/table.js +0 -221
- package/dist/blocks/content.d.ts +0 -48
- package/dist/blocks/content.js +0 -253
- package/dist/blocks/context.d.ts +0 -96
- package/dist/blocks/context.js +0 -65
- package/dist/blocks/diff.d.ts +0 -21
- package/dist/blocks/diff.js +0 -25
- package/dist/blocks/ext-transport.d.ts +0 -70
- package/dist/blocks/ext-transport.js +0 -165
- package/dist/blocks/form/choice.d.ts +0 -54
- package/dist/blocks/form/choice.js +0 -175
- package/dist/blocks/form/fields.d.ts +0 -56
- package/dist/blocks/form/fields.js +0 -136
- package/dist/blocks/form/file.d.ts +0 -61
- package/dist/blocks/form/file.js +0 -121
- package/dist/blocks/form/form.d.ts +0 -53
- package/dist/blocks/form/form.js +0 -703
- package/dist/blocks/form/list.d.ts +0 -37
- package/dist/blocks/form/list.js +0 -70
- package/dist/blocks/form/values.d.ts +0 -20
- package/dist/blocks/form/values.js +0 -104
- package/dist/blocks/icons.d.ts +0 -14
- package/dist/blocks/icons.js +0 -74
- package/dist/blocks/image.d.ts +0 -39
- package/dist/blocks/image.js +0 -82
- package/dist/blocks/layout.d.ts +0 -31
- package/dist/blocks/layout.js +0 -155
- package/dist/blocks/log.d.ts +0 -24
- package/dist/blocks/log.js +0 -111
- package/dist/blocks/meter.d.ts +0 -16
- package/dist/blocks/meter.js +0 -50
- package/dist/blocks/page.d.ts +0 -54
- package/dist/blocks/page.js +0 -355
- package/dist/blocks/render.d.ts +0 -31
- package/dist/blocks/render.js +0 -145
- package/dist/blocks/surface-view.d.ts +0 -33
- package/dist/blocks/surface-view.js +0 -86
- package/dist/blocks/surface.d.ts +0 -75
- package/dist/blocks/surface.js +0 -468
- package/dist/blocks/tones.d.ts +0 -23
- package/dist/blocks/tones.js +0 -63
- package/dist/blocks/treeview.d.ts +0 -22
- package/dist/blocks/treeview.js +0 -121
- package/dist/blocks/width.d.ts +0 -32
- package/dist/blocks/width.js +0 -70
- package/dist/clipboard.d.ts +0 -20
- package/dist/clipboard.js +0 -19
- package/dist/editor-launch.d.ts +0 -34
- package/dist/editor-launch.js +0 -70
- package/dist/keymaps/emacs.d.ts +0 -18
- package/dist/keymaps/emacs.js +0 -172
- package/dist/keymaps/standard.d.ts +0 -17
- package/dist/keymaps/standard.js +0 -175
- package/dist/keymaps/types.d.ts +0 -43
- package/dist/keymaps/types.js +0 -28
- package/dist/keymaps/vim.d.ts +0 -48
- package/dist/keymaps/vim.js +0 -291
- package/dist/launch.d.ts +0 -149
- package/dist/launch.js +0 -446
- package/dist/sse.d.ts +0 -109
- package/dist/sse.js +0 -401
- package/dist/theme.d.ts +0 -51
- package/dist/theme.js +0 -119
- package/dist/ui/ambient.d.ts +0 -81
- package/dist/ui/ambient.js +0 -301
- package/dist/ui/app.d.ts +0 -245
- package/dist/ui/app.js +0 -1325
- package/dist/ui/composer.d.ts +0 -66
- package/dist/ui/composer.js +0 -125
- package/dist/ui/conversation-pane.d.ts +0 -123
- package/dist/ui/conversation-pane.js +0 -463
- package/dist/ui/help.d.ts +0 -15
- package/dist/ui/help.js +0 -80
- package/dist/ui/interactions.d.ts +0 -90
- package/dist/ui/interactions.js +0 -330
- package/dist/ui/picker.d.ts +0 -26
- package/dist/ui/picker.js +0 -130
- package/dist/ui/sidebar.d.ts +0 -99
- package/dist/ui/sidebar.js +0 -269
- package/dist/ui/status-bar.d.ts +0 -77
- package/dist/ui/status-bar.js +0 -146
package/dist/ui/app.js
DELETED
|
@@ -1,1325 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The TUI shell (Task 6): sidebar left, conversation pane right, composer
|
|
3
|
-
* footer — pi's idiom over the Task 4 client layers.
|
|
4
|
-
*
|
|
5
|
-
* File naming (the controller's pick-one ruling): plain `.ts` — pi-tui is
|
|
6
|
-
* not React (components are `render(width): string[]` classes), so the
|
|
7
|
-
* plan's `app.tsx` alternative does not apply.
|
|
8
|
-
*
|
|
9
|
-
* Layout: {@link TuiApp} is a `VStack`: the body row (`HStack`: sidebar
|
|
10
|
-
* at a fixed basis, pane growing), then the interaction seam's prompt
|
|
11
|
-
* strip (Task 8 — the pending stack, answerable via ctrl+o + digits), then
|
|
12
|
-
* the ambient chrome (panel/footer/statusline zones by the record's
|
|
13
|
-
* `place`). Both strips collapse to zero height when empty. The composer
|
|
14
|
-
* is a pi-tui `Editor` the launcher mounts after the app on the TUI root —
|
|
15
|
-
* pi's idiom is `TuiMainScreen` (the terminal owns scrollback; chat content
|
|
16
|
-
* scrolls, the editor anchors the bottom as the last child). Viewport
|
|
17
|
-
* `VStack`/`ScrollView` layouts are alt-screen-only (pi-tui 1.3.0), so the
|
|
18
|
-
* footer composition lives in the launcher, not the app component.
|
|
19
|
-
*
|
|
20
|
-
* Wiring: the sidebar refreshes from `listConversations()`; selecting a
|
|
21
|
-
* conversation swaps the pane's `watchConversation` subscription (the old
|
|
22
|
-
* watch is closed before the new one opens); stream frames REPLACE the
|
|
23
|
-
* pane (the SSE contract — a snapshot subsumes missed frames); the
|
|
24
|
-
* composer submits fire-and-forget via `submit(id, text)` — the stream is
|
|
25
|
-
* the source of truth for the reply (spec §10's composer rule). The fork
|
|
26
|
-
* action (`forkActive`, driven by the `/fork` slash command; task C3's
|
|
27
|
-
* action layer reintroduces a keybinding) calls
|
|
28
|
-
* `forkConversation(id)` at the latest entry (the v1 default, spec §8),
|
|
29
|
-
* refreshes the sidebar, and switches to the fork.
|
|
30
|
-
*
|
|
31
|
-
* Composer behaviors (Task 7): the composer tracks the wire's `steer.busy`
|
|
32
|
-
* per frame; submitting while busy sends `whenBusy: "steer"`. Esc during a
|
|
33
|
-
* live run aborts (the binding rides the launcher's pre-editor global
|
|
34
|
-
* listener through {@link handleKeybinding} — pi-tui 1.3.0's `Editor` has
|
|
35
|
-
* no escape hook of its own). A `/`-prefixed line routes through the
|
|
36
|
-
* slash-command catalog (`getCommands()`, fetched once and cached by
|
|
37
|
-
* {@link SlashCatalog}); unknown commands render a clear error. The POST's
|
|
38
|
-
* immediate failure (4xx) and the abort outcomes render as pane
|
|
39
|
-
* error/notice lines — the pane keeps rendering the reply off the stream.
|
|
40
|
-
*
|
|
41
|
-
* Sidebar focus (fix wave F2): the sidebar is the switcher (spec §2), so it
|
|
42
|
-
* must be keyboard-reachable — ctrl+b toggles key routing between the
|
|
43
|
-
* composer and the conversation list; a selection (or a second ctrl+b)
|
|
44
|
-
* returns keys to the composer. Guarded actions (fix wave F1): every
|
|
45
|
-
* fire-and-forget mutation path (`+ new`, conversation select, ctrl+f
|
|
46
|
-
* fork) catches its rejection into a pane error line — a flaky remote must
|
|
47
|
-
* never crash the process on an unhandled rejection.
|
|
48
|
-
*/
|
|
49
|
-
import { Editor, HStack, Key, matchesKey, VStack, } from "@earendil-works/pi-tui";
|
|
50
|
-
import { SigrunApiError } from "../api.js";
|
|
51
|
-
import { ExtSurfaceHub, surfaceKeyHash, transportKey, } from "../blocks/ext-transport.js";
|
|
52
|
-
import { watchConversation } from "../sse.js";
|
|
53
|
-
import { spawnSync } from "node:child_process";
|
|
54
|
-
import { editInVim, resolveVimEditor } from "../editor-launch.js";
|
|
55
|
-
import { copyToTerminal } from "../clipboard.js";
|
|
56
|
-
import { commandInsertText, describeCommandError, isSkillCommandName, parseSlashInput, SlashCatalog, } from "./composer.js";
|
|
57
|
-
import { AmbientChrome } from "./ambient.js";
|
|
58
|
-
import { ConversationPane } from "./conversation-pane.js";
|
|
59
|
-
import { InteractionPrompter } from "./interactions.js";
|
|
60
|
-
import { openPicker } from "./picker.js";
|
|
61
|
-
import { Sidebar } from "./sidebar.js";
|
|
62
|
-
/** PATH lookup for the vim fallback chain (nvim/vim/vi). */
|
|
63
|
-
function whichPath(name) {
|
|
64
|
-
const result = spawnSync("which", [name], { encoding: "utf8" });
|
|
65
|
-
if (result.status !== 0)
|
|
66
|
-
return undefined;
|
|
67
|
-
return result.stdout.trim().split("\n")[0] || undefined;
|
|
68
|
-
}
|
|
69
|
-
/** No-color theme: the shell pins structure; styling is a later task. */
|
|
70
|
-
function plainEditorTheme() {
|
|
71
|
-
const identity = (text) => text;
|
|
72
|
-
const selectList = {
|
|
73
|
-
selectedPrefix: identity,
|
|
74
|
-
selectedText: identity,
|
|
75
|
-
description: identity,
|
|
76
|
-
scrollInfo: identity,
|
|
77
|
-
noMatch: identity,
|
|
78
|
-
};
|
|
79
|
-
return { borderColor: identity, selectList };
|
|
80
|
-
}
|
|
81
|
-
export class TuiApp {
|
|
82
|
-
#client;
|
|
83
|
-
/** The extension-surface transport hub (U3 Task 5): one per app —
|
|
84
|
-
* `openExtView`'s transport subscribes through it; the launcher
|
|
85
|
-
* dispatches `surface.changed` events into it. */
|
|
86
|
-
#extHub = new ExtSurfaceHub();
|
|
87
|
-
/** The open extension view (the main pane's surface state). The
|
|
88
|
-
* HANDLE (not the bare transport): a reconnect's new stream id
|
|
89
|
-
* lands via setStreamId without re-creating the transport. */
|
|
90
|
-
#extView;
|
|
91
|
-
#watch;
|
|
92
|
-
#watchOptions;
|
|
93
|
-
/** The sidebar (the app mounts it; tests drive it). */
|
|
94
|
-
sidebar;
|
|
95
|
-
/** The conversation pane (the stream replaces its contents). */
|
|
96
|
-
pane;
|
|
97
|
-
/** The composer (the launcher mounts it after the app + focuses it). */
|
|
98
|
-
editor;
|
|
99
|
-
/** The interaction seam's prompt strip (Task 8; zero height when empty). */
|
|
100
|
-
interactions;
|
|
101
|
-
/** The ambient seam's chrome zones (Task 8; zero height when empty). */
|
|
102
|
-
ambient;
|
|
103
|
-
#body = new HStack();
|
|
104
|
-
#layout = new VStack();
|
|
105
|
-
#activeId;
|
|
106
|
-
#unwatch;
|
|
107
|
-
/** Monotonic watch generation: a stale watch's frames must never land. */
|
|
108
|
-
#watchGen = 0;
|
|
109
|
-
/** The last frame's `steer.busy` — the steer gate for the next submit. */
|
|
110
|
-
#busy = false;
|
|
111
|
-
/** The slash-command catalog (session-cached snapshot, live-rechecked
|
|
112
|
-
* on a miss — see SlashCatalog). */
|
|
113
|
-
#catalog;
|
|
114
|
-
/** The repainting surface (pickers, copy flashes, redraw). */
|
|
115
|
-
#tui;
|
|
116
|
-
/** The status bar sink the launcher mounts (notices, flashes, hints). */
|
|
117
|
-
#statusBar;
|
|
118
|
-
/** The focus region the action layer drives (C3). */
|
|
119
|
-
#focus = "composer";
|
|
120
|
-
/** A modal overlay (picker/search) owns the keys. */
|
|
121
|
-
#overlayOpen = false;
|
|
122
|
-
/** The quit sink: the launcher's exit resolution. */
|
|
123
|
-
onQuit;
|
|
124
|
-
/** The status-bar state the launcher last painted (flashes preserve it). */
|
|
125
|
-
#statusSnapshot;
|
|
126
|
-
/** The transcript search overlay state (query + direction). */
|
|
127
|
-
#search;
|
|
128
|
-
/** The last frame's configured model id (status bar + pickers). */
|
|
129
|
-
#modelId;
|
|
130
|
-
/** The last frame's thinking level (the cycle order starts here). */
|
|
131
|
-
#thinkingLevel = "off";
|
|
132
|
-
/** The vim resolution for edit-in-vim (C4; configured → env → PATH). */
|
|
133
|
-
#resolveVim;
|
|
134
|
-
constructor(client, options) {
|
|
135
|
-
this.#client = client;
|
|
136
|
-
this.#watch = options.watch ?? watchConversation;
|
|
137
|
-
this.#watchOptions = options.watchOptions ?? { baseUrl: "" };
|
|
138
|
-
this.#tui = options.tui;
|
|
139
|
-
this.#catalog = new SlashCatalog(client);
|
|
140
|
-
this.#resolveVim = options.resolveVim ?? (() => resolveVimEditor({ configured: options.editor, env: process.env, which: whichPath }));
|
|
141
|
-
const theme = options.theme;
|
|
142
|
-
this.sidebar = new Sidebar(client, theme === undefined ? undefined : { theme });
|
|
143
|
-
this.pane = new ConversationPane(theme === undefined ? undefined : { theme });
|
|
144
|
-
this.editor = new Editor(options.tui, plainEditorTheme());
|
|
145
|
-
this.interactions = new InteractionPrompter(client);
|
|
146
|
-
this.ambient = new AmbientChrome(client);
|
|
147
|
-
this.#body.addChild(this.sidebar, {
|
|
148
|
-
basis: options.sidebarWidth ?? 24,
|
|
149
|
-
shrink: 1,
|
|
150
|
-
});
|
|
151
|
-
this.#body.addChild(this.pane, { basis: 0, grow: 1, minSize: 1 });
|
|
152
|
-
// All entries are intrinsic-height (basis "auto"): the TUI mounts
|
|
153
|
-
// the app on the MAIN screen, where render(width) is the production
|
|
154
|
-
// path and the terminal owns scrollback — a numeric basis would be
|
|
155
|
-
// clamped to its minSize and slice the body to one line (VStack
|
|
156
|
-
// render path, no available height).
|
|
157
|
-
this.#layout.addChild(this.#body, { basis: "auto" });
|
|
158
|
-
this.#layout.addChild(this.interactions, { basis: "auto" });
|
|
159
|
-
this.#layout.addChild(this.ambient, { basis: "auto" });
|
|
160
|
-
// Seam state changes repaint; answer failures are user-initiated,
|
|
161
|
-
// so they land as pane error lines (background polls fail quiet).
|
|
162
|
-
const repaint = () => options.tui.requestRender();
|
|
163
|
-
this.interactions.onChanged = repaint;
|
|
164
|
-
this.ambient.onChanged = repaint;
|
|
165
|
-
this.interactions.onError = (message) => this.pane.showError(message);
|
|
166
|
-
this.sidebar.onSelect = (selection) => {
|
|
167
|
-
if (selection === "new") {
|
|
168
|
-
void this.createAndSelect();
|
|
169
|
-
}
|
|
170
|
-
else if (typeof selection === "object") {
|
|
171
|
-
// An extension VIEW (U3 Task 5): open it in the main pane.
|
|
172
|
-
void this.openExtView(selection.view.slug, selection.view.id).catch((error) => {
|
|
173
|
-
this.pane.showError(describeCommandError("open view", error));
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
else {
|
|
177
|
-
// Caught at the call site (fix wave F1): selectConversation
|
|
178
|
-
// keeps propagating for boot's fail-loud refresh, so the
|
|
179
|
-
// interactive path guards here.
|
|
180
|
-
void this.selectConversation(selection).catch((error) => {
|
|
181
|
-
this.pane.showError(describeCommandError("select", error));
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
};
|
|
185
|
-
this.editor.onSubmit = (text) => {
|
|
186
|
-
this.#submit(text);
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
/** The active conversation id (undefined before the first refresh). */
|
|
190
|
-
get activeConversationId() {
|
|
191
|
-
return this.#activeId;
|
|
192
|
-
}
|
|
193
|
-
/** The current /v1/events stream id (the hello frame's payload);
|
|
194
|
-
* the ext-view transports send it as X-Sigrun-Stream. */
|
|
195
|
-
#eventsStreamId;
|
|
196
|
-
/** The launcher's hello hook (U3 Task 5): the stream id feeds
|
|
197
|
-
* future openExtView transports; an OPEN view's handle gets the
|
|
198
|
-
* new id at once (its next fetch re-registers the watcher entry —
|
|
199
|
-
* spec §7.3's reconnect rule; the webui twin remounts instead). */
|
|
200
|
-
setEventsStreamId(streamId) {
|
|
201
|
-
this.#eventsStreamId = streamId;
|
|
202
|
-
this.#extView?.handle.setStreamId(streamId);
|
|
203
|
-
}
|
|
204
|
-
/** The open extension view's identity (undefined = the conversation
|
|
205
|
-
* pane is the main content). */
|
|
206
|
-
get extView() {
|
|
207
|
-
return this.#extView === undefined
|
|
208
|
-
? undefined
|
|
209
|
-
: { slug: this.#extView.slug, id: this.#extView.id };
|
|
210
|
-
}
|
|
211
|
-
/**
|
|
212
|
-
* Open an extension VIEW in the main pane (U3 Task 5, spec §7.1):
|
|
213
|
-
* the sidebar's view selection swaps the pane's content to the
|
|
214
|
-
* surface's transport. The block renderer is U2's; the app holds
|
|
215
|
-
* the transport + identity here so U2's mount is one prop pass.
|
|
216
|
-
*/
|
|
217
|
-
async openExtView(slug, id, params) {
|
|
218
|
-
const key = transportKey({ slug, id, baseUrl: this.#watchOptions.baseUrl, ...(params === undefined ? {} : { params }) });
|
|
219
|
-
const handle = this.#extHub.attach({
|
|
220
|
-
slug,
|
|
221
|
-
id,
|
|
222
|
-
...(params === undefined ? {} : { params }),
|
|
223
|
-
baseUrl: this.#watchOptions.baseUrl,
|
|
224
|
-
...(this.#watchOptions.user === undefined
|
|
225
|
-
? {}
|
|
226
|
-
: { user: this.#watchOptions.user }),
|
|
227
|
-
...(this.#watchOptions.token === undefined
|
|
228
|
-
? {}
|
|
229
|
-
: { token: this.#watchOptions.token }),
|
|
230
|
-
...(this.#eventsStreamId === undefined
|
|
231
|
-
? {}
|
|
232
|
-
: { streamId: this.#eventsStreamId }),
|
|
233
|
-
}, surfaceKeyHash(slug, id, key));
|
|
234
|
-
this.#extView = { slug, id, handle };
|
|
235
|
-
this.#tui.requestRender();
|
|
236
|
-
}
|
|
237
|
-
/**
|
|
238
|
-
* Close the open extension view (the Esc/back idiom): the pane
|
|
239
|
-
* returns to the conversation AND the surface's fetches stop — no
|
|
240
|
-
* cadence, no keepalive for a surface nobody is looking at (spec
|
|
241
|
-
* §7.3's stop-when-hidden rule; U2's cadence timer consults this).
|
|
242
|
-
* Returns true when a view was open (the key was consumed).
|
|
243
|
-
*/
|
|
244
|
-
closeExtView() {
|
|
245
|
-
if (this.#extView === undefined)
|
|
246
|
-
return false;
|
|
247
|
-
this.#extView = undefined;
|
|
248
|
-
this.#tui.requestRender();
|
|
249
|
-
return true;
|
|
250
|
-
}
|
|
251
|
-
/** The app-level surface.changed dispatch (the launcher's
|
|
252
|
-
* `watchEvents` onEvent routes events here). */
|
|
253
|
-
dispatchSurfaceChanged(event) {
|
|
254
|
-
this.#extHub.dispatch(event);
|
|
255
|
-
}
|
|
256
|
-
/** The focused region (the action layer's routing state). */
|
|
257
|
-
get focus() {
|
|
258
|
-
return this.#focus;
|
|
259
|
-
}
|
|
260
|
-
/** A modal overlay (picker/search) owns the keys. */
|
|
261
|
-
get overlayOpen() {
|
|
262
|
-
return this.#overlayOpen;
|
|
263
|
-
}
|
|
264
|
-
/** The last frame's `steer.busy` (the keymap's abort gate). */
|
|
265
|
-
get busy() {
|
|
266
|
-
return this.#busy;
|
|
267
|
-
}
|
|
268
|
-
/**
|
|
269
|
-
* The launcher's sink: the status bar it mounted (flashes, help, and
|
|
270
|
-
* the search overlay render through it). `set` also snapshots the
|
|
271
|
-
* launcher's steady state so a flash can restore it.
|
|
272
|
-
*/
|
|
273
|
-
setStatusBar(statusBar) {
|
|
274
|
-
this.#statusBar = statusBar;
|
|
275
|
-
const originalSet = statusBar.set.bind(statusBar);
|
|
276
|
-
statusBar.set = (state) => {
|
|
277
|
-
if (state.message === undefined)
|
|
278
|
-
this.#statusSnapshot = state;
|
|
279
|
-
originalSet(state);
|
|
280
|
-
};
|
|
281
|
-
// The launcher paints the steady state BEFORE attaching (the
|
|
282
|
-
// wrapper never saw it): seed the snapshot from the bar's current
|
|
283
|
-
// state, or flashes/config paints stay dead until the next set.
|
|
284
|
-
const current = statusBar.getState();
|
|
285
|
-
if (current.message === undefined)
|
|
286
|
-
this.#statusSnapshot = current;
|
|
287
|
-
// The plan's status-bar mouse rule (C3): the MODEL segment opens
|
|
288
|
-
// the model picker, the THINKING segment cycles the level — the
|
|
289
|
-
// same actions as ctrl+p's picker and ctrl+t, through the one
|
|
290
|
-
// dispatch seam.
|
|
291
|
-
statusBar.onModelClick = () => void this.dispatch({ type: "picker", kind: "model" });
|
|
292
|
-
statusBar.onThinkingClick = () => void this.dispatch({ type: "cycle-thinking" });
|
|
293
|
-
}
|
|
294
|
-
/** Flash a transient status-bar message, restoring the steady state. */
|
|
295
|
-
#flashStatus(message) {
|
|
296
|
-
const base = this.#statusSnapshot;
|
|
297
|
-
if (this.#statusBar === undefined || base === undefined)
|
|
298
|
-
return;
|
|
299
|
-
this.#statusBar.set({ ...base, message });
|
|
300
|
-
}
|
|
301
|
-
/**
|
|
302
|
-
* The configured model/thinking a frame carries (drives the cycle and
|
|
303
|
-
* the status bar's model/thinking segments). The source is the
|
|
304
|
-
* `pi.agent` doc (pi-durable's AgentState) — ConversationRecord is
|
|
305
|
-
* `{id, parent?}` and carries no model/thinking fields.
|
|
306
|
-
*/
|
|
307
|
-
#trackConfig(wire) {
|
|
308
|
-
const doc = wire.docs["pi.agent"];
|
|
309
|
-
if (doc?.model !== undefined) {
|
|
310
|
-
this.#modelId = `${doc.model.provider}/${doc.model.modelId}`;
|
|
311
|
-
}
|
|
312
|
-
if (doc?.thinkingLevel !== undefined) {
|
|
313
|
-
this.#thinkingLevel = doc.thinkingLevel;
|
|
314
|
-
}
|
|
315
|
-
this.#paintConfig();
|
|
316
|
-
}
|
|
317
|
-
/**
|
|
318
|
-
* Feed the tracked model/thinking to the status bar's segments,
|
|
319
|
-
* merged into the launcher's steady state (no `message`) so a flash
|
|
320
|
-
* restores the segments too.
|
|
321
|
-
*/
|
|
322
|
-
#paintConfig() {
|
|
323
|
-
const base = this.#statusSnapshot;
|
|
324
|
-
if (this.#statusBar === undefined || base === undefined)
|
|
325
|
-
return;
|
|
326
|
-
this.#statusBar.set({
|
|
327
|
-
...base,
|
|
328
|
-
...(this.#modelId === undefined ? {} : { model: this.#modelId }),
|
|
329
|
-
thinking: this.#thinkingLevel,
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
/**
|
|
333
|
-
* Refresh the sidebar listing. Boot selects the first conversation;
|
|
334
|
-
* later refreshes keep the current selection.
|
|
335
|
-
*/
|
|
336
|
-
async refresh() {
|
|
337
|
-
await this.sidebar.refresh();
|
|
338
|
-
if (this.#activeId === undefined) {
|
|
339
|
-
const first = this.sidebar.rows[0];
|
|
340
|
-
if (first !== undefined) {
|
|
341
|
-
await this.selectConversation(first.id);
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
else {
|
|
345
|
-
this.sidebar.setActive(this.#activeId);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
/**
|
|
349
|
-
* Swap the pane to `id`: close the old watch, open the new one. The
|
|
350
|
-
* composer is disabled for the swap's duration (review finding): a
|
|
351
|
-
* submission must never split from the pane it was typed against, so
|
|
352
|
-
* Enter cannot fire `onSubmit` mid-swap (pi-tui's `disableSubmit`
|
|
353
|
-
* gates the submit path).
|
|
354
|
-
*/
|
|
355
|
-
async selectConversation(id) {
|
|
356
|
-
if (id === this.#activeId)
|
|
357
|
-
return;
|
|
358
|
-
this.editor.disableSubmit = true;
|
|
359
|
-
try {
|
|
360
|
-
this.#unwatch?.();
|
|
361
|
-
this.#unwatch = undefined;
|
|
362
|
-
this.#activeId = id;
|
|
363
|
-
// Busy state is per-frame: unknown for the new conversation until
|
|
364
|
-
// its first frame arrives.
|
|
365
|
-
this.#busy = false;
|
|
366
|
-
this.sidebar.setActive(id);
|
|
367
|
-
// Notices are per-conversation context (fix wave F4): an error
|
|
368
|
-
// from the OLD conversation must never render in the new one.
|
|
369
|
-
// The clear happens when the NEW watch installs (below): clearing
|
|
370
|
-
// here — at selection time — would race the fork/create action
|
|
371
|
-
// that LED to the swap (fix wave A1's /fork notice must survive
|
|
372
|
-
// into the fork's pane).
|
|
373
|
-
// Generation guard (review finding): a frame already in flight
|
|
374
|
-
// from the JUST-closed watch can still deliver after the swap;
|
|
375
|
-
// without a per-watch token it would land in the new
|
|
376
|
-
// conversation's pane. The guard also fences error callbacks,
|
|
377
|
-
// which carry no conversation id.
|
|
378
|
-
const gen = ++this.#watchGen;
|
|
379
|
-
// Fix wave A6: the swap repaints immediately — notice rows drop
|
|
380
|
-
// and the pane swaps to the empty view (the stream's fresh
|
|
381
|
-
// snapshot is the real first revision; a wholesale replacement
|
|
382
|
-
// is the pane's own idiom, and the container is in entry order,
|
|
383
|
-
// so dropping notice rows mid-list would leave a hole).
|
|
384
|
-
this.pane.clearNotices();
|
|
385
|
-
this.pane.showView(this.#emptyView(id));
|
|
386
|
-
this.#unwatch = this.#watch(id, {
|
|
387
|
-
onSnapshot: (wire) => {
|
|
388
|
-
if (gen !== this.#watchGen)
|
|
389
|
-
return;
|
|
390
|
-
this.#busy = wire.steer.busy;
|
|
391
|
-
this.#trackConfig(wire);
|
|
392
|
-
// A revision may change the seam state: re-poll the
|
|
393
|
-
// pending interactions; the ambient chrome tracks the
|
|
394
|
-
// run state for its expires:"idle" surfaces.
|
|
395
|
-
this.interactions.kick();
|
|
396
|
-
this.ambient.setBusy(wire.steer.busy);
|
|
397
|
-
this.pane.showView(wire);
|
|
398
|
-
},
|
|
399
|
-
onView: (wire) => {
|
|
400
|
-
if (gen !== this.#watchGen)
|
|
401
|
-
return;
|
|
402
|
-
this.#busy = wire.steer.busy;
|
|
403
|
-
this.#trackConfig(wire);
|
|
404
|
-
this.interactions.kick();
|
|
405
|
-
this.ambient.setBusy(wire.steer.busy);
|
|
406
|
-
this.pane.showView(wire);
|
|
407
|
-
},
|
|
408
|
-
onError: (message) => {
|
|
409
|
-
if (gen === this.#watchGen)
|
|
410
|
-
this.onWatchError?.(message);
|
|
411
|
-
},
|
|
412
|
-
onOpen: () => {
|
|
413
|
-
if (gen === this.#watchGen)
|
|
414
|
-
this.onWatchOpen?.();
|
|
415
|
-
},
|
|
416
|
-
}, this.#watchOptions);
|
|
417
|
-
this.interactions.setConversation(id);
|
|
418
|
-
this.ambient.setConversation(id);
|
|
419
|
-
}
|
|
420
|
-
finally {
|
|
421
|
-
this.editor.disableSubmit = false;
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
/** The pre-snapshot pane state on a swap (fix wave A6): no entries,
|
|
425
|
-
* not busy — the stream's fresh snapshot is the real first revision. */
|
|
426
|
-
#emptyView(id) {
|
|
427
|
-
// SAFETY: a structural stub — ConversationViewWire's full shape is
|
|
428
|
-
// server-built; the pane reads only conversation.id + entries.
|
|
429
|
-
return {
|
|
430
|
-
conversation: { id },
|
|
431
|
-
entries: [],
|
|
432
|
-
docs: {},
|
|
433
|
-
steer: { busy: false, queued: 0 },
|
|
434
|
-
};
|
|
435
|
-
}
|
|
436
|
-
/**
|
|
437
|
-
* Stream-error sink: the launcher wires this to the stream-health
|
|
438
|
-
* notices (fix wave F5 — "stream: disconnected" on the first failure,
|
|
439
|
-
* "stream: reconnected" on the next open). The generation guard above
|
|
440
|
-
* fences STALE watches' errors so they can't reach whatever is mounted
|
|
441
|
-
* here.
|
|
442
|
-
*/
|
|
443
|
-
onWatchError;
|
|
444
|
-
/**
|
|
445
|
-
* Stream-open sink (fix wave F5): fires on every (re)connect of the
|
|
446
|
-
* CURRENT watch — the recovery half of the launcher's stream-health
|
|
447
|
-
* notices. Generation-guarded like {@link onWatchError}.
|
|
448
|
-
*/
|
|
449
|
-
onWatchOpen;
|
|
450
|
-
/** Create a conversation (`+ new`), refresh the sidebar, select it. */
|
|
451
|
-
async createAndSelect() {
|
|
452
|
-
// The submit guard spans the WHOLE async swap: the awaits below are
|
|
453
|
-
// the window where an Enter would otherwise target the old id.
|
|
454
|
-
this.editor.disableSubmit = true;
|
|
455
|
-
try {
|
|
456
|
-
const created = await this.#client.createConversation();
|
|
457
|
-
await this.sidebar.refresh();
|
|
458
|
-
await this.selectConversation(created.id);
|
|
459
|
-
}
|
|
460
|
-
catch (error) {
|
|
461
|
-
// Fix wave F1: every fire-and-forget caller (`+ new`, `/new`) is
|
|
462
|
-
// void-ed — a rejection must surface as a pane error, never an
|
|
463
|
-
// unhandled rejection that kills the process.
|
|
464
|
-
this.pane.showError(describeCommandError("new", error));
|
|
465
|
-
}
|
|
466
|
-
finally {
|
|
467
|
-
this.editor.disableSubmit = false;
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
/**
|
|
471
|
-
* Fork the active conversation at the latest entry (the v1 default,
|
|
472
|
-
* spec §8), refresh the sidebar, and switch to the fork. No-op with no
|
|
473
|
-
* active conversation.
|
|
474
|
-
*/
|
|
475
|
-
async forkActive() {
|
|
476
|
-
if (this.#activeId === undefined)
|
|
477
|
-
return;
|
|
478
|
-
this.editor.disableSubmit = true;
|
|
479
|
-
try {
|
|
480
|
-
const forked = await this.#client.forkConversation(this.#activeId);
|
|
481
|
-
await this.sidebar.refresh();
|
|
482
|
-
await this.selectConversation(forked.id);
|
|
483
|
-
}
|
|
484
|
-
catch (error) {
|
|
485
|
-
// Fix wave F1 (see createAndSelect).
|
|
486
|
-
this.pane.showError(describeCommandError("fork", error));
|
|
487
|
-
}
|
|
488
|
-
finally {
|
|
489
|
-
this.editor.disableSubmit = false;
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
/**
|
|
493
|
-
* The launcher's global keybinding seam: ctrl+o enters the interaction
|
|
494
|
-
* seam's answer mode; Esc during a live run aborts it (the composer
|
|
495
|
-
* idiom — the launcher routes input through here before the editor sees
|
|
496
|
-
* it, so Esc while busy never reaches the editor). Returns true when the
|
|
497
|
-
* key was consumed. Task C1b removed ctrl+b (sidebar focus) and ctrl+f
|
|
498
|
-
* (fork): task C3's action layer reintroduces navigation — until then
|
|
499
|
-
* the mouse drives the sidebar and `/fork` carries the fork.
|
|
500
|
-
*/
|
|
501
|
-
handleKeybinding(data) {
|
|
502
|
-
// The search overlay owns every key while open (C3).
|
|
503
|
-
if (this.#search !== undefined) {
|
|
504
|
-
return this.#handleSearchKey(data);
|
|
505
|
-
}
|
|
506
|
-
// A modal overlay (picker) owns the keys: the pre-keymap seams
|
|
507
|
-
// below (answer mode, ctrl+o, busy Esc) must never fire behind it.
|
|
508
|
-
// Returning false hands the key to the keymap, which passes
|
|
509
|
-
// everything but ctrl+c through to the focused overlay.
|
|
510
|
-
if (this.#overlayOpen)
|
|
511
|
-
return false;
|
|
512
|
-
// Answer mode captures the prompt's keys first (Task 8): esc here
|
|
513
|
-
// exits answer mode — it must NOT reach the busy-abort below.
|
|
514
|
-
if (this.interactions.answerMode) {
|
|
515
|
-
if (this.interactions.handleKey(data))
|
|
516
|
-
return true;
|
|
517
|
-
}
|
|
518
|
-
// ctrl+o enters answer mode (ctrl+a is the editor's cursorLineStart).
|
|
519
|
-
// Not consumed when nothing answerable is pending — the key falls
|
|
520
|
-
// through to the editor.
|
|
521
|
-
if (matchesKey(data, Key.ctrl("o"))) {
|
|
522
|
-
return this.interactions.beginAnswer();
|
|
523
|
-
}
|
|
524
|
-
if (matchesKey(data, Key.escape) && this.#busy && this.#activeId !== undefined) {
|
|
525
|
-
this.#abortActive();
|
|
526
|
-
return true;
|
|
527
|
-
}
|
|
528
|
-
// Esc closes an open extension view — back to the
|
|
529
|
-
// conversation pane; the surface's fetches stop (the keepalive
|
|
530
|
-
// stops with the view, spec §7.3's stop-when-hidden).
|
|
531
|
-
if (matchesKey(data, Key.escape) && this.#extView !== undefined) {
|
|
532
|
-
return this.closeExtView();
|
|
533
|
-
}
|
|
534
|
-
return false;
|
|
535
|
-
}
|
|
536
|
-
/**
|
|
537
|
-
* Fire-and-forget submit (the stream carries the reply, spec §10). While
|
|
538
|
-
* the last frame reported a live run, the input goes as a steer
|
|
539
|
-
* (`whenBusy`, spec §7 item 3). The POST's own failure (an immediate 4xx
|
|
540
|
-
* — Review Focus 2) is the only thing awaited, and it lands as a pane
|
|
541
|
-
* error line; the composer never wedges on it.
|
|
542
|
-
*/
|
|
543
|
-
#submit(text) {
|
|
544
|
-
// Belt to the editor's own gate: pi-tui checks `disableSubmit` in
|
|
545
|
-
// handleInput, not in the onSubmit callback — a programmatic
|
|
546
|
-
// onSubmit during a swap must no-op too, and never destroy a draft.
|
|
547
|
-
if (this.editor.disableSubmit)
|
|
548
|
-
return;
|
|
549
|
-
// Guard BEFORE clearing: a no-op submit must never destroy a draft.
|
|
550
|
-
if (text.trim() === "")
|
|
551
|
-
return;
|
|
552
|
-
if (text.startsWith("/")) {
|
|
553
|
-
// A command line is consumed by its dispatch (even when the
|
|
554
|
-
// outcome is an error line).
|
|
555
|
-
this.editor.setText("");
|
|
556
|
-
void this.#runSlashCommand(text);
|
|
557
|
-
return;
|
|
558
|
-
}
|
|
559
|
-
this.#submitLine(text);
|
|
560
|
-
}
|
|
561
|
-
/** Submit one line as a MESSAGE (the composer's plain path — also the
|
|
562
|
-
* expand path, Task 2c: `/skill:name` and prompt-template lines go to
|
|
563
|
-
* the server verbatim and are expanded AT ADMISSION, so the client
|
|
564
|
-
* never calls an endpoint for them). */
|
|
565
|
-
#submitLine(text) {
|
|
566
|
-
const id = this.#activeId;
|
|
567
|
-
if (id === undefined)
|
|
568
|
-
return;
|
|
569
|
-
this.editor.setText("");
|
|
570
|
-
const whenBusy = this.#busy ? "steer" : undefined;
|
|
571
|
-
this.#client.submit(id, text, whenBusy).catch((error) => {
|
|
572
|
-
this.pane.showError(describeCommandError("submit", error));
|
|
573
|
-
});
|
|
574
|
-
}
|
|
575
|
-
/**
|
|
576
|
-
* Abort the active conversation's live run (the Esc binding). The
|
|
577
|
-
* settled race (Review Focus 3) surfaces as a plain notice — the
|
|
578
|
-
* server's 200 `{result: "settled"}` and its 404 `no_active_submission`
|
|
579
|
-
* both mean "nothing left to abort"; a landed abort stays silent (the
|
|
580
|
-
* stream carries the outcome). Anything else is an error line.
|
|
581
|
-
*/
|
|
582
|
-
#abortActive() {
|
|
583
|
-
const id = this.#activeId;
|
|
584
|
-
if (id === undefined)
|
|
585
|
-
return;
|
|
586
|
-
void this.#client.abort(id).then((response) => {
|
|
587
|
-
if (response.result === "settled") {
|
|
588
|
-
this.pane.showNotice("abort: the run had already settled");
|
|
589
|
-
}
|
|
590
|
-
}, (error) => {
|
|
591
|
-
if (error instanceof SigrunApiError && error.status === 404) {
|
|
592
|
-
this.pane.showNotice("abort: no active run (already settled)");
|
|
593
|
-
return;
|
|
594
|
-
}
|
|
595
|
-
this.pane.showError(describeCommandError("abort", error));
|
|
596
|
-
});
|
|
597
|
-
}
|
|
598
|
-
/**
|
|
599
|
-
* Route a `/`-prefixed line through the slash-command catalog. The
|
|
600
|
-
* catalog tells the composer what exists and where it goes (the server's
|
|
601
|
-
* thin-registry rule: no server-side execute indirection); the dispatch
|
|
602
|
-
* itself is the client's typed calls. Unknown commands and catalog
|
|
603
|
-
* commands the composer cannot drive both render clear errors.
|
|
604
|
-
*/
|
|
605
|
-
async #runSlashCommand(input) {
|
|
606
|
-
const parsed = parseSlashInput(input);
|
|
607
|
-
if (parsed === undefined || parsed.name === "") {
|
|
608
|
-
this.pane.showError("empty slash command");
|
|
609
|
-
return;
|
|
610
|
-
}
|
|
611
|
-
let command;
|
|
612
|
-
try {
|
|
613
|
-
command = await this.#catalog.get(parsed.name);
|
|
614
|
-
}
|
|
615
|
-
catch (error) {
|
|
616
|
-
this.pane.showError(describeCommandError("getCommands", error));
|
|
617
|
-
return;
|
|
618
|
-
}
|
|
619
|
-
// Live-catalog re-check (fix round): the cached snapshot is a session
|
|
620
|
-
// snapshot — the server re-scans per request, and the command PICKER
|
|
621
|
-
// fetches fresh, so a template the picker just offered must also
|
|
622
|
-
// SUBMIT. One fresh fetch when the cache lacks the name, then the
|
|
623
|
-
// honest unknown-command error if it is still absent.
|
|
624
|
-
if (command === undefined) {
|
|
625
|
-
try {
|
|
626
|
-
command = await this.#catalog.getFresh(parsed.name);
|
|
627
|
-
}
|
|
628
|
-
catch (error) {
|
|
629
|
-
this.pane.showError(describeCommandError("getCommands", error));
|
|
630
|
-
return;
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
// EXPAND inputs (Task 2c) are submissions, not dispatches: a
|
|
634
|
-
// `kind: "expand"` catalog entry (prompt template, skill) — and ANY
|
|
635
|
-
// `skill:`-prefixed name, known or not (unknown skills pass through
|
|
636
|
-
// server-side) — goes to the server verbatim; expansion happens at
|
|
637
|
-
// admission. Falling through BEFORE the unknown-command error keeps
|
|
638
|
-
// `/skill:nope x` a submission, never a local error.
|
|
639
|
-
if (command?.kind === "expand" || isSkillCommandName(parsed.name)) {
|
|
640
|
-
this.#submitLine(input);
|
|
641
|
-
return;
|
|
642
|
-
}
|
|
643
|
-
if (command === undefined) {
|
|
644
|
-
this.pane.showError(`unknown command: /${parsed.name}`);
|
|
645
|
-
return;
|
|
646
|
-
}
|
|
647
|
-
try {
|
|
648
|
-
switch (parsed.name) {
|
|
649
|
-
case "new":
|
|
650
|
-
await this.createAndSelect();
|
|
651
|
-
return;
|
|
652
|
-
case "archive":
|
|
653
|
-
case "unarchive": {
|
|
654
|
-
const id = this.#activeId;
|
|
655
|
-
if (id === undefined) {
|
|
656
|
-
this.pane.showError(`/${parsed.name} needs an active conversation`);
|
|
657
|
-
return;
|
|
658
|
-
}
|
|
659
|
-
await this.#client.patchMeta(id, { archived: parsed.name === "archive" });
|
|
660
|
-
await this.sidebar.refresh();
|
|
661
|
-
return;
|
|
662
|
-
}
|
|
663
|
-
case "model":
|
|
664
|
-
case "thinking": {
|
|
665
|
-
const id = this.#activeId;
|
|
666
|
-
if (id === undefined) {
|
|
667
|
-
this.pane.showError(`/${parsed.name} needs an active conversation`);
|
|
668
|
-
return;
|
|
669
|
-
}
|
|
670
|
-
if (parsed.args === "") {
|
|
671
|
-
this.pane.showError(`/${parsed.name} needs an argument (${command.argsHint ?? ""})`);
|
|
672
|
-
return;
|
|
673
|
-
}
|
|
674
|
-
const response = parsed.name === "model"
|
|
675
|
-
? await this.#client.configure(id, { model: parsed.args })
|
|
676
|
-
: await this.#client.configure(id, { thinking: parsed.args });
|
|
677
|
-
this.#thinkingLevel = response.thinkingLevel;
|
|
678
|
-
if (response.model !== undefined) {
|
|
679
|
-
this.#modelId = `${response.model.provider}/${response.model.modelId}`;
|
|
680
|
-
}
|
|
681
|
-
const model = this.#modelId ?? "no model";
|
|
682
|
-
this.#flashStatus(`model ${model} · thinking ${response.thinkingLevel}`);
|
|
683
|
-
return;
|
|
684
|
-
}
|
|
685
|
-
case "fork": {
|
|
686
|
-
if (this.#activeId === undefined) {
|
|
687
|
-
this.pane.showError("/fork needs an active conversation");
|
|
688
|
-
return;
|
|
689
|
-
}
|
|
690
|
-
const droppedArgs = parsed.args;
|
|
691
|
-
await this.forkActive();
|
|
692
|
-
// The catalog advertises argsHint "[at]", but the composer
|
|
693
|
-
// has no at-entry selection (notice, not support — the fix
|
|
694
|
-
// wave's ruling): the argument is dropped WITH a notice, and
|
|
695
|
-
// the fork still landed at the latest entry. The notice is
|
|
696
|
-
// posted AFTER the fork: forkActive swaps conversations, and
|
|
697
|
-
// the swap clears the pane (fix wave A6), so a notice posted
|
|
698
|
-
// before would be discarded with the old conversation.
|
|
699
|
-
if (droppedArgs !== "") {
|
|
700
|
-
this.pane.showNotice("fork at-entry selection is not supported by the TUI composer; forking at latest");
|
|
701
|
-
}
|
|
702
|
-
return;
|
|
703
|
-
}
|
|
704
|
-
case "compact": {
|
|
705
|
-
const id = this.#activeId;
|
|
706
|
-
if (id === undefined) {
|
|
707
|
-
this.pane.showError("/compact needs an active conversation");
|
|
708
|
-
return;
|
|
709
|
-
}
|
|
710
|
-
await this.#client.compact(id, parsed.args === "" ? undefined : parsed.args);
|
|
711
|
-
this.pane.showNotice("compaction admitted — the summary arrives on the stream");
|
|
712
|
-
return;
|
|
713
|
-
}
|
|
714
|
-
case "jobs": {
|
|
715
|
-
// W4 Task 5: the job table into the pane's notice area — one
|
|
716
|
-
// row per job (name, next run, last outcome), framed by a
|
|
717
|
-
// header/footer so the list reads as a table, not stray lines.
|
|
718
|
-
const { jobs } = await this.#client.listJobs();
|
|
719
|
-
if (jobs.length === 0) {
|
|
720
|
-
this.pane.showNotice("jobs: none");
|
|
721
|
-
return;
|
|
722
|
-
}
|
|
723
|
-
this.pane.showNotice(`jobs: ${jobs.length} (name · next run · last outcome)`);
|
|
724
|
-
for (const job of jobs) {
|
|
725
|
-
const next = job.nextRun ?? "no next run";
|
|
726
|
-
const last = job.lastRun?.outcome ?? "never run";
|
|
727
|
-
this.pane.showNotice(` ${job.name} · ${next} · ${last}`);
|
|
728
|
-
}
|
|
729
|
-
return;
|
|
730
|
-
}
|
|
731
|
-
default:
|
|
732
|
-
// An extension-contributed command (W5 Task 3): the catalog entry
|
|
733
|
-
// carries its slug — POST the invoke endpoint and flash the
|
|
734
|
-
// returned notice in the status bar (the client surface for
|
|
735
|
-
// extension notices). A failure rejects through the surrounding
|
|
736
|
-
// catch: the pane error line carries the server's words
|
|
737
|
-
// (`command_failed` + the extension slug).
|
|
738
|
-
if (command.extension !== undefined) {
|
|
739
|
-
const id = this.#activeId;
|
|
740
|
-
if (id === undefined) {
|
|
741
|
-
this.pane.showError(`/${parsed.name} needs an active conversation`);
|
|
742
|
-
return;
|
|
743
|
-
}
|
|
744
|
-
const result = await this.#client.runCommand(id, parsed.name, parsed.args);
|
|
745
|
-
if (result.notice !== undefined)
|
|
746
|
-
this.#flashStatus(result.notice);
|
|
747
|
-
return;
|
|
748
|
-
}
|
|
749
|
-
this.pane.showError(`/${parsed.name} is not supported by the TUI composer`);
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
catch (error) {
|
|
753
|
-
this.pane.showError(describeCommandError(`/${parsed.name}`, error));
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
/** Close the stream subscription + seam pollers (the launcher calls this on exit). */
|
|
757
|
-
dispose() {
|
|
758
|
-
this.#unwatch?.();
|
|
759
|
-
this.#unwatch = undefined;
|
|
760
|
-
// Bump the watch generation too (fix wave A9): a handler still
|
|
761
|
-
// held by an unwound delivery must fence after dispose exactly as
|
|
762
|
-
// after a switch — nothing renders post-dispose.
|
|
763
|
-
this.#watchGen += 1;
|
|
764
|
-
this.interactions.dispose();
|
|
765
|
-
this.ambient.dispose();
|
|
766
|
-
}
|
|
767
|
-
// ─── The action layer (Task C3) ────────────────────────────────────
|
|
768
|
-
/**
|
|
769
|
-
* The keymap's context: focus, busy, autocomplete, overlay. The
|
|
770
|
-
* composer's autocomplete state is internal to pi-tui's Editor (no
|
|
771
|
-
* public getter), so the flag is a projection — the composer is the
|
|
772
|
-
* default focus and the editor cancels autocomplete before any
|
|
773
|
-
* focus-leaving key the keymap would act on.
|
|
774
|
-
*/
|
|
775
|
-
keyContext() {
|
|
776
|
-
return {
|
|
777
|
-
focus: this.#focus,
|
|
778
|
-
busy: this.#busy,
|
|
779
|
-
autocompleteOpen: false,
|
|
780
|
-
overlayOpen: this.#overlayOpen,
|
|
781
|
-
};
|
|
782
|
-
}
|
|
783
|
-
/** Run one action (the keymap's / pickers' / mouse's single seam). */
|
|
784
|
-
async dispatch(action) {
|
|
785
|
-
switch (action.type) {
|
|
786
|
-
case "focus":
|
|
787
|
-
this.#focus = action.target;
|
|
788
|
-
this.#applyFocusTarget();
|
|
789
|
-
return;
|
|
790
|
-
case "focus-cycle": {
|
|
791
|
-
const order = ["composer", "list", "transcript"];
|
|
792
|
-
const index = order.indexOf(this.#focus);
|
|
793
|
-
this.#focus = order[(index + action.direction + order.length) % order.length];
|
|
794
|
-
this.#applyFocusTarget();
|
|
795
|
-
return;
|
|
796
|
-
}
|
|
797
|
-
case "move":
|
|
798
|
-
if (this.#focus === "list") {
|
|
799
|
-
this.sidebar.moveCursor(action.delta);
|
|
800
|
-
}
|
|
801
|
-
else if (this.#focus === "transcript") {
|
|
802
|
-
this.#moveTranscriptFocus(action.delta);
|
|
803
|
-
}
|
|
804
|
-
return;
|
|
805
|
-
case "page":
|
|
806
|
-
if (this.#focus === "list") {
|
|
807
|
-
this.sidebar.moveCursor(action.direction * 10);
|
|
808
|
-
}
|
|
809
|
-
else if (this.#focus === "transcript") {
|
|
810
|
-
this.#moveTranscriptFocus(action.direction * 10);
|
|
811
|
-
}
|
|
812
|
-
return;
|
|
813
|
-
case "edge":
|
|
814
|
-
if (this.#focus === "list") {
|
|
815
|
-
this.sidebar.moveCursor(action.to === "top" ? -1_000_000 : 1_000_000);
|
|
816
|
-
}
|
|
817
|
-
else if (this.#focus === "transcript") {
|
|
818
|
-
this.#moveTranscriptFocus(action.to === "top" ? -1_000_000 : 1_000_000);
|
|
819
|
-
}
|
|
820
|
-
return;
|
|
821
|
-
case "prompt-jump":
|
|
822
|
-
this.#promptJump(action.delta);
|
|
823
|
-
return;
|
|
824
|
-
case "toggle-fold":
|
|
825
|
-
this.#foldFocused();
|
|
826
|
-
return;
|
|
827
|
-
case "open":
|
|
828
|
-
if (this.#focus === "list")
|
|
829
|
-
this.sidebar.confirm();
|
|
830
|
-
else if (this.#focus === "transcript")
|
|
831
|
-
this.#foldFocused();
|
|
832
|
-
return;
|
|
833
|
-
case "copy": {
|
|
834
|
-
const index = this.pane.focusIndex;
|
|
835
|
-
if (index === undefined)
|
|
836
|
-
return;
|
|
837
|
-
copyToTerminal(this.#tui.terminal, this.pane.textOf(index));
|
|
838
|
-
this.#flashStatus("Copied");
|
|
839
|
-
return;
|
|
840
|
-
}
|
|
841
|
-
case "fork-here": {
|
|
842
|
-
const id = this.#activeId;
|
|
843
|
-
const index = this.pane.focusIndex;
|
|
844
|
-
if (id === undefined || index === undefined)
|
|
845
|
-
return;
|
|
846
|
-
this.editor.disableSubmit = true;
|
|
847
|
-
try {
|
|
848
|
-
const forked = await this.#client.forkConversation(id, this.pane.entryIdOf(index));
|
|
849
|
-
await this.sidebar.refresh();
|
|
850
|
-
await this.selectConversation(forked.id);
|
|
851
|
-
}
|
|
852
|
-
catch (error) {
|
|
853
|
-
this.pane.showError(describeCommandError("fork", error));
|
|
854
|
-
}
|
|
855
|
-
finally {
|
|
856
|
-
this.editor.disableSubmit = false;
|
|
857
|
-
}
|
|
858
|
-
return;
|
|
859
|
-
}
|
|
860
|
-
case "edit-here":
|
|
861
|
-
await this.#editHere();
|
|
862
|
-
return;
|
|
863
|
-
case "edit-in-vim":
|
|
864
|
-
this.#editInVim();
|
|
865
|
-
return;
|
|
866
|
-
case "picker":
|
|
867
|
-
this.#openPickerFor(action.kind);
|
|
868
|
-
return;
|
|
869
|
-
case "new-conversation":
|
|
870
|
-
await this.createAndSelect();
|
|
871
|
-
return;
|
|
872
|
-
case "cycle-thinking":
|
|
873
|
-
await this.#cycleThinking();
|
|
874
|
-
return;
|
|
875
|
-
case "search":
|
|
876
|
-
this.#beginSearch(action.direction);
|
|
877
|
-
return;
|
|
878
|
-
case "search-next":
|
|
879
|
-
this.#searchNext(action.reverse);
|
|
880
|
-
return;
|
|
881
|
-
case "answer-interaction":
|
|
882
|
-
if (!this.interactions.answerMode)
|
|
883
|
-
this.interactions.beginAnswer();
|
|
884
|
-
return;
|
|
885
|
-
case "cancel-run":
|
|
886
|
-
this.#abortActive();
|
|
887
|
-
return;
|
|
888
|
-
case "redraw":
|
|
889
|
-
this.invalidate();
|
|
890
|
-
this.#tui.requestRender(true);
|
|
891
|
-
return;
|
|
892
|
-
case "quit":
|
|
893
|
-
this.onQuit?.();
|
|
894
|
-
return;
|
|
895
|
-
case "help":
|
|
896
|
-
this.#openHelp();
|
|
897
|
-
return;
|
|
898
|
-
case "command-line":
|
|
899
|
-
this.editor.setText("/");
|
|
900
|
-
return;
|
|
901
|
-
case "run-command":
|
|
902
|
-
await this.runCommand(action.line);
|
|
903
|
-
return;
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
/**
|
|
907
|
-
* The key reference overlay (C5): the launcher wires {@link helpOverlay}
|
|
908
|
-
* to `showHelp(tui, keymap)`. Without the seam the one-line hint flash
|
|
909
|
-
* is the fallback (the unit harness mounts no overlay).
|
|
910
|
-
*/
|
|
911
|
-
#openHelp() {
|
|
912
|
-
if (this.#overlayOpen)
|
|
913
|
-
return;
|
|
914
|
-
if (this.helpOverlay === undefined) {
|
|
915
|
-
this.#flashStatus("tab focus · enter open · y copy · f fork · e edit · / search · ctrl+p conversations");
|
|
916
|
-
return;
|
|
917
|
-
}
|
|
918
|
-
this.#overlayOpen = true;
|
|
919
|
-
this.helpOverlay(() => {
|
|
920
|
-
this.#overlayOpen = false;
|
|
921
|
-
});
|
|
922
|
-
}
|
|
923
|
-
/** pi-tui focus follows the action layer's region. */
|
|
924
|
-
#applyFocusTarget() {
|
|
925
|
-
if (this.#focus === "composer")
|
|
926
|
-
this.#tui.setFocus(this.editor);
|
|
927
|
-
else if (this.#focus === "list")
|
|
928
|
-
this.#tui.setFocus(this.sidebar);
|
|
929
|
-
else
|
|
930
|
-
this.#tui.setFocus(this.pane);
|
|
931
|
-
this.refreshHints();
|
|
932
|
-
}
|
|
933
|
-
/**
|
|
934
|
-
* The launcher's keymap seam (C5 fix): the active keymap's
|
|
935
|
-
* `hints(ctx)` for the current {@link keyContext}. Wired once at
|
|
936
|
-
* launch; {@link refreshHints} re-polls it on every focus change
|
|
937
|
-
* (and the launcher re-polls after each key for vim mode changes).
|
|
938
|
-
*/
|
|
939
|
-
hintsProvider;
|
|
940
|
-
/**
|
|
941
|
-
* Re-poll the keymap's hints into the status bar's steady state.
|
|
942
|
-
* A transient flash or the search overlay owns the bar's right
|
|
943
|
-
* cluster until it restores the snapshot, so only the snapshot is
|
|
944
|
-
* updated while one is up (the restore paints the fresh hints).
|
|
945
|
-
*/
|
|
946
|
-
refreshHints() {
|
|
947
|
-
if (this.#statusBar === undefined || this.hintsProvider === undefined)
|
|
948
|
-
return;
|
|
949
|
-
const hints = this.hintsProvider();
|
|
950
|
-
const base = this.#statusSnapshot ?? { conversation: "", hints: [] };
|
|
951
|
-
const next = { ...base, hints };
|
|
952
|
-
this.#statusSnapshot = next;
|
|
953
|
-
if (this.#search === undefined && this.#statusBar.getState().message === undefined) {
|
|
954
|
-
this.#statusBar.set(next);
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
/** Move the transcript's focused turn, clamped to the turn count. */
|
|
958
|
-
#moveTranscriptFocus(delta) {
|
|
959
|
-
const count = this.pane.turns().length;
|
|
960
|
-
if (count === 0)
|
|
961
|
-
return;
|
|
962
|
-
const current = this.pane.focusIndex;
|
|
963
|
-
if (current === undefined) {
|
|
964
|
-
this.pane.setFocus(delta < 0 ? count - 1 : 0);
|
|
965
|
-
return;
|
|
966
|
-
}
|
|
967
|
-
this.pane.setFocus(Math.max(0, Math.min(current + delta, count - 1)));
|
|
968
|
-
}
|
|
969
|
-
/** Fold/unfold the focused assistant turn (no-op without one). */
|
|
970
|
-
#foldFocused() {
|
|
971
|
-
const index = this.pane.focusIndex;
|
|
972
|
-
if (index !== undefined)
|
|
973
|
-
this.pane.toggleFold(index);
|
|
974
|
-
}
|
|
975
|
-
/** Focus the previous/next user turn (delta -1 backward, 1 forward). */
|
|
976
|
-
#promptJump(delta) {
|
|
977
|
-
const turns = this.pane.turns();
|
|
978
|
-
const current = this.pane.focusIndex ?? (delta < 0 ? turns.length : -1);
|
|
979
|
-
for (let i = current + delta; i >= 0 && i < turns.length; i += delta) {
|
|
980
|
-
if (turns[i].kind === "user") {
|
|
981
|
-
this.pane.setFocus(i);
|
|
982
|
-
return;
|
|
983
|
-
}
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
/** The transcript search overlay: a `/` query line in the status bar. */
|
|
987
|
-
#beginSearch(direction) {
|
|
988
|
-
this.#search = { query: "", direction };
|
|
989
|
-
this.#overlayOpen = true;
|
|
990
|
-
this.#paintSearch();
|
|
991
|
-
}
|
|
992
|
-
/** The search line's status-bar rendering. */
|
|
993
|
-
#paintSearch() {
|
|
994
|
-
const search = this.#search;
|
|
995
|
-
if (this.#statusBar === undefined || search === undefined)
|
|
996
|
-
return;
|
|
997
|
-
const base = this.#statusSnapshot ?? { conversation: "", hints: [] };
|
|
998
|
-
this.#statusBar.set({
|
|
999
|
-
...base,
|
|
1000
|
-
message: `/${search.query}`,
|
|
1001
|
-
});
|
|
1002
|
-
}
|
|
1003
|
-
/** End the search overlay (Enter keeps the match, Esc restores). */
|
|
1004
|
-
#endSearch() {
|
|
1005
|
-
this.#search = undefined;
|
|
1006
|
-
this.#overlayOpen = false;
|
|
1007
|
-
const base = this.#statusSnapshot;
|
|
1008
|
-
if (this.#statusBar !== undefined && base !== undefined)
|
|
1009
|
-
this.#statusBar.set(base);
|
|
1010
|
-
}
|
|
1011
|
-
/** Advance to the next match (reverse flips the remembered direction). */
|
|
1012
|
-
#searchNext(reverse) {
|
|
1013
|
-
const search = this.#search;
|
|
1014
|
-
if (search === undefined || search.query === "")
|
|
1015
|
-
return;
|
|
1016
|
-
const forward = search.direction === "forward";
|
|
1017
|
-
this.#findMatch(search.query, reverse ? !forward : forward);
|
|
1018
|
-
}
|
|
1019
|
-
/** Focus the next turn whose text contains the query (case-insensitive). */
|
|
1020
|
-
#findMatch(query, forward) {
|
|
1021
|
-
const turns = this.pane.turns();
|
|
1022
|
-
if (turns.length === 0)
|
|
1023
|
-
return false;
|
|
1024
|
-
const needle = query.toLowerCase();
|
|
1025
|
-
const delta = forward ? 1 : -1;
|
|
1026
|
-
const current = this.pane.focusIndex ?? (forward ? -1 : turns.length);
|
|
1027
|
-
for (let i = current + delta; i >= 0 && i < turns.length; i += delta) {
|
|
1028
|
-
if (this.pane.textOf(i).toLowerCase().includes(needle)) {
|
|
1029
|
-
this.pane.setFocus(i);
|
|
1030
|
-
return true;
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
|
-
return false;
|
|
1034
|
-
}
|
|
1035
|
-
/** The search overlay's input seam (the launcher routes keys here). */
|
|
1036
|
-
#handleSearchKey(data) {
|
|
1037
|
-
const search = this.#search;
|
|
1038
|
-
if (search === undefined)
|
|
1039
|
-
return false;
|
|
1040
|
-
if (matchesKey(data, Key.escape) || data === "\x07") {
|
|
1041
|
-
// Esc — and C-g, the emacs cancel — close the search.
|
|
1042
|
-
this.#endSearch();
|
|
1043
|
-
return true;
|
|
1044
|
-
}
|
|
1045
|
-
// C-s / C-r walk the matches while the overlay stays open (emacs).
|
|
1046
|
-
if (data === "\x13" || data === "\x12") {
|
|
1047
|
-
if (search.query !== "")
|
|
1048
|
-
this.#findMatch(search.query, data === "\x13");
|
|
1049
|
-
return true;
|
|
1050
|
-
}
|
|
1051
|
-
if (matchesKey(data, Key.enter) || data === "\r") {
|
|
1052
|
-
if (search.query !== "") {
|
|
1053
|
-
this.#findMatch(search.query, search.direction === "forward");
|
|
1054
|
-
}
|
|
1055
|
-
this.#endSearch();
|
|
1056
|
-
return true;
|
|
1057
|
-
}
|
|
1058
|
-
if (matchesKey(data, Key.backspace) || data === "\x7f" || data === "\b") {
|
|
1059
|
-
search.query = search.query.slice(0, -1);
|
|
1060
|
-
this.#paintSearch();
|
|
1061
|
-
return true;
|
|
1062
|
-
}
|
|
1063
|
-
if (data.length === 1 && data >= " ") {
|
|
1064
|
-
search.query += data;
|
|
1065
|
-
this.#paintSearch();
|
|
1066
|
-
return true;
|
|
1067
|
-
}
|
|
1068
|
-
return true; // the overlay owns every key while open
|
|
1069
|
-
}
|
|
1070
|
-
/** Open the conversation/model/command picker for `kind`. */
|
|
1071
|
-
#openPickerFor(kind) {
|
|
1072
|
-
if (this.#overlayOpen)
|
|
1073
|
-
return;
|
|
1074
|
-
this.#overlayOpen = true;
|
|
1075
|
-
const close = () => {
|
|
1076
|
-
this.#overlayOpen = false;
|
|
1077
|
-
};
|
|
1078
|
-
if (kind === "conversation") {
|
|
1079
|
-
const items = this.sidebar.rows.map((row) => ({
|
|
1080
|
-
label: `${" ".repeat(row.depth)}${row.depth > 0 ? "↳ " : ""}${row.name}`,
|
|
1081
|
-
detail: row.age,
|
|
1082
|
-
value: row.id,
|
|
1083
|
-
}));
|
|
1084
|
-
openPicker(this.#tui, items, {
|
|
1085
|
-
title: "Conversations",
|
|
1086
|
-
onPick: (id) => {
|
|
1087
|
-
void this.selectConversation(id).catch((error) => {
|
|
1088
|
-
this.pane.showError(describeCommandError("select", error));
|
|
1089
|
-
});
|
|
1090
|
-
},
|
|
1091
|
-
onClose: close,
|
|
1092
|
-
});
|
|
1093
|
-
return;
|
|
1094
|
-
}
|
|
1095
|
-
if (kind === "model") {
|
|
1096
|
-
this.#client
|
|
1097
|
-
.listModels()
|
|
1098
|
-
.then((response) => {
|
|
1099
|
-
openPicker(this.#tui, response.models.map((model) => ({
|
|
1100
|
-
label: model.id,
|
|
1101
|
-
detail: model.name,
|
|
1102
|
-
value: model.id,
|
|
1103
|
-
})), {
|
|
1104
|
-
title: "Models",
|
|
1105
|
-
onPick: (modelId) => {
|
|
1106
|
-
void this.dispatch({ type: "run-command", line: `/model ${modelId}` });
|
|
1107
|
-
},
|
|
1108
|
-
onClose: close,
|
|
1109
|
-
});
|
|
1110
|
-
})
|
|
1111
|
-
.catch((error) => {
|
|
1112
|
-
close();
|
|
1113
|
-
this.pane.showError(describeCommandError("listModels", error));
|
|
1114
|
-
});
|
|
1115
|
-
return;
|
|
1116
|
-
}
|
|
1117
|
-
this.#client
|
|
1118
|
-
.getCommands()
|
|
1119
|
-
.then((response) => {
|
|
1120
|
-
openPicker(this.#tui, response.commands.map((command) => ({
|
|
1121
|
-
label: `/${command.name}`,
|
|
1122
|
-
// An EXPAND entry's detail carries its argument-hint too (Task
|
|
1123
|
-
// 2c — the hint IS the affordance: `/review <focus>`); endpoint
|
|
1124
|
-
// rows stay exactly as before.
|
|
1125
|
-
detail: command.kind === "expand" && command.argsHint !== undefined
|
|
1126
|
-
? `${command.description} ${command.argsHint}`
|
|
1127
|
-
: command.description,
|
|
1128
|
-
value: command,
|
|
1129
|
-
})), {
|
|
1130
|
-
title: "Commands",
|
|
1131
|
-
onPick: (command) => {
|
|
1132
|
-
this.#focus = "composer";
|
|
1133
|
-
this.#applyFocusTarget();
|
|
1134
|
-
this.editor.setText(commandInsertText(command));
|
|
1135
|
-
},
|
|
1136
|
-
onClose: close,
|
|
1137
|
-
});
|
|
1138
|
-
})
|
|
1139
|
-
.catch((error) => {
|
|
1140
|
-
close();
|
|
1141
|
-
this.pane.showError(describeCommandError("getCommands", error));
|
|
1142
|
-
});
|
|
1143
|
-
}
|
|
1144
|
-
/** The thinking cycle (off → minimal → low → medium → high → xhigh → off). */
|
|
1145
|
-
async #cycleThinking() {
|
|
1146
|
-
const id = this.#activeId;
|
|
1147
|
-
if (id === undefined)
|
|
1148
|
-
return;
|
|
1149
|
-
const order = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
1150
|
-
const index = order.indexOf(this.#thinkingLevel);
|
|
1151
|
-
const next = order[(index + 1) % order.length];
|
|
1152
|
-
try {
|
|
1153
|
-
const response = await this.#client.configure(id, { thinking: next });
|
|
1154
|
-
this.#thinkingLevel = response.thinkingLevel;
|
|
1155
|
-
this.#flashStatus(`thinking: ${response.thinkingLevel}`);
|
|
1156
|
-
}
|
|
1157
|
-
catch (error) {
|
|
1158
|
-
this.pane.showError(describeCommandError("configure", error));
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
/**
|
|
1162
|
-
* Edit-from-here (the web B4 Edit rule): fork at the PREVIOUS turn so
|
|
1163
|
-
* the message is re-entered; on the first turn there is no fork point,
|
|
1164
|
-
* so a fresh conversation is created. Either way the text prefills the
|
|
1165
|
-
* durable draft (`patchMeta`) and the local composer. The fork point
|
|
1166
|
-
* comes from the TURNS list (webui TurnList's rule): pi-durable mints
|
|
1167
|
-
* ids from ONE global counter (entries, conversations, submissions,
|
|
1168
|
-
* tasks), so entry-id arithmetic (`entryId - 1`) cannot locate the
|
|
1169
|
-
* previous entry.
|
|
1170
|
-
*/
|
|
1171
|
-
async #editHere() {
|
|
1172
|
-
const id = this.#activeId;
|
|
1173
|
-
const index = this.pane.focusIndex;
|
|
1174
|
-
if (id === undefined || index === undefined)
|
|
1175
|
-
return;
|
|
1176
|
-
const turn = this.pane.turns()[index];
|
|
1177
|
-
if (turn === undefined || turn.kind !== "user") {
|
|
1178
|
-
this.pane.showError("edit: focus a user turn");
|
|
1179
|
-
return;
|
|
1180
|
-
}
|
|
1181
|
-
const text = this.pane.textOf(index);
|
|
1182
|
-
this.editor.disableSubmit = true;
|
|
1183
|
-
try {
|
|
1184
|
-
const newId = index === 0
|
|
1185
|
-
? (await this.#client.createConversation()).id
|
|
1186
|
-
: (await this.#client.forkConversation(id, this.pane.entryIdOf(index - 1))).id;
|
|
1187
|
-
await this.#client.patchMeta(newId, { draft: { text } });
|
|
1188
|
-
await this.sidebar.refresh();
|
|
1189
|
-
await this.selectConversation(newId);
|
|
1190
|
-
this.editor.setText(text);
|
|
1191
|
-
this.#focus = "composer";
|
|
1192
|
-
this.#applyFocusTarget();
|
|
1193
|
-
}
|
|
1194
|
-
catch (error) {
|
|
1195
|
-
this.pane.showError(describeCommandError("edit", error));
|
|
1196
|
-
}
|
|
1197
|
-
finally {
|
|
1198
|
-
this.editor.disableSubmit = false;
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
/**
|
|
1202
|
-
* The vim command line's command table (Task C4). A `/`-prefixed line
|
|
1203
|
-
* (or any name outside the table) routes through the existing slash
|
|
1204
|
-
* catalog — the table holds only the vim-native commands.
|
|
1205
|
-
*/
|
|
1206
|
-
async runCommand(line) {
|
|
1207
|
-
if (line.startsWith("/")) {
|
|
1208
|
-
await this.#runSlashCommand(line);
|
|
1209
|
-
return;
|
|
1210
|
-
}
|
|
1211
|
-
const space = line.indexOf(" ");
|
|
1212
|
-
const name = space === -1 ? line : line.slice(0, space);
|
|
1213
|
-
const arg = space === -1 ? "" : line.slice(space + 1).trim();
|
|
1214
|
-
switch (name) {
|
|
1215
|
-
case "q":
|
|
1216
|
-
case "quit":
|
|
1217
|
-
this.onQuit?.();
|
|
1218
|
-
return;
|
|
1219
|
-
case "new":
|
|
1220
|
-
await this.createAndSelect();
|
|
1221
|
-
return;
|
|
1222
|
-
case "fork":
|
|
1223
|
-
await this.dispatch({ type: "fork-here" });
|
|
1224
|
-
return;
|
|
1225
|
-
case "ls":
|
|
1226
|
-
this.#openPickerFor("conversation");
|
|
1227
|
-
return;
|
|
1228
|
-
case "model":
|
|
1229
|
-
if (arg === "") {
|
|
1230
|
-
this.pane.showError("usage: model <provider/id>");
|
|
1231
|
-
return;
|
|
1232
|
-
}
|
|
1233
|
-
await this.#runSlashCommand(`/model ${arg}`);
|
|
1234
|
-
return;
|
|
1235
|
-
case "set": {
|
|
1236
|
-
const thinking = /^thinking=(\S+)$/.exec(arg);
|
|
1237
|
-
if (thinking === null) {
|
|
1238
|
-
this.pane.showError("usage: set thinking=<level>");
|
|
1239
|
-
return;
|
|
1240
|
-
}
|
|
1241
|
-
await this.#runSlashCommand(`/thinking ${thinking[1]}`);
|
|
1242
|
-
return;
|
|
1243
|
-
}
|
|
1244
|
-
case "b": {
|
|
1245
|
-
if (arg === "") {
|
|
1246
|
-
this.pane.showError("usage: b <id|name>");
|
|
1247
|
-
return;
|
|
1248
|
-
}
|
|
1249
|
-
const hit = /^\d+$/.test(arg)
|
|
1250
|
-
? this.sidebar.rows.find((row) => row.id === Number(arg))
|
|
1251
|
-
: this.sidebar.rows.find((row) => row.name.toLowerCase().includes(arg.toLowerCase()));
|
|
1252
|
-
if (hit === undefined) {
|
|
1253
|
-
this.#flashStatus("no match");
|
|
1254
|
-
return;
|
|
1255
|
-
}
|
|
1256
|
-
await this.selectConversation(hit.id);
|
|
1257
|
-
return;
|
|
1258
|
-
}
|
|
1259
|
-
default:
|
|
1260
|
-
await this.#runSlashCommand(`/${line}`);
|
|
1261
|
-
}
|
|
1262
|
-
}
|
|
1263
|
-
/**
|
|
1264
|
-
* Edit the composer draft in a vim-family editor (Task C4): the TUI
|
|
1265
|
-
* suspends while vim owns the terminal. No vim found → a status
|
|
1266
|
-
* message and the draft stays untouched; nothing is spawned.
|
|
1267
|
-
*/
|
|
1268
|
-
#editInVim() {
|
|
1269
|
-
const editor = this.#resolveVim();
|
|
1270
|
-
if (editor === undefined) {
|
|
1271
|
-
this.#flashStatus("no vim found");
|
|
1272
|
-
return;
|
|
1273
|
-
}
|
|
1274
|
-
const tui = this.#tui;
|
|
1275
|
-
const result = editInVim({ text: this.editor.getText(), editor, tui });
|
|
1276
|
-
if (!result.ok) {
|
|
1277
|
-
this.pane.showError(result.reason);
|
|
1278
|
-
return;
|
|
1279
|
-
}
|
|
1280
|
-
this.editor.setText(result.text);
|
|
1281
|
-
this.#focus = "composer";
|
|
1282
|
-
this.#applyFocusTarget();
|
|
1283
|
-
// Focus and mode must agree: the composer is focused, so the vim
|
|
1284
|
-
// keymap returns to INSERT (the launcher wires this seam).
|
|
1285
|
-
this.enterInsertMode?.();
|
|
1286
|
-
}
|
|
1287
|
-
/** The launcher's vim-keymap seam: re-enter INSERT after edit-in-vim. */
|
|
1288
|
-
enterInsertMode;
|
|
1289
|
-
/**
|
|
1290
|
-
* The launcher's help-overlay seam (C5): opens the key reference for
|
|
1291
|
-
* the active keymap; the callback fires when the overlay closes.
|
|
1292
|
-
*/
|
|
1293
|
-
helpOverlay;
|
|
1294
|
-
/**
|
|
1295
|
-
* The slash catalog's command names (C3's picker source, cached) —
|
|
1296
|
-
* the launcher feeds them to the vim `:` Tab completion.
|
|
1297
|
-
*/
|
|
1298
|
-
async slashCommandNames() {
|
|
1299
|
-
return this.#catalog.names();
|
|
1300
|
-
}
|
|
1301
|
-
/**
|
|
1302
|
-
* The vim keymap's status-bar seam (Task C4): the app polls the keymap
|
|
1303
|
-
* after each key. `commandLine` set → the bar shows the `:` line;
|
|
1304
|
-
* otherwise the steady state is restored with the keymap's mode chip
|
|
1305
|
-
* in the `mode` segment.
|
|
1306
|
-
*/
|
|
1307
|
-
renderCommandLine(commandLine, modeLabel) {
|
|
1308
|
-
const base = this.#statusSnapshot;
|
|
1309
|
-
if (this.#statusBar === undefined || base === undefined)
|
|
1310
|
-
return;
|
|
1311
|
-
if (commandLine !== undefined) {
|
|
1312
|
-
// A dedicated non-expiring segment — the `:` line must outlive the
|
|
1313
|
-
// flash TTL for the whole command mode.
|
|
1314
|
-
this.#statusBar.set({ ...base, commandLine });
|
|
1315
|
-
return;
|
|
1316
|
-
}
|
|
1317
|
-
this.#statusBar.set({ ...base, commandLine: undefined, mode: modeLabel ?? base.mode });
|
|
1318
|
-
}
|
|
1319
|
-
render(width) {
|
|
1320
|
-
return this.#layout.render(width);
|
|
1321
|
-
}
|
|
1322
|
-
invalidate() {
|
|
1323
|
-
this.#layout.invalidate();
|
|
1324
|
-
}
|
|
1325
|
-
}
|