omp-onlyne 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/types.ts ADDED
@@ -0,0 +1,150 @@
1
+ import type { MessageRef, NodeId, ThreadRef, TrustPhase, TrustSeed } from "@onlyne/lib";
2
+ import type { Locale } from "@onlyne/node-host/i18n";
3
+ import type { AcquisitionPolicy } from "./acquire.ts";
4
+
5
+ /**
6
+ * Project-scoped configuration in `<project>/.onlyne/config.toml`.
7
+ *
8
+ * Delivery decisions are made by OMP's `judge` model role, so no model
9
+ * endpoint or model name belongs here.
10
+ */
11
+ export interface OnlyneConfig {
12
+ path: string;
13
+ language?: Locale;
14
+ /** Protocol membership, independent from the node identity. */
15
+ region: string;
16
+ node: {
17
+ id: NodeId;
18
+ key_ref?: string;
19
+ /** Where the node identity lives: the project's `.onlyne/keys`, the user's key store, or no key at all. */
20
+ key_scope?: "project" | "global" | "none";
21
+ };
22
+ server: {
23
+ bind: string;
24
+ /**
25
+ * The port this node answers on. Absent asks the operating system for one, and the
26
+ * chosen port is written back here: a node whose address changes on every start
27
+ * leaves every peer that recorded it calling a port nobody serves.
28
+ */
29
+ port?: number;
30
+ /** The externally reachable base URL when a reverse proxy fronts the node. */
31
+ public_url?: string;
32
+ };
33
+ threads: {
34
+ page_size: number;
35
+ };
36
+ /** Bootstrap contact seeds the node reaches at every start. */
37
+ discovery?: {
38
+ seeds?: string[];
39
+ };
40
+ /** Pinned peer fingerprints; an arriving key that matches is trusted without a prompt. */
41
+ trust?: TrustSeed[];
42
+ /** RFC 0018 §2: peers this node may approve without a human confirming first. */
43
+ acquisition?: AcquisitionPolicy;
44
+ }
45
+
46
+ /** One execution context the plugin registered for a delivered message. */
47
+ export interface ContextRecord {
48
+ alias: string;
49
+ node_id: NodeId;
50
+ main_agent_id: string;
51
+ source?: { sender: NodeId; id: string };
52
+ agent_id?: string;
53
+ session_file?: string;
54
+ parent_id?: string;
55
+ thread_ref?: ThreadRef;
56
+ summary: string;
57
+ state: "unbound" | "idle" | "running" | "parked" | "lost";
58
+ created_at: string;
59
+ bound_at?: string;
60
+ }
61
+
62
+ /** One row of the viewer's queue page. */
63
+ export interface QueueItem {
64
+ id: string;
65
+ kind: "inbound" | "outbound" | "context" | "decision";
66
+ label: string;
67
+ detail: string;
68
+ at?: string;
69
+ }
70
+
71
+ /** One message row in the viewer's chat page. */
72
+ export interface ViewerMessage {
73
+ ref: MessageRef;
74
+ seq: string;
75
+ direction: "in" | "out";
76
+ author: string;
77
+ at: string;
78
+ text: string;
79
+ relation?: string;
80
+ }
81
+
82
+ /** One thread row in the viewer's chat page. */
83
+ export interface ViewerThread {
84
+ ref: ThreadRef;
85
+ alias: string;
86
+ title: string;
87
+ state: "open" | "closed";
88
+ unread: number;
89
+ last_seq: string;
90
+ messages: ViewerMessage[];
91
+ /**
92
+ * A thread another node hosts: this node was invited into it, so the viewer can show it and
93
+ * cannot offer the writes that belong to its owner.
94
+ */
95
+ read_only?: boolean;
96
+ }
97
+
98
+ /** One discovery record row the viewer lists as an automatic-discovery candidate. */
99
+ export interface ViewerDiscovery {
100
+ node_id: NodeId;
101
+ instance_id: string;
102
+ base_url: string;
103
+ source: string;
104
+ region?: string;
105
+ membership_state?: "active" | "left";
106
+ verified_at?: string;
107
+ expires_at: string;
108
+ last_seen: string;
109
+ error?: string;
110
+ }
111
+
112
+ /** One reachable peer the viewer lists on its contacts page. */
113
+ export interface ViewerContact {
114
+ node_id: NodeId;
115
+ instance_id: string;
116
+ base_url: string;
117
+ region?: string;
118
+ last_seen: string;
119
+ }
120
+
121
+ /** Read-only snapshot the viewer renders. */
122
+ export interface ViewerSnapshot {
123
+ region?: string;
124
+ endpoint?: string;
125
+ queue: QueueItem[];
126
+ threads: ViewerThread[];
127
+ contexts: ContextRecord[];
128
+ discovery: ViewerDiscovery[];
129
+ contacts: ViewerContact[];
130
+ discovery_error?: string;
131
+ /** Every trust row, projected through `trustPhase`: the only stage this package renders. */
132
+ trust?: Array<{
133
+ node_id: NodeId;
134
+ fingerprint: string;
135
+ phase: TrustPhase;
136
+ base_url: string;
137
+ }>;
138
+ identity?: { public_key: string; fingerprint: string };
139
+ }
140
+
141
+ /** The one-line runtime status a status widget renders. */
142
+ export interface OnlyneStatus {
143
+ region: string;
144
+ node_id: NodeId;
145
+ nodes: number;
146
+ neighbors: number;
147
+ alerts: number;
148
+ unread: number;
149
+ discovery_error?: string;
150
+ }
@@ -0,0 +1,125 @@
1
+ import { createTranslator } from "@onlyne/node-host/i18n";
2
+
3
+ /**
4
+ * Text of the first-run form, the status row, the mail card and the relative
5
+ * time phrases: one UI layer, one catalog. Keys are English source text; the
6
+ * translator reads the process locale on every call, so nothing here is frozen
7
+ * at import time.
8
+ */
9
+ const en = {
10
+ "bootstrap.title": "Onlyne init",
11
+ "bootstrap.overlayHead": "Onlyne init",
12
+ "bootstrap.field.region": "Region",
13
+ "bootstrap.field.nodeId": "Node id",
14
+ "bootstrap.field.bind": "Server bind",
15
+ "bootstrap.field.keyScope": "Node key (project/global/none)",
16
+ "bootstrap.field.pageSize": "Page size",
17
+ "bootstrap.field.contact": "Node addresses (optional)",
18
+ "bootstrap.hint.region": "A short name is stored as urn:onlyne:region:<name>",
19
+ "bootstrap.hint.nodeId": "A short name is stored as urn:onlyne:agent:<name>",
20
+ "bootstrap.hint.bind": "127.0.0.1",
21
+ "bootstrap.hint.keyScope":
22
+ "project: keys in .onlyne/keys (must be git-ignored); global: keys in ~/.config/onlyne/keys; none: loopback development only",
23
+ "bootstrap.hint.pageSize": "1..100, default 50",
24
+ "bootstrap.hint.contact":
25
+ "Nodes discovery cannot reach are contacted by address; stored as discovery.seeds and contacted on every start. Separate addresses with commas",
26
+ "bootstrap.empty": "[empty]",
27
+ "bootstrap.regionMissing": "Region is required; a short name is stored as urn:onlyne:region:<name>",
28
+ "bootstrap.regionPreview": "Will be stored as {uri}",
29
+ "bootstrap.regionEmpty": "Region must not be empty.",
30
+ "bootstrap.keyScopeInvalid": "Node key must be project, global or none.",
31
+ "bootstrap.seedFailed": "Configuration saved and the node started, but {seed} did not connect: {reason}",
32
+ "bootstrap.started": "Onlyne initialized: {region}",
33
+ "bootstrap.startFailed": "Configuration written, start failed: {reason}",
34
+ "bootstrap.notSaved": "Not saved: {reason}",
35
+ "bootstrap.save": "Save and start",
36
+ "bootstrap.saving": "Saving…",
37
+ "bootstrap.cancel": "Cancel",
38
+ "bootstrap.keys": "Tab/Shift+Tab newline · Enter next field · Ctrl+Enter save · Esc cancel",
39
+ "bootstrap.header": "{source}. Saving starts this node and publishes discovery.{existing}",
40
+ "bootstrap.headerExisting": " A project configuration already exists and saving overwrites it.",
41
+ "status.nodes": "nodes",
42
+ "status.neighbors": "neighbors",
43
+ "status.alert": "alert",
44
+ "status.unread": "unread",
45
+ "status.discovery": "discovery",
46
+ "status.unconfigured": "unconfigured",
47
+ "status.starting": "starting",
48
+ "mail.supplement": "supplement",
49
+ "mail.routing": "forwarded",
50
+ "mail.letter": "letter",
51
+ "mail.from": "From",
52
+ "mail.date": "Date",
53
+ "mail.thread": "Thread",
54
+ "mail.relation": "Relation",
55
+ "mail.inReplyTo": "In-Reply-To",
56
+ "mail.delivery": "Delivery",
57
+ "mail.status": "Status",
58
+ "mail.consumed": " · consumed {state}",
59
+ "mail.rawRecord": "Raw record",
60
+ "mail.wholeThread": "Whole thread",
61
+ "time.justNow": "just now",
62
+ "time.minutesAgo": "{count} min ago",
63
+ "time.hoursAgo": "{count} h ago",
64
+ "time.daysAgo": "{count} d ago",
65
+ } as const;
66
+
67
+ const zh: Record<keyof typeof en, string> = {
68
+ "bootstrap.title": "Onlyne init",
69
+ "bootstrap.overlayHead": "Onlyne init",
70
+ "bootstrap.field.region": "Region",
71
+ "bootstrap.field.nodeId": "Node id",
72
+ "bootstrap.field.bind": "Server bind",
73
+ "bootstrap.field.keyScope": "Node key (project/global/none)",
74
+ "bootstrap.field.pageSize": "Page size",
75
+ "bootstrap.field.contact": "节点地址(可选)",
76
+ "bootstrap.hint.region": "短名保存为 urn:onlyne:region:<短名>",
77
+ "bootstrap.hint.nodeId": "短名保存为 urn:onlyne:agent:<短名>",
78
+ "bootstrap.hint.bind": "127.0.0.1",
79
+ "bootstrap.hint.keyScope":
80
+ "project:密钥存 .onlyne/keys(git 内需 ignore);global:存 ~/.config/onlyne/keys;none:回环开发绑定",
81
+ "bootstrap.hint.pageSize": "1..100,默认 50",
82
+ "bootstrap.hint.contact": "自动发现到不了的节点按地址联系;保存为 discovery.seeds,每次启动都会联系。多个地址用逗号分隔",
83
+ "bootstrap.empty": "[空]",
84
+ "bootstrap.regionMissing": "Region 必填;短名将保存为 urn:onlyne:region:<短名>",
85
+ "bootstrap.regionPreview": "将保存为 {uri}",
86
+ "bootstrap.regionEmpty": "Region 不能为空。",
87
+ "bootstrap.keyScopeInvalid": "Node key 只能是 project、global 或 none。",
88
+ "bootstrap.seedFailed": "配置已保存并启动,但 {seed} 没连上:{reason}",
89
+ "bootstrap.started": "Onlyne 已初始化:{region}",
90
+ "bootstrap.startFailed": "配置已写入,启动失败:{reason}",
91
+ "bootstrap.notSaved": "未保存:{reason}",
92
+ "bootstrap.save": "保存并启动",
93
+ "bootstrap.saving": "保存中…",
94
+ "bootstrap.cancel": "取消",
95
+ "bootstrap.keys": "Tab/Shift+Tab 换行 · Enter 下一项 · Ctrl+Enter 保存 · Esc 取消",
96
+ "bootstrap.header": "{source}。保存后启动本节点并发布发现。{existing}",
97
+ "bootstrap.headerExisting": "已存在项目配置,保存将覆盖。",
98
+ "status.nodes": "节点",
99
+ "status.neighbors": "邻居",
100
+ "status.alert": "提醒",
101
+ "status.unread": "未读",
102
+ "status.discovery": "发现",
103
+ "status.unconfigured": "未配置",
104
+ "status.starting": "启动中",
105
+ "mail.supplement": "补笺",
106
+ "mail.routing": "转递",
107
+ "mail.letter": "来信",
108
+ "mail.from": "发件人",
109
+ "mail.date": "日期",
110
+ "mail.thread": "线程",
111
+ "mail.relation": "关系",
112
+ "mail.inReplyTo": "回复",
113
+ "mail.delivery": "投送",
114
+ "mail.status": "状态",
115
+ "mail.consumed": " · 已消费 {state}",
116
+ "mail.rawRecord": "原始记录",
117
+ "mail.wholeThread": "整段对话",
118
+ "time.justNow": "刚刚",
119
+ "time.minutesAgo": "{count} 分钟前",
120
+ "time.hoursAgo": "{count} 小时前",
121
+ "time.daysAgo": "{count} 天前",
122
+ };
123
+
124
+ export const t = createTranslator({ en, "zh-CN": zh });
125
+ export type UiTextKey = keyof typeof en;
@@ -0,0 +1,363 @@
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: {
176
+ bind,
177
+ ...(previous?.server.port === undefined ? {} : { port: previous.server.port }),
178
+ ...(previous?.server.public_url === undefined ? {} : { public_url: previous.server.public_url }),
179
+ },
180
+ threads: { page_size: Number(this.value("pageSize")) },
181
+ ...(seeds.length === 0 ? {} : { discovery: { seeds } }),
182
+ ...(previous?.trust === undefined ? {} : { trust: previous.trust }),
183
+ ...(previous?.acquisition === undefined ? {} : { acquisition: previous.acquisition }),
184
+ ...(previous?.language === undefined ? {} : { language: previous.language }),
185
+ };
186
+ this.saving = true;
187
+ this.error = undefined;
188
+ this.changed();
189
+ let persisted = false;
190
+ try {
191
+ await saveConfig(config);
192
+ persisted = true;
193
+ const runtime = await startRuntime(this.pi, this.ctx);
194
+ // The start already read every saved seed; a seed typed just now is read again only
195
+ // to report its failure in the form the user typed it in.
196
+ const known = new Set(previous?.discovery?.seeds ?? []);
197
+ for (const seed of seeds.filter(seed => !known.has(seed))) {
198
+ try { await runtime.contact(seed); }
199
+ catch (error) {
200
+ if (this.signal.aborted) return;
201
+ this.fail(
202
+ t("bootstrap.seedFailed", {
203
+ seed,
204
+ reason: error instanceof Error ? error.message : String(error),
205
+ }),
206
+ );
207
+ return;
208
+ }
209
+ }
210
+ if (this.disposed || this.signal.aborted) return;
211
+ this.ctx.ui.notify(t("bootstrap.started", { region: config.region }), "info");
212
+ this.done();
213
+ } catch (error) {
214
+ if (this.signal.aborted) return;
215
+ const reason = error instanceof Error ? error.message : String(error);
216
+ this.error = persisted ? t("bootstrap.startFailed", { reason }) : t("bootstrap.notSaved", { reason });
217
+ } finally {
218
+ if (!this.disposed && !this.signal.aborted) {
219
+ this.saving = false;
220
+ this.changed();
221
+ }
222
+ }
223
+ }
224
+
225
+ private fail(message: string): void {
226
+ this.error = message;
227
+ this.changed();
228
+ }
229
+
230
+ private fieldRow(name: FieldName): NativeNode {
231
+ const selected = name === this.active;
232
+ return node(
233
+ "row",
234
+ {
235
+ role: "omp.btn",
236
+ actions: { click: `onlyne.bootstrap.field.${name}` },
237
+ title: t(LABELS[name]),
238
+ tone: selected ? "accent" : undefined,
239
+ },
240
+ [
241
+ node("text", {
242
+ spans: [
243
+ span(t(LABELS[name]), "strong"),
244
+ span(`: ${this.value(name) || t("bootstrap.empty")}`, "muted"),
245
+ ],
246
+ wrap: "word",
247
+ }),
248
+ ],
249
+ name,
250
+ );
251
+ }
252
+
253
+ private button(label: string, act: string): NativeNode {
254
+ return node("row", { role: "omp.btn", actions: { click: act }, title: label }, [textNode(label)], act);
255
+ }
256
+
257
+ describe(_cx: DescribeContext): NativeNode {
258
+ if (this.locale !== getLocale()) {
259
+ this.locale = getLocale();
260
+ this.described = undefined;
261
+ }
262
+ if (this.described !== undefined) return this.described;
263
+ const body: NativeChild[] = [node("text", { text: this.header(), wrap: "word" })];
264
+ for (const name of FIELDS) {
265
+ if (name === this.active && !this.saving) {
266
+ const draft = this.fields[name];
267
+ draft.field.focused = this.focused;
268
+ body.push(draft.field);
269
+ } else body.push(this.fieldRow(name));
270
+ if (name === "region") body.push(node("text", { text: this.regionPreview(), wrap: "word" }));
271
+ }
272
+ if (this.error !== undefined) body.push(node("text", { spans: [span(this.error, "error")], wrap: "word" }));
273
+ body.push(
274
+ node("col", { gap: "xs" }, [
275
+ this.button(this.saving ? t("bootstrap.saving") : t("bootstrap.save"), "onlyne.bootstrap.save"),
276
+ this.button(t("bootstrap.cancel"), "onlyne.bootstrap.cancel"),
277
+ ]),
278
+ );
279
+ body.push(node("text", { text: t("bootstrap.keys"), wrap: "word" }));
280
+ this.described = node("card", { role: "onlyne.bootstrap" }, body);
281
+ return this.described;
282
+ }
283
+
284
+ handleNativeEvent(event: NativeUiEvent): void {
285
+ if (this.disposed || this.signal.aborted || event.type !== "action") return;
286
+ const act = event.act;
287
+ if (act === "onlyne.bootstrap.save") {
288
+ void this.save();
289
+ return;
290
+ }
291
+ if (act === "onlyne.bootstrap.cancel" || act === "onlyne.close") {
292
+ this.done();
293
+ return;
294
+ }
295
+ if (act.startsWith("onlyne.bootstrap.field.")) {
296
+ const name = act.slice("onlyne.bootstrap.field.".length) as FieldName;
297
+ if (FIELDS.includes(name)) {
298
+ this.active = name;
299
+ this.changed();
300
+ }
301
+ }
302
+ }
303
+
304
+ handleInput(data: string): void {
305
+ if (this.disposed || this.signal.aborted) return;
306
+ if (matchesKey(data, "escape")) {
307
+ this.done();
308
+ return;
309
+ }
310
+ if (matchesKey(data, "ctrl+enter")) {
311
+ void this.save();
312
+ return;
313
+ }
314
+ if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) {
315
+ const step = matchesKey(data, "shift+tab") ? FIELDS.length - 1 : 1;
316
+ this.active = FIELDS[(FIELDS.indexOf(this.active) + step) % FIELDS.length]!;
317
+ this.changed();
318
+ return;
319
+ }
320
+ if (matchesKey(data, "enter")) {
321
+ this.active = FIELDS[(FIELDS.indexOf(this.active) + 1) % FIELDS.length]!;
322
+ this.changed();
323
+ return;
324
+ }
325
+ this.fields[this.active].editor.handleInput(data);
326
+ this.changed();
327
+ }
328
+
329
+ render(width: number): readonly string[] {
330
+ const lines: string[] = [this.theme.bold(t("bootstrap.title")), this.header()];
331
+ for (const name of FIELDS) {
332
+ if (name === this.active && !this.saving) {
333
+ const draft = this.fields[name];
334
+ draft.field.focused = this.focused;
335
+ lines.push(...draft.field.render(width));
336
+ } else {
337
+ lines.push(
338
+ `${name === this.active ? ">" : " "} ${t(LABELS[name])}: ${this.value(name) || t("bootstrap.empty")}`,
339
+ );
340
+ }
341
+ if (name === "region") lines.push(this.regionPreview());
342
+ }
343
+ if (this.error !== undefined) lines.push(this.theme.fg("error", this.error));
344
+ lines.push(t("bootstrap.keys"));
345
+ return new Text(lines.join("\n"), 0, 0).render(width);
346
+ }
347
+
348
+ invalidate(): void {
349
+ this.described = undefined;
350
+ }
351
+
352
+ private header(): string {
353
+ const existing = this.defaults.current === undefined ? "" : t("bootstrap.headerExisting");
354
+ return t("bootstrap.header", { source: this.defaults.regionSource, existing });
355
+ }
356
+
357
+ dispose(): void {
358
+ if (this.disposed) return;
359
+ this.disposed = true;
360
+ for (const name of FIELDS) this.fields[name].field.dispose();
361
+ this.described = undefined;
362
+ }
363
+ }
@@ -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 });