@sigrun/tui 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +10 -1
  2. package/dist/actions.d.ts +119 -0
  3. package/dist/actions.js +12 -0
  4. package/dist/api.d.ts +209 -0
  5. package/dist/api.js +282 -0
  6. package/dist/blocks/braille.d.ts +41 -0
  7. package/dist/blocks/braille.js +131 -0
  8. package/dist/blocks/chart.d.ts +20 -0
  9. package/dist/blocks/chart.js +397 -0
  10. package/dist/blocks/chat-transport.d.ts +44 -0
  11. package/dist/blocks/chat-transport.js +116 -0
  12. package/dist/blocks/collection/calendar.d.ts +28 -0
  13. package/dist/blocks/collection/calendar.js +232 -0
  14. package/dist/blocks/collection/card.d.ts +36 -0
  15. package/dist/blocks/collection/card.js +128 -0
  16. package/dist/blocks/collection/collection.d.ts +9 -0
  17. package/dist/blocks/collection/collection.js +26 -0
  18. package/dist/blocks/collection/grid.d.ts +18 -0
  19. package/dist/blocks/collection/grid.js +72 -0
  20. package/dist/blocks/collection/lanes.d.ts +32 -0
  21. package/dist/blocks/collection/lanes.js +320 -0
  22. package/dist/blocks/collection/list.d.ts +30 -0
  23. package/dist/blocks/collection/list.js +273 -0
  24. package/dist/blocks/collection/move.d.ts +67 -0
  25. package/dist/blocks/collection/move.js +96 -0
  26. package/dist/blocks/collection/shared.d.ts +87 -0
  27. package/dist/blocks/collection/shared.js +177 -0
  28. package/dist/blocks/collection/table.d.ts +26 -0
  29. package/dist/blocks/collection/table.js +221 -0
  30. package/dist/blocks/content.d.ts +48 -0
  31. package/dist/blocks/content.js +253 -0
  32. package/dist/blocks/context.d.ts +117 -0
  33. package/dist/blocks/context.js +65 -0
  34. package/dist/blocks/diff.d.ts +21 -0
  35. package/dist/blocks/diff.js +25 -0
  36. package/dist/blocks/ext-transport.d.ts +70 -0
  37. package/dist/blocks/ext-transport.js +165 -0
  38. package/dist/blocks/form/choice.d.ts +54 -0
  39. package/dist/blocks/form/choice.js +175 -0
  40. package/dist/blocks/form/fields.d.ts +56 -0
  41. package/dist/blocks/form/fields.js +136 -0
  42. package/dist/blocks/form/file.d.ts +61 -0
  43. package/dist/blocks/form/file.js +121 -0
  44. package/dist/blocks/form/form.d.ts +66 -0
  45. package/dist/blocks/form/form.js +764 -0
  46. package/dist/blocks/form/list.d.ts +37 -0
  47. package/dist/blocks/form/list.js +70 -0
  48. package/dist/blocks/form/values.d.ts +20 -0
  49. package/dist/blocks/form/values.js +106 -0
  50. package/dist/blocks/icons.d.ts +14 -0
  51. package/dist/blocks/icons.js +74 -0
  52. package/dist/blocks/image.d.ts +39 -0
  53. package/dist/blocks/image.js +82 -0
  54. package/dist/blocks/interaction-transport.d.ts +42 -0
  55. package/dist/blocks/interaction-transport.js +79 -0
  56. package/dist/blocks/layout.d.ts +31 -0
  57. package/dist/blocks/layout.js +155 -0
  58. package/dist/blocks/log.d.ts +24 -0
  59. package/dist/blocks/log.js +111 -0
  60. package/dist/blocks/meter.d.ts +16 -0
  61. package/dist/blocks/meter.js +50 -0
  62. package/dist/blocks/page.d.ts +54 -0
  63. package/dist/blocks/page.js +360 -0
  64. package/dist/blocks/render.d.ts +31 -0
  65. package/dist/blocks/render.js +163 -0
  66. package/dist/blocks/surface-view.d.ts +41 -0
  67. package/dist/blocks/surface-view.js +137 -0
  68. package/dist/blocks/surface.d.ts +80 -0
  69. package/dist/blocks/surface.js +506 -0
  70. package/dist/blocks/tones.d.ts +23 -0
  71. package/dist/blocks/tones.js +63 -0
  72. package/dist/blocks/treeview.d.ts +22 -0
  73. package/dist/blocks/treeview.js +121 -0
  74. package/dist/blocks/width.d.ts +32 -0
  75. package/dist/blocks/width.js +70 -0
  76. package/dist/clipboard.d.ts +20 -0
  77. package/dist/clipboard.js +19 -0
  78. package/dist/editor-launch.d.ts +34 -0
  79. package/dist/editor-launch.js +70 -0
  80. package/dist/keymaps/emacs.d.ts +18 -0
  81. package/dist/keymaps/emacs.js +172 -0
  82. package/dist/keymaps/standard.d.ts +17 -0
  83. package/dist/keymaps/standard.js +175 -0
  84. package/dist/keymaps/types.d.ts +43 -0
  85. package/dist/keymaps/types.js +28 -0
  86. package/dist/keymaps/vim.d.ts +48 -0
  87. package/dist/keymaps/vim.js +291 -0
  88. package/dist/launch.d.ts +159 -0
  89. package/dist/launch.js +478 -0
  90. package/dist/sse.d.ts +112 -0
  91. package/dist/sse.js +418 -0
  92. package/dist/theme.d.ts +51 -0
  93. package/dist/theme.js +119 -0
  94. package/dist/ui/ambient.d.ts +78 -0
  95. package/dist/ui/ambient.js +264 -0
  96. package/dist/ui/app.d.ts +319 -0
  97. package/dist/ui/app.js +1539 -0
  98. package/dist/ui/composer.d.ts +66 -0
  99. package/dist/ui/composer.js +133 -0
  100. package/dist/ui/conversation-pane.d.ts +143 -0
  101. package/dist/ui/conversation-pane.js +615 -0
  102. package/dist/ui/help.d.ts +15 -0
  103. package/dist/ui/help.js +80 -0
  104. package/dist/ui/interactions.d.ts +91 -0
  105. package/dist/ui/interactions.js +477 -0
  106. package/dist/ui/picker.d.ts +26 -0
  107. package/dist/ui/picker.js +130 -0
  108. package/dist/ui/settings.d.ts +68 -0
  109. package/dist/ui/settings.js +254 -0
  110. package/dist/ui/sidebar.d.ts +99 -0
  111. package/dist/ui/sidebar.js +269 -0
  112. package/dist/ui/status-bar.d.ts +77 -0
  113. package/dist/ui/status-bar.js +146 -0
  114. package/package.json +4 -3
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The picker overlay (Task C3, UI refresh): a centred `showOverlay` box —
3
+ * a title line, a one-line fuzzy filter, and a `SelectList`. Typing
4
+ * re-filters (pi-tui's `fuzzyFilter` over label + detail), Enter picks,
5
+ * Esc closes, and a mouse click picks (SelectList's own mouse handling).
6
+ *
7
+ * Conversation/model/command pickers are built by the app from the
8
+ * sidebar tree, `listModels()`, and the slash catalog respectively.
9
+ */
10
+ import { fuzzyFilter, Key, matchesKey, SelectList, } from "@earendil-works/pi-tui";
11
+ const MAX_VISIBLE = 10;
12
+ function plainSelectListTheme() {
13
+ const identity = (text) => text;
14
+ return {
15
+ selectedPrefix: identity,
16
+ selectedText: identity,
17
+ description: identity,
18
+ scrollInfo: identity,
19
+ noMatch: identity,
20
+ };
21
+ }
22
+ class Picker {
23
+ #items;
24
+ #options;
25
+ #selectTheme;
26
+ #muted;
27
+ #query = "";
28
+ #filtered;
29
+ #list;
30
+ #overlay;
31
+ constructor(items, options) {
32
+ this.#items = items;
33
+ this.#options = options;
34
+ const theme = options.theme;
35
+ this.#selectTheme =
36
+ theme === undefined
37
+ ? plainSelectListTheme()
38
+ : {
39
+ selectedPrefix: (text) => theme.fg("accent")(text),
40
+ selectedText: (text) => theme.bold(text),
41
+ description: (text) => theme.fg("muted")(text),
42
+ scrollInfo: (text) => theme.fg("muted")(text),
43
+ noMatch: (text) => theme.fg("muted")(text),
44
+ };
45
+ this.#muted =
46
+ theme === undefined ? (text) => text : (text) => theme.fg("muted")(text);
47
+ this.#filtered = items;
48
+ this.#list = this.#buildList();
49
+ }
50
+ /** The overlay handle is installed by {@link openPicker} after show. */
51
+ setOverlay(overlay) {
52
+ this.#overlay = overlay;
53
+ }
54
+ #buildList() {
55
+ const list = new SelectList(this.#filtered.map((item, index) => ({
56
+ value: String(index),
57
+ label: item.label,
58
+ ...(item.detail === undefined ? {} : { description: item.detail }),
59
+ })), MAX_VISIBLE, this.#selectTheme);
60
+ list.onSelect = (item) => {
61
+ const picked = this.#filtered[Number(item.value)];
62
+ if (picked === undefined)
63
+ return;
64
+ this.#close();
65
+ this.#options.onPick(picked.value);
66
+ };
67
+ list.onCancel = () => this.#close();
68
+ return list;
69
+ }
70
+ #close() {
71
+ this.#overlay?.hide();
72
+ this.#options.onClose?.();
73
+ }
74
+ #refilter() {
75
+ const filtered = this.#query === ""
76
+ ? this.#items
77
+ : fuzzyFilter([...this.#items], this.#query, (item) => item.detail === undefined ? item.label : `${item.label} ${item.detail}`);
78
+ this.#filtered = filtered;
79
+ this.#list = this.#buildList();
80
+ }
81
+ handleInput(data) {
82
+ // C-g is the emacs cancel: close like Esc (the emacs keymap passes it
83
+ // through while an overlay owns the keys).
84
+ if (data === "\x07") {
85
+ this.#close();
86
+ return;
87
+ }
88
+ if (matchesKey(data, Key.backspace) || matchesKey(data, "backspace")) {
89
+ if (this.#query !== "") {
90
+ this.#query = this.#query.slice(0, -1);
91
+ this.#refilter();
92
+ }
93
+ return;
94
+ }
95
+ // One printable character extends the filter; everything else
96
+ // (arrows, Enter, Esc, mouse) is the list's own input.
97
+ if (data.length === 1 && data >= " ") {
98
+ this.#query += data;
99
+ this.#refilter();
100
+ return;
101
+ }
102
+ this.#list.handleInput(data);
103
+ }
104
+ render(width) {
105
+ return [
106
+ this.#muted(this.#options.title),
107
+ this.#muted(`filter: ${this.#query}`),
108
+ ...this.#list.render(width),
109
+ ];
110
+ }
111
+ /** The click is additive: delegate to the list under the filter rows. */
112
+ handleMouse(event) {
113
+ if (event.y < 2)
114
+ return { handled: true };
115
+ return this.#list.handleMouse({ ...event, y: event.y - 2 });
116
+ }
117
+ invalidate() {
118
+ this.#list.invalidate();
119
+ }
120
+ }
121
+ /** Open a centred picker overlay; Enter/click picks, Esc closes. */
122
+ export function openPicker(tui, items, options) {
123
+ const picker = new Picker(items, options);
124
+ const overlay = tui.showOverlay(picker, {
125
+ anchor: "center",
126
+ width: "60%",
127
+ maxHeight: "60%",
128
+ });
129
+ picker.setOverlay(overlay);
130
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The TUI Settings view (UI-blocks U4 Task 4, spec §13.4): the settings
3
+ * panel's terminal twin — the section list from `GET
4
+ * /v1/settings/sections` as a picker (the search IS the picker's fuzzy
5
+ * filter: typing "naming" filters the index to General → Naming model;
6
+ * picking it opens the section's form), each section rendered as a
7
+ * block form ({@link SurfaceState} + {@link SurfaceView} over the
8
+ * section's `/v1/ext/<slug>/surfaces/<id>` mount — ONE renderer across
9
+ * core and extensions, the same code path the webui's SurfaceView
10
+ * runs).
11
+ *
12
+ * Scope rulings (plan Task 4):
13
+ * - **Interface** (client prefs) has no TUI-local state yet — the view
14
+ * renders it as its (empty) surface form; the prefs live in the
15
+ * webui's localStorage by design (§13.4's "client-side" ruling).
16
+ * - **Providers** (the §7.6 credential flows) renders as its settings
17
+ * surface form; the per-provider readiness rows and the write-only
18
+ * credential editor are the webui's cove — the credential routes
19
+ * stay reachable through the API, and the TUI does not render the
20
+ * readiness list (a TUI cove is the follow-up).
21
+ * - **Extensions**: an extension WITHOUT a declared settings section
22
+ * keeps its raw-JSON reachability as a NOTICE in the section list
23
+ * (one line naming its `/v1/extensions/<slug>/settings` routes —
24
+ * the raw JSON editor is the webui's cove).
25
+ *
26
+ * The pending-restart callout (§13.2/§13.3) renders above the section
27
+ * body: the keys the General section's `meta` block carries, with the
28
+ * Restart action when the server is supervised AND the session is an
29
+ * admin (the app's `restart()` runs the §13.3 flow), otherwise the
30
+ * text "Restart sigrun to apply".
31
+ */
32
+ import type { Component, TUI } from "@earendil-works/pi-tui";
33
+ import { filterSettingsIndex } from "@sigrun/shared/views";
34
+ import type { SettingsSectionEntry, SigrunClient } from "../api.js";
35
+ import type { TuiTheme } from "../theme.js";
36
+ export { filterSettingsIndex };
37
+ /** The settings view (the app's `/settings` mount). */
38
+ export declare class SettingsView implements Component {
39
+ #private;
40
+ constructor(client: SigrunClient, options: {
41
+ readonly tui: TUI;
42
+ readonly theme: TuiTheme;
43
+ readonly unicode: boolean;
44
+ readonly onRestart: () => Promise<void>;
45
+ readonly onClose: () => void;
46
+ });
47
+ /** Load the sections list + the admin/supervised flags. */
48
+ load(): Promise<void>;
49
+ /** The open section's id (undefined = the section list; tests read it). */
50
+ get openSectionId(): string | undefined;
51
+ /** The pending-restart keys the General section's meta carried. */
52
+ get pendingRestart(): readonly string[];
53
+ /** The section picker (the search: the picker's fuzzy filter is the
54
+ * index query — typing "naming" filters to the Naming model row). */
55
+ openSearch(): void;
56
+ /** Open one section (mount its surface). When a search hit carries
57
+ * `focusKey`, the matching field takes the focus after load (the
58
+ * search's resolve half — "naming" lands on the naming model row). */
59
+ openSection(section: SettingsSectionEntry, focusKey?: string): Promise<void>;
60
+ /** Back to the section list (Esc). Returns true when a section was open. */
61
+ closeSection(): boolean;
62
+ /** The callout's Restart action (the app wires it). */
63
+ restart(): Promise<void>;
64
+ render(width: number): string[];
65
+ handleInput(data: string): void;
66
+ dispose(): void;
67
+ invalidate(): void;
68
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * The TUI Settings view (UI-blocks U4 Task 4, spec §13.4): the settings
3
+ * panel's terminal twin — the section list from `GET
4
+ * /v1/settings/sections` as a picker (the search IS the picker's fuzzy
5
+ * filter: typing "naming" filters the index to General → Naming model;
6
+ * picking it opens the section's form), each section rendered as a
7
+ * block form ({@link SurfaceState} + {@link SurfaceView} over the
8
+ * section's `/v1/ext/<slug>/surfaces/<id>` mount — ONE renderer across
9
+ * core and extensions, the same code path the webui's SurfaceView
10
+ * runs).
11
+ *
12
+ * Scope rulings (plan Task 4):
13
+ * - **Interface** (client prefs) has no TUI-local state yet — the view
14
+ * renders it as its (empty) surface form; the prefs live in the
15
+ * webui's localStorage by design (§13.4's "client-side" ruling).
16
+ * - **Providers** (the §7.6 credential flows) renders as its settings
17
+ * surface form; the per-provider readiness rows and the write-only
18
+ * credential editor are the webui's cove — the credential routes
19
+ * stay reachable through the API, and the TUI does not render the
20
+ * readiness list (a TUI cove is the follow-up).
21
+ * - **Extensions**: an extension WITHOUT a declared settings section
22
+ * keeps its raw-JSON reachability as a NOTICE in the section list
23
+ * (one line naming its `/v1/extensions/<slug>/settings` routes —
24
+ * the raw JSON editor is the webui's cove).
25
+ *
26
+ * The pending-restart callout (§13.2/§13.3) renders above the section
27
+ * body: the keys the General section's `meta` block carries, with the
28
+ * Restart action when the server is supervised AND the session is an
29
+ * admin (the app's `restart()` runs the §13.3 flow), otherwise the
30
+ * text "Restart sigrun to apply".
31
+ */
32
+ import { truncateToWidth } from "@earendil-works/pi-tui";
33
+ import { filterSettingsIndex } from "@sigrun/shared/views";
34
+ import { SurfaceState } from "../blocks/surface.js";
35
+ import { SurfaceView } from "../blocks/surface-view.js";
36
+ import { createExtSurfaceTransport } from "../blocks/ext-transport.js";
37
+ import { openPicker } from "./picker.js";
38
+ // The index filter is `@sigrun/shared`'s (U4 Task 4 round-2) — ONE
39
+ // implementation across the channels; the re-export keeps the view's
40
+ // pre-round-2 import surface.
41
+ export { filterSettingsIndex };
42
+ /** The settings view (the app's `/settings` mount). */
43
+ export class SettingsView {
44
+ #client;
45
+ #tui;
46
+ #theme;
47
+ #unicode;
48
+ /** The app's restart action (§13.3) — the callout's Restart. */
49
+ #onRestart;
50
+ #onClose;
51
+ #sections = [];
52
+ #index = [];
53
+ #error;
54
+ /** The open section's surface (undefined = the section list). */
55
+ #surface;
56
+ /** Section-less loaded extensions (the raw-JSON notice lines). */
57
+ #sectionlessExtensions = [];
58
+ /** The pending-restart block (the General section's `meta`). */
59
+ #pendingRestart = [];
60
+ #supervised = false;
61
+ /** Admin defaults FALSE (the routes are admin-gated — an unknown
62
+ * principal must not see the Restart affordance). */
63
+ #admin = false;
64
+ constructor(client, options) {
65
+ this.#client = client;
66
+ this.#tui = options.tui;
67
+ this.#theme = options.theme;
68
+ this.#unicode = options.unicode;
69
+ this.#onRestart = options.onRestart;
70
+ this.#onClose = options.onClose;
71
+ }
72
+ /** Load the sections list + the admin/supervised flags. */
73
+ async load() {
74
+ try {
75
+ const [sections, whoami, meta, extensions] = await Promise.all([
76
+ this.#client.listSettingsSections(),
77
+ this.#client.whoami().catch(() => undefined),
78
+ this.#client.getBootMeta().catch(() => undefined),
79
+ this.#client.listExtensions().catch(() => undefined),
80
+ ]);
81
+ this.#admin = whoami?.admin === true;
82
+ this.#sections = sections.sections.filter((section) => section.scope !== "user" || section.admin !== true || this.#admin);
83
+ const allowed = new Set(this.#sections.map((section) => `${section.slug}\u0000${section.id}`));
84
+ this.#index = sections.index.filter((entry) => allowed.has(`${entry.slug}\u0000${entry.sectionId}`));
85
+ this.#supervised = meta?.supervised === true;
86
+ // Section-less loaded extensions (the raw-JSON notice lines):
87
+ // every listed extension whose slug declares NO settings section.
88
+ const withSections = new Set(sections.sections.filter((entry) => entry.slug !== "core").map((entry) => entry.slug));
89
+ this.#sectionlessExtensions = (extensions?.extensions ?? [])
90
+ .map((extension) => extension.name)
91
+ .filter((name) => !withSections.has(name));
92
+ }
93
+ catch (cause) {
94
+ this.#error = cause instanceof Error ? cause.message : String(cause);
95
+ }
96
+ this.#tui.requestRender();
97
+ }
98
+ /** The open section's id (undefined = the section list; tests read it). */
99
+ get openSectionId() {
100
+ return this.#surface?.id;
101
+ }
102
+ /** The pending-restart keys the General section's meta carried. */
103
+ get pendingRestart() {
104
+ return this.#pendingRestart;
105
+ }
106
+ /** The section picker (the search: the picker's fuzzy filter is the
107
+ * index query — typing "naming" filters to the Naming model row). */
108
+ openSearch() {
109
+ const items = this.#index.map((entry) => ({
110
+ label: entry.label,
111
+ detail: `${entry.slug === "core" ? "" : `${entry.slug} · `}${entry.sectionId}`,
112
+ value: entry,
113
+ }));
114
+ openPicker(this.#tui, items, {
115
+ title: "Settings",
116
+ onPick: (entry) => {
117
+ const section = this.#sections.find((candidate) => candidate.slug === entry.slug && candidate.id === entry.sectionId);
118
+ if (section !== undefined)
119
+ void this.openSection(section, entry.key);
120
+ },
121
+ });
122
+ }
123
+ /** Open one section (mount its surface). When a search hit carries
124
+ * `focusKey`, the matching field takes the focus after load (the
125
+ * search's resolve half — "naming" lands on the naming model row). */
126
+ async openSection(section, focusKey) {
127
+ this.#surface?.state.dispose();
128
+ const token = this.#client.token;
129
+ const user = this.#client.user;
130
+ const transport = createExtSurfaceTransport({
131
+ slug: section.slug,
132
+ id: section.id,
133
+ baseUrl: this.#baseUrl(),
134
+ ...(token === undefined ? {} : { token }),
135
+ ...(user === undefined ? {} : { user }),
136
+ fetch: this.#client.fetch,
137
+ });
138
+ const state = new SurfaceState(transport.transport);
139
+ const view = new SurfaceView(state, {
140
+ theme: this.#theme,
141
+ unicode: this.#unicode,
142
+ });
143
+ this.#surface = { state, view, id: section.id };
144
+ state.onChange(() => {
145
+ // The core sections' meta block feeds the callout (the
146
+ // pendingRestart diff lands on EVERY core section's meta — a
147
+ // save in Advanced flips the callout without reopening).
148
+ if (section.slug === "core") {
149
+ const meta = state.view.data.meta;
150
+ if (typeof meta === "object" &&
151
+ meta !== null &&
152
+ !Array.isArray(meta) &&
153
+ Array.isArray(meta.pendingRestart)) {
154
+ this.#pendingRestart = meta.pendingRestart.filter((key) => typeof key === "string");
155
+ // The surface's `supervised` is authoritative for the
156
+ // callout once the section loaded (the load() value is
157
+ // the fallback for an unopened General).
158
+ const supervised = meta.supervised;
159
+ if (typeof supervised === "boolean")
160
+ this.#supervised = supervised;
161
+ }
162
+ }
163
+ this.#tui.requestRender();
164
+ });
165
+ await state.load();
166
+ if (focusKey !== undefined)
167
+ view.focusField(focusKey);
168
+ this.#tui.requestRender();
169
+ }
170
+ /** Back to the section list (Esc). Returns true when a section was open. */
171
+ closeSection() {
172
+ if (this.#surface === undefined)
173
+ return false;
174
+ this.#surface.state.dispose();
175
+ this.#surface = undefined;
176
+ this.#tui.requestRender();
177
+ return true;
178
+ }
179
+ /** The callout's Restart action (the app wires it). */
180
+ async restart() {
181
+ await this.#onRestart();
182
+ }
183
+ /** The transport's base URL (the client's — the client owns it). */
184
+ #baseUrl() {
185
+ return this.#client.baseUrl;
186
+ }
187
+ render(width) {
188
+ const lines = [];
189
+ lines.push(truncateToWidth(this.#theme.bold("Settings"), width));
190
+ if (this.#error !== undefined) {
191
+ lines.push(truncateToWidth(this.#theme.fg("bad")(`error: ${this.#error}`), width));
192
+ return lines;
193
+ }
194
+ // The pending-restart callout (§13.2/§13.3): the keys, plus the
195
+ // Restart affordance when supervised + admin.
196
+ if (this.#pendingRestart.length > 0) {
197
+ lines.push(truncateToWidth(this.#theme.fg("warn")(`Restart sigrun to apply: ${this.#pendingRestart.join(", ")}`), width));
198
+ if (this.#supervised && this.#admin) {
199
+ lines.push(truncateToWidth(this.#theme.dim("press ctrl+r to restart now"), width));
200
+ }
201
+ }
202
+ if (this.#surface === undefined) {
203
+ // The section list (the picker's items carry the search).
204
+ if (this.#sections.length === 0) {
205
+ lines.push(truncateToWidth(this.#theme.dim("loading…"), width));
206
+ return lines;
207
+ }
208
+ lines.push(truncateToWidth(this.#theme.dim("/ to search · Enter opens · Esc closes"), width));
209
+ for (const section of this.#sections.filter((entry) => entry.scope !== "user")) {
210
+ lines.push(truncateToWidth(` ${section.title}${section.slug === "core" ? "" : ` (${section.slug})`}`, width));
211
+ }
212
+ const personal = this.#sections.filter((entry) => entry.scope === "user");
213
+ if (personal.length > 0) {
214
+ lines.push(truncateToWidth(this.#theme.bold("My settings"), width));
215
+ for (const section of personal) {
216
+ lines.push(truncateToWidth(` ${section.title}${section.slug === "core" ? "" : ` (${section.slug})`}`, width));
217
+ }
218
+ }
219
+ // Extensions WITHOUT a declared section keep their raw-JSON
220
+ // routes reachable (the docblock's ruling): one notice line
221
+ // per section-less extension naming its routes.
222
+ for (const slug of this.#sectionlessExtensions) {
223
+ // A plain (unstyled) line: the dim theme's escape codes would
224
+ // break substring matching against the wire contract, and the
225
+ // notice is informational, not chrome.
226
+ lines.push(truncateToWidth(` ${slug}: no settings section — raw JSON at GET/PUT /v1/extensions/${slug}/settings`, width));
227
+ }
228
+ return lines;
229
+ }
230
+ // The open section: its surface view renders below the callout.
231
+ lines.push(...this.#surface.view.render(width));
232
+ return lines;
233
+ }
234
+ handleInput(data) {
235
+ if (this.#surface !== undefined) {
236
+ // The restart affordance is ctrl+r — a bare "r" is ordinary
237
+ // text in a focused editor field and must type through.
238
+ if (data === "\x12" && this.#supervised && this.#admin && this.#pendingRestart.length > 0) {
239
+ void this.restart();
240
+ return;
241
+ }
242
+ this.#surface.view.handleInput?.(data);
243
+ return;
244
+ }
245
+ }
246
+ dispose() {
247
+ this.#surface?.state.dispose();
248
+ this.#surface = undefined;
249
+ this.#onClose();
250
+ }
251
+ invalidate() {
252
+ this.#surface?.view.invalidate();
253
+ }
254
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The sidebar (Task 6; restyled in C2, UI refresh): the conversation
3
+ * TREE — rows come from B1's `buildConversationTree` (fork children
4
+ * indent two spaces + `↳ `), names are single-line via B1's
5
+ * `displayName` + pi-tui's `truncateToWidth`, ages right-align via B1's
6
+ * `formatAge`, and the active row highlights accent-fg + bold. Archived
7
+ * conversations stay hidden (the tree's `includeArchived: false`; the
8
+ * server has no delete, archive is the hide). `+ New conversation` is
9
+ * always the last row.
10
+ *
11
+ * The listing itself carries every field the rows need (`name`,
12
+ * `archived`, `pinned`, `parent`, `lastActivityAt` ride
13
+ * `ConversationSummary` — A1), so {@link refresh} makes ONE request: no
14
+ * per-row meta fetch. The app re-refreshes on a 10 s poll (an unref'd
15
+ * `setInterval` the launcher clears on dispose).
16
+ *
17
+ * Keyboard-first (mouse is additive): `cursor` / `moveCursor` /
18
+ * `confirm()` are the C3 action layer's seam; a click on a row selects
19
+ * it (`MouseRegion` per row). Selection reports to {@link onSelect}: a
20
+ * conversation id, or `"new"` for the `+ New conversation` row
21
+ * (creation is the app's job — the sidebar never calls
22
+ * createConversation itself).
23
+ */
24
+ import { type Component } from "@earendil-works/pi-tui";
25
+ import { type TuiTheme } from "../theme.js";
26
+ import type { SigrunClient } from "../api.js";
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
+ };
36
+ /** One display row: a conversation, or the trailing `+ New` action. */
37
+ interface SidebarRow {
38
+ /** The conversation id; -1 for the `+ New conversation` action row. */
39
+ readonly id: number;
40
+ /** The display name (displayName — unnamed → "New conversation"). */
41
+ readonly name: string;
42
+ /** The tree depth (fork children render indented). */
43
+ readonly depth: number;
44
+ /** The right-aligned age column ("" when never used). */
45
+ readonly age: string;
46
+ /** Whether this row is the active conversation. */
47
+ readonly active: boolean;
48
+ }
49
+ export interface SidebarOptions {
50
+ /** The token theme; defaults to the plain (identity) fallback. */
51
+ readonly theme?: TuiTheme;
52
+ /** The clock seam for age rendering (tests pin it). */
53
+ readonly now?: () => Date;
54
+ }
55
+ export declare class Sidebar implements Component {
56
+ #private;
57
+ /** Selection callback: the conversation id, or `"new"` for `+ new`. */
58
+ onSelect?: (selection: SidebarSelection) => void;
59
+ constructor(client: SigrunClient, options?: SidebarOptions);
60
+ /** The conversation rows, in display order (the app's boot default
61
+ * reads `rows[0]`); the trailing `+ New conversation` action row is
62
+ * NOT part of this list — it renders last via {@link #newRow}. */
63
+ get rows(): readonly SidebarRow[];
64
+ /** The cursor's row index (0-based over {@link rows}). */
65
+ get cursor(): number;
66
+ /**
67
+ * Re-fetch the listing and rebuild the rows from the tree (summary
68
+ * fields only — no per-row meta fetch). The cursor follows its
69
+ * conversation when it still exists.
70
+ */
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;
88
+ /** Mark the active conversation (accent fg + bold); pass `undefined` to clear. */
89
+ setActive(id: number | undefined): void;
90
+ /** Move the cursor by `delta` rows, clamped to the list. */
91
+ moveCursor(delta: number): void;
92
+ /** Select the cursor's row (Enter — the action layer's seam). */
93
+ confirm(): void;
94
+ /** The keyboard seam: arrows move, Enter confirms (pre-C3 minimal). */
95
+ handleInput(data: string): void;
96
+ render(width: number): string[];
97
+ invalidate(): void;
98
+ }
99
+ export {};