@sigrun/tui 0.1.0 → 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 +12 -0
- package/package.json +3 -3
- package/dist/actions.d.ts +0 -78
- package/dist/actions.js +0 -12
- package/dist/api.d.ts +0 -147
- package/dist/api.js +0 -185
- 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 -133
- package/dist/keymaps/standard.d.ts +0 -17
- package/dist/keymaps/standard.js +0 -131
- 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 -254
- package/dist/launch.d.ts +0 -149
- package/dist/launch.js +0 -433
- package/dist/sse.d.ts +0 -101
- package/dist/sse.js +0 -374
- package/dist/theme.d.ts +0 -51
- package/dist/theme.js +0 -105
- package/dist/ui/ambient.d.ts +0 -81
- package/dist/ui/ambient.js +0 -301
- package/dist/ui/app.d.ts +0 -209
- package/dist/ui/app.js +0 -1241
- 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 -335
- package/dist/ui/picker.d.ts +0 -26
- package/dist/ui/picker.js +0 -130
- package/dist/ui/sidebar.d.ts +0 -76
- package/dist/ui/sidebar.js +0 -205
- package/dist/ui/status-bar.d.ts +0 -77
- package/dist/ui/status-bar.js +0 -146
|
@@ -1,463 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The conversation pane (Task 6; restyled in C2, UI refresh): renders a
|
|
3
|
-
* `ConversationViewWire`'s entries as TURNS (B1's `groupTurns`) instead
|
|
4
|
-
* of raw entries — a user turn is the accent `▎` bar on every line, an
|
|
5
|
-
* assistant turn is prose plus ONE collapsible activity line
|
|
6
|
-
* (`▸ Thinking · 2 tools`; `▾` when open) that unfolds into the thinking
|
|
7
|
-
* text and `✓ tool preview` rows (`✗` error, `·` no result yet — the
|
|
8
|
-
* wire carries no per-tool running marker, so `·` covers pending too).
|
|
9
|
-
* `pi.compaction` renders the centred `── context compacted ──` divider. The `[tool]`/`[thinking]` tags of
|
|
10
|
-
* the entry-era pane are gone.
|
|
11
|
-
*
|
|
12
|
-
* Task 2c: an EXPANDED input (`/skill:name`, a prompt template) is one
|
|
13
|
-
* entry (`sigrun.user.expanded`, `data {typed, command}`, the expanded
|
|
14
|
-
* text on `model`) that groups as a USER turn — the body is the TYPED
|
|
15
|
-
* command with a dim `▸ expanded from /name` chip line that folds the
|
|
16
|
-
* full admitted text open/closed (closed by default).
|
|
17
|
-
*
|
|
18
|
-
* Focus (the C3 action layer's seam): `focusIndex` names the focused
|
|
19
|
-
* turn (`undefined` = none — the ScrollView follows the end), the
|
|
20
|
-
* focused turn's FIRST line carries the `surface2` background and a `›`
|
|
21
|
-
* gutter marker; `lineRangeOf` reports the turn's line offsets from the
|
|
22
|
-
* last render for `ScrollView.scrollTo`; `textOf`/`entryIdOf` are the
|
|
23
|
-
* copy payload and wire identity of a turn.
|
|
24
|
-
*
|
|
25
|
-
* Mouse (additive — the pane is fully keyboard-usable): each activity
|
|
26
|
-
* line is a `MouseRegion` whose left click toggles the fold; a click on
|
|
27
|
-
* a turn's body sets the focus to that turn.
|
|
28
|
-
*
|
|
29
|
-
* The SSE contract (Task 4): every snapshot/frame is a FULL revision, so
|
|
30
|
-
* {@link showView} REPLACES the rendered state wholesale. Folds and
|
|
31
|
-
* focus are keyed by turn INDEX within the latest view — a replacement
|
|
32
|
-
* re-derives the turns and keeps the index-based state (the action
|
|
33
|
-
* layer owns re-targeting when the structure shifts).
|
|
34
|
-
*
|
|
35
|
-
* Unknown entry kinds render a fail-visible fallback naming the kind
|
|
36
|
-
* (the webui's rule — a new server-side kind must never vanish silently).
|
|
37
|
-
*
|
|
38
|
-
* Error/notice lines (Task 7): the composer surfaces its immediate
|
|
39
|
-
* failures as `[error]`/`[notice]` rows. They are NOT stream state, so
|
|
40
|
-
* {@link showView}'s wholesale replacement preserves them (fix wave F4:
|
|
41
|
-
* the shell discards them on every conversation swap via
|
|
42
|
-
* {@link ConversationPane.clearNotices}).
|
|
43
|
-
*/
|
|
44
|
-
import { Container, Markdown, MouseRegion, Text, } from "@earendil-works/pi-tui";
|
|
45
|
-
import { activityLabel, expandedUserEntry, groupTurns, } from "@sigrun/shared/turns";
|
|
46
|
-
import { plainTuiTheme } from "../theme.js";
|
|
47
|
-
/**
|
|
48
|
-
* The message's text: string content as-is, or its text blocks joined
|
|
49
|
-
* with a blank line (the webui `messageText` rule, mirrored).
|
|
50
|
-
*/
|
|
51
|
-
function messageText(message) {
|
|
52
|
-
const content = message.content;
|
|
53
|
-
if (typeof content === "string")
|
|
54
|
-
return content;
|
|
55
|
-
if (content === undefined)
|
|
56
|
-
return "";
|
|
57
|
-
return content
|
|
58
|
-
.filter((block) => block.type === "text" && typeof block.text === "string")
|
|
59
|
-
.map((block) => block.text)
|
|
60
|
-
.join("\n\n");
|
|
61
|
-
}
|
|
62
|
-
/** The message's non-text/non-thinking parts (uploads), as path markers. */
|
|
63
|
-
function attachmentMarkers(message) {
|
|
64
|
-
const content = message.content;
|
|
65
|
-
if (typeof content === "string" || content === undefined)
|
|
66
|
-
return [];
|
|
67
|
-
return content
|
|
68
|
-
.filter((block) => block.type !== "text" && block.type !== "thinking")
|
|
69
|
-
.map((block) => `[${block.type ?? "part"}: ${block.path ?? "?"}]`);
|
|
70
|
-
}
|
|
71
|
-
/** A turn's fallback body (an `other` kind's text), if any. */
|
|
72
|
-
function entryText(entry) {
|
|
73
|
-
return (entry.model ?? [])
|
|
74
|
-
.map((message) => messageText(message))
|
|
75
|
-
.filter((part) => part !== "")
|
|
76
|
-
.join(" ");
|
|
77
|
-
}
|
|
78
|
-
/** A user entry's model-facing text (message texts + upload markers) — the
|
|
79
|
-
* plain-turn body, and the EXPANDED text an expanded turn's chip reveals. */
|
|
80
|
-
function userModelText(entry) {
|
|
81
|
-
const parts = (entry.model ?? []).flatMap((message) => [
|
|
82
|
-
messageText(message),
|
|
83
|
-
...attachmentMarkers(message),
|
|
84
|
-
]);
|
|
85
|
-
return parts.filter((part) => part !== "").join("\n");
|
|
86
|
-
}
|
|
87
|
-
/** The markdown theme rides the TuiTheme's token colours. */
|
|
88
|
-
function markdownTheme(theme) {
|
|
89
|
-
return {
|
|
90
|
-
heading: (text) => theme.bold(theme.fg("accent")(text)),
|
|
91
|
-
link: theme.fg("accent"),
|
|
92
|
-
linkUrl: theme.dim,
|
|
93
|
-
code: theme.fg("code"),
|
|
94
|
-
codeBlock: theme.fg("code"),
|
|
95
|
-
codeBlockBorder: theme.fg("border"),
|
|
96
|
-
quote: theme.italic,
|
|
97
|
-
quoteBorder: theme.fg("border"),
|
|
98
|
-
hr: theme.fg("border"),
|
|
99
|
-
listBullet: theme.fg("accent"),
|
|
100
|
-
bold: theme.bold,
|
|
101
|
-
italic: theme.italic,
|
|
102
|
-
strikethrough: (text) => text,
|
|
103
|
-
underline: (text) => text,
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
/** Pad/centre the compaction divider to the render width. */
|
|
107
|
-
function compactionLine(theme) {
|
|
108
|
-
return new (class {
|
|
109
|
-
render(width) {
|
|
110
|
-
const label = "── context compacted ──";
|
|
111
|
-
const pad = Math.max(0, Math.floor((width - label.length) / 2));
|
|
112
|
-
return [theme.dim(`${" ".repeat(pad)}${label}`)];
|
|
113
|
-
}
|
|
114
|
-
invalidate() { }
|
|
115
|
-
})();
|
|
116
|
-
}
|
|
117
|
-
function noticeComponent(notice) {
|
|
118
|
-
return new Text(`[${notice.kind}] ${notice.text}`, 0, 0);
|
|
119
|
-
}
|
|
120
|
-
/** The notice backlog cap (fix wave A5 — most recent win). */
|
|
121
|
-
const MAX_NOTICES = 10;
|
|
122
|
-
/**
|
|
123
|
-
* A one-line render slot whose text is painted by the owner each frame
|
|
124
|
-
* (pi-tui's `Text` drops whitespace-only text and caches by content —
|
|
125
|
-
* neither fits a line whose text changes under a `MouseRegion`).
|
|
126
|
-
*/
|
|
127
|
-
class RenderSlot {
|
|
128
|
-
paint = () => "";
|
|
129
|
-
render(width) {
|
|
130
|
-
return [this.paint(width)];
|
|
131
|
-
}
|
|
132
|
-
invalidate() { }
|
|
133
|
-
}
|
|
134
|
-
/** The blank separator between turns. */
|
|
135
|
-
const TURN_GAP = 1;
|
|
136
|
-
export class ConversationPane {
|
|
137
|
-
#container = new Container();
|
|
138
|
-
#theme;
|
|
139
|
-
#markdownTheme;
|
|
140
|
-
#notices = [];
|
|
141
|
-
/** The notices' mounted components, aligned with `#notices`. */
|
|
142
|
-
#noticeComponents = [];
|
|
143
|
-
#turns = [];
|
|
144
|
-
#focus;
|
|
145
|
-
/** Fold state by turn index (assistant turns only fold). */
|
|
146
|
-
#open = new Set();
|
|
147
|
-
/** Line spans from the last render, aligned with `#turns`. */
|
|
148
|
-
#ranges = [];
|
|
149
|
-
/** The mounted turn records, aligned with `#turns`. */
|
|
150
|
-
#mounted = [];
|
|
151
|
-
constructor(options) {
|
|
152
|
-
this.#theme = options?.theme ?? plainTuiTheme();
|
|
153
|
-
this.#markdownTheme = markdownTheme(this.#theme);
|
|
154
|
-
}
|
|
155
|
-
/** The current view's turns (B1 grouping), in render order. */
|
|
156
|
-
turns() {
|
|
157
|
-
return this.#turns;
|
|
158
|
-
}
|
|
159
|
-
/** The focused turn index; `undefined` = none (follow the end). */
|
|
160
|
-
get focusIndex() {
|
|
161
|
-
return this.#focus;
|
|
162
|
-
}
|
|
163
|
-
/** Focus a turn (or clear with `undefined`); repaints the marker. */
|
|
164
|
-
setFocus(index) {
|
|
165
|
-
if (index !== undefined && (index < 0 || index >= this.#turns.length)) {
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
if (index === this.#focus)
|
|
169
|
-
return;
|
|
170
|
-
this.#focus = index;
|
|
171
|
-
this.#container.invalidate();
|
|
172
|
-
}
|
|
173
|
-
/** Open/close an assistant turn's activity fold — or an expanded user
|
|
174
|
-
* turn's chip disclosure (Task 2c: the full admitted text hides behind
|
|
175
|
-
* the `expanded from /name` line, same fold state, same seam). */
|
|
176
|
-
toggleFold(index) {
|
|
177
|
-
const turn = this.#turns[index];
|
|
178
|
-
if (turn === undefined)
|
|
179
|
-
return;
|
|
180
|
-
const foldable = turn.kind === "assistant" ||
|
|
181
|
-
(turn.kind === "user" && expandedUserEntry(turn.entry) !== undefined);
|
|
182
|
-
if (!foldable)
|
|
183
|
-
return;
|
|
184
|
-
if (this.#open.has(index)) {
|
|
185
|
-
this.#open.delete(index);
|
|
186
|
-
}
|
|
187
|
-
else {
|
|
188
|
-
this.#open.add(index);
|
|
189
|
-
}
|
|
190
|
-
this.#rebuild();
|
|
191
|
-
}
|
|
192
|
-
/**
|
|
193
|
-
* The turn's line offsets from the last render (for
|
|
194
|
-
* `ScrollView.scrollTo`): `start` is the turn's first line, `end` the
|
|
195
|
-
* first line after it. Undefined before a render or out of range.
|
|
196
|
-
*/
|
|
197
|
-
lineRangeOf(index) {
|
|
198
|
-
return this.#ranges[index];
|
|
199
|
-
}
|
|
200
|
-
/** The turn's markdown source (the copy payload). */
|
|
201
|
-
textOf(index) {
|
|
202
|
-
const turn = this.#turns[index];
|
|
203
|
-
if (turn === undefined)
|
|
204
|
-
return "";
|
|
205
|
-
switch (turn.kind) {
|
|
206
|
-
case "user": {
|
|
207
|
-
// An expanded turn copies the TYPED command (what the user
|
|
208
|
-
// wrote — re-submitting it re-expands server-side).
|
|
209
|
-
const expanded = expandedUserEntry(turn.entry);
|
|
210
|
-
if (expanded !== undefined)
|
|
211
|
-
return expanded.typed;
|
|
212
|
-
return userModelText(turn.entry);
|
|
213
|
-
}
|
|
214
|
-
case "assistant":
|
|
215
|
-
return turn.text;
|
|
216
|
-
case "other":
|
|
217
|
-
return entryText(turn.entry);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
/** The turn's wire identity: the user entry id / assistant lastEntryId. */
|
|
221
|
-
entryIdOf(index) {
|
|
222
|
-
const turn = this.#turns[index];
|
|
223
|
-
if (turn === undefined)
|
|
224
|
-
return -1;
|
|
225
|
-
switch (turn.kind) {
|
|
226
|
-
case "user":
|
|
227
|
-
return turn.entry.id;
|
|
228
|
-
case "assistant":
|
|
229
|
-
return turn.lastEntryId;
|
|
230
|
-
case "other":
|
|
231
|
-
return turn.entry.id;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
/**
|
|
235
|
-
* Replace the pane with the projection of `wire` (the SSE rule: a full
|
|
236
|
-
* revision replaces; a reconnect's snapshot subsumes missed frames).
|
|
237
|
-
* Composer notices are not stream state: they re-render after the
|
|
238
|
-
* turns.
|
|
239
|
-
*/
|
|
240
|
-
showView(wire) {
|
|
241
|
-
this.#turns = groupTurns(wire.entries);
|
|
242
|
-
if (this.#focus !== undefined && this.#focus >= this.#turns.length) {
|
|
243
|
-
this.#focus = undefined;
|
|
244
|
-
}
|
|
245
|
-
this.#rebuild();
|
|
246
|
-
}
|
|
247
|
-
/** Append an `[error]` line (a failed composer action's surface). */
|
|
248
|
-
showError(text) {
|
|
249
|
-
this.#appendNotice({ kind: "error", text });
|
|
250
|
-
}
|
|
251
|
-
/** Append a `[notice]` line (an outcome, e.g. an abort that settled). */
|
|
252
|
-
showNotice(text) {
|
|
253
|
-
this.#appendNotice({ kind: "notice", text });
|
|
254
|
-
}
|
|
255
|
-
/**
|
|
256
|
-
* Discard all composer-surfaced lines (fix wave F4): the shell calls
|
|
257
|
-
* this on every conversation swap so a stale error never renders in
|
|
258
|
-
* the new conversation.
|
|
259
|
-
*/
|
|
260
|
-
clearNotices() {
|
|
261
|
-
if (this.#notices.length === 0)
|
|
262
|
-
return;
|
|
263
|
-
this.#notices.length = 0;
|
|
264
|
-
for (const component of this.#noticeComponents.splice(0)) {
|
|
265
|
-
this.#container.removeChild(component);
|
|
266
|
-
}
|
|
267
|
-
this.#container.invalidate();
|
|
268
|
-
}
|
|
269
|
-
#appendNotice(notice) {
|
|
270
|
-
this.#notices.push(notice);
|
|
271
|
-
const component = noticeComponent(notice);
|
|
272
|
-
this.#noticeComponents.push(component);
|
|
273
|
-
this.#container.addChild(component);
|
|
274
|
-
while (this.#notices.length > MAX_NOTICES) {
|
|
275
|
-
this.#notices.shift();
|
|
276
|
-
const oldest = this.#noticeComponents.shift();
|
|
277
|
-
if (oldest === undefined)
|
|
278
|
-
break;
|
|
279
|
-
this.#container.removeChild(oldest);
|
|
280
|
-
}
|
|
281
|
-
this.#container.invalidate();
|
|
282
|
-
}
|
|
283
|
-
/** Rebuild the container from the current turns + folds + notices. */
|
|
284
|
-
#rebuild() {
|
|
285
|
-
this.#container.clear();
|
|
286
|
-
this.#noticeComponents.length = 0;
|
|
287
|
-
this.#mounted = this.#turns.map((turn, index) => this.#mountTurn(turn, index));
|
|
288
|
-
for (const mounted of this.#mounted) {
|
|
289
|
-
for (const component of mounted.components) {
|
|
290
|
-
this.#container.addChild(component);
|
|
291
|
-
}
|
|
292
|
-
this.#container.addChild(new RenderSlot()); // turn gap
|
|
293
|
-
}
|
|
294
|
-
for (const notice of this.#notices) {
|
|
295
|
-
const component = noticeComponent(notice);
|
|
296
|
-
this.#noticeComponents.push(component);
|
|
297
|
-
this.#container.addChild(component);
|
|
298
|
-
}
|
|
299
|
-
this.#container.invalidate();
|
|
300
|
-
}
|
|
301
|
-
/** Project one turn into its components (mouse regions included). */
|
|
302
|
-
#mountTurn(turn, index) {
|
|
303
|
-
const components = [];
|
|
304
|
-
const focusThis = () => {
|
|
305
|
-
this.setFocus(index);
|
|
306
|
-
return { handled: true };
|
|
307
|
-
};
|
|
308
|
-
const body = (component) => new MouseRegion(component, (event) => event.type === "click" && event.button === "left"
|
|
309
|
-
? focusThis()
|
|
310
|
-
: undefined);
|
|
311
|
-
switch (turn.kind) {
|
|
312
|
-
case "user": {
|
|
313
|
-
const expanded = expandedUserEntry(turn.entry);
|
|
314
|
-
if (expanded !== undefined) {
|
|
315
|
-
// The expanded turn (Task 2c): the TYPED command is the body
|
|
316
|
-
// (the accent bar still marks it a user turn); a dim chip line
|
|
317
|
-
// folds the full admitted text — closed by default, or
|
|
318
|
-
// `/skill:greet hej` would dump the whole skill body.
|
|
319
|
-
components.push(body(new Markdown(expanded.typed, 0, 0, this.#markdownTheme)));
|
|
320
|
-
const slot = new RenderSlot();
|
|
321
|
-
slot.paint = () => this.#theme.dim(`${this.#open.has(index) ? "▾" : "▸"} expanded from /${expanded.command ?? "…"}`);
|
|
322
|
-
components.push(new MouseRegion(slot, (event) => {
|
|
323
|
-
if (event.type === "click" && event.button === "left") {
|
|
324
|
-
this.toggleFold(index);
|
|
325
|
-
return { handled: true };
|
|
326
|
-
}
|
|
327
|
-
return undefined;
|
|
328
|
-
}));
|
|
329
|
-
if (this.#open.has(index)) {
|
|
330
|
-
for (const line of userModelText(turn.entry).split("\n")) {
|
|
331
|
-
components.push(new Text(this.#theme.dim(line), 0, 0));
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
break;
|
|
335
|
-
}
|
|
336
|
-
const text = this.textOf(index);
|
|
337
|
-
if (text !== "") {
|
|
338
|
-
components.push(body(new Markdown(text, 0, 0, this.#markdownTheme)));
|
|
339
|
-
}
|
|
340
|
-
break;
|
|
341
|
-
}
|
|
342
|
-
case "assistant": {
|
|
343
|
-
if (turn.text.trim() !== "") {
|
|
344
|
-
components.push(body(new Markdown(turn.text, 0, 0, this.#markdownTheme)));
|
|
345
|
-
}
|
|
346
|
-
const label = activityLabel(turn);
|
|
347
|
-
if (label !== "") {
|
|
348
|
-
// The slot renders one line; the fold marker + label are
|
|
349
|
-
// painted per frame (the fold state changes after mount, and
|
|
350
|
-
// a Text's content cache would pin the stale marker).
|
|
351
|
-
const slot = new RenderSlot();
|
|
352
|
-
slot.paint = () => this.#theme.dim(`${this.#open.has(index) ? "▾" : "▸"} ${label}`);
|
|
353
|
-
components.push(new MouseRegion(slot, (event) => {
|
|
354
|
-
if (event.type === "click" && event.button === "left") {
|
|
355
|
-
this.toggleFold(index);
|
|
356
|
-
return { handled: true };
|
|
357
|
-
}
|
|
358
|
-
return undefined;
|
|
359
|
-
}));
|
|
360
|
-
if (this.#open.has(index)) {
|
|
361
|
-
for (const thinking of turn.thinking) {
|
|
362
|
-
if (thinking.trim() === "")
|
|
363
|
-
continue;
|
|
364
|
-
components.push(new Text(this.#theme.dim(this.#theme.italic(thinking)), 0, 0));
|
|
365
|
-
}
|
|
366
|
-
for (const tool of turn.tools) {
|
|
367
|
-
const glyph = tool.isError
|
|
368
|
-
? "✗"
|
|
369
|
-
: tool.resultEntryId === undefined
|
|
370
|
-
? "·"
|
|
371
|
-
: "✓";
|
|
372
|
-
const preview = tool.preview === "" ? "" : ` ${tool.preview}`;
|
|
373
|
-
const row = `${glyph} ${tool.name}${preview}`;
|
|
374
|
-
components.push(new Text(tool.isError ? this.#theme.fg("muted")(row) : row, 0, 0));
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
break;
|
|
379
|
-
}
|
|
380
|
-
case "other": {
|
|
381
|
-
if (turn.entry.kind === "pi.compaction") {
|
|
382
|
-
components.push(compactionLine(this.#theme));
|
|
383
|
-
break;
|
|
384
|
-
}
|
|
385
|
-
const text = entryText(turn.entry);
|
|
386
|
-
if (turn.entry.kind === "pi.system") {
|
|
387
|
-
if (text !== "")
|
|
388
|
-
components.push(new Text(text, 0, 0));
|
|
389
|
-
}
|
|
390
|
-
else {
|
|
391
|
-
// Fail visible (the webui rule): name the kind plus any text.
|
|
392
|
-
const suffix = text === "" ? "" : ` — ${text}`;
|
|
393
|
-
components.push(new Text(`unsupported entry kind: ${turn.entry.kind}${suffix}`, 0, 0));
|
|
394
|
-
}
|
|
395
|
-
break;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
return { start: 0, end: 0, components, heights: [] };
|
|
399
|
-
}
|
|
400
|
-
render(width) {
|
|
401
|
-
const lines = this.#container.render(width);
|
|
402
|
-
this.#recordRanges(lines, width);
|
|
403
|
-
return this.#decorate(lines, width);
|
|
404
|
-
}
|
|
405
|
-
/**
|
|
406
|
-
* Record each turn's line span from the render: every turn is followed
|
|
407
|
-
* by one blank gap line (see `#rebuild`), so heights + TURN_GAP walk
|
|
408
|
-
* the offsets deterministically.
|
|
409
|
-
*/
|
|
410
|
-
#recordRanges(_lines, width) {
|
|
411
|
-
const ranges = [];
|
|
412
|
-
let offset = 0;
|
|
413
|
-
for (const [index, mounted] of this.#mounted.entries()) {
|
|
414
|
-
let height = 0;
|
|
415
|
-
mounted.heights.length = 0;
|
|
416
|
-
for (const component of mounted.components) {
|
|
417
|
-
const componentHeight = component.render(width).length;
|
|
418
|
-
mounted.heights.push(componentHeight);
|
|
419
|
-
height += componentHeight;
|
|
420
|
-
}
|
|
421
|
-
mounted.start = offset;
|
|
422
|
-
mounted.end = offset + height;
|
|
423
|
-
ranges.push({ start: mounted.start, end: mounted.end });
|
|
424
|
-
offset += height + TURN_GAP;
|
|
425
|
-
}
|
|
426
|
-
this.#ranges = ranges;
|
|
427
|
-
}
|
|
428
|
-
/**
|
|
429
|
-
* Apply the focus + user-bar chrome: the focused turn's FIRST line
|
|
430
|
-
* gets the `surface2` background and the `›` gutter marker; every line
|
|
431
|
-
* of a user turn carries the accent `▎` bar. The two are independent —
|
|
432
|
-
* a focused user turn's first line composes `› ▎ …` on surface2.
|
|
433
|
-
*/
|
|
434
|
-
#decorate(lines, width) {
|
|
435
|
-
const out = [...lines];
|
|
436
|
-
for (const [index, mounted] of this.#mounted.entries()) {
|
|
437
|
-
const turn = this.#turns[index];
|
|
438
|
-
if (turn === undefined)
|
|
439
|
-
continue;
|
|
440
|
-
const focused = index === this.#focus;
|
|
441
|
-
const bar = turn.kind === "user"
|
|
442
|
-
? `${this.#theme.fg("accent")("▎")} `
|
|
443
|
-
: "";
|
|
444
|
-
for (let line = mounted.start; line < mounted.end; line++) {
|
|
445
|
-
const original = out[line] ?? "";
|
|
446
|
-
if (line === mounted.start && focused) {
|
|
447
|
-
const marked = `› ${bar}${original}`;
|
|
448
|
-
out[line] = this.#theme.bg("surface2")(marked + " ".repeat(Math.max(0, width - marked.length)));
|
|
449
|
-
}
|
|
450
|
-
else if (turn.kind === "user") {
|
|
451
|
-
out[line] = `${bar}${original}`;
|
|
452
|
-
}
|
|
453
|
-
else if (focused) {
|
|
454
|
-
out[line] = ` ${original}`;
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
return out;
|
|
459
|
-
}
|
|
460
|
-
invalidate() {
|
|
461
|
-
this.#container.invalidate();
|
|
462
|
-
}
|
|
463
|
-
}
|
package/dist/ui/help.d.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The key reference overlay (Task C5, UI refresh): `showHelp` mounts a
|
|
3
|
-
* centred overlay listing the active keymap's `describe()` table in two
|
|
4
|
-
* columns. Esc, `q`, or the keymap's own help key (`?` standard, `C-h`
|
|
5
|
-
* emacs, `g?` vim — a two-key sequence, matched via a pending-`g` flag)
|
|
6
|
-
* closes it. Built on the same `showOverlay` mechanism as C3's picker.
|
|
7
|
-
*/
|
|
8
|
-
import { type TUI } from "@earendil-works/pi-tui";
|
|
9
|
-
import type { Keymap } from "../keymaps/types.js";
|
|
10
|
-
export interface HelpOptions {
|
|
11
|
-
/** Fires on close so the app can clear its overlayOpen flag. */
|
|
12
|
-
readonly onClose?: () => void;
|
|
13
|
-
}
|
|
14
|
-
/** Open the key reference overlay for `keymap`; Esc/q/help-key closes. */
|
|
15
|
-
export declare function showHelp(tui: TUI, keymap: Keymap, options?: HelpOptions): void;
|
package/dist/ui/help.js
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The key reference overlay (Task C5, UI refresh): `showHelp` mounts a
|
|
3
|
-
* centred overlay listing the active keymap's `describe()` table in two
|
|
4
|
-
* columns. Esc, `q`, or the keymap's own help key (`?` standard, `C-h`
|
|
5
|
-
* emacs, `g?` vim — a two-key sequence, matched via a pending-`g` flag)
|
|
6
|
-
* closes it. Built on the same `showOverlay` mechanism as C3's picker.
|
|
7
|
-
*/
|
|
8
|
-
import { Key, matchesKey } from "@earendil-works/pi-tui";
|
|
9
|
-
/** The keymap's own help key doubles as a close key (undefined = none). */
|
|
10
|
-
function helpKey(keymap) {
|
|
11
|
-
if (keymap.name === "standard")
|
|
12
|
-
return "?";
|
|
13
|
-
if (keymap.name === "emacs")
|
|
14
|
-
return "\x08"; // C-h
|
|
15
|
-
if (keymap.name === "vim")
|
|
16
|
-
return "g?"; // the g-prefix chord (? stays search)
|
|
17
|
-
return undefined;
|
|
18
|
-
}
|
|
19
|
-
class HelpOverlay {
|
|
20
|
-
#rows;
|
|
21
|
-
#helpKey;
|
|
22
|
-
#options;
|
|
23
|
-
#overlay;
|
|
24
|
-
/** Set after a bare "g" while #helpKey is "g?" (the vim close chord). */
|
|
25
|
-
#pendingG = false;
|
|
26
|
-
constructor(keymap, options) {
|
|
27
|
-
this.#rows = keymap.describe();
|
|
28
|
-
this.#helpKey = helpKey(keymap);
|
|
29
|
-
this.#options = options;
|
|
30
|
-
}
|
|
31
|
-
/** The overlay handle is installed by {@link showHelp} after show. */
|
|
32
|
-
setOverlay(overlay) {
|
|
33
|
-
this.#overlay = overlay;
|
|
34
|
-
}
|
|
35
|
-
#close() {
|
|
36
|
-
this.#overlay?.hide();
|
|
37
|
-
this.#options.onClose?.();
|
|
38
|
-
}
|
|
39
|
-
handleInput(data) {
|
|
40
|
-
const helpClose = this.#helpKey === "g?"
|
|
41
|
-
? this.#pendingG && data === "?"
|
|
42
|
-
: this.#helpKey !== undefined && data === this.#helpKey;
|
|
43
|
-
this.#pendingG = this.#helpKey === "g?" && data === "g";
|
|
44
|
-
if (matchesKey(data, Key.escape) || data === "q" || helpClose) {
|
|
45
|
-
this.#close();
|
|
46
|
-
}
|
|
47
|
-
// Every other key is consumed: the overlay is modal.
|
|
48
|
-
}
|
|
49
|
-
render(width) {
|
|
50
|
-
const half = Math.ceil(this.#rows.length / 2);
|
|
51
|
-
// The left column's field width: its longest "keys action" pair.
|
|
52
|
-
const leftWidth = Math.min(Math.max(...this.#rows
|
|
53
|
-
.slice(0, half)
|
|
54
|
-
.map((row) => row.keys.length + 2 + row.action.length)) + 4, Math.floor(width / 2));
|
|
55
|
-
const lines = ["key reference (esc or q closes)"];
|
|
56
|
-
for (let i = 0; i < half; i++) {
|
|
57
|
-
const left = this.#rows[i];
|
|
58
|
-
const right = this.#rows[i + half];
|
|
59
|
-
const leftText = `${left.keys} ${left.action}`;
|
|
60
|
-
if (right === undefined) {
|
|
61
|
-
lines.push(leftText);
|
|
62
|
-
}
|
|
63
|
-
else {
|
|
64
|
-
lines.push(`${leftText.padEnd(leftWidth)}${right.keys} ${right.action}`);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return lines;
|
|
68
|
-
}
|
|
69
|
-
invalidate() { }
|
|
70
|
-
}
|
|
71
|
-
/** Open the key reference overlay for `keymap`; Esc/q/help-key closes. */
|
|
72
|
-
export function showHelp(tui, keymap, options = {}) {
|
|
73
|
-
const help = new HelpOverlay(keymap, options);
|
|
74
|
-
const overlay = tui.showOverlay(help, {
|
|
75
|
-
anchor: "center",
|
|
76
|
-
width: "70%",
|
|
77
|
-
maxHeight: "70%",
|
|
78
|
-
});
|
|
79
|
-
help.setOverlay(overlay);
|
|
80
|
-
}
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The interaction seam's TUI renderer (Task 8 — the parity rule): the
|
|
3
|
-
* pending-interaction stack rendered as an answerable prompt strip
|
|
4
|
-
* between the conversation pane and the composer (spec §3.2: "a visible
|
|
5
|
-
* prompt in the conversation pane" — the TUI's medium-appropriate form is
|
|
6
|
-
* a strip directly above the composer, where the answer keys live).
|
|
7
|
-
*
|
|
8
|
-
* Genericity (the seam's whole point): this component renders the closed
|
|
9
|
-
* primitives (text / options / fields / diff) and the envelope, and knows
|
|
10
|
-
* NOTHING about kinds — there is no gate-specific concept here. `details` renders as
|
|
11
|
-
* one collapsed JSON line, never interpreted (the single read is the
|
|
12
|
-
* webui-parity `details.multi === true` multi-select flag).
|
|
13
|
-
*
|
|
14
|
-
* Answering: ctrl+o enters answer mode (the app routes it via
|
|
15
|
-
* {@link TuiApp.handleKeybinding}; ctrl+a is the editor's cursorLineStart,
|
|
16
|
-
* so the prompt uses ctrl+o). Digits 1-9 pick options — single-choice
|
|
17
|
-
* answers immediately; multi (`details.multi`) toggles and enter submits
|
|
18
|
-
* the accumulated set; esc exits. Answer mode PINS the record it was
|
|
19
|
-
* armed on: if a poll replaces the top of the stack before the user keys
|
|
20
|
-
* an answer, the mode exits instead of answering a record the user never
|
|
21
|
-
* saw. The answer POSTs through the ONE generic route
|
|
22
|
-
* (`answerInteraction`); the record clears when the next list poll
|
|
23
|
-
* reflects the settle (the server drops answered records from pending).
|
|
24
|
-
*
|
|
25
|
-
* Medium limits (the parity rule's documented exceptions):
|
|
26
|
-
* - Form `fields` render read-only with a "answer in the webui" hint —
|
|
27
|
-
* the TUI's composer is the conversation's text input; capturing it for
|
|
28
|
-
* form values would collide with submission routing. v1 answers
|
|
29
|
-
* option-based prompts only.
|
|
30
|
-
* - Option `tone` is a color/severity hint; the shell pins a no-color
|
|
31
|
-
* theme (styling is a later task), so tone is not rendered.
|
|
32
|
-
*
|
|
33
|
-
* Refetch triggers (W3 Task 4 — the 2s poll interval is RETIRED): kicks
|
|
34
|
-
* arrive on each stream frame (a revision may change the pending set) and
|
|
35
|
-
* on `interaction.*` host events (the app wires `watchEvents`'s
|
|
36
|
-
* `interaction.pending`/`interaction.settled` to {@link kick}). Kicks
|
|
37
|
-
* coalesce (one fetch in flight, one queued). Poll failures are
|
|
38
|
-
* QUIET — the prompt keeps its last state; stream transport health is
|
|
39
|
-
* reported separately by the watcher's error/open transition notices
|
|
40
|
-
* (the launcher's stream-health wiring). Answer failures (user-initiated)
|
|
41
|
-
* are LOUD via {@link onError} (the app routes them to the pane's
|
|
42
|
-
* `[error]` line).
|
|
43
|
-
*/
|
|
44
|
-
import { type Component } from "@earendil-works/pi-tui";
|
|
45
|
-
import type { InteractionWire, SigrunClient } from "../api.js";
|
|
46
|
-
export declare class InteractionPrompter implements Component {
|
|
47
|
-
#private;
|
|
48
|
-
/** Answer failures (user-initiated — loud). The app routes to the pane. */
|
|
49
|
-
onError?: (message: string) => void;
|
|
50
|
-
/** Rendered-state changes (the app requests a repaint). */
|
|
51
|
-
onChanged?: () => void;
|
|
52
|
-
constructor(client: SigrunClient);
|
|
53
|
-
/** The latest pending list (top of stack first). */
|
|
54
|
-
get pending(): readonly InteractionWire[];
|
|
55
|
-
/** Whether digit/enter/esc keys are captured for answering. */
|
|
56
|
-
get answerMode(): boolean;
|
|
57
|
-
/**
|
|
58
|
-
* Retarget the prompter: reset state and fetch immediately.
|
|
59
|
-
* `undefined` clears the prompt. (W3 Task 4: no poll interval —
|
|
60
|
-
* stream frames and `interaction.*` host events own the refreshes.)
|
|
61
|
-
*/
|
|
62
|
-
setConversation(id: number | undefined): void;
|
|
63
|
-
/** Coalesced poll (the app's stream frames call this). */
|
|
64
|
-
kick(): void;
|
|
65
|
-
/**
|
|
66
|
-
* kick(), awaited. Production polls are fire-and-forget; tests need to
|
|
67
|
-
* await the poll's settle to assert the state after it.
|
|
68
|
-
*/
|
|
69
|
-
kickAndWait(): Promise<void>;
|
|
70
|
-
/**
|
|
71
|
-
* Enter answer mode — only when the top pending item is option-
|
|
72
|
-
* answerable. Returns whether the mode was entered (the caller consumes
|
|
73
|
-
* the key only then).
|
|
74
|
-
*/
|
|
75
|
-
beginAnswer(): boolean;
|
|
76
|
-
/**
|
|
77
|
-
* Route one key while in answer mode. Returns true when consumed.
|
|
78
|
-
* Unrelated keys pass through (the seam never blocks — the user can
|
|
79
|
-
* keep typing while a prompt waits).
|
|
80
|
-
*/
|
|
81
|
-
handleKey(data: string): boolean;
|
|
82
|
-
/** Detach (the app calls this on dispose): stale fetches never land. */
|
|
83
|
-
dispose(): void;
|
|
84
|
-
/**
|
|
85
|
-
* The prompt strip: nothing when no item is pending; otherwise the top
|
|
86
|
-
* item's header, body primitives, answer affordance, and a stack count.
|
|
87
|
-
*/
|
|
88
|
-
render(_width: number): string[];
|
|
89
|
-
invalidate(): void;
|
|
90
|
-
}
|