@sigrun/tui 0.7.0 → 0.9.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 +13 -0
- package/dist/actions.d.ts +14 -0
- package/dist/api.d.ts +18 -2
- package/dist/api.js +24 -0
- package/dist/blocks/form/form.js +22 -0
- package/dist/keymaps/emacs.js +14 -0
- package/dist/keymaps/standard.js +15 -0
- package/dist/keymaps/vim.js +14 -0
- package/dist/launch.js +11 -1
- package/dist/ui/ambient.d.ts +13 -0
- package/dist/ui/ambient.js +22 -0
- package/dist/ui/app.d.ts +9 -1
- package/dist/ui/app.js +71 -0
- package/dist/ui/interactions.d.ts +12 -0
- package/dist/ui/interactions.js +79 -2
- package/dist/ui/notices.d.ts +77 -0
- package/dist/ui/notices.js +290 -0
- package/dist/ui/status-bar.d.ts +2 -0
- package/dist/ui/status-bar.js +2 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
## [0.9.0] - 2026-11-05
|
|
10
|
+
|
|
11
|
+
- Decline on declinable interaction cards (`alt+s` in answer mode; `alt+d` is the
|
|
12
|
+
notices dismiss), the armed-hint chord, and the stepper review fixes (answer
|
|
13
|
+
labels, "Not answered yet" naming, Enter guard).
|
|
14
|
+
|
|
15
|
+
## [0.8.0] - 2026-11-04
|
|
16
|
+
|
|
17
|
+
- Notices: an inline line for the current conversation's newest notice (a key dismisses
|
|
18
|
+
it), `notices:N` in the status bar, and an overlay listing all notices with the
|
|
19
|
+
buttons (extension actions run, `open` prints the URL, navigate routes); ambient
|
|
20
|
+
navigate/open are wired. No peek — the count is the announcement.
|
|
21
|
+
|
|
9
22
|
## [0.7.0] - 2026-11-04
|
|
10
23
|
|
|
11
24
|
- The 401 auth notice dedupes across streams and survives conversation switches; SSE
|
package/dist/actions.d.ts
CHANGED
|
@@ -63,6 +63,12 @@ export type Action = {
|
|
|
63
63
|
readonly reverse: boolean;
|
|
64
64
|
} | {
|
|
65
65
|
readonly type: "answer-interaction";
|
|
66
|
+
}
|
|
67
|
+
/** Ask-user Task 5 (spec §5.2): decline the armed interaction record
|
|
68
|
+
* (alt+s in every keymap — alt+d is the notices dismiss). Acts ONLY
|
|
69
|
+
* in answer mode on a declinable record. */
|
|
70
|
+
| {
|
|
71
|
+
readonly type: "decline-interaction";
|
|
66
72
|
} | {
|
|
67
73
|
readonly type: "cancel-run";
|
|
68
74
|
} | {
|
|
@@ -112,6 +118,14 @@ export type Action = {
|
|
|
112
118
|
/** Return the calendar range to today (t). */
|
|
113
119
|
| {
|
|
114
120
|
readonly type: "blocks.today";
|
|
121
|
+
}
|
|
122
|
+
/** Open the notices overlay. */
|
|
123
|
+
| {
|
|
124
|
+
readonly type: "notices.open";
|
|
125
|
+
}
|
|
126
|
+
/** Dismiss the newest notice (the inline line's notice). */
|
|
127
|
+
| {
|
|
128
|
+
readonly type: "notices.dismissNewest";
|
|
115
129
|
};
|
|
116
130
|
/** The dispatch surface {@link TuiApp} implements. */
|
|
117
131
|
export interface ActionHost {
|
package/dist/api.d.ts
CHANGED
|
@@ -17,16 +17,18 @@
|
|
|
17
17
|
* silent `undefined` the UI could misrender as empty state.
|
|
18
18
|
*/
|
|
19
19
|
import type { CommandRunResponse, CommandsResponse } from "@sigrun/shared/commands";
|
|
20
|
+
import type { NoticeActionResponse, NoticesResponse } from "@sigrun/shared/notices";
|
|
20
21
|
import type { JobsResponse, JobRunResponse } from "@sigrun/shared/views";
|
|
21
22
|
import type { JobRevisionWire, JobRunWire } from "@sigrun/shared/jobs";
|
|
22
23
|
import type { SessionMeta, SessionMetaPatch } from "@sigrun/shared/session-meta";
|
|
23
|
-
import type { AbortResponse, AmbientList, CompactResponse, ConfigureResponse, ConversationCreateResponse, ConversationForkResponse, ConversationList, ConversationViewWire, InteractionAnswered, InteractionList, InteractionWire, MetaResponse, ModelsResponse, SettingsResponse, SettingsSectionsResponse, SubmissionResponse, WhoamiResponse } from "@sigrun/shared/views";
|
|
24
|
-
export type { AbortResponse, AmbientList, AmbientWire, CompactResponse, ConfigureResponse, ConversationCreateResponse, ConversationForkResponse, ConversationList, ConversationSummary, ConversationViewWire, InteractionAnswered, InteractionList, InteractionWire, MetaResponse, ModelEntry, ModelsResponse, SettingsResponse, SettingsSectionsResponse, SettingsSectionEntry, SettingsSectionIndexEntry, SubmissionResponse, WhoamiResponse, } from "@sigrun/shared/views";
|
|
24
|
+
import type { AbortResponse, AmbientList, CompactResponse, ConfigureResponse, ConversationCreateResponse, ConversationForkResponse, ConversationList, ConversationViewWire, InteractionAnswered, InteractionDeclined, InteractionList, InteractionWire, MetaResponse, ModelsResponse, SettingsResponse, SettingsSectionsResponse, SubmissionResponse, WhoamiResponse } from "@sigrun/shared/views";
|
|
25
|
+
export type { AbortResponse, AmbientList, AmbientWire, CompactResponse, ConfigureResponse, ConversationCreateResponse, ConversationForkResponse, ConversationList, ConversationSummary, ConversationViewWire, InteractionAnswered, InteractionDeclined, InteractionList, InteractionWire, MetaResponse, ModelEntry, ModelsResponse, SettingsResponse, SettingsSectionsResponse, SettingsSectionEntry, SettingsSectionIndexEntry, SubmissionResponse, WhoamiResponse, } from "@sigrun/shared/views";
|
|
25
26
|
export type { CommandRunResponse, CommandsResponse, SlashCommand } from "@sigrun/shared/commands";
|
|
26
27
|
export type { JobSpec, JobSchedule, JobAction, JobWire, JobRunWire, JobRevisionWire, } from "@sigrun/shared/jobs";
|
|
27
28
|
export type { JobsResponse, JobRunResponse } from "@sigrun/shared/views";
|
|
28
29
|
export type { ExtensionsResponse, ExtensionInfo, ExtensionSurfaceInfo } from "@sigrun/shared/views";
|
|
29
30
|
export type { SessionMeta, SessionMetaPatch } from "@sigrun/shared/session-meta";
|
|
31
|
+
export type { UserNotice, UserNoticeAction, NoticesResponse, NoticeActionResponse, } from "@sigrun/shared/notices";
|
|
30
32
|
/**
|
|
31
33
|
* One answer to a pending interaction (the ONE generic route's body).
|
|
32
34
|
* U6 Task 2 (ruling 20): `{values}` ONLY — every field's value under
|
|
@@ -149,6 +151,12 @@ export declare class SigrunClient {
|
|
|
149
151
|
getInteraction(id: number, callId: string): Promise<InteractionWire>;
|
|
150
152
|
/** `POST .../interactions/:callId/answer` — the ONE generic answer route. */
|
|
151
153
|
answerInteraction(id: number, callId: string, answer: InteractionAnswerInput): Promise<InteractionAnswered>;
|
|
154
|
+
/** `POST .../interactions/:callId/decline` — decline a pending
|
|
155
|
+
* declinable record (ask-user spec §5.2; settles `skipped` with
|
|
156
|
+
* `declinedVia: "button"`). A 404 `not_pending` means the record
|
|
157
|
+
* settled under the user's fingers — the caller refetches through
|
|
158
|
+
* {@link getInteraction} (the mid-stepper settle path). */
|
|
159
|
+
declineInteraction(id: number, callId: string): Promise<InteractionDeclined>;
|
|
152
160
|
/** `GET /v1/conversations/:id/ambient`. */
|
|
153
161
|
getAmbient(id: number): Promise<AmbientList>;
|
|
154
162
|
/** `GET /v1/commands` — the slash-command catalog (a boot constant). */
|
|
@@ -206,4 +214,12 @@ export declare class SigrunClient {
|
|
|
206
214
|
listJobRuns(id: string, limit?: number): Promise<JobRunsResponse>;
|
|
207
215
|
/** `GET /v1/jobs/:id/revisions` — the job's revisions, newest first. */
|
|
208
216
|
listJobRevisions(id: string): Promise<JobRevisionsResponse>;
|
|
217
|
+
/** `GET /v1/notices` — the caller's notices, newest first (notices Task 8). */
|
|
218
|
+
listNotices(): Promise<NoticesResponse>;
|
|
219
|
+
/** `POST /v1/notices/:id/dismiss` — 204; a stale id 404s (callers refetch). */
|
|
220
|
+
dismissNotice(id: string): Promise<void>;
|
|
221
|
+
/** `POST /v1/notices/dismiss-all` — clear the caller's pile (204). */
|
|
222
|
+
dismissAllNotices(): Promise<void>;
|
|
223
|
+
/** `POST /v1/notices/:id/actions/:actionId` — run an extension action. */
|
|
224
|
+
runNoticeAction(id: string, actionId: string): Promise<NoticeActionResponse>;
|
|
209
225
|
}
|
package/dist/api.js
CHANGED
|
@@ -181,6 +181,14 @@ export class SigrunClient {
|
|
|
181
181
|
answerInteraction(id, callId, answer) {
|
|
182
182
|
return this.#send("POST", `/v1/conversations/${id}/interactions/${encodeURIComponent(callId)}/answer`, answer);
|
|
183
183
|
}
|
|
184
|
+
/** `POST .../interactions/:callId/decline` — decline a pending
|
|
185
|
+
* declinable record (ask-user spec §5.2; settles `skipped` with
|
|
186
|
+
* `declinedVia: "button"`). A 404 `not_pending` means the record
|
|
187
|
+
* settled under the user's fingers — the caller refetches through
|
|
188
|
+
* {@link getInteraction} (the mid-stepper settle path). */
|
|
189
|
+
declineInteraction(id, callId) {
|
|
190
|
+
return this.#send("POST", `/v1/conversations/${id}/interactions/${encodeURIComponent(callId)}/decline`, {});
|
|
191
|
+
}
|
|
184
192
|
/** `GET /v1/conversations/:id/ambient`. */
|
|
185
193
|
getAmbient(id) {
|
|
186
194
|
return this.#get(`/v1/conversations/${id}/ambient`);
|
|
@@ -279,4 +287,20 @@ export class SigrunClient {
|
|
|
279
287
|
listJobRevisions(id) {
|
|
280
288
|
return this.#get(`/v1/jobs/${encodeURIComponent(id)}/revisions`);
|
|
281
289
|
}
|
|
290
|
+
/** `GET /v1/notices` — the caller's notices, newest first (notices Task 8). */
|
|
291
|
+
listNotices() {
|
|
292
|
+
return this.#get("/v1/notices");
|
|
293
|
+
}
|
|
294
|
+
/** `POST /v1/notices/:id/dismiss` — 204; a stale id 404s (callers refetch). */
|
|
295
|
+
dismissNotice(id) {
|
|
296
|
+
return this.#send("POST", `/v1/notices/${encodeURIComponent(id)}/dismiss`, {});
|
|
297
|
+
}
|
|
298
|
+
/** `POST /v1/notices/dismiss-all` — clear the caller's pile (204). */
|
|
299
|
+
dismissAllNotices() {
|
|
300
|
+
return this.#send("POST", "/v1/notices/dismiss-all", {});
|
|
301
|
+
}
|
|
302
|
+
/** `POST /v1/notices/:id/actions/:actionId` — run an extension action. */
|
|
303
|
+
runNoticeAction(id, actionId) {
|
|
304
|
+
return this.#send("POST", `/v1/notices/${encodeURIComponent(id)}/actions/${encodeURIComponent(actionId)}`, {});
|
|
305
|
+
}
|
|
282
306
|
}
|
package/dist/blocks/form/form.js
CHANGED
|
@@ -60,6 +60,21 @@ export function stepperSections(block, interaction) {
|
|
|
60
60
|
child.type === "choice"));
|
|
61
61
|
return allChoice ? sections : null;
|
|
62
62
|
}
|
|
63
|
+
/** The unanswered REQUIRED choices of the stepper (the review names
|
|
64
|
+
* them and holds the submit — §7.2 of the ask-user design). Answered
|
|
65
|
+
* means choiceValueText yields text (absent/""/[]/{other: ""} do not
|
|
66
|
+
* count). */
|
|
67
|
+
function unansweredSections(sections, ctx, values) {
|
|
68
|
+
return sections.filter((section) => {
|
|
69
|
+
for (const field of sectionFields(section)) {
|
|
70
|
+
if (field.type !== "choice" || field.required !== true)
|
|
71
|
+
continue;
|
|
72
|
+
if (choiceValueText(field, ctx, values[field.name] ?? null) === "")
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
63
78
|
/** The answerable fields of a stepper section (every field the review
|
|
64
79
|
* summarizes — §9.1's review step covers ALL of a section's fields,
|
|
65
80
|
* not only the choice). */
|
|
@@ -310,6 +325,9 @@ export class FormComponent {
|
|
|
310
325
|
const summary = answers.filter((text) => text !== "").join(", ");
|
|
311
326
|
lines.push(truncate(` ${title}: ${summary}`, width));
|
|
312
327
|
});
|
|
328
|
+
for (const section of unansweredSections(sections, this.#ctx, this.#values)) {
|
|
329
|
+
lines.push(truncate(`Not answered yet: ${bindText(section.title, this.#ctx)}`, width));
|
|
330
|
+
}
|
|
313
331
|
if (!this.#readOnly && !this.#submitted) {
|
|
314
332
|
lines.push(truncate(`> [ ${this.#submitLabel} ]`, width));
|
|
315
333
|
}
|
|
@@ -435,6 +453,10 @@ export class FormComponent {
|
|
|
435
453
|
return;
|
|
436
454
|
}
|
|
437
455
|
if (data === "\r" && !this.#submitted) {
|
|
456
|
+
// The review holds the submit while a required question is
|
|
457
|
+
// unanswered (§7.2) — Enter is ignored, not an error.
|
|
458
|
+
if (unansweredSections(sections, this.#ctx, this.#values).length > 0)
|
|
459
|
+
return;
|
|
438
460
|
if (this.#submit())
|
|
439
461
|
this.#submitted = true;
|
|
440
462
|
}
|
package/dist/keymaps/emacs.js
CHANGED
|
@@ -32,8 +32,11 @@ const BINDINGS = [
|
|
|
32
32
|
{ keys: "C-n / C-p", action: "move down / up" },
|
|
33
33
|
{ keys: "enter", action: "open / fold" },
|
|
34
34
|
{ keys: "C-o", action: "answer interaction" },
|
|
35
|
+
{ keys: "M-s", action: "decline interaction (answer mode)" },
|
|
35
36
|
{ keys: "C-l", action: "redraw" },
|
|
36
37
|
{ keys: "C-h", action: "key reference" },
|
|
38
|
+
{ keys: "M-N", action: "notices" },
|
|
39
|
+
{ keys: "M-d", action: "dismiss newest notice" },
|
|
37
40
|
{ keys: "M-m", action: "pick up (block surface)" },
|
|
38
41
|
{ keys: "enter", action: "drop (block surface)" },
|
|
39
42
|
{ keys: "C-g", action: "cancel move (block surface)" },
|
|
@@ -145,10 +148,21 @@ export function createEmacsKeymap() {
|
|
|
145
148
|
return { type: "prompt-jump", delta: 1 };
|
|
146
149
|
if (data === "\x0f")
|
|
147
150
|
return { type: "answer-interaction" };
|
|
151
|
+
// Ask-user Task 5: M-s declines the armed interaction (M-d stays
|
|
152
|
+
// the notices dismiss; the app gates decline to answer mode).
|
|
153
|
+
if (data === "\x1bs")
|
|
154
|
+
return { type: "decline-interaction" };
|
|
148
155
|
if (data === "\x0c")
|
|
149
156
|
return { type: "redraw" };
|
|
150
157
|
if (data === "\x08")
|
|
151
158
|
return { type: "help" };
|
|
159
|
+
// Notices Task 8: M-N / M-d are the notice surface in every
|
|
160
|
+
// focus — the emacs dismiss keys are upper/lower meta-letters
|
|
161
|
+
// readline never sends (C-g stays the cancel key).
|
|
162
|
+
if (data === "\x1bN")
|
|
163
|
+
return { type: "notices.open" };
|
|
164
|
+
if (data === "\x1bd")
|
|
165
|
+
return { type: "notices.dismissNewest" };
|
|
152
166
|
if (ctx.focus === "composer")
|
|
153
167
|
return "pass";
|
|
154
168
|
// Outside the composer: the transcript/list actions.
|
package/dist/keymaps/standard.js
CHANGED
|
@@ -21,6 +21,9 @@ const BINDINGS = [
|
|
|
21
21
|
{ keys: "ctrl+n", action: "new conversation" },
|
|
22
22
|
{ keys: "ctrl+t", action: "cycle thinking" },
|
|
23
23
|
{ keys: "ctrl+o", action: "answer interaction" },
|
|
24
|
+
{ keys: "alt+s", action: "decline interaction (answer mode)" },
|
|
25
|
+
{ keys: "ctrl+g", action: "notices" },
|
|
26
|
+
{ keys: "alt+d", action: "dismiss newest notice" },
|
|
24
27
|
{ keys: "ctrl+l", action: "redraw" },
|
|
25
28
|
{ keys: "ctrl+c", action: "quit" },
|
|
26
29
|
{ keys: "tab / shift+tab", action: "cycle focus" },
|
|
@@ -58,6 +61,18 @@ export function createStandardKeymap() {
|
|
|
58
61
|
return { type: "cycle-thinking" };
|
|
59
62
|
if (matchesKey(data, Key.ctrl("o")))
|
|
60
63
|
return { type: "answer-interaction" };
|
|
64
|
+
// Ask-user Task 5: alt+s declines the armed interaction (answer
|
|
65
|
+
// mode only — the app gates the action; outside answer mode this
|
|
66
|
+
// falls through as a no-op). alt+d stays the notices dismiss.
|
|
67
|
+
if (matchesKey(data, Key.alt("s")))
|
|
68
|
+
return { type: "decline-interaction" };
|
|
69
|
+
// Notices Task 8: ctrl+g (the notices overlay — a control code,
|
|
70
|
+
// never typed text) and alt+d (dismiss the inline notice; M-d is
|
|
71
|
+
// readline's kill-word, a chord reserved readline never sends).
|
|
72
|
+
if (matchesKey(data, Key.ctrl("g")))
|
|
73
|
+
return { type: "notices.open" };
|
|
74
|
+
if (data === "\x1bd")
|
|
75
|
+
return { type: "notices.dismissNewest" };
|
|
61
76
|
if (matchesKey(data, Key.ctrl("l")))
|
|
62
77
|
return { type: "redraw" };
|
|
63
78
|
if (matchesKey(data, Key.ctrl("c")))
|
package/dist/keymaps/vim.js
CHANGED
|
@@ -47,9 +47,12 @@ const BINDINGS = [
|
|
|
47
47
|
{ keys: ":", action: "command line" },
|
|
48
48
|
{ keys: "ctrl+p", action: "conversation picker" },
|
|
49
49
|
{ keys: "ctrl+o", action: "answer interaction" },
|
|
50
|
+
{ keys: "alt+s", action: "decline interaction (answer mode)" },
|
|
50
51
|
{ keys: "ctrl+l", action: "redraw" },
|
|
51
52
|
{ keys: "ctrl+c", action: "abort run (busy)" },
|
|
52
53
|
{ keys: "g?", action: "key reference" },
|
|
54
|
+
{ keys: "gN", action: "notices" },
|
|
55
|
+
{ keys: "gx", action: "dismiss newest notice" },
|
|
53
56
|
{ keys: "ZZ / :q", action: "quit" },
|
|
54
57
|
{ keys: "m", action: "pick up (block surface)" },
|
|
55
58
|
{ keys: "enter", action: "drop (block surface)" },
|
|
@@ -95,6 +98,13 @@ export function createVimKeymap(options) {
|
|
|
95
98
|
// g? is vim normal's help key — "?" stays search-backward.
|
|
96
99
|
if (prefix.key === "g" && data === "?")
|
|
97
100
|
return { type: "help" };
|
|
101
|
+
// Notices Task 8: gN / gx are the notice surface (vim's own
|
|
102
|
+
// gx open-under-cursor doesn't exist here — the TUI has no
|
|
103
|
+
// browser; the notice dismiss claims the key).
|
|
104
|
+
if (prefix.key === "g" && data === "N")
|
|
105
|
+
return { type: "notices.open" };
|
|
106
|
+
if (prefix.key === "g" && data === "x")
|
|
107
|
+
return { type: "notices.dismissNewest" };
|
|
98
108
|
if (prefix.key === "Z" && data === "Z")
|
|
99
109
|
return { type: "quit" };
|
|
100
110
|
if (prefix.key === "z" && data === "a")
|
|
@@ -123,6 +133,10 @@ export function createVimKeymap(options) {
|
|
|
123
133
|
}
|
|
124
134
|
if (matchesKey(data, Key.ctrl("o")))
|
|
125
135
|
return { type: "answer-interaction" };
|
|
136
|
+
// Ask-user Task 5: alt+s declines the armed interaction (the app
|
|
137
|
+
// gates the action to answer mode + a declinable record).
|
|
138
|
+
if (matchesKey(data, Key.alt("s")))
|
|
139
|
+
return { type: "decline-interaction" };
|
|
126
140
|
return "pass";
|
|
127
141
|
};
|
|
128
142
|
/** Block-surface keys (spec §12.4): NORMAL mode over a `surface`
|
package/dist/launch.js
CHANGED
|
@@ -220,6 +220,7 @@ export async function launchTuiShell(options) {
|
|
|
220
220
|
{ component: body, grow: 1 },
|
|
221
221
|
{ component: app.interactions, basis: "auto" },
|
|
222
222
|
{ component: app.ambient, basis: "auto" },
|
|
223
|
+
{ component: app.notices, basis: "auto" },
|
|
223
224
|
{ component: app.editor, basis: "auto" },
|
|
224
225
|
{ component: statusBar, basis: 1 },
|
|
225
226
|
]);
|
|
@@ -248,6 +249,10 @@ export async function launchTuiShell(options) {
|
|
|
248
249
|
else if (event.type === "ambient.changed") {
|
|
249
250
|
app.ambient.kick(event.conversationId);
|
|
250
251
|
}
|
|
252
|
+
else if (event.type === "notice.changed") {
|
|
253
|
+
// Notices Task 8: the user-only notice feed refetches the pile.
|
|
254
|
+
app.notices.kick();
|
|
255
|
+
}
|
|
251
256
|
else if (event.type === "surface.changed") {
|
|
252
257
|
// U3 Task 5: fan the EXT surface event out to the open view's
|
|
253
258
|
// transport (the hub matches slug+id+k). The CHAT shape (U5
|
|
@@ -267,7 +272,12 @@ export async function launchTuiShell(options) {
|
|
|
267
272
|
// entry's stream — open ext-view transports send it as
|
|
268
273
|
// X-Sigrun-Stream. A reconnect's new id re-registers on the
|
|
269
274
|
// next fetch.
|
|
270
|
-
onHello: (streamId) =>
|
|
275
|
+
onHello: (streamId) => {
|
|
276
|
+
app.setEventsStreamId(streamId);
|
|
277
|
+
// Notices Task 8: a reconnect heals the pile (the hello
|
|
278
|
+
// refetch — same posture as the webui's refetch token).
|
|
279
|
+
app.notices.kick();
|
|
280
|
+
},
|
|
271
281
|
}, {
|
|
272
282
|
baseUrl: options.baseUrl,
|
|
273
283
|
...(options.user === undefined ? {} : { user: options.user }),
|
package/dist/ui/ambient.d.ts
CHANGED
|
@@ -35,12 +35,25 @@
|
|
|
35
35
|
* has a live run ({@link setBusy}, driven by the stream's `steer.busy`).
|
|
36
36
|
*/
|
|
37
37
|
import type { Component } from "@earendil-works/pi-tui";
|
|
38
|
+
import type { Action as BlockAction, CoreTarget } from "@sigrun/shared/blocks";
|
|
38
39
|
import type { AmbientWire, SigrunClient } from "../api.js";
|
|
39
40
|
import { type TuiTheme } from "../theme.js";
|
|
40
41
|
export declare class AmbientChrome implements Component {
|
|
41
42
|
#private;
|
|
42
43
|
/** Rendered-state changes (the app requests a repaint). */
|
|
43
44
|
onChanged?: () => void;
|
|
45
|
+
/** A `navigate` action's core target (the app routes it — notices
|
|
46
|
+
* Task 8: ambient navigate actions route like notice actions). */
|
|
47
|
+
onNavigate?: (target: CoreTarget) => void;
|
|
48
|
+
/** An `open` action's URL (the app prints it — the TUI has no browser). */
|
|
49
|
+
onOpenUrl?: (url: string) => void;
|
|
50
|
+
/**
|
|
51
|
+
* Route an ambient block action (notices Task 8): `navigate` and
|
|
52
|
+
* `open` are client-side actions the read-only render context cannot
|
|
53
|
+
* answer, so the app dispatches them here. Every other action is a
|
|
54
|
+
* no-op (ambient surfaces are read-only).
|
|
55
|
+
*/
|
|
56
|
+
dispatchAmbientAction(action: BlockAction): void;
|
|
44
57
|
constructor(client: SigrunClient, options?: {
|
|
45
58
|
theme?: TuiTheme;
|
|
46
59
|
});
|
package/dist/ui/ambient.js
CHANGED
|
@@ -66,6 +66,28 @@ export class AmbientChrome {
|
|
|
66
66
|
#busy = false;
|
|
67
67
|
/** Rendered-state changes (the app requests a repaint). */
|
|
68
68
|
onChanged;
|
|
69
|
+
/** A `navigate` action's core target (the app routes it — notices
|
|
70
|
+
* Task 8: ambient navigate actions route like notice actions). */
|
|
71
|
+
onNavigate;
|
|
72
|
+
/** An `open` action's URL (the app prints it — the TUI has no browser). */
|
|
73
|
+
onOpenUrl;
|
|
74
|
+
/**
|
|
75
|
+
* Route an ambient block action (notices Task 8): `navigate` and
|
|
76
|
+
* `open` are client-side actions the read-only render context cannot
|
|
77
|
+
* answer, so the app dispatches them here. Every other action is a
|
|
78
|
+
* no-op (ambient surfaces are read-only).
|
|
79
|
+
*/
|
|
80
|
+
dispatchAmbientAction(action) {
|
|
81
|
+
if (action.type === "navigate") {
|
|
82
|
+
if (action.target !== undefined)
|
|
83
|
+
this.onNavigate?.(action.target);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (action.type === "open") {
|
|
87
|
+
this.onOpenUrl?.(action.url);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
69
91
|
constructor(client, options = {}) {
|
|
70
92
|
this.#client = client;
|
|
71
93
|
this.#theme = options.theme ?? plainTuiTheme();
|
package/dist/ui/app.d.ts
CHANGED
|
@@ -48,11 +48,12 @@
|
|
|
48
48
|
*/
|
|
49
49
|
import { Editor, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
50
50
|
import type { SigrunClient } from "../api.js";
|
|
51
|
-
import type { JsonObject } from "@sigrun/shared/blocks";
|
|
51
|
+
import type { CoreTarget, JsonObject } from "@sigrun/shared/blocks";
|
|
52
52
|
import { type Unwatch, type WatchHandlers, type WatchOptions } from "../sse.js";
|
|
53
53
|
import { type TuiTheme } from "../theme.js";
|
|
54
54
|
import type { Action, ActionHost, Focus } from "../actions.js";
|
|
55
55
|
import { AmbientChrome } from "./ambient.js";
|
|
56
|
+
import { NoticeChrome } from "./notices.js";
|
|
56
57
|
import { ConversationPane } from "./conversation-pane.js";
|
|
57
58
|
import { InteractionPrompter } from "./interactions.js";
|
|
58
59
|
import { SettingsView } from "./settings.js";
|
|
@@ -126,6 +127,8 @@ export declare class TuiApp implements Component, ActionHost {
|
|
|
126
127
|
readonly interactions: InteractionPrompter;
|
|
127
128
|
/** The ambient seam's chrome zones (Task 8; zero height when empty). */
|
|
128
129
|
readonly ambient: AmbientChrome;
|
|
130
|
+
/** The user-notice surface (notices Task 8; zero height when empty). */
|
|
131
|
+
readonly notices: NoticeChrome;
|
|
129
132
|
/** The main column's slot: renders the conversation pane normally,
|
|
130
133
|
* the open settings view while one is up (U4 Task 4 round-2 — the
|
|
131
134
|
* settings view renders IN the body slot; the launcher's root
|
|
@@ -197,6 +200,11 @@ export declare class TuiApp implements Component, ActionHost {
|
|
|
197
200
|
get overlayOpen(): boolean;
|
|
198
201
|
/** The last frame's `steer.busy` (the keymap's abort gate). */
|
|
199
202
|
get busy(): boolean;
|
|
203
|
+
/** Route a core navigation target (notices Task 8): a notice or
|
|
204
|
+
* ambient action's `navigate` — conversations select, settings
|
|
205
|
+
* opens the view, views open the ext surface, jobs flash (the TUI
|
|
206
|
+
* has no job page). Failures land as pane errors. */
|
|
207
|
+
navigateCoreTarget(target: CoreTarget): Promise<void>;
|
|
200
208
|
/**
|
|
201
209
|
* The launcher's sink: the status bar it mounted (flashes, help, and
|
|
202
210
|
* the search overlay render through it). `set` also snapshots the
|
package/dist/ui/app.js
CHANGED
|
@@ -57,6 +57,7 @@ import { editInVim, resolveVimEditor } from "../editor-launch.js";
|
|
|
57
57
|
import { copyToTerminal } from "../clipboard.js";
|
|
58
58
|
import { commandInsertText, describeCommandError, isSkillCommandName, parseSlashInput, SlashCatalog, } from "./composer.js";
|
|
59
59
|
import { AmbientChrome } from "./ambient.js";
|
|
60
|
+
import { NoticeChrome } from "./notices.js";
|
|
60
61
|
import { ConversationPane } from "./conversation-pane.js";
|
|
61
62
|
import { InteractionPrompter } from "./interactions.js";
|
|
62
63
|
import { SettingsView } from "./settings.js";
|
|
@@ -152,6 +153,8 @@ export class TuiApp {
|
|
|
152
153
|
interactions;
|
|
153
154
|
/** The ambient seam's chrome zones (Task 8; zero height when empty). */
|
|
154
155
|
ambient;
|
|
156
|
+
/** The user-notice surface (notices Task 8; zero height when empty). */
|
|
157
|
+
notices;
|
|
155
158
|
#body = new HStack();
|
|
156
159
|
#layout = new VStack();
|
|
157
160
|
/** The main column's slot: renders the conversation pane normally,
|
|
@@ -225,6 +228,7 @@ export class TuiApp {
|
|
|
225
228
|
this.editor = new Editor(options.tui, plainEditorTheme());
|
|
226
229
|
this.interactions = new InteractionPrompter(client, theme === undefined ? undefined : { theme });
|
|
227
230
|
this.ambient = new AmbientChrome(client, theme === undefined ? undefined : { theme });
|
|
231
|
+
this.notices = new NoticeChrome(client, theme === undefined ? undefined : { theme });
|
|
228
232
|
this.#body.addChild(this.sidebar, {
|
|
229
233
|
basis: options.sidebarWidth ?? 24,
|
|
230
234
|
shrink: 1,
|
|
@@ -239,12 +243,26 @@ export class TuiApp {
|
|
|
239
243
|
this.#layout.addChild(this.#body, { basis: "auto" });
|
|
240
244
|
this.#layout.addChild(this.interactions, { basis: "auto" });
|
|
241
245
|
this.#layout.addChild(this.ambient, { basis: "auto" });
|
|
246
|
+
this.#layout.addChild(this.notices, { basis: "auto" });
|
|
242
247
|
// Seam state changes repaint; answer failures are user-initiated,
|
|
243
248
|
// so they land as pane error lines (background polls fail quiet).
|
|
244
249
|
const repaint = () => options.tui.requestRender();
|
|
245
250
|
this.interactions.onChanged = repaint;
|
|
246
251
|
this.ambient.onChanged = repaint;
|
|
247
252
|
this.interactions.onError = (message) => this.pane.showError(message);
|
|
253
|
+
// Notices Task 8: list changes repaint AND update the status-bar
|
|
254
|
+
// count; action results print as pane notices; `open` prints the
|
|
255
|
+
// URL (the TUI has no browser); navigate routes through the app's
|
|
256
|
+
// core-target router (shared with ambient actions).
|
|
257
|
+
this.notices.onChanged = () => {
|
|
258
|
+
this.#paintNoticeCount();
|
|
259
|
+
repaint();
|
|
260
|
+
};
|
|
261
|
+
this.notices.onResult = (text) => this.pane.showNotice(text);
|
|
262
|
+
this.notices.onOpenUrl = (url) => this.pane.showNotice(url);
|
|
263
|
+
this.notices.onNavigate = (target) => void this.navigateCoreTarget(target);
|
|
264
|
+
this.ambient.onNavigate = (target) => void this.navigateCoreTarget(target);
|
|
265
|
+
this.ambient.onOpenUrl = (url) => this.pane.showNotice(url);
|
|
248
266
|
this.sidebar.onSelect = (selection) => {
|
|
249
267
|
if (selection === "new") {
|
|
250
268
|
void this.createAndSelect();
|
|
@@ -396,6 +414,37 @@ export class TuiApp {
|
|
|
396
414
|
get busy() {
|
|
397
415
|
return this.#busy;
|
|
398
416
|
}
|
|
417
|
+
/** Route a core navigation target (notices Task 8): a notice or
|
|
418
|
+
* ambient action's `navigate` — conversations select, settings
|
|
419
|
+
* opens the view, views open the ext surface, jobs flash (the TUI
|
|
420
|
+
* has no job page). Failures land as pane errors. */
|
|
421
|
+
async navigateCoreTarget(target) {
|
|
422
|
+
try {
|
|
423
|
+
if ("conversation" in target) {
|
|
424
|
+
await this.selectConversation(target.conversation);
|
|
425
|
+
}
|
|
426
|
+
else if ("settings" in target) {
|
|
427
|
+
await this.openSettings();
|
|
428
|
+
}
|
|
429
|
+
else if ("view" in target) {
|
|
430
|
+
await this.openExtView(target.view.extension, target.view.id, target.view.params);
|
|
431
|
+
}
|
|
432
|
+
else if ("job" in target) {
|
|
433
|
+
this.pane.showNotice(`job: ${target.job}`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
catch (error) {
|
|
437
|
+
this.pane.showError(describeCommandError("navigate", error));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/** Feed the notice count to the status bar's `notices:N` segment,
|
|
441
|
+
* merged into the steady snapshot (a flash preserves it). */
|
|
442
|
+
#paintNoticeCount() {
|
|
443
|
+
const base = this.#statusSnapshot;
|
|
444
|
+
if (this.#statusBar === undefined || base === undefined)
|
|
445
|
+
return;
|
|
446
|
+
this.#statusBar.set({ ...base, notices: this.notices.notices.length });
|
|
447
|
+
}
|
|
399
448
|
/**
|
|
400
449
|
* The launcher's sink: the status bar it mounted (flashes, help, and
|
|
401
450
|
* the search overlay render through it). `set` also snapshots the
|
|
@@ -466,6 +515,9 @@ export class TuiApp {
|
|
|
466
515
|
*/
|
|
467
516
|
async refresh() {
|
|
468
517
|
await this.sidebar.refresh();
|
|
518
|
+
// Notices Task 8: the count rides the boot refresh too (quiet —
|
|
519
|
+
// the chrome keeps its last list on a fetch failure).
|
|
520
|
+
void this.notices.refresh().catch(() => { });
|
|
469
521
|
if (this.#activeId === undefined) {
|
|
470
522
|
const first = this.sidebar.rows[0];
|
|
471
523
|
if (first !== undefined) {
|
|
@@ -547,6 +599,7 @@ export class TuiApp {
|
|
|
547
599
|
}, this.#watchOptions);
|
|
548
600
|
this.interactions.setConversation(id);
|
|
549
601
|
this.ambient.setConversation(id);
|
|
602
|
+
this.notices.setConversation(id);
|
|
550
603
|
}
|
|
551
604
|
finally {
|
|
552
605
|
this.editor.disableSubmit = false;
|
|
@@ -1096,6 +1149,10 @@ export class TuiApp {
|
|
|
1096
1149
|
if (!this.interactions.answerMode)
|
|
1097
1150
|
this.interactions.beginAnswer();
|
|
1098
1151
|
return;
|
|
1152
|
+
case "decline-interaction":
|
|
1153
|
+
// Gated inside: answer mode + a declinable record only.
|
|
1154
|
+
await this.interactions.decline();
|
|
1155
|
+
return;
|
|
1099
1156
|
case "cancel-run":
|
|
1100
1157
|
this.#abortActive();
|
|
1101
1158
|
return;
|
|
@@ -1109,6 +1166,20 @@ export class TuiApp {
|
|
|
1109
1166
|
case "help":
|
|
1110
1167
|
this.#openHelp();
|
|
1111
1168
|
return;
|
|
1169
|
+
case "notices.open": {
|
|
1170
|
+
if (this.#overlayOpen)
|
|
1171
|
+
return;
|
|
1172
|
+
this.#overlayOpen = true;
|
|
1173
|
+
this.notices.openOverlay(this.#tui, {
|
|
1174
|
+
onClose: () => {
|
|
1175
|
+
this.#overlayOpen = false;
|
|
1176
|
+
},
|
|
1177
|
+
});
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
case "notices.dismissNewest":
|
|
1181
|
+
await this.notices.dismissNewest();
|
|
1182
|
+
return;
|
|
1112
1183
|
case "command-line":
|
|
1113
1184
|
this.editor.setText("/");
|
|
1114
1185
|
return;
|
|
@@ -79,6 +79,18 @@ export declare class InteractionPrompter implements Component {
|
|
|
79
79
|
* keep typing while a prompt waits).
|
|
80
80
|
*/
|
|
81
81
|
handleKey(data: string): boolean;
|
|
82
|
+
/**
|
|
83
|
+
* Decline the armed interaction (ask-user spec §5.2 — the keymap's
|
|
84
|
+
* `decline-interaction` action routes here). Gated exactly like the
|
|
85
|
+
* affordance: answer mode armed on the top record, and the record is
|
|
86
|
+
* declinable. Success exits answer mode (the list poll drops the
|
|
87
|
+
* settled record). A 404 `not_pending` — the record settled under
|
|
88
|
+
* the user's fingers — refetches through
|
|
89
|
+
* {@link SigrunClient.getInteraction} and swaps the settled envelope
|
|
90
|
+
* in read-only (the mid-edit settle path); any other failure reports
|
|
91
|
+
* loud and keeps the card writable.
|
|
92
|
+
*/
|
|
93
|
+
decline(): Promise<void>;
|
|
82
94
|
/** Detach (the app calls this on dispose): stale fetches never land. */
|
|
83
95
|
dispose(): void;
|
|
84
96
|
/**
|
package/dist/ui/interactions.js
CHANGED
|
@@ -174,6 +174,13 @@ export class InteractionPrompter {
|
|
|
174
174
|
this.#changed();
|
|
175
175
|
return true;
|
|
176
176
|
}
|
|
177
|
+
// The decline chord is the KEYMAP's (decline-interaction action,
|
|
178
|
+
// dispatched by the launcher): pass it through so the keymap sees
|
|
179
|
+
// it — the surface fall-through below would swallow it and the
|
|
180
|
+
// chord would never fire (review fix; the same ownership rule as
|
|
181
|
+
// the prompt's other action keys).
|
|
182
|
+
if (matchesKey(data, Key.alt("s")))
|
|
183
|
+
return false;
|
|
177
184
|
this.#ensureSurface(top);
|
|
178
185
|
if (this.#surface === undefined) {
|
|
179
186
|
this.#exitAnswer();
|
|
@@ -332,6 +339,67 @@ export class InteractionPrompter {
|
|
|
332
339
|
this.#answerMode = false;
|
|
333
340
|
this.#answerCallId = undefined;
|
|
334
341
|
}
|
|
342
|
+
/**
|
|
343
|
+
* Decline the armed interaction (ask-user spec §5.2 — the keymap's
|
|
344
|
+
* `decline-interaction` action routes here). Gated exactly like the
|
|
345
|
+
* affordance: answer mode armed on the top record, and the record is
|
|
346
|
+
* declinable. Success exits answer mode (the list poll drops the
|
|
347
|
+
* settled record). A 404 `not_pending` — the record settled under
|
|
348
|
+
* the user's fingers — refetches through
|
|
349
|
+
* {@link SigrunClient.getInteraction} and swaps the settled envelope
|
|
350
|
+
* in read-only (the mid-edit settle path); any other failure reports
|
|
351
|
+
* loud and keeps the card writable.
|
|
352
|
+
*/
|
|
353
|
+
async decline() {
|
|
354
|
+
const top = this.#pending[0];
|
|
355
|
+
if (!this.#answerMode || top === undefined)
|
|
356
|
+
return;
|
|
357
|
+
if (this.#answerCallId !== top.callId)
|
|
358
|
+
return;
|
|
359
|
+
if (top.declinable !== true)
|
|
360
|
+
return;
|
|
361
|
+
const id = this.#conversationId;
|
|
362
|
+
if (id === undefined)
|
|
363
|
+
return;
|
|
364
|
+
try {
|
|
365
|
+
await this.#client.declineInteraction(id, top.callId);
|
|
366
|
+
}
|
|
367
|
+
catch (cause) {
|
|
368
|
+
const isNotPending = typeof cause === "object" &&
|
|
369
|
+
cause !== null &&
|
|
370
|
+
cause.status === 404;
|
|
371
|
+
if (!isNotPending) {
|
|
372
|
+
this.onError?.(describeCommandError("decline", cause));
|
|
373
|
+
this.#changed();
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
// 404 not_pending: the settle raced the user. Refetch; a still-
|
|
377
|
+
// pending record must NOT wedge the card read-only.
|
|
378
|
+
try {
|
|
379
|
+
const fresh = await this.#client.getInteraction(id, top.callId);
|
|
380
|
+
if (this.#surface?.record.callId !== top.callId)
|
|
381
|
+
return;
|
|
382
|
+
if (fresh.status === "pending") {
|
|
383
|
+
this.onError?.(describeCommandError("decline", cause));
|
|
384
|
+
this.#changed();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
this.#surface =
|
|
388
|
+
this.#surface === undefined
|
|
389
|
+
? undefined
|
|
390
|
+
: { ...this.#surface, settled: fresh, access: "read" };
|
|
391
|
+
this.#surface?.state.setAccess("read");
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
this.onError?.(describeCommandError("decline", cause));
|
|
395
|
+
this.#changed();
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
this.#exitAnswer();
|
|
400
|
+
void this.#refresh();
|
|
401
|
+
this.#changed();
|
|
402
|
+
}
|
|
335
403
|
/** Generation-guarded, coalesced fetch of the pending list. */
|
|
336
404
|
async #refresh() {
|
|
337
405
|
const id = this.#conversationId;
|
|
@@ -408,11 +476,18 @@ export class InteractionPrompter {
|
|
|
408
476
|
return [];
|
|
409
477
|
const lines = [];
|
|
410
478
|
const shown = this.#surface?.settled ?? top;
|
|
479
|
+
// Settled declines name their outcome (spec §5.2's "declined" /
|
|
480
|
+
// "declined — replied in chat"), never the raw 'skipped'.
|
|
481
|
+
const statusText = shown.status === "skipped" && shown.declinedVia !== undefined
|
|
482
|
+
? shown.declinedVia === "message"
|
|
483
|
+
? "declined — replied in chat"
|
|
484
|
+
: "declined"
|
|
485
|
+
: shown.status;
|
|
411
486
|
const suffix = this.#answerMode
|
|
412
487
|
? " — answering"
|
|
413
488
|
: shown.status === "pending"
|
|
414
489
|
? ""
|
|
415
|
-
: ` — ${
|
|
490
|
+
: ` — ${statusText}`;
|
|
416
491
|
lines.push(`[interaction] ${shown.kind}${suffix}`);
|
|
417
492
|
if (top.body !== undefined) {
|
|
418
493
|
this.#ensureSurface(top);
|
|
@@ -428,7 +503,9 @@ export class InteractionPrompter {
|
|
|
428
503
|
}
|
|
429
504
|
if (top.body !== undefined) {
|
|
430
505
|
lines.push(this.#answerMode
|
|
431
|
-
?
|
|
506
|
+
? top.declinable === true
|
|
507
|
+
? "1-9 answer · alt+s decline · Tab next · esc cancel"
|
|
508
|
+
: "1-9 answer · Tab next · esc cancel"
|
|
432
509
|
: "ctrl+o to answer");
|
|
433
510
|
}
|
|
434
511
|
return lines;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user notices surface (notices Task 8): the per-user notice list in
|
|
3
|
+
* the TUI — an inline line between the ambient chrome and the composer,
|
|
4
|
+
* and a centred overlay with the full pile.
|
|
5
|
+
*
|
|
6
|
+
* - The INLINE LINE shows the current conversation's newest notice
|
|
7
|
+
* (a notice carrying this conversationId), falling back to the newest
|
|
8
|
+
* app-wide notice (no conversationId). Zero height when empty.
|
|
9
|
+
* - The OVERLAY lists every notice as a plain-text card: tone marker,
|
|
10
|
+
* title, body, buttons, age (no emoji — the TUI avoids double-width
|
|
11
|
+
* glyphs). Arrows move over the action rows, Enter runs the selected
|
|
12
|
+
* action, `d` dismisses its notice, Esc/q/C-g closes.
|
|
13
|
+
*
|
|
14
|
+
* Action semantics mirror the webui's `useNotices.run`: `navigate`
|
|
15
|
+
* routes through {@link NoticeChrome.onNavigate} then dismisses, `open`
|
|
16
|
+
* prints the URL through {@link NoticeChrome.onOpenUrl} (the TUI cannot
|
|
17
|
+
* open a browser — the URL is the hand-off) then dismisses, and
|
|
18
|
+
* `extension` POSTs the action route and surfaces the response's
|
|
19
|
+
* `message`/`error` through {@link NoticeChrome.onResult}.
|
|
20
|
+
*
|
|
21
|
+
* Freshness (W3 Task 4's idiom): the app kicks on `notice.changed`
|
|
22
|
+
* host events and on the events-stream hello (a reconnect heals the
|
|
23
|
+
* list). Fetch failures are QUIET (best-effort chrome).
|
|
24
|
+
*/
|
|
25
|
+
import { type Component, type TUI } from "@earendil-works/pi-tui";
|
|
26
|
+
import type { CoreTarget } from "@sigrun/shared/blocks";
|
|
27
|
+
import type { SigrunClient, UserNotice, UserNoticeAction } from "../api.js";
|
|
28
|
+
import { type TuiTheme } from "../theme.js";
|
|
29
|
+
/** A compact relative age ("2 min", "1 h", "just now"). */
|
|
30
|
+
export declare function relativeAge(at: string, now?: number): string;
|
|
31
|
+
export interface NoticeOverlayOptions {
|
|
32
|
+
/** Fires on close so the app can clear its overlayOpen flag. */
|
|
33
|
+
readonly onClose?: () => void;
|
|
34
|
+
}
|
|
35
|
+
export declare class NoticeChrome implements Component {
|
|
36
|
+
#private;
|
|
37
|
+
/** Rendered-state changes (the app repaints + repaints the count). */
|
|
38
|
+
onChanged?: () => void;
|
|
39
|
+
/** An action result's text (the app shows it as a pane notice). */
|
|
40
|
+
onResult?: (text: string) => void;
|
|
41
|
+
/** An `open` action's URL (the app prints it — the TUI has no browser). */
|
|
42
|
+
onOpenUrl?: (url: string) => void;
|
|
43
|
+
/** A `navigate` action's core target (the app routes it). */
|
|
44
|
+
onNavigate?: (target: CoreTarget) => void;
|
|
45
|
+
constructor(client: SigrunClient, options?: {
|
|
46
|
+
theme?: TuiTheme;
|
|
47
|
+
});
|
|
48
|
+
/** The latest served notices (newest first). */
|
|
49
|
+
get notices(): readonly UserNotice[];
|
|
50
|
+
/**
|
|
51
|
+
* The inline line's notice: the newest notice scoped to the current
|
|
52
|
+
* conversation, else the newest app-wide notice, else undefined.
|
|
53
|
+
*/
|
|
54
|
+
inlineNotice(): UserNotice | undefined;
|
|
55
|
+
/** The current conversation (the inline line's scope). */
|
|
56
|
+
setConversation(id: number | undefined): void;
|
|
57
|
+
/** Refetch (`notice.changed`, a reconnect hello). Quiet on failure. */
|
|
58
|
+
kick(): void;
|
|
59
|
+
/** The list GET (quiet: a failed fetch keeps the last list). */
|
|
60
|
+
refresh(): Promise<void>;
|
|
61
|
+
/** Dismiss one notice (204); the list refetches after the write. */
|
|
62
|
+
dismiss(id: string): Promise<void>;
|
|
63
|
+
/** Dismiss the inline line's notice (the dismiss key). */
|
|
64
|
+
dismissNewest(): Promise<void>;
|
|
65
|
+
/**
|
|
66
|
+
* Run one notice action (the webui's `useNotices.run` semantics):
|
|
67
|
+
* `navigate` routes then dismisses; `open` prints the URL then
|
|
68
|
+
* dismisses; `extension` POSTs the action route, surfaces the
|
|
69
|
+
* result text, and refetches (a server-side clear is then visible).
|
|
70
|
+
*/
|
|
71
|
+
run(notice: UserNotice, action: UserNoticeAction): Promise<void>;
|
|
72
|
+
/** Open the centred overlay (Enter runs, d dismisses, Esc closes). */
|
|
73
|
+
openOverlay(tui: TUI, options?: NoticeOverlayOptions): void;
|
|
74
|
+
/** The inline line: one row, or zero height without an inline notice. */
|
|
75
|
+
render(width: number): string[];
|
|
76
|
+
invalidate(): void;
|
|
77
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user notices surface (notices Task 8): the per-user notice list in
|
|
3
|
+
* the TUI — an inline line between the ambient chrome and the composer,
|
|
4
|
+
* and a centred overlay with the full pile.
|
|
5
|
+
*
|
|
6
|
+
* - The INLINE LINE shows the current conversation's newest notice
|
|
7
|
+
* (a notice carrying this conversationId), falling back to the newest
|
|
8
|
+
* app-wide notice (no conversationId). Zero height when empty.
|
|
9
|
+
* - The OVERLAY lists every notice as a plain-text card: tone marker,
|
|
10
|
+
* title, body, buttons, age (no emoji — the TUI avoids double-width
|
|
11
|
+
* glyphs). Arrows move over the action rows, Enter runs the selected
|
|
12
|
+
* action, `d` dismisses its notice, Esc/q/C-g closes.
|
|
13
|
+
*
|
|
14
|
+
* Action semantics mirror the webui's `useNotices.run`: `navigate`
|
|
15
|
+
* routes through {@link NoticeChrome.onNavigate} then dismisses, `open`
|
|
16
|
+
* prints the URL through {@link NoticeChrome.onOpenUrl} (the TUI cannot
|
|
17
|
+
* open a browser — the URL is the hand-off) then dismisses, and
|
|
18
|
+
* `extension` POSTs the action route and surfaces the response's
|
|
19
|
+
* `message`/`error` through {@link NoticeChrome.onResult}.
|
|
20
|
+
*
|
|
21
|
+
* Freshness (W3 Task 4's idiom): the app kicks on `notice.changed`
|
|
22
|
+
* host events and on the events-stream hello (a reconnect heals the
|
|
23
|
+
* list). Fetch failures are QUIET (best-effort chrome).
|
|
24
|
+
*/
|
|
25
|
+
import { Key, matchesKey } from "@earendil-works/pi-tui";
|
|
26
|
+
import { truncate } from "../blocks/width.js";
|
|
27
|
+
import { plainTuiTheme } from "../theme.js";
|
|
28
|
+
/** A compact relative age ("2 min", "1 h", "just now"). */
|
|
29
|
+
export function relativeAge(at, now = Date.now()) {
|
|
30
|
+
const then = Date.parse(at);
|
|
31
|
+
if (Number.isNaN(then))
|
|
32
|
+
return "";
|
|
33
|
+
const seconds = Math.max(0, Math.floor((now - then) / 1000));
|
|
34
|
+
if (seconds < 45)
|
|
35
|
+
return "just now";
|
|
36
|
+
const minutes = Math.floor(seconds / 60);
|
|
37
|
+
if (minutes < 60)
|
|
38
|
+
return `${minutes} min`;
|
|
39
|
+
const hours = Math.floor(minutes / 60);
|
|
40
|
+
if (hours < 48)
|
|
41
|
+
return `${hours} h`;
|
|
42
|
+
return `${Math.floor(hours / 24)} d`;
|
|
43
|
+
}
|
|
44
|
+
/** The plain-text tone marker (ASCII only — no double-width glyphs). */
|
|
45
|
+
function toneMark(tone) {
|
|
46
|
+
switch (tone) {
|
|
47
|
+
case "bad":
|
|
48
|
+
return "[!]";
|
|
49
|
+
case "warn":
|
|
50
|
+
return "[?]";
|
|
51
|
+
case "good":
|
|
52
|
+
return "[+]";
|
|
53
|
+
case "info":
|
|
54
|
+
return "[i]";
|
|
55
|
+
case "accent":
|
|
56
|
+
return "[*]";
|
|
57
|
+
default:
|
|
58
|
+
return "[-]";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** The overlay component (mounted through `tui.showOverlay`). */
|
|
62
|
+
class NoticesOverlay {
|
|
63
|
+
#chrome;
|
|
64
|
+
#options;
|
|
65
|
+
#overlay;
|
|
66
|
+
/** The selected row index into {@link rows}. */
|
|
67
|
+
#index = 0;
|
|
68
|
+
constructor(chrome, options) {
|
|
69
|
+
this.#chrome = chrome;
|
|
70
|
+
this.#options = options;
|
|
71
|
+
}
|
|
72
|
+
setOverlay(overlay) {
|
|
73
|
+
this.#overlay = overlay;
|
|
74
|
+
}
|
|
75
|
+
/** The flattened action rows (the chrome's list, newest first). */
|
|
76
|
+
get rows() {
|
|
77
|
+
const rows = [];
|
|
78
|
+
for (const notice of this.#chrome.notices) {
|
|
79
|
+
const actions = notice.actions ?? [];
|
|
80
|
+
if (actions.length === 0) {
|
|
81
|
+
rows.push({ notice, action: undefined });
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
for (const action of actions)
|
|
85
|
+
rows.push({ notice, action });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return rows;
|
|
89
|
+
}
|
|
90
|
+
#close() {
|
|
91
|
+
this.#overlay?.hide();
|
|
92
|
+
this.#options.onClose?.();
|
|
93
|
+
}
|
|
94
|
+
#runSelected() {
|
|
95
|
+
const row = this.rows[this.#index];
|
|
96
|
+
if (row === undefined || row.action === undefined)
|
|
97
|
+
return;
|
|
98
|
+
void this.#chrome.run(row.notice, row.action);
|
|
99
|
+
}
|
|
100
|
+
#dismissSelected() {
|
|
101
|
+
const row = this.rows[this.#index];
|
|
102
|
+
if (row === undefined)
|
|
103
|
+
return;
|
|
104
|
+
void this.#chrome.dismiss(row.notice.id);
|
|
105
|
+
if (this.#index > 0)
|
|
106
|
+
this.#index -= 1;
|
|
107
|
+
}
|
|
108
|
+
handleInput(data) {
|
|
109
|
+
if (matchesKey(data, Key.escape) || data === "q" || data === "\x07") {
|
|
110
|
+
this.#close();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (matchesKey(data, Key.up) || matchesKey(data, "up")) {
|
|
114
|
+
this.#index = Math.max(0, this.#index - 1);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (matchesKey(data, Key.down) || matchesKey(data, "down")) {
|
|
118
|
+
this.#index = Math.min(this.rows.length - 1, this.#index + 1);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (matchesKey(data, Key.enter) || data === "\r") {
|
|
122
|
+
this.#runSelected();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (data === "d") {
|
|
126
|
+
this.#dismissSelected();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// Every other key is consumed: the overlay is modal.
|
|
130
|
+
}
|
|
131
|
+
render(width) {
|
|
132
|
+
const lines = [];
|
|
133
|
+
const rows = this.rows;
|
|
134
|
+
let rowIndex = 0;
|
|
135
|
+
lines.push(`notices (${rows.length}) — enter run · d dismiss · esc close`);
|
|
136
|
+
for (const notice of this.#chrome.notices) {
|
|
137
|
+
const age = relativeAge(notice.at);
|
|
138
|
+
lines.push(`${toneMark(notice.tone)} ${notice.title}${age === "" ? "" : ` · ${age}`}`);
|
|
139
|
+
if (notice.body !== undefined && notice.body !== "") {
|
|
140
|
+
lines.push(` ${notice.body}`);
|
|
141
|
+
}
|
|
142
|
+
const actions = notice.actions ?? [];
|
|
143
|
+
if (actions.length === 0) {
|
|
144
|
+
lines.push(`${rowIndex === this.#index ? "> " : " "}(dismiss)`);
|
|
145
|
+
rowIndex += 1;
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
for (const action of actions) {
|
|
149
|
+
lines.push(`${rowIndex === this.#index ? "> " : " "}${action.label}`);
|
|
150
|
+
rowIndex += 1;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (this.#chrome.notices.length === 0)
|
|
155
|
+
lines.push("no notices");
|
|
156
|
+
return lines.map((line) => truncate(line, width));
|
|
157
|
+
}
|
|
158
|
+
invalidate() { }
|
|
159
|
+
}
|
|
160
|
+
export class NoticeChrome {
|
|
161
|
+
#client;
|
|
162
|
+
#theme;
|
|
163
|
+
#notices = [];
|
|
164
|
+
#conversationId;
|
|
165
|
+
/** Monotonic fetch generation: a stale fetch never lands. */
|
|
166
|
+
#gen = 0;
|
|
167
|
+
/** Rendered-state changes (the app repaints + repaints the count). */
|
|
168
|
+
onChanged;
|
|
169
|
+
/** An action result's text (the app shows it as a pane notice). */
|
|
170
|
+
onResult;
|
|
171
|
+
/** An `open` action's URL (the app prints it — the TUI has no browser). */
|
|
172
|
+
onOpenUrl;
|
|
173
|
+
/** A `navigate` action's core target (the app routes it). */
|
|
174
|
+
onNavigate;
|
|
175
|
+
constructor(client, options = {}) {
|
|
176
|
+
this.#client = client;
|
|
177
|
+
this.#theme = options.theme ?? plainTuiTheme();
|
|
178
|
+
}
|
|
179
|
+
/** The latest served notices (newest first). */
|
|
180
|
+
get notices() {
|
|
181
|
+
return this.#notices;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The inline line's notice: the newest notice scoped to the current
|
|
185
|
+
* conversation, else the newest app-wide notice, else undefined.
|
|
186
|
+
*/
|
|
187
|
+
inlineNotice() {
|
|
188
|
+
const scoped = this.#notices.find((one) => one.conversationId !== undefined && one.conversationId === this.#conversationId);
|
|
189
|
+
if (scoped !== undefined)
|
|
190
|
+
return scoped;
|
|
191
|
+
return this.#notices.find((one) => one.conversationId === undefined);
|
|
192
|
+
}
|
|
193
|
+
/** The current conversation (the inline line's scope). */
|
|
194
|
+
setConversation(id) {
|
|
195
|
+
if (id === this.#conversationId)
|
|
196
|
+
return;
|
|
197
|
+
this.#conversationId = id;
|
|
198
|
+
this.#changed();
|
|
199
|
+
}
|
|
200
|
+
/** Refetch (`notice.changed`, a reconnect hello). Quiet on failure. */
|
|
201
|
+
kick() {
|
|
202
|
+
void this.refresh();
|
|
203
|
+
}
|
|
204
|
+
/** The list GET (quiet: a failed fetch keeps the last list). */
|
|
205
|
+
async refresh() {
|
|
206
|
+
const gen = ++this.#gen;
|
|
207
|
+
let list;
|
|
208
|
+
try {
|
|
209
|
+
list = await this.#client.listNotices();
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return; // quiet: keep the last pile
|
|
213
|
+
}
|
|
214
|
+
if (gen !== this.#gen)
|
|
215
|
+
return; // stale
|
|
216
|
+
this.#notices = list.notices;
|
|
217
|
+
this.#changed();
|
|
218
|
+
}
|
|
219
|
+
/** Dismiss one notice (204); the list refetches after the write. */
|
|
220
|
+
async dismiss(id) {
|
|
221
|
+
await this.#client.dismissNotice(id).catch(() => undefined);
|
|
222
|
+
await this.refresh();
|
|
223
|
+
}
|
|
224
|
+
/** Dismiss the inline line's notice (the dismiss key). */
|
|
225
|
+
async dismissNewest() {
|
|
226
|
+
const newest = this.inlineNotice();
|
|
227
|
+
if (newest === undefined)
|
|
228
|
+
return;
|
|
229
|
+
await this.dismiss(newest.id);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Run one notice action (the webui's `useNotices.run` semantics):
|
|
233
|
+
* `navigate` routes then dismisses; `open` prints the URL then
|
|
234
|
+
* dismisses; `extension` POSTs the action route, surfaces the
|
|
235
|
+
* result text, and refetches (a server-side clear is then visible).
|
|
236
|
+
*/
|
|
237
|
+
async run(notice, action) {
|
|
238
|
+
if (action.kind === "navigate") {
|
|
239
|
+
this.onNavigate?.(action.target);
|
|
240
|
+
await this.dismiss(notice.id);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (action.kind === "open") {
|
|
244
|
+
this.onOpenUrl?.(action.url);
|
|
245
|
+
await this.dismiss(notice.id);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
const response = await this.#client.runNoticeAction(notice.id, action.id);
|
|
250
|
+
if (response.ok && response.navigate !== undefined) {
|
|
251
|
+
this.onNavigate?.(response.navigate);
|
|
252
|
+
}
|
|
253
|
+
const text = response.ok ? response.message : response.error.message;
|
|
254
|
+
if (text !== undefined)
|
|
255
|
+
this.onResult?.(text);
|
|
256
|
+
}
|
|
257
|
+
catch (cause) {
|
|
258
|
+
// A rejected POST (404 stale card, 409 unavailable, network) is a
|
|
259
|
+
// user-facing outcome: the notice stays on the server; the pile
|
|
260
|
+
// heals to the truth on the refetch below.
|
|
261
|
+
this.onResult?.(cause instanceof Error ? cause.message : String(cause));
|
|
262
|
+
}
|
|
263
|
+
await this.refresh();
|
|
264
|
+
}
|
|
265
|
+
/** Open the centred overlay (Enter runs, d dismisses, Esc closes). */
|
|
266
|
+
openOverlay(tui, options = {}) {
|
|
267
|
+
const overlay = new NoticesOverlay(this, options);
|
|
268
|
+
const handle = tui.showOverlay(overlay, {
|
|
269
|
+
anchor: "center",
|
|
270
|
+
width: "60%",
|
|
271
|
+
maxHeight: "60%",
|
|
272
|
+
});
|
|
273
|
+
overlay.setOverlay(handle);
|
|
274
|
+
}
|
|
275
|
+
#changed() {
|
|
276
|
+
this.onChanged?.();
|
|
277
|
+
}
|
|
278
|
+
/** The inline line: one row, or zero height without an inline notice. */
|
|
279
|
+
render(width) {
|
|
280
|
+
const newest = this.inlineNotice();
|
|
281
|
+
if (newest === undefined)
|
|
282
|
+
return [];
|
|
283
|
+
const age = relativeAge(newest.at);
|
|
284
|
+
const line = `${toneMark(newest.tone)} ${newest.title}${age === "" ? "" : ` · ${age}`}`;
|
|
285
|
+
return [truncate(this.#theme.fg("muted")(line), width)];
|
|
286
|
+
}
|
|
287
|
+
invalidate() {
|
|
288
|
+
// Stateless render: the line assembles from the current list.
|
|
289
|
+
}
|
|
290
|
+
}
|
package/dist/ui/status-bar.d.ts
CHANGED
|
@@ -27,6 +27,8 @@ export interface StatusBarState {
|
|
|
27
27
|
readonly thinking?: string;
|
|
28
28
|
/** 0-100; absent → no ctx segment. */
|
|
29
29
|
readonly contextPct?: number;
|
|
30
|
+
/** The caller's notice count (notices Task 8); absent → no segment. */
|
|
31
|
+
readonly notices?: number;
|
|
30
32
|
readonly hints: readonly string[];
|
|
31
33
|
/** Transient notice; replaces the hints for 4 s. */
|
|
32
34
|
readonly message?: string;
|
package/dist/ui/status-bar.js
CHANGED
|
@@ -28,6 +28,8 @@ function rightCluster(state) {
|
|
|
28
28
|
parts.push(state.thinking);
|
|
29
29
|
if (state.contextPct !== undefined)
|
|
30
30
|
parts.push(`ctx ${Math.round(state.contextPct)}%`);
|
|
31
|
+
if (state.notices !== undefined && state.notices > 0)
|
|
32
|
+
parts.push(`notices:${state.notices}`);
|
|
31
33
|
const head = parts.join(SEPARATOR);
|
|
32
34
|
const tail = state.commandLine ?? state.message ?? state.hints.join(" ");
|
|
33
35
|
return tail === "" ? head : `${head}${SEPARATOR}${tail}`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sigrun/tui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "sigrun terminal client (pi-tui based) over the \u00a75 API \u2014 spawned by the sigrun CLI, or standalone against a server URL",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@earendil-works/pi-tui": "^1.3.0",
|
|
24
|
-
"@sigrun/shared": "0.
|
|
24
|
+
"@sigrun/shared": "0.9.0"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"typescript": "^7.0.2",
|