@sigrun/tui 0.4.1 → 0.5.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 +10 -1
- package/dist/actions.d.ts +119 -0
- package/dist/actions.js +12 -0
- package/dist/api.d.ts +209 -0
- package/dist/api.js +282 -0
- package/dist/blocks/braille.d.ts +41 -0
- package/dist/blocks/braille.js +131 -0
- package/dist/blocks/chart.d.ts +20 -0
- package/dist/blocks/chart.js +397 -0
- package/dist/blocks/chat-transport.d.ts +44 -0
- package/dist/blocks/chat-transport.js +116 -0
- package/dist/blocks/collection/calendar.d.ts +28 -0
- package/dist/blocks/collection/calendar.js +232 -0
- package/dist/blocks/collection/card.d.ts +36 -0
- package/dist/blocks/collection/card.js +128 -0
- package/dist/blocks/collection/collection.d.ts +9 -0
- package/dist/blocks/collection/collection.js +26 -0
- package/dist/blocks/collection/grid.d.ts +18 -0
- package/dist/blocks/collection/grid.js +72 -0
- package/dist/blocks/collection/lanes.d.ts +32 -0
- package/dist/blocks/collection/lanes.js +320 -0
- package/dist/blocks/collection/list.d.ts +30 -0
- package/dist/blocks/collection/list.js +273 -0
- package/dist/blocks/collection/move.d.ts +67 -0
- package/dist/blocks/collection/move.js +96 -0
- package/dist/blocks/collection/shared.d.ts +87 -0
- package/dist/blocks/collection/shared.js +177 -0
- package/dist/blocks/collection/table.d.ts +26 -0
- package/dist/blocks/collection/table.js +221 -0
- package/dist/blocks/content.d.ts +48 -0
- package/dist/blocks/content.js +253 -0
- package/dist/blocks/context.d.ts +117 -0
- package/dist/blocks/context.js +65 -0
- package/dist/blocks/diff.d.ts +21 -0
- package/dist/blocks/diff.js +25 -0
- package/dist/blocks/ext-transport.d.ts +70 -0
- package/dist/blocks/ext-transport.js +165 -0
- package/dist/blocks/form/choice.d.ts +54 -0
- package/dist/blocks/form/choice.js +175 -0
- package/dist/blocks/form/fields.d.ts +56 -0
- package/dist/blocks/form/fields.js +136 -0
- package/dist/blocks/form/file.d.ts +61 -0
- package/dist/blocks/form/file.js +121 -0
- package/dist/blocks/form/form.d.ts +66 -0
- package/dist/blocks/form/form.js +764 -0
- package/dist/blocks/form/list.d.ts +37 -0
- package/dist/blocks/form/list.js +70 -0
- package/dist/blocks/form/values.d.ts +20 -0
- package/dist/blocks/form/values.js +106 -0
- package/dist/blocks/icons.d.ts +14 -0
- package/dist/blocks/icons.js +74 -0
- package/dist/blocks/image.d.ts +39 -0
- package/dist/blocks/image.js +82 -0
- package/dist/blocks/interaction-transport.d.ts +42 -0
- package/dist/blocks/interaction-transport.js +79 -0
- package/dist/blocks/layout.d.ts +31 -0
- package/dist/blocks/layout.js +155 -0
- package/dist/blocks/log.d.ts +24 -0
- package/dist/blocks/log.js +111 -0
- package/dist/blocks/meter.d.ts +16 -0
- package/dist/blocks/meter.js +50 -0
- package/dist/blocks/page.d.ts +54 -0
- package/dist/blocks/page.js +360 -0
- package/dist/blocks/render.d.ts +31 -0
- package/dist/blocks/render.js +163 -0
- package/dist/blocks/surface-view.d.ts +41 -0
- package/dist/blocks/surface-view.js +137 -0
- package/dist/blocks/surface.d.ts +80 -0
- package/dist/blocks/surface.js +506 -0
- package/dist/blocks/tones.d.ts +23 -0
- package/dist/blocks/tones.js +63 -0
- package/dist/blocks/treeview.d.ts +22 -0
- package/dist/blocks/treeview.js +121 -0
- package/dist/blocks/width.d.ts +32 -0
- package/dist/blocks/width.js +70 -0
- package/dist/clipboard.d.ts +20 -0
- package/dist/clipboard.js +19 -0
- package/dist/editor-launch.d.ts +34 -0
- package/dist/editor-launch.js +70 -0
- package/dist/keymaps/emacs.d.ts +18 -0
- package/dist/keymaps/emacs.js +172 -0
- package/dist/keymaps/standard.d.ts +17 -0
- package/dist/keymaps/standard.js +175 -0
- package/dist/keymaps/types.d.ts +43 -0
- package/dist/keymaps/types.js +28 -0
- package/dist/keymaps/vim.d.ts +48 -0
- package/dist/keymaps/vim.js +291 -0
- package/dist/launch.d.ts +159 -0
- package/dist/launch.js +478 -0
- package/dist/sse.d.ts +112 -0
- package/dist/sse.js +418 -0
- package/dist/theme.d.ts +51 -0
- package/dist/theme.js +119 -0
- package/dist/ui/ambient.d.ts +78 -0
- package/dist/ui/ambient.js +264 -0
- package/dist/ui/app.d.ts +319 -0
- package/dist/ui/app.js +1539 -0
- package/dist/ui/composer.d.ts +66 -0
- package/dist/ui/composer.js +133 -0
- package/dist/ui/conversation-pane.d.ts +143 -0
- package/dist/ui/conversation-pane.js +615 -0
- package/dist/ui/help.d.ts +15 -0
- package/dist/ui/help.js +80 -0
- package/dist/ui/interactions.d.ts +91 -0
- package/dist/ui/interactions.js +477 -0
- package/dist/ui/picker.d.ts +26 -0
- package/dist/ui/picker.js +130 -0
- package/dist/ui/settings.d.ts +68 -0
- package/dist/ui/settings.js +254 -0
- package/dist/ui/sidebar.d.ts +99 -0
- package/dist/ui/sidebar.js +269 -0
- package/dist/ui/status-bar.d.ts +77 -0
- package/dist/ui/status-bar.js +146 -0
- package/package.json +4 -3
|
@@ -0,0 +1,615 @@
|
|
|
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
|
+
import { SurfaceState } from "../blocks/surface.js";
|
|
48
|
+
import { SurfaceView } from "../blocks/surface-view.js";
|
|
49
|
+
/**
|
|
50
|
+
* The message's text: string content as-is, or its text blocks joined
|
|
51
|
+
* with a blank line (the webui `messageText` rule, mirrored).
|
|
52
|
+
*/
|
|
53
|
+
function messageText(message) {
|
|
54
|
+
const content = message.content;
|
|
55
|
+
if (typeof content === "string")
|
|
56
|
+
return content;
|
|
57
|
+
if (content === undefined)
|
|
58
|
+
return "";
|
|
59
|
+
return content
|
|
60
|
+
.filter((block) => block.type === "text" && typeof block.text === "string")
|
|
61
|
+
.map((block) => block.text)
|
|
62
|
+
.join("\n\n");
|
|
63
|
+
}
|
|
64
|
+
/** The message's non-text/non-thinking parts (uploads), as path markers. */
|
|
65
|
+
function attachmentMarkers(message) {
|
|
66
|
+
const content = message.content;
|
|
67
|
+
if (typeof content === "string" || content === undefined)
|
|
68
|
+
return [];
|
|
69
|
+
return content
|
|
70
|
+
.filter((block) => block.type !== "text" && block.type !== "thinking")
|
|
71
|
+
.map((block) => `[${block.type ?? "part"}: ${block.path ?? "?"}]`);
|
|
72
|
+
}
|
|
73
|
+
/** A turn's fallback body (an `other` kind's text), if any. */
|
|
74
|
+
function entryText(entry) {
|
|
75
|
+
return (entry.model ?? [])
|
|
76
|
+
.map((message) => messageText(message))
|
|
77
|
+
.filter((part) => part !== "")
|
|
78
|
+
.join(" ");
|
|
79
|
+
}
|
|
80
|
+
/** A user entry's model-facing text (message texts + upload markers) — the
|
|
81
|
+
* plain-turn body, and the EXPANDED text an expanded turn's chip reveals. */
|
|
82
|
+
function userModelText(entry) {
|
|
83
|
+
const parts = (entry.model ?? []).flatMap((message) => [
|
|
84
|
+
messageText(message),
|
|
85
|
+
...attachmentMarkers(message),
|
|
86
|
+
]);
|
|
87
|
+
return parts.filter((part) => part !== "").join("\n");
|
|
88
|
+
}
|
|
89
|
+
/** The markdown theme rides the TuiTheme's token colours. */
|
|
90
|
+
function markdownTheme(theme) {
|
|
91
|
+
return {
|
|
92
|
+
heading: (text) => theme.bold(theme.fg("accent")(text)),
|
|
93
|
+
link: theme.fg("accent"),
|
|
94
|
+
linkUrl: theme.dim,
|
|
95
|
+
code: theme.fg("code"),
|
|
96
|
+
codeBlock: theme.fg("code"),
|
|
97
|
+
codeBlockBorder: theme.fg("border"),
|
|
98
|
+
quote: theme.italic,
|
|
99
|
+
quoteBorder: theme.fg("border"),
|
|
100
|
+
hr: theme.fg("border"),
|
|
101
|
+
listBullet: theme.fg("accent"),
|
|
102
|
+
bold: theme.bold,
|
|
103
|
+
italic: theme.italic,
|
|
104
|
+
strikethrough: (text) => text,
|
|
105
|
+
underline: (text) => text,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/** Pad/centre the compaction divider to the render width. */
|
|
109
|
+
function compactionLine(theme) {
|
|
110
|
+
return new (class {
|
|
111
|
+
render(width) {
|
|
112
|
+
const label = "── context compacted ──";
|
|
113
|
+
const pad = Math.max(0, Math.floor((width - label.length) / 2));
|
|
114
|
+
return [theme.dim(`${" ".repeat(pad)}${label}`)];
|
|
115
|
+
}
|
|
116
|
+
invalidate() { }
|
|
117
|
+
})();
|
|
118
|
+
}
|
|
119
|
+
function noticeComponent(notice) {
|
|
120
|
+
return new Text(`[${notice.kind}] ${notice.text}`, 0, 0);
|
|
121
|
+
}
|
|
122
|
+
/** The notice backlog cap (fix wave A5 — most recent win). */
|
|
123
|
+
const MAX_NOTICES = 10;
|
|
124
|
+
/**
|
|
125
|
+
* A one-line render slot whose text is painted by the owner each frame
|
|
126
|
+
* (pi-tui's `Text` drops whitespace-only text and caches by content —
|
|
127
|
+
* neither fits a line whose text changes under a `MouseRegion`).
|
|
128
|
+
*/
|
|
129
|
+
class RenderSlot {
|
|
130
|
+
paint = () => "";
|
|
131
|
+
render(width) {
|
|
132
|
+
return [this.paint(width)];
|
|
133
|
+
}
|
|
134
|
+
invalidate() { }
|
|
135
|
+
}
|
|
136
|
+
/** The folded note row's summary (spec §8.3): collapsed to this line;
|
|
137
|
+
* opening the turn's fold reveals the note text beneath it. */
|
|
138
|
+
export const NOTE_SUMMARY = "UI changes sent to the agent";
|
|
139
|
+
/** The blocks entry's title from its payload (the answer chip's label). */
|
|
140
|
+
function blocksTitleOf(data) {
|
|
141
|
+
if (typeof data !== "object" || data === null)
|
|
142
|
+
return undefined;
|
|
143
|
+
const title = data.title;
|
|
144
|
+
return typeof title === "string" && title !== "" ? title : undefined;
|
|
145
|
+
}
|
|
146
|
+
/** The answer entry's block entry id (spec §8.4's `data.entryId`). */
|
|
147
|
+
function answerEntryIdOf(data) {
|
|
148
|
+
if (typeof data !== "object" || data === null)
|
|
149
|
+
return undefined;
|
|
150
|
+
const entryId = data.entryId;
|
|
151
|
+
return typeof entryId === "string" && entryId !== "" ? entryId : undefined;
|
|
152
|
+
}
|
|
153
|
+
/** The blank separator between turns. */
|
|
154
|
+
const TURN_GAP = 1;
|
|
155
|
+
export class ConversationPane {
|
|
156
|
+
#container = new Container();
|
|
157
|
+
#theme;
|
|
158
|
+
#markdownTheme;
|
|
159
|
+
/** The chat-block attach seam (U5 Task 4; absent = placeholder). */
|
|
160
|
+
#attachChatTransport;
|
|
161
|
+
#notices = [];
|
|
162
|
+
/** The notices' mounted components, aligned with `#notices`. */
|
|
163
|
+
#noticeComponents = [];
|
|
164
|
+
#turns = [];
|
|
165
|
+
/** The open view's conversation id (the chat-block attach's address). */
|
|
166
|
+
#conversationId = 0;
|
|
167
|
+
/** The mounted chat-block surface states, keyed by entry id
|
|
168
|
+
* (`<conversationId>:<entryId>`). Every rebuild REUSES the state
|
|
169
|
+
* (one `load`, one hub subscription per block); states are disposed
|
|
170
|
+
* on conversation swap and when the block's event leaves the view
|
|
171
|
+
* (a fork rewound the transcript). Without this, every stream frame
|
|
172
|
+
* remounted a fresh state and subscription and never disposed the
|
|
173
|
+
* old one — N frames = N stale subscriptions fanning out refetches. */
|
|
174
|
+
#surfaceStates = new Map();
|
|
175
|
+
/** The surface keys `#mountTurn` mounted during the in-flight
|
|
176
|
+
* `#rebuild` (undefined outside a rebuild) — the prune in `#rebuild`
|
|
177
|
+
* disposes every cached state NOT in this set. */
|
|
178
|
+
#mountKeysSeen;
|
|
179
|
+
#focus;
|
|
180
|
+
/** Fold state by turn index (assistant turns only fold). */
|
|
181
|
+
#open = new Set();
|
|
182
|
+
/** Line spans from the last render, aligned with `#turns`. */
|
|
183
|
+
#ranges = [];
|
|
184
|
+
/** The mounted turn records, aligned with `#turns`. */
|
|
185
|
+
#mounted = [];
|
|
186
|
+
constructor(options) {
|
|
187
|
+
this.#theme = options?.theme ?? plainTuiTheme();
|
|
188
|
+
this.#markdownTheme = markdownTheme(this.#theme);
|
|
189
|
+
this.#attachChatTransport = options?.attachChatTransport;
|
|
190
|
+
}
|
|
191
|
+
/** The current view's turns (B1 grouping), in render order. */
|
|
192
|
+
turns() {
|
|
193
|
+
return this.#turns;
|
|
194
|
+
}
|
|
195
|
+
/** The focused turn index; `undefined` = none (follow the end). */
|
|
196
|
+
get focusIndex() {
|
|
197
|
+
return this.#focus;
|
|
198
|
+
}
|
|
199
|
+
/** Focus a turn (or clear with `undefined`); repaints the marker. */
|
|
200
|
+
setFocus(index) {
|
|
201
|
+
if (index !== undefined && (index < 0 || index >= this.#turns.length)) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (index === this.#focus)
|
|
205
|
+
return;
|
|
206
|
+
this.#focus = index;
|
|
207
|
+
this.#container.invalidate();
|
|
208
|
+
}
|
|
209
|
+
/** Open/close an assistant turn's activity fold — or an expanded user
|
|
210
|
+
* turn's chip disclosure (Task 2c: the full admitted text hides behind
|
|
211
|
+
* the `expanded from /name` line, same fold state, same seam). */
|
|
212
|
+
toggleFold(index) {
|
|
213
|
+
const turn = this.#turns[index];
|
|
214
|
+
if (turn === undefined)
|
|
215
|
+
return;
|
|
216
|
+
const foldable = turn.kind === "assistant" ||
|
|
217
|
+
(turn.kind === "user" && expandedUserEntry(turn.entry) !== undefined);
|
|
218
|
+
if (!foldable)
|
|
219
|
+
return;
|
|
220
|
+
if (this.#open.has(index)) {
|
|
221
|
+
this.#open.delete(index);
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
this.#open.add(index);
|
|
225
|
+
}
|
|
226
|
+
this.#rebuild();
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* The turn's line offsets from the last render (for
|
|
230
|
+
* `ScrollView.scrollTo`): `start` is the turn's first line, `end` the
|
|
231
|
+
* first line after it. Undefined before a render or out of range.
|
|
232
|
+
*/
|
|
233
|
+
lineRangeOf(index) {
|
|
234
|
+
return this.#ranges[index];
|
|
235
|
+
}
|
|
236
|
+
/** The turn's markdown source (the copy payload). */
|
|
237
|
+
textOf(index) {
|
|
238
|
+
const turn = this.#turns[index];
|
|
239
|
+
if (turn === undefined)
|
|
240
|
+
return "";
|
|
241
|
+
switch (turn.kind) {
|
|
242
|
+
case "user": {
|
|
243
|
+
// An expanded turn copies the TYPED command (what the user
|
|
244
|
+
// wrote — re-submitting it re-expands server-side).
|
|
245
|
+
const expanded = expandedUserEntry(turn.entry);
|
|
246
|
+
if (expanded !== undefined)
|
|
247
|
+
return expanded.typed;
|
|
248
|
+
return userModelText(turn.entry);
|
|
249
|
+
}
|
|
250
|
+
case "assistant":
|
|
251
|
+
return turn.text;
|
|
252
|
+
case "other":
|
|
253
|
+
return entryText(turn.entry);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/** The turn's wire identity: the user entry id / assistant lastEntryId. */
|
|
257
|
+
entryIdOf(index) {
|
|
258
|
+
const turn = this.#turns[index];
|
|
259
|
+
if (turn === undefined)
|
|
260
|
+
return -1;
|
|
261
|
+
switch (turn.kind) {
|
|
262
|
+
case "user":
|
|
263
|
+
return turn.entry.id;
|
|
264
|
+
case "assistant":
|
|
265
|
+
return turn.lastEntryId;
|
|
266
|
+
case "other":
|
|
267
|
+
return turn.entry.id;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Replace the pane with the projection of `wire` (the SSE rule: a full
|
|
272
|
+
* revision replaces; a reconnect's snapshot subsumes missed frames).
|
|
273
|
+
* Composer notices are not stream state: they re-render after the
|
|
274
|
+
* turns.
|
|
275
|
+
*/
|
|
276
|
+
showView(wire) {
|
|
277
|
+
const conversationId = wire.conversation.id;
|
|
278
|
+
if (conversationId !== this.#conversationId) {
|
|
279
|
+
// Conversation swap: dispose every surface state (unsubscribe +
|
|
280
|
+
// cancel timers) — a stale state would keep refetching the OLD
|
|
281
|
+
// conversation's block on every hub event.
|
|
282
|
+
for (const state of this.#surfaceStates.values())
|
|
283
|
+
state.dispose();
|
|
284
|
+
this.#surfaceStates.clear();
|
|
285
|
+
}
|
|
286
|
+
this.#conversationId = conversationId;
|
|
287
|
+
this.#turns = groupTurns(wire.entries);
|
|
288
|
+
if (this.#focus !== undefined && this.#focus >= this.#turns.length) {
|
|
289
|
+
this.#focus = undefined;
|
|
290
|
+
}
|
|
291
|
+
this.#rebuild();
|
|
292
|
+
}
|
|
293
|
+
/** Dispose every mounted chat-block surface state (the shell calls
|
|
294
|
+
* this on teardown — the pane never leaves a hub subscription or an
|
|
295
|
+
* action timer behind). */
|
|
296
|
+
dispose() {
|
|
297
|
+
for (const state of this.#surfaceStates.values())
|
|
298
|
+
state.dispose();
|
|
299
|
+
this.#surfaceStates.clear();
|
|
300
|
+
}
|
|
301
|
+
/** The cached surface state for a blocks entry id (test/debug seam —
|
|
302
|
+
* the turn components dispatch through their own closures). */
|
|
303
|
+
surfaceStateFor(entryId) {
|
|
304
|
+
return this.#surfaceStates.get(`${this.#conversationId}:${entryId}`);
|
|
305
|
+
}
|
|
306
|
+
/** Append an `[error]` line (a failed composer action's surface). */
|
|
307
|
+
showError(text) {
|
|
308
|
+
this.#appendNotice({ kind: "error", text });
|
|
309
|
+
}
|
|
310
|
+
/** Append a `[notice]` line (an outcome, e.g. an abort that settled). */
|
|
311
|
+
showNotice(text) {
|
|
312
|
+
this.#appendNotice({ kind: "notice", text });
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Discard all composer-surfaced lines (fix wave F4): the shell calls
|
|
316
|
+
* this on every conversation swap so a stale error never renders in
|
|
317
|
+
* the new conversation.
|
|
318
|
+
*/
|
|
319
|
+
clearNotices() {
|
|
320
|
+
if (this.#notices.length === 0)
|
|
321
|
+
return;
|
|
322
|
+
this.#notices.length = 0;
|
|
323
|
+
for (const component of this.#noticeComponents.splice(0)) {
|
|
324
|
+
this.#container.removeChild(component);
|
|
325
|
+
}
|
|
326
|
+
this.#container.invalidate();
|
|
327
|
+
}
|
|
328
|
+
#appendNotice(notice) {
|
|
329
|
+
this.#notices.push(notice);
|
|
330
|
+
const component = noticeComponent(notice);
|
|
331
|
+
this.#noticeComponents.push(component);
|
|
332
|
+
this.#container.addChild(component);
|
|
333
|
+
while (this.#notices.length > MAX_NOTICES) {
|
|
334
|
+
this.#notices.shift();
|
|
335
|
+
const oldest = this.#noticeComponents.shift();
|
|
336
|
+
if (oldest === undefined)
|
|
337
|
+
break;
|
|
338
|
+
this.#container.removeChild(oldest);
|
|
339
|
+
}
|
|
340
|
+
this.#container.invalidate();
|
|
341
|
+
}
|
|
342
|
+
/** Rebuild the container from the current turns + folds + notices. */
|
|
343
|
+
#rebuild() {
|
|
344
|
+
this.#container.clear();
|
|
345
|
+
this.#noticeComponents.length = 0;
|
|
346
|
+
this.#mountKeysSeen = new Set();
|
|
347
|
+
this.#mounted = this.#turns.map((turn, index) => this.#mountTurn(turn, index));
|
|
348
|
+
// Prune states whose block left the view (a fork rewound the
|
|
349
|
+
// transcript): an orphaned state would keep its live hub
|
|
350
|
+
// subscription + action timers forever. `#mountTurn` records the
|
|
351
|
+
// keys it mounted this pass in `#mountKeysSeen`.
|
|
352
|
+
const seen = this.#mountKeysSeen;
|
|
353
|
+
if (seen !== undefined) {
|
|
354
|
+
for (const [key, state] of this.#surfaceStates) {
|
|
355
|
+
if (!seen.has(key)) {
|
|
356
|
+
state.dispose();
|
|
357
|
+
this.#surfaceStates.delete(key);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
this.#mountKeysSeen = undefined;
|
|
361
|
+
}
|
|
362
|
+
for (const mounted of this.#mounted) {
|
|
363
|
+
for (const component of mounted.components) {
|
|
364
|
+
this.#container.addChild(component);
|
|
365
|
+
}
|
|
366
|
+
this.#container.addChild(new RenderSlot()); // turn gap
|
|
367
|
+
}
|
|
368
|
+
for (const notice of this.#notices) {
|
|
369
|
+
const component = noticeComponent(notice);
|
|
370
|
+
this.#noticeComponents.push(component);
|
|
371
|
+
this.#container.addChild(component);
|
|
372
|
+
}
|
|
373
|
+
this.#container.invalidate();
|
|
374
|
+
}
|
|
375
|
+
/** Project one turn into its components (mouse regions included). */
|
|
376
|
+
#mountTurn(turn, index) {
|
|
377
|
+
const components = [];
|
|
378
|
+
const focusThis = () => {
|
|
379
|
+
this.setFocus(index);
|
|
380
|
+
return { handled: true };
|
|
381
|
+
};
|
|
382
|
+
const body = (component) => new MouseRegion(component, (event) => event.type === "click" && event.button === "left"
|
|
383
|
+
? focusThis()
|
|
384
|
+
: undefined);
|
|
385
|
+
switch (turn.kind) {
|
|
386
|
+
case "user": {
|
|
387
|
+
const expanded = expandedUserEntry(turn.entry);
|
|
388
|
+
if (expanded !== undefined) {
|
|
389
|
+
// The expanded turn (Task 2c): the TYPED command is the body
|
|
390
|
+
// (the accent bar still marks it a user turn); a dim chip line
|
|
391
|
+
// folds the full admitted text — closed by default, or
|
|
392
|
+
// `/skill:greet hej` would dump the whole skill body.
|
|
393
|
+
components.push(body(new Markdown(expanded.typed, 0, 0, this.#markdownTheme)));
|
|
394
|
+
const slot = new RenderSlot();
|
|
395
|
+
slot.paint = () => this.#theme.dim(`${this.#open.has(index) ? "▾" : "▸"} expanded from /${expanded.command ?? "…"}`);
|
|
396
|
+
components.push(new MouseRegion(slot, (event) => {
|
|
397
|
+
if (event.type === "click" && event.button === "left") {
|
|
398
|
+
this.toggleFold(index);
|
|
399
|
+
return { handled: true };
|
|
400
|
+
}
|
|
401
|
+
return undefined;
|
|
402
|
+
}));
|
|
403
|
+
if (this.#open.has(index)) {
|
|
404
|
+
for (const line of userModelText(turn.entry).split("\n")) {
|
|
405
|
+
components.push(new Text(this.#theme.dim(line), 0, 0));
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
const text = this.textOf(index);
|
|
411
|
+
if (text !== "") {
|
|
412
|
+
components.push(body(new Markdown(text, 0, 0, this.#markdownTheme)));
|
|
413
|
+
}
|
|
414
|
+
// U5 Task 4 (spec §8.4): a block answer is a user turn whose
|
|
415
|
+
// chip names the surface it answered (`from <title>` — the
|
|
416
|
+
// `sigrun.blocks` entry's title, else its entry id).
|
|
417
|
+
if (turn.entry.kind === "sigrun.blocks.answer") {
|
|
418
|
+
const answerEntryId = answerEntryIdOf(turn.entry.data);
|
|
419
|
+
const title = this.#turns
|
|
420
|
+
.map((candidate) => candidate.kind === "assistant"
|
|
421
|
+
? candidate.entries.find((entry) => entry.kind === "sigrun.blocks" &&
|
|
422
|
+
String(entry.id) === answerEntryId)
|
|
423
|
+
: undefined)
|
|
424
|
+
.map((entry) => entry === undefined ? undefined : blocksTitleOf(entry.data))
|
|
425
|
+
.find((candidate) => candidate !== undefined);
|
|
426
|
+
components.push(new Text(this.#theme.dim(`from ${title ?? `entry ${answerEntryId ?? "?"}`}`), 0, 0));
|
|
427
|
+
}
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
case "assistant": {
|
|
431
|
+
if (turn.text.trim() !== "") {
|
|
432
|
+
components.push(body(new Markdown(turn.text, 0, 0, this.#markdownTheme)));
|
|
433
|
+
}
|
|
434
|
+
// U5 Task 4: the chat-block surfaces render INLINE, OUTSIDE
|
|
435
|
+
// the folded activity line (the surface is the turn's visible
|
|
436
|
+
// artifact, never hidden behind the fold). Each mounts a
|
|
437
|
+
// SurfaceView over the attach seam's transport (the hub's
|
|
438
|
+
// onChanged refreshes it when another viewer edits the block).
|
|
439
|
+
for (const event of turn.timeline) {
|
|
440
|
+
if (event.kind !== "blocks")
|
|
441
|
+
continue;
|
|
442
|
+
const attach = this.#attachChatTransport;
|
|
443
|
+
const key = `${this.#conversationId}:${event.entryId}`;
|
|
444
|
+
// Reuse the cached state across rebuilds; create + load once.
|
|
445
|
+
let state = this.#surfaceStates.get(key);
|
|
446
|
+
if (state !== undefined)
|
|
447
|
+
this.#mountKeysSeen?.add(key);
|
|
448
|
+
if (state === undefined && attach !== undefined) {
|
|
449
|
+
const fresh = new SurfaceState(attach(this.#conversationId, String(event.entryId)));
|
|
450
|
+
void fresh.load();
|
|
451
|
+
fresh.onChange(() => {
|
|
452
|
+
// Surface rejections/toasts as pane notices (the chat
|
|
453
|
+
// pane owns no toast surface of its own).
|
|
454
|
+
for (const notice of fresh.consumeNotices()) {
|
|
455
|
+
this.#appendNotice({ kind: "error", text: notice });
|
|
456
|
+
}
|
|
457
|
+
this.#container.invalidate();
|
|
458
|
+
});
|
|
459
|
+
this.#surfaceStates.set(key, fresh);
|
|
460
|
+
this.#mountKeysSeen?.add(key);
|
|
461
|
+
state = fresh;
|
|
462
|
+
}
|
|
463
|
+
if (attach === undefined) {
|
|
464
|
+
components.push(new Text(this.#theme.dim(`[blocks entry ${event.entryId} — no chat transport]`), 0, 0));
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
// Narrowed above (state === undefined implies attach was too).
|
|
468
|
+
components.push(new SurfaceView(state, {
|
|
469
|
+
theme: this.#theme,
|
|
470
|
+
unicode: true,
|
|
471
|
+
}));
|
|
472
|
+
}
|
|
473
|
+
const notes = turn.timeline.filter((event) => event.kind === "note");
|
|
474
|
+
const label = activityLabel(turn);
|
|
475
|
+
const summaryParts = [
|
|
476
|
+
...(label === "" ? [] : [label]),
|
|
477
|
+
...(notes.length > 0
|
|
478
|
+
? [
|
|
479
|
+
notes.length === 1
|
|
480
|
+
? NOTE_SUMMARY
|
|
481
|
+
: `${notes.length}× ${NOTE_SUMMARY}`,
|
|
482
|
+
]
|
|
483
|
+
: []),
|
|
484
|
+
];
|
|
485
|
+
if (summaryParts.length > 0) {
|
|
486
|
+
// The slot renders one line; the fold marker + label are
|
|
487
|
+
// painted per frame (the fold state changes after mount, and
|
|
488
|
+
// a Text's content cache would pin the stale marker).
|
|
489
|
+
const slot = new RenderSlot();
|
|
490
|
+
slot.paint = () => this.#theme.dim(`${this.#open.has(index) ? "▾" : "▸"} ${summaryParts.join(" · ")}`);
|
|
491
|
+
components.push(new MouseRegion(slot, (event) => {
|
|
492
|
+
if (event.type === "click" && event.button === "left") {
|
|
493
|
+
this.toggleFold(index);
|
|
494
|
+
return { handled: true };
|
|
495
|
+
}
|
|
496
|
+
return undefined;
|
|
497
|
+
}));
|
|
498
|
+
if (this.#open.has(index)) {
|
|
499
|
+
for (const thinking of turn.thinking) {
|
|
500
|
+
if (thinking.trim() === "")
|
|
501
|
+
continue;
|
|
502
|
+
components.push(new Text(this.#theme.dim(this.#theme.italic(thinking)), 0, 0));
|
|
503
|
+
}
|
|
504
|
+
for (const tool of turn.tools) {
|
|
505
|
+
const glyph = tool.isError
|
|
506
|
+
? "✗"
|
|
507
|
+
: tool.resultEntryId === undefined
|
|
508
|
+
? "·"
|
|
509
|
+
: "✓";
|
|
510
|
+
const preview = tool.preview === "" ? "" : ` ${tool.preview}`;
|
|
511
|
+
const row = `${glyph} ${tool.name}${preview}`;
|
|
512
|
+
components.push(new Text(tool.isError ? this.#theme.fg("muted")(row) : row, 0, 0));
|
|
513
|
+
}
|
|
514
|
+
// The note texts ride the SAME fold (the note row's
|
|
515
|
+
// disclosure): opening the fold reveals the notes
|
|
516
|
+
// the turn admitted, in timeline order.
|
|
517
|
+
for (const event of turn.timeline) {
|
|
518
|
+
if (event.kind !== "note")
|
|
519
|
+
continue;
|
|
520
|
+
const entry = turn.entries.find((candidate) => candidate.id === event.entryId);
|
|
521
|
+
const text = entry === undefined ? "" : entryText(entry);
|
|
522
|
+
for (const line of text.split("\n")) {
|
|
523
|
+
if (line.trim() === "")
|
|
524
|
+
continue;
|
|
525
|
+
components.push(new Text(this.#theme.dim(line), 0, 0));
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
case "other": {
|
|
533
|
+
if (turn.entry.kind === "pi.compaction") {
|
|
534
|
+
components.push(compactionLine(this.#theme));
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
const text = entryText(turn.entry);
|
|
538
|
+
if (turn.entry.kind === "pi.system") {
|
|
539
|
+
if (text !== "")
|
|
540
|
+
components.push(new Text(text, 0, 0));
|
|
541
|
+
}
|
|
542
|
+
else {
|
|
543
|
+
// Fail visible (the webui rule): name the kind plus any text.
|
|
544
|
+
const suffix = text === "" ? "" : ` — ${text}`;
|
|
545
|
+
components.push(new Text(`unsupported entry kind: ${turn.entry.kind}${suffix}`, 0, 0));
|
|
546
|
+
}
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return { start: 0, end: 0, components, heights: [] };
|
|
551
|
+
}
|
|
552
|
+
render(width) {
|
|
553
|
+
const lines = this.#container.render(width);
|
|
554
|
+
this.#recordRanges(lines, width);
|
|
555
|
+
return this.#decorate(lines, width);
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Record each turn's line span from the render: every turn is followed
|
|
559
|
+
* by one blank gap line (see `#rebuild`), so heights + TURN_GAP walk
|
|
560
|
+
* the offsets deterministically.
|
|
561
|
+
*/
|
|
562
|
+
#recordRanges(_lines, width) {
|
|
563
|
+
const ranges = [];
|
|
564
|
+
let offset = 0;
|
|
565
|
+
for (const mounted of this.#mounted) {
|
|
566
|
+
let height = 0;
|
|
567
|
+
mounted.heights.length = 0;
|
|
568
|
+
for (const component of mounted.components) {
|
|
569
|
+
const componentHeight = component.render(width).length;
|
|
570
|
+
mounted.heights.push(componentHeight);
|
|
571
|
+
height += componentHeight;
|
|
572
|
+
}
|
|
573
|
+
mounted.start = offset;
|
|
574
|
+
mounted.end = offset + height;
|
|
575
|
+
ranges.push({ start: mounted.start, end: mounted.end });
|
|
576
|
+
offset += height + TURN_GAP;
|
|
577
|
+
}
|
|
578
|
+
this.#ranges = ranges;
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Apply the focus + user-bar chrome: the focused turn's FIRST line
|
|
582
|
+
* gets the `surface2` background and the `›` gutter marker; every line
|
|
583
|
+
* of a user turn carries the accent `▎` bar. The two are independent —
|
|
584
|
+
* a focused user turn's first line composes `› ▎ …` on surface2.
|
|
585
|
+
*/
|
|
586
|
+
#decorate(lines, width) {
|
|
587
|
+
const out = [...lines];
|
|
588
|
+
for (const [index, mounted] of this.#mounted.entries()) {
|
|
589
|
+
const turn = this.#turns[index];
|
|
590
|
+
if (turn === undefined)
|
|
591
|
+
continue;
|
|
592
|
+
const focused = index === this.#focus;
|
|
593
|
+
const bar = turn.kind === "user"
|
|
594
|
+
? `${this.#theme.fg("accent")("▎")} `
|
|
595
|
+
: "";
|
|
596
|
+
for (let line = mounted.start; line < mounted.end; line++) {
|
|
597
|
+
const original = out[line] ?? "";
|
|
598
|
+
if (line === mounted.start && focused) {
|
|
599
|
+
const marked = `› ${bar}${original}`;
|
|
600
|
+
out[line] = this.#theme.bg("surface2")(marked + " ".repeat(Math.max(0, width - marked.length)));
|
|
601
|
+
}
|
|
602
|
+
else if (turn.kind === "user") {
|
|
603
|
+
out[line] = `${bar}${original}`;
|
|
604
|
+
}
|
|
605
|
+
else if (focused) {
|
|
606
|
+
out[line] = ` ${original}`;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return out;
|
|
611
|
+
}
|
|
612
|
+
invalidate() {
|
|
613
|
+
this.#container.invalidate();
|
|
614
|
+
}
|
|
615
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
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;
|