@sigrun/tui 0.1.0 → 0.1.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.
Files changed (84) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/actions.d.ts +43 -2
  3. package/dist/api.d.ts +5 -0
  4. package/dist/api.js +5 -1
  5. package/dist/blocks/braille.d.ts +41 -0
  6. package/dist/blocks/braille.js +131 -0
  7. package/dist/blocks/chart.d.ts +20 -0
  8. package/dist/blocks/chart.js +397 -0
  9. package/dist/blocks/collection/calendar.d.ts +28 -0
  10. package/dist/blocks/collection/calendar.js +232 -0
  11. package/dist/blocks/collection/card.d.ts +36 -0
  12. package/dist/blocks/collection/card.js +128 -0
  13. package/dist/blocks/collection/collection.d.ts +9 -0
  14. package/dist/blocks/collection/collection.js +26 -0
  15. package/dist/blocks/collection/grid.d.ts +18 -0
  16. package/dist/blocks/collection/grid.js +72 -0
  17. package/dist/blocks/collection/lanes.d.ts +32 -0
  18. package/dist/blocks/collection/lanes.js +320 -0
  19. package/dist/blocks/collection/list.d.ts +30 -0
  20. package/dist/blocks/collection/list.js +273 -0
  21. package/dist/blocks/collection/move.d.ts +67 -0
  22. package/dist/blocks/collection/move.js +96 -0
  23. package/dist/blocks/collection/shared.d.ts +87 -0
  24. package/dist/blocks/collection/shared.js +177 -0
  25. package/dist/blocks/collection/table.d.ts +26 -0
  26. package/dist/blocks/collection/table.js +221 -0
  27. package/dist/blocks/content.d.ts +48 -0
  28. package/dist/blocks/content.js +253 -0
  29. package/dist/blocks/context.d.ts +96 -0
  30. package/dist/blocks/context.js +65 -0
  31. package/dist/blocks/diff.d.ts +21 -0
  32. package/dist/blocks/diff.js +25 -0
  33. package/dist/blocks/ext-transport.d.ts +70 -0
  34. package/dist/blocks/ext-transport.js +165 -0
  35. package/dist/blocks/form/choice.d.ts +54 -0
  36. package/dist/blocks/form/choice.js +175 -0
  37. package/dist/blocks/form/fields.d.ts +56 -0
  38. package/dist/blocks/form/fields.js +136 -0
  39. package/dist/blocks/form/file.d.ts +61 -0
  40. package/dist/blocks/form/file.js +121 -0
  41. package/dist/blocks/form/form.d.ts +53 -0
  42. package/dist/blocks/form/form.js +703 -0
  43. package/dist/blocks/form/list.d.ts +37 -0
  44. package/dist/blocks/form/list.js +70 -0
  45. package/dist/blocks/form/values.d.ts +20 -0
  46. package/dist/blocks/form/values.js +104 -0
  47. package/dist/blocks/icons.d.ts +14 -0
  48. package/dist/blocks/icons.js +74 -0
  49. package/dist/blocks/image.d.ts +39 -0
  50. package/dist/blocks/image.js +82 -0
  51. package/dist/blocks/layout.d.ts +31 -0
  52. package/dist/blocks/layout.js +155 -0
  53. package/dist/blocks/log.d.ts +24 -0
  54. package/dist/blocks/log.js +111 -0
  55. package/dist/blocks/meter.d.ts +16 -0
  56. package/dist/blocks/meter.js +50 -0
  57. package/dist/blocks/page.d.ts +54 -0
  58. package/dist/blocks/page.js +355 -0
  59. package/dist/blocks/render.d.ts +31 -0
  60. package/dist/blocks/render.js +145 -0
  61. package/dist/blocks/surface-view.d.ts +33 -0
  62. package/dist/blocks/surface-view.js +86 -0
  63. package/dist/blocks/surface.d.ts +75 -0
  64. package/dist/blocks/surface.js +468 -0
  65. package/dist/blocks/tones.d.ts +23 -0
  66. package/dist/blocks/tones.js +63 -0
  67. package/dist/blocks/treeview.d.ts +22 -0
  68. package/dist/blocks/treeview.js +121 -0
  69. package/dist/blocks/width.d.ts +32 -0
  70. package/dist/blocks/width.js +70 -0
  71. package/dist/keymaps/emacs.js +39 -0
  72. package/dist/keymaps/standard.js +45 -1
  73. package/dist/keymaps/vim.js +37 -0
  74. package/dist/launch.js +13 -0
  75. package/dist/sse.d.ts +8 -0
  76. package/dist/sse.js +27 -0
  77. package/dist/theme.d.ts +2 -2
  78. package/dist/theme.js +16 -2
  79. package/dist/ui/app.d.ts +36 -0
  80. package/dist/ui/app.js +84 -0
  81. package/dist/ui/interactions.js +2 -7
  82. package/dist/ui/sidebar.d.ts +25 -2
  83. package/dist/ui/sidebar.js +64 -0
  84. package/package.json +3 -3
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Terminal width for block rendering (UI-blocks U2 Task 1): the width
3
+ * classes (spec §12.1), the `s`/`m`/`l` token scale (spec §5.2), and the
4
+ * clamp/pad helpers every block renderer funnels through.
5
+ *
6
+ * The display-width measure is pi-tui's `visibleWidth` (grapheme-aware,
7
+ * wcwidth-style, ANSI-safe) — re-exported here so block code has ONE
8
+ * width import site and the Review-Focus-2 invariant ("every rendered
9
+ * line's display width ≤ the pane") is enforced in one place:
10
+ * {@link truncate}.
11
+ */
12
+ import type { Size } from "@sigrun/shared/blocks";
13
+ /** The container width classes (spec §12.1, TUI column). */
14
+ export type WidthClass = "compact" | "regular" | "wide";
15
+ /** The compact/regular/wide boundary: cells (compact < 80 ≤ regular < 140 ≤ wide). */
16
+ export declare function widthClassOf(cells: number): WidthClass;
17
+ /** Width tokens in cells (spec §5.2: the TUI maps s/m/l to 30/45/60). */
18
+ export declare const SIZE_CELLS: Readonly<Record<Size, number>>;
19
+ /**
20
+ * Clamp one rendered line to `width` cells with an ellipsis (Review
21
+ * Focus 2): ANSI-aware, wide-character-aware, never a mid-sequence cut.
22
+ * Empty lines pass through.
23
+ */
24
+ export declare function truncate(line: string, width: number): string;
25
+ /** Pad a line to `width` cells by its DISPLAY width (for column joins). */
26
+ export declare function pad(line: string, width: number): string;
27
+ /**
28
+ * Fit a run of source lines into `width` cells: short runs stay one
29
+ * line (truncated); longer word-wraps fold at spaces, each folded line
30
+ * truncated. Used for plain-text values (markdown wraps itself).
31
+ */
32
+ export declare function wrapPlain(text: string, width: number): string[];
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Terminal width for block rendering (UI-blocks U2 Task 1): the width
3
+ * classes (spec §12.1), the `s`/`m`/`l` token scale (spec §5.2), and the
4
+ * clamp/pad helpers every block renderer funnels through.
5
+ *
6
+ * The display-width measure is pi-tui's `visibleWidth` (grapheme-aware,
7
+ * wcwidth-style, ANSI-safe) — re-exported here so block code has ONE
8
+ * width import site and the Review-Focus-2 invariant ("every rendered
9
+ * line's display width ≤ the pane") is enforced in one place:
10
+ * {@link truncate}.
11
+ */
12
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
13
+ /** The compact/regular/wide boundary: cells (compact < 80 ≤ regular < 140 ≤ wide). */
14
+ export function widthClassOf(cells) {
15
+ if (cells < 80)
16
+ return "compact";
17
+ if (cells < 140)
18
+ return "regular";
19
+ return "wide";
20
+ }
21
+ /** Width tokens in cells (spec §5.2: the TUI maps s/m/l to 30/45/60). */
22
+ export const SIZE_CELLS = {
23
+ s: 30,
24
+ m: 45,
25
+ l: 60,
26
+ };
27
+ /**
28
+ * Clamp one rendered line to `width` cells with an ellipsis (Review
29
+ * Focus 2): ANSI-aware, wide-character-aware, never a mid-sequence cut.
30
+ * Empty lines pass through.
31
+ */
32
+ export function truncate(line, width) {
33
+ if (line === "")
34
+ return "";
35
+ return truncateToWidth(line, width, "…");
36
+ }
37
+ /** Pad a line to `width` cells by its DISPLAY width (for column joins). */
38
+ export function pad(line, width) {
39
+ const rest = width - visibleWidth(line);
40
+ return rest > 0 ? line + " ".repeat(rest) : line;
41
+ }
42
+ /**
43
+ * Fit a run of source lines into `width` cells: short runs stay one
44
+ * line (truncated); longer word-wraps fold at spaces, each folded line
45
+ * truncated. Used for plain-text values (markdown wraps itself).
46
+ */
47
+ export function wrapPlain(text, width) {
48
+ if (width <= 0)
49
+ return [];
50
+ const words = text.split(/\s+/).filter((w) => w !== "");
51
+ if (words.length === 0)
52
+ return [""];
53
+ const lines = [];
54
+ let current = "";
55
+ for (const word of words) {
56
+ if (current === "") {
57
+ current = word;
58
+ }
59
+ else if (visibleWidth(current) + 1 + visibleWidth(word) <= width) {
60
+ current += ` ${word}`;
61
+ }
62
+ else {
63
+ lines.push(current);
64
+ current = word;
65
+ }
66
+ }
67
+ if (current !== "")
68
+ lines.push(current);
69
+ return lines.map((line) => truncate(line, width));
70
+ }
@@ -34,6 +34,14 @@ const BINDINGS = [
34
34
  { keys: "C-o", action: "answer interaction" },
35
35
  { keys: "C-l", action: "redraw" },
36
36
  { keys: "C-h", action: "key reference" },
37
+ { keys: "M-m", action: "pick up (block surface)" },
38
+ { keys: "enter", action: "drop (block surface)" },
39
+ { keys: "C-g", action: "cancel move (block surface)" },
40
+ { keys: "space", action: "select (block surface)" },
41
+ { keys: ".", action: "action menu (block surface)" },
42
+ { keys: "x", action: "check (block surface)" },
43
+ { keys: "[ / ]", action: "previous / next range (calendar)" },
44
+ { keys: "t", action: "today (calendar)" },
37
45
  ];
38
46
  /** Match `data` against any reserved chord (they always pass). */
39
47
  function isReserved(data) {
@@ -71,6 +79,30 @@ export function createEmacsKeymap() {
71
79
  }
72
80
  return undefined;
73
81
  };
82
+ /** Block-surface keys (spec §12.4; emacs picks up with M-m). C-g is
83
+ * the cancel key — it cancels a move on a surface BEFORE the global
84
+ * C-g (cancel-run / focus composer) below sees it. */
85
+ const surface = (data) => {
86
+ if (data === "\x1bm")
87
+ return { type: "blocks.pickUp" }; // M-m
88
+ if (matchesKey(data, Key.enter) || data === "\r")
89
+ return { type: "blocks.drop" };
90
+ if (data === "\x07")
91
+ return { type: "blocks.cancelMove" }; // C-g
92
+ if (data === " ")
93
+ return { type: "blocks.select" };
94
+ if (data === ".")
95
+ return { type: "blocks.actionMenu" };
96
+ if (data === "x")
97
+ return { type: "blocks.check" };
98
+ if (data === "[")
99
+ return { type: "blocks.rangePrev" };
100
+ if (data === "]")
101
+ return { type: "blocks.rangeNext" };
102
+ if (data === "t")
103
+ return { type: "blocks.today" };
104
+ return undefined;
105
+ };
74
106
  return {
75
107
  name: "emacs",
76
108
  modeLabel: () => undefined,
@@ -89,6 +121,13 @@ export function createEmacsKeymap() {
89
121
  const chorded = chord(data);
90
122
  if (chorded !== undefined)
91
123
  return chorded;
124
+ // A block surface owns the §12.4 keys (its C-g is cancel-move,
125
+ // which must win over the global C-g cancel-run).
126
+ if (ctx.focus === "surface") {
127
+ const surfaceResult = surface(data);
128
+ if (surfaceResult !== undefined)
129
+ return surfaceResult;
130
+ }
92
131
  if (data === "\x1bx")
93
132
  return { type: "picker", kind: "command" };
94
133
  if (data === "\x13")
@@ -35,6 +35,14 @@ const BINDINGS = [
35
35
  { keys: "/", action: "search transcript" },
36
36
  { keys: "n / N", action: "next / previous match" },
37
37
  { keys: "?", action: "help" },
38
+ { keys: "m", action: "pick up (block surface)" },
39
+ { keys: "enter", action: "drop (block surface)" },
40
+ { keys: "esc", action: "cancel move (block surface)" },
41
+ { keys: "space", action: "select (block surface)" },
42
+ { keys: ".", action: "action menu (block surface)" },
43
+ { keys: "x", action: "check (block surface)" },
44
+ { keys: "[ / ]", action: "previous / next range (calendar)" },
45
+ { keys: "t", action: "today (calendar)" },
38
46
  ];
39
47
  /** Match `data` against any reserved chord (they always pass). */
40
48
  function isReserved(data) {
@@ -64,7 +72,11 @@ export function createStandardKeymap() {
64
72
  return "pass";
65
73
  if (ctx.busy)
66
74
  return { type: "cancel-run" };
67
- return { type: "focus", target: "composer" };
75
+ // A block surface owns Esc: it cancels a pick-up
76
+ // (blocks.cancelMove) — the surface branch below handles it.
77
+ if (ctx.focus !== "surface")
78
+ return { type: "focus", target: "composer" };
79
+ return undefined;
68
80
  }
69
81
  return undefined;
70
82
  };
@@ -99,6 +111,27 @@ export function createStandardKeymap() {
99
111
  return { type: "help" };
100
112
  return undefined;
101
113
  };
114
+ const surface = (data) => {
115
+ // Block-surface keys (spec §12.4): routed ONLY here; the app
116
+ // forwards the action to the focused block component.
117
+ if (data === "m")
118
+ return { type: "blocks.pickUp" };
119
+ if (matchesKey(data, Key.enter))
120
+ return { type: "blocks.drop" };
121
+ if (data === " ")
122
+ return { type: "blocks.select" };
123
+ if (data === ".")
124
+ return { type: "blocks.actionMenu" };
125
+ if (data === "x")
126
+ return { type: "blocks.check" };
127
+ if (data === "[")
128
+ return { type: "blocks.rangePrev" };
129
+ if (data === "]")
130
+ return { type: "blocks.rangeNext" };
131
+ if (data === "t")
132
+ return { type: "blocks.today" };
133
+ return undefined;
134
+ };
102
135
  return {
103
136
  name: "standard",
104
137
  modeLabel: () => undefined,
@@ -113,6 +146,17 @@ export function createStandardKeymap() {
113
146
  const globalResult = global(data, ctx);
114
147
  if (globalResult !== undefined)
115
148
  return globalResult;
149
+ if (ctx.focus === "surface") {
150
+ const surfaceResult = surface(data);
151
+ if (surfaceResult !== undefined)
152
+ return surfaceResult;
153
+ // Esc on a surface: cancel a pick-up (§12.4). The host treats
154
+ // a cancel with NO pick-up in flight as focus-composer (the
155
+ // emacs C-g ordering rule, mirrored here).
156
+ if (matchesKey(data, Key.escape))
157
+ return { type: "blocks.cancelMove" };
158
+ return "pass";
159
+ }
116
160
  if (ctx.focus !== "composer") {
117
161
  const navResult = outsideComposer(data);
118
162
  if (navResult !== undefined)
@@ -51,6 +51,14 @@ const BINDINGS = [
51
51
  { keys: "ctrl+c", action: "abort run (busy)" },
52
52
  { keys: "g?", action: "key reference" },
53
53
  { keys: "ZZ / :q", action: "quit" },
54
+ { keys: "m", action: "pick up (block surface)" },
55
+ { keys: "enter", action: "drop (block surface)" },
56
+ { keys: "esc", action: "cancel move (block surface)" },
57
+ { keys: "space", action: "select (block surface)" },
58
+ { keys: ".", action: "action menu (block surface)" },
59
+ { keys: "x", action: "check (block surface)" },
60
+ { keys: "[ / ]", action: "previous / next range (calendar)" },
61
+ { keys: "t", action: "today (calendar)" },
54
62
  ];
55
63
  function isReserved(data) {
56
64
  return RESERVED_KEYS.some((id) => matchesKey(data, id));
@@ -117,11 +125,40 @@ export function createVimKeymap(options) {
117
125
  return { type: "answer-interaction" };
118
126
  return "pass";
119
127
  };
128
+ /** Block-surface keys (spec §12.4): NORMAL mode over a `surface`
129
+ * focus. The surface wins over transcript/list bindings for the
130
+ * shared keys (o/x/etc). */
131
+ const surfaceNormal = (data) => {
132
+ if (data === "m")
133
+ return { type: "blocks.pickUp" };
134
+ if (matchesKey(data, Key.enter))
135
+ return { type: "blocks.drop" };
136
+ if (matchesKey(data, Key.escape))
137
+ return { type: "blocks.cancelMove" };
138
+ if (data === " ")
139
+ return { type: "blocks.select" };
140
+ if (data === ".")
141
+ return { type: "blocks.actionMenu" };
142
+ if (data === "x")
143
+ return { type: "blocks.check" };
144
+ if (data === "[")
145
+ return { type: "blocks.rangePrev" };
146
+ if (data === "]")
147
+ return { type: "blocks.rangeNext" };
148
+ if (data === "t")
149
+ return { type: "blocks.today" };
150
+ return undefined;
151
+ };
120
152
  const normal = (data, ctx) => {
121
153
  // ctrl+b / ctrl+f are NOT bound in vim normal — reserved chords pass.
122
154
  const chorded = chord(data);
123
155
  if (chorded !== undefined)
124
156
  return chorded;
157
+ if (ctx.focus === "surface") {
158
+ const surfaceResult = surfaceNormal(data);
159
+ if (surfaceResult !== undefined)
160
+ return surfaceResult;
161
+ }
125
162
  if (data === "y")
126
163
  return { type: "copy" }; // yy is two copies — same action
127
164
  if (data === "j")
package/dist/launch.js CHANGED
@@ -204,6 +204,9 @@ export async function launchTuiShell(options) {
204
204
  ]);
205
205
  tui.setLayoutRoot(root);
206
206
  await app.refresh();
207
+ // U3 Task 5: the sidebar's Views group loads with the conversations
208
+ // (one /v1/extensions GET per boot; failures fail quiet).
209
+ void app.sidebar.refreshViews().catch(() => { });
207
210
  // The host events subscription (core hardening W3 Task 4, spec §7.4):
208
211
  // ONE stream per process replaces the poll timers — `conversation.*`
209
212
  // refetches the sidebar listing, `interaction.*` kicks the prompter,
@@ -224,8 +227,18 @@ export async function launchTuiShell(options) {
224
227
  else if (event.type === "ambient.changed") {
225
228
  app.ambient.kick(event.conversationId);
226
229
  }
230
+ else if (event.type === "surface.changed") {
231
+ // U3 Task 5: fan the surface event out to the open
232
+ // view's transport (the hub matches slug+id+k).
233
+ app.dispatchSurfaceChanged(event);
234
+ }
227
235
  },
228
236
  onError: (message) => app.pane.showNotice(message),
237
+ // U3 Task 5 (§7.3): the hello frame's streamId is the watcher
238
+ // entry's stream — open ext-view transports send it as
239
+ // X-Sigrun-Stream. A reconnect's new id re-registers on the
240
+ // next fetch.
241
+ onHello: (streamId) => app.setEventsStreamId(streamId),
229
242
  }, {
230
243
  baseUrl: options.baseUrl,
231
244
  ...(options.user === undefined ? {} : { user: options.user }),
package/dist/sse.d.ts CHANGED
@@ -92,6 +92,14 @@ export declare function watchConversation(conversationId: number, handlers: Watc
92
92
  export interface EventsHandlers {
93
93
  /** A host event arrived (already JSON-parsed). */
94
94
  readonly onEvent: (event: HostEventWire) => void;
95
+ /**
96
+ * The hello frame's streamId (UI-blocks U3 Task 4, spec §7.3's W3
97
+ * amendment): fired on EVERY (re)connect — a reconnect's hello
98
+ * carries a NEW id, and the channel re-fetches every visible surface
99
+ * with it (re-registering the watcher entries). Surface fetches send
100
+ * the current id as `X-Sigrun-Stream`.
101
+ */
102
+ readonly onHello?: (streamId: string) => void;
95
103
  /** A failure: malformed frame (stays open) or transport error (reconnects). */
96
104
  readonly onError?: (message: string) => void;
97
105
  /** The underlying connection (re)opened with an OK response. */
package/dist/sse.js CHANGED
@@ -326,6 +326,33 @@ export function watchEvents(handlers, options) {
326
326
  const parser = createFrameParser((frame) => {
327
327
  if (closed)
328
328
  return;
329
+ // The hello frame (U3 Task 4) is connection-local: it names
330
+ // THIS stream for the watcher registry — surface fetches send
331
+ // the id as `X-Sigrun-Stream`; a NEW id (reconnect) re-fires
332
+ // so the channel re-registers. It never reaches onEvent (the
333
+ // launch wiring's switch would ignore it anyway — event types
334
+ // are an open set). Parsed loosely: the hello payload carries
335
+ // `streamId`, not a `type` member.
336
+ if (frame.event === "hello") {
337
+ let parsed;
338
+ try {
339
+ parsed = JSON.parse(frame.data);
340
+ }
341
+ catch {
342
+ handlers.onError?.("malformed hello frame (not JSON)");
343
+ return;
344
+ }
345
+ // SAFETY: the JSON.parse output is unknown; the read below
346
+ // narrows `streamId` by typeof before use.
347
+ const streamId = parsed.streamId;
348
+ if (typeof streamId === "string") {
349
+ handlers.onHello?.(streamId);
350
+ }
351
+ else {
352
+ handlers.onError?.("malformed hello frame (no streamId)");
353
+ }
354
+ return;
355
+ }
329
356
  const event = parseEvent(frame.data);
330
357
  if (event === undefined) {
331
358
  handlers.onError?.(`malformed ${frame.event === "" ? "message" : frame.event} frame (not an event object)`);
package/dist/theme.d.ts CHANGED
@@ -18,8 +18,8 @@
18
18
  */
19
19
  import { type TerminalColorMode } from "@earendil-works/pi-tui";
20
20
  import { type AccentName, type ModeName, type PaletteName, type PaletteTokens } from "@sigrun/shared/themes";
21
- /** A palette token, or the accent colour. */
22
- export type TuiToken = keyof PaletteTokens | "accent";
21
+ /** A palette token, the accent colour, or a state colour (warn/bad). */
22
+ export type TuiToken = keyof PaletteTokens | "accent" | "warn" | "bad";
23
23
  /**
24
24
  * Terminal styling surface: token-named fg/bg plus the plain attribute
25
25
  * helpers. `fg`/`bg` return string wrappers so call sites compose
package/dist/theme.js CHANGED
@@ -43,10 +43,22 @@ function rgbToHex(r, g, b) {
43
43
  const two = (n) => n.toString(16).padStart(2, "0");
44
44
  return `#${two(r)}${two(g)}${two(b)}`;
45
45
  }
46
+ /**
47
+ * The state colours (warnings + errors), defined per mode like the
48
+ * webui's `--warn`/`--danger` (webui/src/app.css — they are NOT palette
49
+ * members in either channel). The TUI's tone table (blocks/tones.ts)
50
+ * paints `warn`/`bad` tones with them.
51
+ */
52
+ const STATE_COLORS = {
53
+ warn: { dark: "#d9a35a", light: "#8a6116" },
54
+ bad: { dark: "#e0707a", light: "#b3363f" },
55
+ };
46
56
  /** Resolve a token to a parseable colour for the palette+mode. */
47
- function resolveToken(tokens, accentHex, token) {
57
+ function resolveToken(tokens, accentHex, mode, token) {
48
58
  if (token === "accent")
49
59
  return accentHex;
60
+ if (token === "warn" || token === "bad")
61
+ return STATE_COLORS[token][mode];
50
62
  const value = tokens[token];
51
63
  // `code` is the rgba overlay chip; composite over the mode's bg.
52
64
  if (value.startsWith("rgba"))
@@ -75,8 +87,10 @@ export function createTuiTheme(options) {
75
87
  "ok",
76
88
  "code",
77
89
  "accent",
90
+ "warn",
91
+ "bad",
78
92
  ]) {
79
- const color = parseColor(resolveToken(tokens, accentHex, token));
93
+ const color = parseColor(resolveToken(tokens, accentHex, options.mode, token));
80
94
  fgSequences.set(token, foregroundAnsi(color, mode));
81
95
  bgSequences.set(token, backgroundAnsi(color, mode));
82
96
  }
package/dist/ui/app.d.ts CHANGED
@@ -48,6 +48,7 @@
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
52
  import { type Unwatch, type WatchHandlers, type WatchOptions } from "../sse.js";
52
53
  import type { TuiTheme } from "../theme.js";
53
54
  import type { Action, ActionHost, Focus } from "../actions.js";
@@ -91,6 +92,41 @@ export declare class TuiApp implements Component, ActionHost {
91
92
  constructor(client: SigrunClient, options: TuiAppOptions);
92
93
  /** The active conversation id (undefined before the first refresh). */
93
94
  get activeConversationId(): number | undefined;
95
+ /** The launcher's hello hook (U3 Task 5): the stream id feeds
96
+ * future openExtView transports; an OPEN view's handle gets the
97
+ * new id at once (its next fetch re-registers the watcher entry —
98
+ * spec §7.3's reconnect rule; the webui twin remounts instead). */
99
+ setEventsStreamId(streamId: string): void;
100
+ /** The open extension view's identity (undefined = the conversation
101
+ * pane is the main content). */
102
+ get extView(): {
103
+ readonly slug: string;
104
+ readonly id: string;
105
+ } | undefined;
106
+ /**
107
+ * Open an extension VIEW in the main pane (U3 Task 5, spec §7.1):
108
+ * the sidebar's view selection swaps the pane's content to the
109
+ * surface's transport. The block renderer is U2's; the app holds
110
+ * the transport + identity here so U2's mount is one prop pass.
111
+ */
112
+ openExtView(slug: string, id: string, params?: JsonObject): Promise<void>;
113
+ /**
114
+ * Close the open extension view (the Esc/back idiom): the pane
115
+ * returns to the conversation AND the surface's fetches stop — no
116
+ * cadence, no keepalive for a surface nobody is looking at (spec
117
+ * §7.3's stop-when-hidden rule; U2's cadence timer consults this).
118
+ * Returns true when a view was open (the key was consumed).
119
+ */
120
+ closeExtView(): boolean;
121
+ /** The app-level surface.changed dispatch (the launcher's
122
+ * `watchEvents` onEvent routes events here). */
123
+ dispatchSurfaceChanged(event: {
124
+ slug: string;
125
+ id: string;
126
+ k: string;
127
+ rev: number;
128
+ treeRev: number;
129
+ }): void;
94
130
  /** The focused region (the action layer's routing state). */
95
131
  get focus(): Focus;
96
132
  /** A modal overlay (picker/search) owns the keys. */
package/dist/ui/app.js CHANGED
@@ -48,6 +48,7 @@
48
48
  */
49
49
  import { Editor, HStack, Key, matchesKey, VStack, } from "@earendil-works/pi-tui";
50
50
  import { SigrunApiError } from "../api.js";
51
+ import { ExtSurfaceHub, surfaceKeyHash, transportKey, } from "../blocks/ext-transport.js";
51
52
  import { watchConversation } from "../sse.js";
52
53
  import { spawnSync } from "node:child_process";
53
54
  import { editInVim, resolveVimEditor } from "../editor-launch.js";
@@ -79,6 +80,14 @@ function plainEditorTheme() {
79
80
  }
80
81
  export class TuiApp {
81
82
  #client;
83
+ /** The extension-surface transport hub (U3 Task 5): one per app —
84
+ * `openExtView`'s transport subscribes through it; the launcher
85
+ * dispatches `surface.changed` events into it. */
86
+ #extHub = new ExtSurfaceHub();
87
+ /** The open extension view (the main pane's surface state). The
88
+ * HANDLE (not the bare transport): a reconnect's new stream id
89
+ * lands via setStreamId without re-creating the transport. */
90
+ #extView;
82
91
  #watch;
83
92
  #watchOptions;
84
93
  /** The sidebar (the app mounts it; tests drive it). */
@@ -158,6 +167,12 @@ export class TuiApp {
158
167
  if (selection === "new") {
159
168
  void this.createAndSelect();
160
169
  }
170
+ else if (typeof selection === "object") {
171
+ // An extension VIEW (U3 Task 5): open it in the main pane.
172
+ void this.openExtView(selection.view.slug, selection.view.id).catch((error) => {
173
+ this.pane.showError(describeCommandError("open view", error));
174
+ });
175
+ }
161
176
  else {
162
177
  // Caught at the call site (fix wave F1): selectConversation
163
178
  // keeps propagating for boot's fail-loud refresh, so the
@@ -175,6 +190,69 @@ export class TuiApp {
175
190
  get activeConversationId() {
176
191
  return this.#activeId;
177
192
  }
193
+ /** The current /v1/events stream id (the hello frame's payload);
194
+ * the ext-view transports send it as X-Sigrun-Stream. */
195
+ #eventsStreamId;
196
+ /** The launcher's hello hook (U3 Task 5): the stream id feeds
197
+ * future openExtView transports; an OPEN view's handle gets the
198
+ * new id at once (its next fetch re-registers the watcher entry —
199
+ * spec §7.3's reconnect rule; the webui twin remounts instead). */
200
+ setEventsStreamId(streamId) {
201
+ this.#eventsStreamId = streamId;
202
+ this.#extView?.handle.setStreamId(streamId);
203
+ }
204
+ /** The open extension view's identity (undefined = the conversation
205
+ * pane is the main content). */
206
+ get extView() {
207
+ return this.#extView === undefined
208
+ ? undefined
209
+ : { slug: this.#extView.slug, id: this.#extView.id };
210
+ }
211
+ /**
212
+ * Open an extension VIEW in the main pane (U3 Task 5, spec §7.1):
213
+ * the sidebar's view selection swaps the pane's content to the
214
+ * surface's transport. The block renderer is U2's; the app holds
215
+ * the transport + identity here so U2's mount is one prop pass.
216
+ */
217
+ async openExtView(slug, id, params) {
218
+ const key = transportKey({ slug, id, baseUrl: this.#watchOptions.baseUrl, ...(params === undefined ? {} : { params }) });
219
+ const handle = this.#extHub.attach({
220
+ slug,
221
+ id,
222
+ ...(params === undefined ? {} : { params }),
223
+ baseUrl: this.#watchOptions.baseUrl,
224
+ ...(this.#watchOptions.user === undefined
225
+ ? {}
226
+ : { user: this.#watchOptions.user }),
227
+ ...(this.#watchOptions.token === undefined
228
+ ? {}
229
+ : { token: this.#watchOptions.token }),
230
+ ...(this.#eventsStreamId === undefined
231
+ ? {}
232
+ : { streamId: this.#eventsStreamId }),
233
+ }, surfaceKeyHash(slug, id, key));
234
+ this.#extView = { slug, id, handle };
235
+ this.#tui.requestRender();
236
+ }
237
+ /**
238
+ * Close the open extension view (the Esc/back idiom): the pane
239
+ * returns to the conversation AND the surface's fetches stop — no
240
+ * cadence, no keepalive for a surface nobody is looking at (spec
241
+ * §7.3's stop-when-hidden rule; U2's cadence timer consults this).
242
+ * Returns true when a view was open (the key was consumed).
243
+ */
244
+ closeExtView() {
245
+ if (this.#extView === undefined)
246
+ return false;
247
+ this.#extView = undefined;
248
+ this.#tui.requestRender();
249
+ return true;
250
+ }
251
+ /** The app-level surface.changed dispatch (the launcher's
252
+ * `watchEvents` onEvent routes events here). */
253
+ dispatchSurfaceChanged(event) {
254
+ this.#extHub.dispatch(event);
255
+ }
178
256
  /** The focused region (the action layer's routing state). */
179
257
  get focus() {
180
258
  return this.#focus;
@@ -447,6 +525,12 @@ export class TuiApp {
447
525
  this.#abortActive();
448
526
  return true;
449
527
  }
528
+ // Esc closes an open extension view — back to the
529
+ // conversation pane; the surface's fetches stop (the keepalive
530
+ // stops with the view, spec §7.3's stop-when-hidden).
531
+ if (matchesKey(data, Key.escape) && this.#extView !== undefined) {
532
+ return this.closeExtView();
533
+ }
450
534
  return false;
451
535
  }
452
536
  /**
@@ -42,6 +42,7 @@
42
42
  * `[error]` line).
43
43
  */
44
44
  import { Key, matchesKey } from "@earendil-works/pi-tui";
45
+ import { diffLines } from "../blocks/diff.js";
45
46
  import { describeCommandError } from "./composer.js";
46
47
  /** The answer-mode option pick keys: digits 1-9. */
47
48
  const DIGIT = /^[1-9]$/;
@@ -281,13 +282,7 @@ export class InteractionPrompter {
281
282
  lines.push(line);
282
283
  }
283
284
  if (body?.diff !== undefined) {
284
- lines.push(body.diff.path === undefined
285
- ? "[diff]"
286
- : `[diff] ${body.diff.path}`);
287
- for (const line of body.diff.before.split("\n"))
288
- lines.push(`- ${line}`);
289
- for (const line of body.diff.after.split("\n"))
290
- lines.push(`+ ${line}`);
285
+ lines.push(...diffLines(body.diff));
291
286
  }
292
287
  for (const [index, option] of options.entries()) {
293
288
  const marker = this.#answerMode && multi
@@ -24,8 +24,15 @@
24
24
  import { type Component } from "@earendil-works/pi-tui";
25
25
  import { type TuiTheme } from "../theme.js";
26
26
  import type { SigrunClient } from "../api.js";
27
- /** The sidebar selection: a conversation id, or the `+ new` action. */
28
- export type SidebarSelection = number | "new";
27
+ /** The sidebar selection: a conversation id, the `+ new` action, or an
28
+ * extension VIEW (UI-blocks U3 Task 5 — the app opens it in the main
29
+ * pane). */
30
+ export type SidebarSelection = number | "new" | {
31
+ readonly view: {
32
+ readonly slug: string;
33
+ readonly id: string;
34
+ };
35
+ };
29
36
  /** One display row: a conversation, or the trailing `+ New` action. */
30
37
  interface SidebarRow {
31
38
  /** The conversation id; -1 for the `+ New conversation` action row. */
@@ -62,6 +69,22 @@ export declare class Sidebar implements Component {
62
69
  * conversation when it still exists.
63
70
  */
64
71
  refresh(): Promise<void>;
72
+ /**
73
+ * The extension views (U3 Task 5): refetch `GET /v1/extensions` and
74
+ * rebuild the VIEWS group (one row per declared view surface, in
75
+ * declaration order, status "ok" only). Panels live in the side pane,
76
+ * not here. Failures keep the last good list (a flaky server must
77
+ * never blank the sidebar).
78
+ */
79
+ refreshViews(): Promise<void>;
80
+ /** The extension view rows (the app's openExtView reads them). */
81
+ get views(): readonly {
82
+ slug: string;
83
+ id: string;
84
+ title: string;
85
+ }[];
86
+ /** Select the Nth extension view (the action layer's seam). */
87
+ confirmView(index: number): void;
65
88
  /** Mark the active conversation (accent fg + bold); pass `undefined` to clear. */
66
89
  setActive(id: number | undefined): void;
67
90
  /** Move the cursor by `delta` rows, clamped to the list. */