@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 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
  }
@@ -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
  }
@@ -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.
@@ -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")))
@@ -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) => app.setEventsStreamId(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 }),
@@ -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
  });
@@ -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
  /**
@@ -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
- : ` — ${shown.status}`;
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
- ? "1-9 answer · Tab next · esc cancel"
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
+ }
@@ -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;
@@ -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.7.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.7.0"
24
+ "@sigrun/shared": "0.9.0"
25
25
  },
26
26
  "devDependencies": {
27
27
  "typescript": "^7.0.2",