@sigrun/tui 0.9.0 → 0.10.1

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,16 @@ 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.10.1] - 2026-11-06
10
+
11
+ - Version-locked bump with the 0.10.1 core line (no changes).
12
+
13
+ ## [0.10.0] - 2026-11-06
14
+
15
+ - Extension views render in the main pane (mount, key routing, overlay-aware Esc
16
+ back-to-list/close, dispose) with block navigation (`SurfaceState.onNavigate`), notice
17
+ flashes on the status bar, and the ext-transport URL fix (suffix routes after `?params=`).
18
+
9
19
  ## [0.9.0] - 2026-11-05
10
20
 
11
21
  - Decline on declinable interaction cards (`alt+s` in answer mode; `alt+d` is the
@@ -2,8 +2,8 @@
2
2
  * The TUI's extension-surface transport (UI-blocks U3 Task 5): implements
3
3
  * the shared `SurfaceTransport` contract over the U3 routes (spec §7.2)
4
4
  * as the TUI's remote client. One transport = one surface instance
5
- * (slug + id + params); the wire `k` (hashed instance key) keys the
6
- * change-event routing in {@link ExtSurfaceHub}.
5
+ * (slug + id + params); the hub routes change events by (slug, id) —
6
+ * the wire `k` is not matched (see ExtSurfaceHub).
7
7
  *
8
8
  * Like the webui twin, every fetch carries `X-Sigrun-Stream` once the
9
9
  * launcher hands the stream id in (the watcher entry's stream);
@@ -52,14 +52,22 @@ export declare function createExtSurfaceTransport(options: ExtSurfaceTransportOp
52
52
  /**
53
53
  * The change-event router (the launcher's dispatch seam): `attach`
54
54
  * creates a transport whose `onChanged` subscribes into the hub under
55
- * the instance's (slug, id, k); `dispatch` fans a `surface.changed`
56
- * event out to the matching subscriptions only.
55
+ * the instance's (slug, id); `dispatch` fans a `surface.changed` event
56
+ * out to every subscription of the event's view. The event's `k` is NOT
57
+ * matched: the server hashes the principal-scoped instance key (jobs /
58
+ * skills / curator fold the user in), which no client hash can
59
+ * reproduce. The server already targets the event at the streams
60
+ * watching that exact instance (watchers.streamsFor), so matching the
61
+ * view is safe; a second instance of the same view refetches once.
57
62
  */
58
63
  export declare class ExtSurfaceHub {
59
64
  #private;
60
- /** Create a transport whose `onChanged` subscribes into this hub. */
61
- attach(options: ExtSurfaceTransportOptions, keyHash: string): ExtSurfaceTransportHandle;
62
- /** Fire every subscription matching the event's (slug, id, k). */
65
+ /** Create a transport whose `onChanged` subscribes into this hub. The
66
+ * `keyHash` argument is retained for call-site compatibility (dispatch
67
+ * no longer routes on it — see the class doc). */
68
+ attach(options: ExtSurfaceTransportOptions, _keyHash: string): ExtSurfaceTransportHandle;
69
+ /** Fire every subscription of the event's (slug, id), whatever the
70
+ * event's `k`. */
63
71
  dispatch(event: {
64
72
  readonly slug: string;
65
73
  readonly id: string;
@@ -2,8 +2,8 @@
2
2
  * The TUI's extension-surface transport (UI-blocks U3 Task 5): implements
3
3
  * the shared `SurfaceTransport` contract over the U3 routes (spec §7.2)
4
4
  * as the TUI's remote client. One transport = one surface instance
5
- * (slug + id + params); the wire `k` (hashed instance key) keys the
6
- * change-event routing in {@link ExtSurfaceHub}.
5
+ * (slug + id + params); the hub routes change events by (slug, id) —
6
+ * the wire `k` is not matched (see ExtSurfaceHub).
7
7
  *
8
8
  * Like the webui twin, every fetch carries `X-Sigrun-Stream` once the
9
9
  * launcher hands the stream id in (the watcher entry's stream);
@@ -28,13 +28,24 @@ export function surfaceKeyHash(slug, id, key) {
28
28
  export function transportKey(options) {
29
29
  return instanceKey(options.params ?? {});
30
30
  }
31
+ /** The surface path WITHOUT any query — suffix routes (/data, /actions,
32
+ * /uploads) build from this and add their own query last (appending a
33
+ * suffix after `?params=` would land after the query string — the
34
+ * webui's E8 find, now the TUI's fetchData fix). */
35
+ function surfaceBase(options) {
36
+ return `/v1/ext/${encodeURIComponent(options.slug)}/surfaces/${encodeURIComponent(options.id)}`;
37
+ }
38
+ /** The URL-encoded params JSON. */
39
+ function paramsQuery(options) {
40
+ return encodeURIComponent(JSON.stringify(options.params ?? {}));
41
+ }
31
42
  /** The base path for one surface instance (`?params=` omitted when `{}`). */
32
43
  function surfacePath(options) {
33
- const base = `/v1/ext/${encodeURIComponent(options.slug)}/surfaces/${encodeURIComponent(options.id)}`;
44
+ const base = surfaceBase(options);
34
45
  if (options.params === undefined || Object.keys(options.params).length === 0) {
35
46
  return base;
36
47
  }
37
- return `${base}?params=${encodeURIComponent(JSON.stringify(options.params))}`;
48
+ return `${base}?params=${paramsQuery(options)}`;
38
49
  }
39
50
  function withQuery(path, key, value) {
40
51
  return `${path}${path.includes("?") ? "&" : "?"}${key}=${value}`;
@@ -75,11 +86,11 @@ export function createExtSurfaceTransport(options) {
75
86
  return get(surfacePath(options));
76
87
  },
77
88
  fetchData(since) {
78
- const path = `${surfacePath(options)}/data`;
89
+ const path = `${surfaceBase(options)}/data?params=${paramsQuery(options)}`;
79
90
  return get(since === undefined ? path : withQuery(path, "since", String(since)));
80
91
  },
81
92
  async act(body) {
82
- const response = await doFetch(`${baseUrl}${surfacePath(options)}/actions`, {
93
+ const response = await doFetch(`${baseUrl}${surfaceBase(options)}/actions`, {
83
94
  method: "POST",
84
95
  headers: { ...headers(), "content-type": "application/json" },
85
96
  body: JSON.stringify({ ...body, params: options.params ?? {} }),
@@ -87,7 +98,7 @@ export function createExtSurfaceTransport(options) {
87
98
  return (await response.json());
88
99
  },
89
100
  async upload(body) {
90
- const base = surfacePath(options);
101
+ const base = surfaceBase(options);
91
102
  const path = withQuery(`${baseUrl}${base}/uploads/${encodeURIComponent(body.field)}/${encodeURIComponent(body.name)}`, "params", encodeURIComponent(JSON.stringify(options.params ?? {})));
92
103
  const response = await doFetch(path, {
93
104
  method: "PUT",
@@ -119,13 +130,20 @@ export function createExtSurfaceTransport(options) {
119
130
  /**
120
131
  * The change-event router (the launcher's dispatch seam): `attach`
121
132
  * creates a transport whose `onChanged` subscribes into the hub under
122
- * the instance's (slug, id, k); `dispatch` fans a `surface.changed`
123
- * event out to the matching subscriptions only.
133
+ * the instance's (slug, id); `dispatch` fans a `surface.changed` event
134
+ * out to every subscription of the event's view. The event's `k` is NOT
135
+ * matched: the server hashes the principal-scoped instance key (jobs /
136
+ * skills / curator fold the user in), which no client hash can
137
+ * reproduce. The server already targets the event at the streams
138
+ * watching that exact instance (watchers.streamsFor), so matching the
139
+ * view is safe; a second instance of the same view refetches once.
124
140
  */
125
141
  export class ExtSurfaceHub {
126
142
  #subscriptions = new Set();
127
- /** Create a transport whose `onChanged` subscribes into this hub. */
128
- attach(options, keyHash) {
143
+ /** Create a transport whose `onChanged` subscribes into this hub. The
144
+ * `keyHash` argument is retained for call-site compatibility (dispatch
145
+ * no longer routes on it — see the class doc). */
146
+ attach(options, _keyHash) {
129
147
  const base = createExtSurfaceTransport(options);
130
148
  const hub = this;
131
149
  const transport = {
@@ -138,7 +156,6 @@ export class ExtSurfaceHub {
138
156
  const subscription = {
139
157
  slug: options.slug,
140
158
  id: options.id,
141
- keyHash,
142
159
  listener,
143
160
  };
144
161
  hub.#subscriptions.add(subscription);
@@ -152,12 +169,12 @@ export class ExtSurfaceHub {
152
169
  setStreamId: (next) => base.setStreamId(next),
153
170
  };
154
171
  }
155
- /** Fire every subscription matching the event's (slug, id, k). */
172
+ /** Fire every subscription of the event's (slug, id), whatever the
173
+ * event's `k`. */
156
174
  dispatch(event) {
157
175
  for (const subscription of this.#subscriptions) {
158
176
  if (subscription.slug === event.slug &&
159
- subscription.id === event.id &&
160
- subscription.keyHash === event.k) {
177
+ subscription.id === event.id) {
161
178
  subscription.listener({ rev: event.rev, treeRev: event.treeRev });
162
179
  }
163
180
  }
@@ -27,7 +27,7 @@
27
27
  * identical to U1 (a move against an unknown handler still goes to the
28
28
  * transport, unoverlaid).
29
29
  */
30
- import type { Action, JsonObject, JsonValue, SurfaceTransport, Tree } from "@sigrun/shared/blocks";
30
+ import type { Action, CoreTarget, JsonObject, JsonValue, SurfaceTransport, Tree } from "@sigrun/shared/blocks";
31
31
  /** An action has 10 s to answer before it counts as a reject (§6.3). */
32
32
  export declare const ACT_TIMEOUT_MS = 10000;
33
33
  /** The notice cap: pending notices drop oldest-first past 50 (a flood
@@ -49,6 +49,14 @@ export interface SurfaceView {
49
49
  /** The surface state container (class shape — the TUI has no hook layer). */
50
50
  export declare class SurfaceState {
51
51
  #private;
52
+ /** The shell's navigate sink (jobs-editor Task 7): navigate actions
53
+ * and accepted results' `navigate` route here — shell-local, never
54
+ * posted (spec §6.1; the webui's useSurface.onNavigate). */
55
+ onNavigate: ((action: {
56
+ type: "navigate";
57
+ params?: JsonObject;
58
+ target?: CoreTarget;
59
+ }) => void) | undefined;
52
60
  constructor(transport: SurfaceTransport);
53
61
  /** The current snapshot: confirmed data with the overlay applied. */
54
62
  get view(): SurfaceView;
@@ -75,6 +83,8 @@ export declare class SurfaceState {
75
83
  load(): Promise<void>;
76
84
  /** Stop listening and cancel every pending action timer. */
77
85
  dispose(): void;
78
- /** Dispatch an action to the surface; a move joins the overlay first. */
86
+ /** Dispatch an action to the surface; a move joins the overlay first.
87
+ * A navigate action is SHELL-LOCAL: it routes through `onNavigate`
88
+ * and never reaches the transport (spec §6.1). */
79
89
  dispatch(action: Action, payload: JsonValue): void;
80
90
  }
@@ -190,6 +190,10 @@ export class SurfaceState {
190
190
  * never reaches the transport. */
191
191
  #viewBranch = {};
192
192
  #actionSeq = 0;
193
+ /** The shell's navigate sink (jobs-editor Task 7): navigate actions
194
+ * and accepted results' `navigate` route here — shell-local, never
195
+ * posted (spec §6.1; the webui's useSurface.onNavigate). */
196
+ onNavigate;
193
197
  /** The highest rev a change event announced (gap detect). */
194
198
  #announcedRev = 0;
195
199
  #timers = new Map();
@@ -403,8 +407,14 @@ export class SurfaceState {
403
407
  // event or keepalive retries (§7.3).
404
408
  }
405
409
  }
406
- /** Dispatch an action to the surface; a move joins the overlay first. */
410
+ /** Dispatch an action to the surface; a move joins the overlay first.
411
+ * A navigate action is SHELL-LOCAL: it routes through `onNavigate`
412
+ * and never reaches the transport (spec §6.1). */
407
413
  dispatch(action, payload) {
414
+ if (action.type === "navigate") {
415
+ this.onNavigate?.(action);
416
+ return;
417
+ }
408
418
  const payloadObject = typeof payload === "object" && payload !== null && !Array.isArray(payload)
409
419
  ? payload
410
420
  : {};
@@ -451,8 +461,17 @@ export class SurfaceState {
451
461
  return;
452
462
  }
453
463
  this.#serverErrors = {};
454
- if (result.toast !== undefined)
455
- this.#notice(result.toast);
464
+ // An accepted act's `navigate` routes through the shell LAST
465
+ // (jobs-editor Task 7, plan-review P2): after the toast and
466
+ // after the non-move refetch is kicked off — the app's
467
+ // handler may dispose this state.
468
+ const finishAccept = () => {
469
+ if (result.toast !== undefined)
470
+ this.#notice(result.toast);
471
+ if (result.navigate?.type === "navigate") {
472
+ this.onNavigate?.(result.navigate);
473
+ }
474
+ };
456
475
  if (!isMove) {
457
476
  // A non-move accept (the settings save): the rev already
458
477
  // bumped server-side (the optimistic check needs the form's
@@ -462,6 +481,7 @@ export class SurfaceState {
462
481
  // surface.changed hub — the app owns the probe).
463
482
  this.#rev = result.rev;
464
483
  void this.#fullRefetch();
484
+ finishAccept();
465
485
  return;
466
486
  }
467
487
  if (result.ops !== undefined) {
@@ -471,6 +491,7 @@ export class SurfaceState {
471
491
  catch (error) {
472
492
  if (error instanceof DataOpError) {
473
493
  void this.#fullRefetch();
494
+ finishAccept();
474
495
  return;
475
496
  }
476
497
  throw error;
@@ -483,6 +504,7 @@ export class SurfaceState {
483
504
  }
484
505
  this.#overlay = this.#overlay.filter((entry) => entry.actionId !== actionId);
485
506
  this.#emit();
507
+ finishAccept();
486
508
  }
487
509
  else {
488
510
  // Accept without ops: hold the entry until confirmed reaches
@@ -495,6 +517,7 @@ export class SurfaceState {
495
517
  this.#rev = Math.max(this.#rev, result.rev);
496
518
  this.#overlay = this.#overlay.map((entry) => entry.actionId === actionId ? { ...entry, untilRev: result.rev } : entry);
497
519
  this.#emit();
520
+ finishAccept();
498
521
  }
499
522
  })
500
523
  .catch(() => {
package/dist/ui/app.d.ts CHANGED
@@ -49,6 +49,7 @@
49
49
  import { Editor, type Component, type TUI } from "@earendil-works/pi-tui";
50
50
  import type { SigrunClient } from "../api.js";
51
51
  import type { CoreTarget, JsonObject } from "@sigrun/shared/blocks";
52
+ import { SurfaceState } from "../blocks/surface.js";
52
53
  import { type Unwatch, type WatchHandlers, type WatchOptions } from "../sse.js";
53
54
  import { type TuiTheme } from "../theme.js";
54
55
  import type { Action, ActionHost, Focus } from "../actions.js";
@@ -150,7 +151,10 @@ export declare class TuiApp implements Component, ActionHost {
150
151
  get extView(): {
151
152
  readonly slug: string;
152
153
  readonly id: string;
154
+ readonly params: JsonObject;
153
155
  } | undefined;
156
+ /** The open view's surface state (tests + Task 7's navigation). */
157
+ get extViewState(): SurfaceState | undefined;
154
158
  /**
155
159
  * Open an extension VIEW in the main pane (U3 Task 5, spec §7.1):
156
160
  * the sidebar's view selection swaps the pane's content to the
package/dist/ui/app.js CHANGED
@@ -50,6 +50,8 @@ import { Editor, HStack, Key, matchesKey, VStack, } from "@earendil-works/pi-tui
50
50
  import { SigrunApiError } from "../api.js";
51
51
  import { ExtSurfaceHub, surfaceKeyHash, transportKey, } from "../blocks/ext-transport.js";
52
52
  import { ChatSurfaceHub } from "../blocks/chat-transport.js";
53
+ import { SurfaceState } from "../blocks/surface.js";
54
+ import { SurfaceView } from "../blocks/surface-view.js";
53
55
  import { watchConversation } from "../sse.js";
54
56
  import { spawnSync } from "node:child_process";
55
57
  import { plainTuiTheme } from "../theme.js";
@@ -306,7 +308,15 @@ export class TuiApp {
306
308
  get extView() {
307
309
  return this.#extView === undefined
308
310
  ? undefined
309
- : { slug: this.#extView.slug, id: this.#extView.id };
311
+ : {
312
+ slug: this.#extView.slug,
313
+ id: this.#extView.id,
314
+ params: this.#extView.params,
315
+ };
316
+ }
317
+ /** The open view's surface state (tests + Task 7's navigation). */
318
+ get extViewState() {
319
+ return this.#extView?.state;
310
320
  }
311
321
  /**
312
322
  * Open an extension VIEW in the main pane (U3 Task 5, spec §7.1):
@@ -315,11 +325,12 @@ export class TuiApp {
315
325
  * the transport + identity here so U2's mount is one prop pass.
316
326
  */
317
327
  async openExtView(slug, id, params) {
318
- const key = transportKey({ slug, id, baseUrl: this.#watchOptions.baseUrl, ...(params === undefined ? {} : { params }) });
328
+ const instanceParams = params ?? {};
329
+ const key = transportKey({ slug, id, baseUrl: this.#watchOptions.baseUrl, params: instanceParams });
319
330
  const handle = this.#extHub.attach({
320
331
  slug,
321
332
  id,
322
- ...(params === undefined ? {} : { params }),
333
+ params: instanceParams,
323
334
  baseUrl: this.#watchOptions.baseUrl,
324
335
  ...(this.#watchOptions.user === undefined
325
336
  ? {}
@@ -330,8 +341,44 @@ export class TuiApp {
330
341
  ...(this.#eventsStreamId === undefined
331
342
  ? {}
332
343
  : { streamId: this.#eventsStreamId }),
344
+ // A test-injected client fetch carries the view's loads (the
345
+ // settings view's shape; production's client leaves it ambient).
346
+ ...(this.#client.fetch === undefined
347
+ ? {}
348
+ : { fetch: this.#client.fetch }),
333
349
  }, surfaceKeyHash(slug, id, key));
334
- this.#extView = { slug, id, handle };
350
+ // A swap disposes the previous view's state BEFORE the new one
351
+ // opens (the settings view's openSection shape): no orphaned
352
+ // subscriptions or action timers survive a re-open.
353
+ this.#extView?.state.dispose();
354
+ const state = new SurfaceState(handle.transport);
355
+ // Navigation wiring (jobs-editor Task 7): a navigate action or an
356
+ // accepted result's `navigate` reopens THIS view with the params
357
+ // (params-only) or routes a core target through the shell — never
358
+ // posted to the server (SurfaceState.dispatch short-circuits it).
359
+ state.onNavigate = (nav) => nav.target !== undefined
360
+ ? void this.navigateCoreTarget(nav.target)
361
+ : void this.openExtView(slug, id, nav.params ?? {});
362
+ const view = new SurfaceView(state, {
363
+ theme: this.#theme,
364
+ unicode: this.#unicode,
365
+ });
366
+ state.onChange(() => {
367
+ // The surface's notices (action toasts, rejections) land as
368
+ // pane notices (the post-close backlog) AND flash the status
369
+ // bar while mounted — the pane is not painted while this view
370
+ // owns the main slot, so the flash is the visible channel
371
+ // (SurfaceState's contract names the status bar as the host
372
+ // notice seam).
373
+ for (const notice of state.consumeNotices()) {
374
+ this.pane.showNotice(notice);
375
+ this.#flashStatus(notice);
376
+ }
377
+ this.#tui.requestRender();
378
+ });
379
+ this.#extView = { slug, id, params: instanceParams, handle, state, view };
380
+ this.mainContent.set(view);
381
+ void state.load();
335
382
  this.#tui.requestRender();
336
383
  }
337
384
  /**
@@ -388,7 +435,9 @@ export class TuiApp {
388
435
  closeExtView() {
389
436
  if (this.#extView === undefined)
390
437
  return false;
438
+ this.#extView.state.dispose();
391
439
  this.#extView = undefined;
440
+ this.mainContent.set(this.pane);
392
441
  this.#tui.requestRender();
393
442
  return true;
394
443
  }
@@ -725,11 +774,28 @@ export class TuiApp {
725
774
  this.#abortActive();
726
775
  return true;
727
776
  }
728
- // Esc closes an open extension view — back to the
729
- // conversation pane; the surface's fetches stop (the keepalive
730
- // stops with the view, spec §7.3's stop-when-hidden).
731
- if (matchesKey(data, Key.escape) && this.#extView !== undefined) {
732
- return this.closeExtView();
777
+ // The open extension view owns its keys (Task 6): Esc forwards to
778
+ // the view while its page has an overlay (actions menu, sidebar
779
+ // drawer, pushed detail — spec §12.3's page contract); otherwise
780
+ // Esc with params reopens the view at the list (`{}`), and Esc at
781
+ // the list closes the view. Non-Esc keys route to the view.
782
+ if (this.#extView !== undefined) {
783
+ if (matchesKey(data, Key.escape)) {
784
+ const inner = this.#extView.view.inner;
785
+ if (inner?.hasOverlay === true) {
786
+ this.#extView.view.handleInput(data);
787
+ return true;
788
+ }
789
+ if (Object.keys(this.#extView.params).length > 0) {
790
+ void this.openExtView(this.#extView.slug, this.#extView.id, {}).catch((error) => {
791
+ this.pane.showError(describeCommandError("open view", error));
792
+ });
793
+ return true;
794
+ }
795
+ return this.closeExtView();
796
+ }
797
+ this.#extView.view.handleInput(data);
798
+ return true;
733
799
  }
734
800
  return false;
735
801
  }
@@ -970,6 +1036,8 @@ export class TuiApp {
970
1036
  this.#restartGeneration += 1;
971
1037
  this.interactions.dispose();
972
1038
  this.ambient.dispose();
1039
+ this.#extView?.state.dispose();
1040
+ this.#extView = undefined;
973
1041
  // The pane's chat-block surface states hold live hub subscriptions
974
1042
  // + action timers — the shell owns their teardown (the pane never
975
1043
  // leaves a subscription behind).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigrun/tui",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
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.9.0"
24
+ "@sigrun/shared": "0.10.1"
25
25
  },
26
26
  "devDependencies": {
27
27
  "typescript": "^7.0.2",