omp-onlyne 0.0.0-stage → 0.1.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.
@@ -0,0 +1,358 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
2
+ import { getLocale } from "@onlyne/node-host/i18n";
3
+ import {
4
+ type Component,
5
+ type DescribeContext,
6
+ Editor,
7
+ FormField,
8
+ type NativeChild,
9
+ type NativeNode,
10
+ type NativeUiEvent,
11
+ Text,
12
+ type Theme,
13
+ getEditorTheme,
14
+ matchesKey,
15
+ node,
16
+ span,
17
+ text as textNode,
18
+ } from "@oh-my-pi/pi-tui";
19
+ import { configPath, normalizeNodeId, normalizeRegion, normalizeServiceUrl, saveConfig, shortNodeId } from "../config.ts";
20
+ import { startRuntime } from "../runtime.ts";
21
+ import type { OnlyneConfig } from "../types.ts";
22
+ import { t, type UiTextKey } from "./bootstrap-text.ts";
23
+
24
+ const FIELDS = ["region", "nodeId", "bind", "keyScope", "pageSize", "contact"] as const;
25
+ type FieldName = (typeof FIELDS)[number];
26
+
27
+ const LABELS: Record<FieldName, UiTextKey> = {
28
+ region: "bootstrap.field.region",
29
+ nodeId: "bootstrap.field.nodeId",
30
+ bind: "bootstrap.field.bind",
31
+ keyScope: "bootstrap.field.keyScope",
32
+ pageSize: "bootstrap.field.pageSize",
33
+ contact: "bootstrap.field.contact",
34
+ };
35
+
36
+ const HINTS: Record<FieldName, UiTextKey> = {
37
+ region: "bootstrap.hint.region",
38
+ nodeId: "bootstrap.hint.nodeId",
39
+ bind: "bootstrap.hint.bind",
40
+ keyScope: "bootstrap.hint.keyScope",
41
+ pageSize: "bootstrap.hint.pageSize",
42
+ contact: "bootstrap.hint.contact",
43
+ };
44
+
45
+ /** The values the command handler derived from Tern and the saved config. */
46
+ export interface BootstrapDefaults {
47
+ /** Saved region when re-running, otherwise the Tern session suggestion; empty without Tern. */
48
+ region: string;
49
+ regionSource: string;
50
+ nodeId: string;
51
+ bind: string;
52
+ keyScope: string;
53
+ pageSize: number;
54
+ /** Existing configuration; the draft carries what the form does not edit. */
55
+ current?: OnlyneConfig;
56
+ }
57
+
58
+ type DraftField = { editor: Editor; field: FormField };
59
+
60
+ /** First-run form. Save writes the project config and starts the node; cancel writes nothing. */
61
+ export class OnlyneBootstrap implements Component {
62
+ private readonly fields: Record<FieldName, DraftField>;
63
+ private active: FieldName = "region";
64
+ private saving = false;
65
+ private error: string | undefined;
66
+ private described: NativeNode | undefined;
67
+ private disposed = false;
68
+ private locale = getLocale();
69
+ focused = true;
70
+ get nativeOverlay() {
71
+ return {
72
+ role: "onlyne.bootstrap",
73
+ size: "lg" as const,
74
+ anchor: "center" as const,
75
+ head: t("bootstrap.overlayHead"),
76
+ };
77
+ }
78
+
79
+ constructor(
80
+ private readonly pi: ExtensionAPI,
81
+ private readonly ctx: ExtensionContext,
82
+ private readonly defaults: BootstrapDefaults,
83
+ private readonly theme: Theme,
84
+ private readonly requestRender: () => void,
85
+ private readonly done: () => void,
86
+ private readonly signal: AbortSignal,
87
+ ) {
88
+ this.fields = {
89
+ region: this.makeField(t(LABELS.region), defaults.region, t(HINTS.region)),
90
+ nodeId: this.makeField(t(LABELS.nodeId), shortNodeId(defaults.nodeId), t(HINTS.nodeId)),
91
+ bind: this.makeField(t(LABELS.bind), defaults.bind, t(HINTS.bind)),
92
+ keyScope: this.makeField(t(LABELS.keyScope), defaults.keyScope, t(HINTS.keyScope)),
93
+ pageSize: this.makeField(t(LABELS.pageSize), String(defaults.pageSize), t(HINTS.pageSize)),
94
+ contact: this.makeField(
95
+ t(LABELS.contact),
96
+ (defaults.current?.discovery?.seeds ?? []).join(", "),
97
+ t(HINTS.contact),
98
+ ),
99
+ };
100
+ }
101
+
102
+ private changed(): void {
103
+ if (this.disposed || this.signal.aborted) return;
104
+ this.described = undefined;
105
+ this.requestRender();
106
+ }
107
+
108
+ private makeField(label: string, value: string, hint: string): DraftField {
109
+ const editor = new Editor(getEditorTheme());
110
+ editor.disableSubmit = true;
111
+ editor.setText(value);
112
+ editor.setMaxHeight(2);
113
+ editor.onChange = () => this.changed();
114
+ const field = new FormField(editor, {
115
+ label,
116
+ hint,
117
+ theme: {
118
+ label: text => this.theme.bold(text),
119
+ description: text => this.theme.fg("muted", text),
120
+ error: text => this.theme.fg("error", text),
121
+ hint: text => this.theme.fg("muted", text),
122
+ },
123
+ });
124
+ return { editor, field };
125
+ }
126
+
127
+ private value(name: FieldName): string {
128
+ return this.fields[name].editor.getExpandedText().trim();
129
+ }
130
+
131
+ /** The URI that saving will store; empty input reports the required field instead. */
132
+ private regionPreview(): string {
133
+ const raw = this.value("region");
134
+ return raw.length === 0
135
+ ? t("bootstrap.regionMissing")
136
+ : t("bootstrap.regionPreview", { uri: normalizeRegion(raw) });
137
+ }
138
+
139
+ private async save(): Promise<void> {
140
+ if (this.disposed || this.signal.aborted || this.saving) return;
141
+ const region = this.value("region");
142
+ const nodeId = this.value("nodeId");
143
+ const bind = this.value("bind");
144
+ const keyScope = this.value("keyScope").trim().toLowerCase();
145
+ if (region.length === 0) {
146
+ this.fail(t("bootstrap.regionEmpty"));
147
+ return;
148
+ }
149
+ const scope = keyScope === "" ? "project" : keyScope;
150
+ if (scope !== "project" && scope !== "global" && scope !== "none") {
151
+ this.fail(t("bootstrap.keyScopeInvalid"));
152
+ return;
153
+ }
154
+ let seeds: string[];
155
+ try {
156
+ seeds = this.value("contact")
157
+ .split(/[,\s]+/)
158
+ .map(part => part.trim())
159
+ .filter(part => part.length > 0)
160
+ .map(normalizeServiceUrl);
161
+ } catch (error) {
162
+ this.fail(error instanceof Error ? error.message : String(error));
163
+ return;
164
+ }
165
+ const previous = this.defaults.current;
166
+ const config: OnlyneConfig = {
167
+ path: configPath(this.ctx.cwd),
168
+ region: normalizeRegion(region),
169
+ node: {
170
+ id: normalizeNodeId(nodeId),
171
+ key_scope: scope,
172
+ // The form does not edit these; saving must not erase them.
173
+ ...(previous?.node.key_ref === undefined ? {} : { key_ref: previous.node.key_ref }),
174
+ },
175
+ server: { bind, ...(previous?.server.public_url === undefined ? {} : { public_url: previous.server.public_url }) },
176
+ threads: { page_size: Number(this.value("pageSize")) },
177
+ ...(seeds.length === 0 ? {} : { discovery: { seeds } }),
178
+ ...(previous?.trust === undefined ? {} : { trust: previous.trust }),
179
+ ...(previous?.language === undefined ? {} : { language: previous.language }),
180
+ };
181
+ this.saving = true;
182
+ this.error = undefined;
183
+ this.changed();
184
+ let persisted = false;
185
+ try {
186
+ await saveConfig(config);
187
+ persisted = true;
188
+ const runtime = await startRuntime(this.pi, this.ctx);
189
+ // The start already read every saved seed; a seed typed just now is read again only
190
+ // to report its failure in the form the user typed it in.
191
+ const known = new Set(previous?.discovery?.seeds ?? []);
192
+ for (const seed of seeds.filter(seed => !known.has(seed))) {
193
+ try { await runtime.contact(seed); }
194
+ catch (error) {
195
+ if (this.signal.aborted) return;
196
+ this.fail(
197
+ t("bootstrap.seedFailed", {
198
+ seed,
199
+ reason: error instanceof Error ? error.message : String(error),
200
+ }),
201
+ );
202
+ return;
203
+ }
204
+ }
205
+ if (this.disposed || this.signal.aborted) return;
206
+ this.ctx.ui.notify(t("bootstrap.started", { region: config.region }), "info");
207
+ this.done();
208
+ } catch (error) {
209
+ if (this.signal.aborted) return;
210
+ const reason = error instanceof Error ? error.message : String(error);
211
+ this.error = persisted ? t("bootstrap.startFailed", { reason }) : t("bootstrap.notSaved", { reason });
212
+ } finally {
213
+ if (!this.disposed && !this.signal.aborted) {
214
+ this.saving = false;
215
+ this.changed();
216
+ }
217
+ }
218
+ }
219
+
220
+ private fail(message: string): void {
221
+ this.error = message;
222
+ this.changed();
223
+ }
224
+
225
+ private fieldRow(name: FieldName): NativeNode {
226
+ const selected = name === this.active;
227
+ return node(
228
+ "row",
229
+ {
230
+ role: "omp.btn",
231
+ actions: { click: `onlyne.bootstrap.field.${name}` },
232
+ title: t(LABELS[name]),
233
+ tone: selected ? "accent" : undefined,
234
+ },
235
+ [
236
+ node("text", {
237
+ spans: [
238
+ span(t(LABELS[name]), "strong"),
239
+ span(`: ${this.value(name) || t("bootstrap.empty")}`, "muted"),
240
+ ],
241
+ wrap: "word",
242
+ }),
243
+ ],
244
+ name,
245
+ );
246
+ }
247
+
248
+ private button(label: string, act: string): NativeNode {
249
+ return node("row", { role: "omp.btn", actions: { click: act }, title: label }, [textNode(label)], act);
250
+ }
251
+
252
+ describe(_cx: DescribeContext): NativeNode {
253
+ if (this.locale !== getLocale()) {
254
+ this.locale = getLocale();
255
+ this.described = undefined;
256
+ }
257
+ if (this.described !== undefined) return this.described;
258
+ const body: NativeChild[] = [node("text", { text: this.header(), wrap: "word" })];
259
+ for (const name of FIELDS) {
260
+ if (name === this.active && !this.saving) {
261
+ const draft = this.fields[name];
262
+ draft.field.focused = this.focused;
263
+ body.push(draft.field);
264
+ } else body.push(this.fieldRow(name));
265
+ if (name === "region") body.push(node("text", { text: this.regionPreview(), wrap: "word" }));
266
+ }
267
+ if (this.error !== undefined) body.push(node("text", { spans: [span(this.error, "error")], wrap: "word" }));
268
+ body.push(
269
+ node("col", { gap: "xs" }, [
270
+ this.button(this.saving ? t("bootstrap.saving") : t("bootstrap.save"), "onlyne.bootstrap.save"),
271
+ this.button(t("bootstrap.cancel"), "onlyne.bootstrap.cancel"),
272
+ ]),
273
+ );
274
+ body.push(node("text", { text: t("bootstrap.keys"), wrap: "word" }));
275
+ this.described = node("card", { role: "onlyne.bootstrap" }, body);
276
+ return this.described;
277
+ }
278
+
279
+ handleNativeEvent(event: NativeUiEvent): void {
280
+ if (this.disposed || this.signal.aborted || event.type !== "action") return;
281
+ const act = event.act;
282
+ if (act === "onlyne.bootstrap.save") {
283
+ void this.save();
284
+ return;
285
+ }
286
+ if (act === "onlyne.bootstrap.cancel" || act === "onlyne.close") {
287
+ this.done();
288
+ return;
289
+ }
290
+ if (act.startsWith("onlyne.bootstrap.field.")) {
291
+ const name = act.slice("onlyne.bootstrap.field.".length) as FieldName;
292
+ if (FIELDS.includes(name)) {
293
+ this.active = name;
294
+ this.changed();
295
+ }
296
+ }
297
+ }
298
+
299
+ handleInput(data: string): void {
300
+ if (this.disposed || this.signal.aborted) return;
301
+ if (matchesKey(data, "escape")) {
302
+ this.done();
303
+ return;
304
+ }
305
+ if (matchesKey(data, "ctrl+enter")) {
306
+ void this.save();
307
+ return;
308
+ }
309
+ if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) {
310
+ const step = matchesKey(data, "shift+tab") ? FIELDS.length - 1 : 1;
311
+ this.active = FIELDS[(FIELDS.indexOf(this.active) + step) % FIELDS.length]!;
312
+ this.changed();
313
+ return;
314
+ }
315
+ if (matchesKey(data, "enter")) {
316
+ this.active = FIELDS[(FIELDS.indexOf(this.active) + 1) % FIELDS.length]!;
317
+ this.changed();
318
+ return;
319
+ }
320
+ this.fields[this.active].editor.handleInput(data);
321
+ this.changed();
322
+ }
323
+
324
+ render(width: number): readonly string[] {
325
+ const lines: string[] = [this.theme.bold(t("bootstrap.title")), this.header()];
326
+ for (const name of FIELDS) {
327
+ if (name === this.active && !this.saving) {
328
+ const draft = this.fields[name];
329
+ draft.field.focused = this.focused;
330
+ lines.push(...draft.field.render(width));
331
+ } else {
332
+ lines.push(
333
+ `${name === this.active ? ">" : " "} ${t(LABELS[name])}: ${this.value(name) || t("bootstrap.empty")}`,
334
+ );
335
+ }
336
+ if (name === "region") lines.push(this.regionPreview());
337
+ }
338
+ if (this.error !== undefined) lines.push(this.theme.fg("error", this.error));
339
+ lines.push(t("bootstrap.keys"));
340
+ return new Text(lines.join("\n"), 0, 0).render(width);
341
+ }
342
+
343
+ invalidate(): void {
344
+ this.described = undefined;
345
+ }
346
+
347
+ private header(): string {
348
+ const existing = this.defaults.current === undefined ? "" : t("bootstrap.headerExisting");
349
+ return t("bootstrap.header", { source: this.defaults.regionSource, existing });
350
+ }
351
+
352
+ dispose(): void {
353
+ if (this.disposed) return;
354
+ this.disposed = true;
355
+ for (const name of FIELDS) this.fields[name].field.dispose();
356
+ this.described = undefined;
357
+ }
358
+ }
@@ -0,0 +1,38 @@
1
+ import { createTranslator } from '@onlyne/node-host/i18n';
2
+ const en = {
3
+ usage: 'Usage: /onlyne or /onlyne menu opens the viewer. /onlyne init creates configuration. /onlyne config edits configuration. /onlyne language en|zh-CN switches language.',
4
+ init: 'Create project configuration and node keys',
5
+ config: 'Edit region, node identity, key location and seed addresses',
6
+ menu: 'Open the Onlyne viewer',
7
+ language: 'Switch language: en or zh-CN',
8
+ description: 'Show Onlyne messages, edit configuration or switch language',
9
+ unavailable: 'The Onlyne viewer and configuration form need an interactive terminal. Run /onlyne init in an interactive terminal.',
10
+ configured: 'This project is configured. Use /onlyne config to edit it.',
11
+ savedRegion: 'Region comes from the current configuration',
12
+ noTern: 'No Tern session was detected. Enter a region',
13
+ ternRegion: 'Region comes from Tern session "{name}" and can be changed',
14
+ selected: 'Onlyne language: {language}.',
15
+ initializing: 'Onlyne is initializing',
16
+ main: 'Main OMP session',
17
+ thread: 'Thread from {node}',
18
+ pending: '{author} · pending consumption',
19
+ };
20
+ const zh: Record<keyof typeof en, string> = {
21
+ usage: '用法:/onlyne 或 /onlyne menu 打开界面;/onlyne init 创建配置;/onlyne config 修改配置;/onlyne language en|zh-CN 切换语言。',
22
+ init: '创建项目配置与节点密钥',
23
+ config: '修改区域、节点身份、密钥位置与种子地址',
24
+ menu: '打开 Onlyne 界面',
25
+ language: '切换语言:en 或 zh-CN',
26
+ description: '查看 Onlyne 消息、修改配置或切换语言',
27
+ unavailable: 'Onlyne 查看界面和配置面板需要交互式终端。请在交互式终端运行 /onlyne init。',
28
+ configured: '这个项目已有配置。请用 /onlyne config 修改。',
29
+ savedRegion: '区域来自当前配置',
30
+ noTern: '未检测到 Tern 会话,请填写区域',
31
+ ternRegion: '区域建议值来自 Tern 会话“{name}”,可以修改',
32
+ selected: 'Onlyne 语言:{language}。',
33
+ initializing: 'Onlyne 正在初始化',
34
+ main: 'OMP 主会话',
35
+ thread: '来自 {node} 的线程',
36
+ pending: '{author} · 等待消费',
37
+ };
38
+ export const commandText = createTranslator({ en, 'zh-CN': zh });
@@ -0,0 +1,56 @@
1
+ import { truncateToWidth } from "@oh-my-pi/pi-tui";
2
+ import { t } from "./bootstrap-text.ts";
3
+
4
+ /** Collapse a body to one line without losing the first useful characters. */
5
+ export function oneLine(text: string): string {
6
+ return text.replace(/\s+/g, " ").trim();
7
+ }
8
+
9
+ /** Short elapsed time for a queue row, computed when the snapshot is loaded. */
10
+ export function ageText(at: string | undefined, now = Date.now()): string {
11
+ if (at === undefined) return "";
12
+ const time = Date.parse(at);
13
+ if (!Number.isFinite(time)) return "";
14
+ const seconds = Math.max(0, Math.floor((now - time) / 1000));
15
+ if (seconds < 60) return `${seconds}s`;
16
+ const minutes = Math.floor(seconds / 60);
17
+ if (minutes < 60) return `${minutes}m`;
18
+ const hours = Math.floor(minutes / 60);
19
+ if (hours < 48) return `${hours}h`;
20
+ return `${Math.floor(hours / 24)}d`;
21
+ }
22
+
23
+ export function clockText(at: string): string {
24
+ const time = Date.parse(at);
25
+ if (!Number.isFinite(time)) return at;
26
+ return new Date(time).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
27
+ }
28
+
29
+ /**
30
+ * How long ago something happened, in words. `ageText` is the compact column
31
+ * form (`12m`), which stays untranslated because the unit letter is the whole
32
+ * label; a sentence needs the unit spelled out in the reader's language. The
33
+ * first minute reads "just now", since "0 min ago" is not what anyone says.
34
+ */
35
+ export function sinceText(at: string | undefined, now = Date.now()): string {
36
+ if (at === undefined) return "";
37
+ const time = Date.parse(at);
38
+ if (!Number.isFinite(time)) return "";
39
+ const seconds = Math.max(0, Math.floor((now - time) / 1000));
40
+ if (seconds < 60) return t("time.justNow");
41
+ const minutes = Math.floor(seconds / 60);
42
+ if (minutes < 60) return t("time.minutesAgo", { count: minutes });
43
+ const hours = Math.floor(minutes / 60);
44
+ if (hours < 48) return t("time.hoursAgo", { count: hours });
45
+ return t("time.daysAgo", { count: Math.floor(hours / 24) });
46
+ }
47
+
48
+ /** The head of a thread alias: its first UUID segment, which is what a narrow row can afford. */
49
+ export function shortAlias(alias: string): string {
50
+ const head = alias.split("-", 1)[0] ?? alias;
51
+ return head === "" ? alias : head;
52
+ }
53
+
54
+ export function fit(text: string, width: number): string {
55
+ return truncateToWidth(text, Math.max(0, width));
56
+ }
@@ -0,0 +1,156 @@
1
+ import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
2
+ import { ConfigError, loadConfig } from "../config.ts";
3
+ import { getRuntime, startRuntime } from "../runtime.ts";
4
+ import { detectTernSession } from "../tern.ts";
5
+ import type { OnlyneConfig } from "../types.ts";
6
+ import { OnlyneBootstrap, type BootstrapDefaults } from "./bootstrap.ts";
7
+ import { OnlyneViewer } from "./viewer.ts";
8
+ import { LOCALES, setLocale, type Locale } from "@onlyne/node-host/i18n";
9
+ import { saveConfig } from "../config.ts";
10
+ import { commandText as t } from "./command-text.ts";
11
+ import { registerMsgTool } from "../tool.ts";
12
+ import { refreshMailCards } from "./mail-message.ts";
13
+
14
+ export { attachStatus, OnlyneStatus } from "./status.ts";
15
+
16
+ /** The subcommands the slash command accepts, with the one-line hint the editor shows. */
17
+ const SUBCOMMANDS = ["init", "config", "menu", "language"] as const;
18
+
19
+ export function registerUi(pi: ExtensionAPI): void {
20
+ let active: AbortController | undefined;
21
+ const cancel = (): void => {
22
+ active?.abort();
23
+ active = undefined;
24
+ };
25
+ pi.on("session_switch", cancel);
26
+ pi.on("session_branch", cancel);
27
+ pi.on("session_tree", cancel);
28
+ pi.on("session_shutdown", cancel);
29
+
30
+ pi.registerCommand("onlyne", {
31
+ description: t("description"),
32
+ getArgumentCompletions(argumentPrefix: string) {
33
+ const prefix = argumentPrefix.trim().toLowerCase();
34
+ const matches = SUBCOMMANDS.filter(item => item.startsWith(prefix));
35
+ return matches.length === 0 ? null : matches.map(item => ({ label: item, value: item, description: t(item) }));
36
+ },
37
+ handler: async (args, ctx) => {
38
+ const command = args.trim();
39
+ if (command === "language" || command.startsWith("language ")) {
40
+ const language = command.slice("language".length).trim();
41
+ if (!LOCALES.includes(language as Locale)) { ctx.ui.notify(t("usage"), "warning"); return; }
42
+ try {
43
+ const runtime = getRuntime();
44
+ const config = runtime?.config ?? await loadConfig(ctx.cwd);
45
+ await saveConfig({ ...config, language: language as Locale });
46
+ if (runtime !== undefined) runtime.config.language = language as Locale;
47
+ setLocale(language as Locale);
48
+ registerMsgTool(pi);
49
+ refreshMailCards();
50
+ ctx.ui.notify(t("selected", { language }), "info");
51
+ } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); }
52
+ return;
53
+ }
54
+ if (command !== "" && command !== "init" && command !== "config" && command !== "menu" && command !== "settings") {
55
+ ctx.ui.notify(t("usage"), "warning");
56
+ return;
57
+ }
58
+ if (!ctx.hasUI) {
59
+ pi.sendMessage({
60
+ customType: "onlyne.ui-unavailable",
61
+ content: t("unavailable"),
62
+ display: true,
63
+ attribution: "agent",
64
+ });
65
+ return;
66
+ }
67
+ if (active !== undefined) return;
68
+ const controller = new AbortController();
69
+ active = controller;
70
+ try {
71
+ let runtime = getRuntime();
72
+ if (command === "init") {
73
+ const existing = runtime?.config ?? (await loadConfig(ctx.cwd).catch(() => undefined));
74
+ if (existing !== undefined) {
75
+ ctx.ui.notify(t("configured"), "info");
76
+ return;
77
+ }
78
+ }
79
+ // A saved config with the runtime down is not a first run: start the node and show
80
+ // the viewer. Opening the init form here is what made `/onlyne` look like it had
81
+ // cleared the project.
82
+ if (runtime === undefined && command !== "init" && command !== "config") {
83
+ try {
84
+ runtime = await startRuntime(pi, ctx);
85
+ } catch (error) {
86
+ if (!(error instanceof ConfigError && error.code === "missing")) throw error;
87
+ }
88
+ }
89
+ const needsBootstrap = command === "init" || command === "config" || command === "settings" || runtime === undefined;
90
+ if (needsBootstrap) {
91
+ const tern = detectTernSession();
92
+ const current = runtime?.config ?? (await loadConfig(ctx.cwd).catch(() => undefined));
93
+ const savedRegion = current?.region;
94
+ const regionSource = savedRegion !== undefined
95
+ ? t("savedRegion")
96
+ : tern === undefined
97
+ ? t("noTern")
98
+ : t("ternRegion", { name: tern });
99
+ const defaults: BootstrapDefaults = {
100
+ region: savedRegion ?? tern ?? "",
101
+ regionSource,
102
+ nodeId: current?.node.id ?? "",
103
+ bind: current?.server.bind ?? "127.0.0.1",
104
+ keyScope: current?.node.key_scope ?? "project",
105
+ pageSize: current?.threads.page_size ?? 50,
106
+ ...(current === undefined ? {} : { current }),
107
+ };
108
+ await ctx.ui.custom<void>(
109
+ (tui, theme, _keys, done) =>
110
+ new OnlyneBootstrap(
111
+ pi,
112
+ ctx,
113
+ defaults,
114
+ theme,
115
+ () => tui.requestRender(),
116
+ () => done(),
117
+ controller.signal,
118
+ ),
119
+ {
120
+ overlay: true,
121
+ overlayOptions: { anchor: "center", width: "70%", minWidth: 56, maxHeight: "85%" },
122
+ signal: controller.signal,
123
+ },
124
+ );
125
+ } else {
126
+ await ctx.ui.custom<void>(
127
+ (tui, theme, _keys, done) =>
128
+ new OnlyneViewer(
129
+ theme,
130
+ () => tui.requestRender(),
131
+ () => done(),
132
+ controller.signal,
133
+ () => tui.terminal.rows,
134
+ ),
135
+ {
136
+ overlay: true,
137
+ // Fullscreen is what turns on pointer reporting: a normal overlay never gets mouse
138
+ // events, so every click on a described node would go nowhere. The alt screen also
139
+ // gives the sheet the whole pane, which the four columns need.
140
+ overlayOptions: { margin: 0, fullscreen: true },
141
+ signal: controller.signal,
142
+ },
143
+ );
144
+ }
145
+ } catch (error) {
146
+ if (!controller.signal.aborted) {
147
+ const message = error instanceof Error ? error.message : String(error);
148
+ pi.logger.warn("onlyne.ui.failed", { error: message });
149
+ ctx.ui.notify(message, "error");
150
+ }
151
+ } finally {
152
+ if (active === controller) active = undefined;
153
+ }
154
+ },
155
+ });
156
+ }