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/AGENTS.sample.md +27 -0
- package/LICENSE +21 -0
- package/README.md +140 -2
- package/index.ts +55 -0
- package/package.json +62 -5
- package/src/acquire.ts +182 -0
- package/src/binding.ts +132 -0
- package/src/bridge-text.ts +145 -0
- package/src/config.ts +332 -0
- package/src/decider-text.ts +438 -0
- package/src/decider.ts +2233 -0
- package/src/drafts.ts +57 -0
- package/src/handshake.ts +291 -0
- package/src/host-text.ts +92 -0
- package/src/messages.ts +427 -0
- package/src/node.ts +139 -0
- package/src/resources.ts +36 -0
- package/src/runtime.ts +676 -0
- package/src/store.ts +189 -0
- package/src/tern.ts +66 -0
- package/src/todo-native.ts +230 -0
- package/src/todo.ts +896 -0
- package/src/tool-text.ts +167 -0
- package/src/tool.ts +342 -0
- package/src/types.ts +150 -0
- package/src/ui/bootstrap-text.ts +125 -0
- package/src/ui/bootstrap.ts +363 -0
- package/src/ui/command-text.ts +38 -0
- package/src/ui/format.ts +56 -0
- package/src/ui/index.ts +156 -0
- package/src/ui/mail-message.ts +263 -0
- package/src/ui/status.ts +213 -0
- package/src/ui/viewer-text.ts +309 -0
- package/src/ui/viewer.ts +1403 -0
- package/src/urls-text.ts +304 -0
- package/src/urls.ts +1488 -0
package/src/ui/format.ts
ADDED
|
@@ -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
|
+
}
|
package/src/ui/index.ts
ADDED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import type { MessageRenderer } from "@oh-my-pi/pi-coding-agent";
|
|
2
|
+
import { getLocale } from "@onlyne/node-host/i18n";
|
|
3
|
+
import { Markdown, Text, getMarkdownTheme, node, span, text, type Component, type NativeNode, type Theme, type TspSpan } from "@oh-my-pi/pi-tui";
|
|
4
|
+
import type { HistoryMessage, MessageRef, ThreadRef } from "@onlyne/lib";
|
|
5
|
+
import { shortNodeId } from "../config.ts";
|
|
6
|
+
import { getRuntime, type PluginRuntime } from "../runtime.ts";
|
|
7
|
+
import { conversationPath, messagePath } from "../urls.ts";
|
|
8
|
+
import { t } from "./bootstrap-text.ts";
|
|
9
|
+
|
|
10
|
+
export interface OnlyneMessageDetails {
|
|
11
|
+
thread_ref: ThreadRef;
|
|
12
|
+
seq: string;
|
|
13
|
+
message_ref: MessageRef;
|
|
14
|
+
/** What the payload can attest: this entry reached this node's inbox. */
|
|
15
|
+
delivery: string;
|
|
16
|
+
/** The submission-time consumption fact; the confirmed one is read from history. */
|
|
17
|
+
consumption: string;
|
|
18
|
+
mail?: {
|
|
19
|
+
from: string;
|
|
20
|
+
at: string;
|
|
21
|
+
subject: string;
|
|
22
|
+
body: string;
|
|
23
|
+
relation?: "reply" | "followup";
|
|
24
|
+
in_reply_to?: MessageRef;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What the card reports about one message: the live history row, or why it could not read one. */
|
|
29
|
+
interface CardStatus {
|
|
30
|
+
delivery: string;
|
|
31
|
+
consumption?: string;
|
|
32
|
+
error?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The cards currently in a transcript. A message renderer is handed no TUI handle, so the
|
|
37
|
+
* cards hold themselves weakly and share one subscription: a rebuilt transcript drops its old
|
|
38
|
+
* components without leaking listeners.
|
|
39
|
+
*/
|
|
40
|
+
const cards = new Set<WeakRef<MailMessage>>();
|
|
41
|
+
let watched: PluginRuntime | undefined;
|
|
42
|
+
/** A status key this card never writes: clearing an unset key is a no-op that asks for a frame. */
|
|
43
|
+
const REPAINT_KEY = "onlyne.mail";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Ask the host to paint again. A message renderer receives no repaint callback, and the one
|
|
47
|
+
* host call that renders unconditionally is a status write; clearing a key this card never
|
|
48
|
+
* sets changes nothing on screen and still requests the frame.
|
|
49
|
+
*/
|
|
50
|
+
function repaint(): void {
|
|
51
|
+
try {
|
|
52
|
+
getRuntime()?.main.ui.setStatus(REPAINT_KEY, undefined);
|
|
53
|
+
} catch {
|
|
54
|
+
// A closed session has no status line; the next host paint still shows the new state.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One subscription per runtime: a card refreshes when the node reports a lifecycle change. */
|
|
59
|
+
function watch(): void {
|
|
60
|
+
const runtime = getRuntime();
|
|
61
|
+
if (runtime === undefined || watched === runtime) return;
|
|
62
|
+
watched = runtime;
|
|
63
|
+
runtime.onChange(() => refreshMailCards());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Re-read every card still in a transcript. A consumption is recorded without a notification
|
|
68
|
+
* of its own, so the host calls this when it confirms one.
|
|
69
|
+
*/
|
|
70
|
+
export function refreshMailCards(): void {
|
|
71
|
+
for (const reference of cards) {
|
|
72
|
+
const card = reference.deref();
|
|
73
|
+
if (card === undefined) cards.delete(reference);
|
|
74
|
+
else void card.refresh();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
class MailMessage implements Component {
|
|
79
|
+
private status: CardStatus;
|
|
80
|
+
private native: NativeNode | undefined;
|
|
81
|
+
private headers: Text | undefined;
|
|
82
|
+
private footer: Text | undefined;
|
|
83
|
+
private rendered: readonly string[] | undefined;
|
|
84
|
+
private refreshing = false;
|
|
85
|
+
private readonly body: Markdown;
|
|
86
|
+
private locale = getLocale();
|
|
87
|
+
|
|
88
|
+
constructor(
|
|
89
|
+
private readonly details: OnlyneMessageDetails,
|
|
90
|
+
private readonly kind: string,
|
|
91
|
+
private readonly theme: Theme,
|
|
92
|
+
) {
|
|
93
|
+
this.body = new Markdown(details.mail?.body ?? "", 0, 0, getMarkdownTheme());
|
|
94
|
+
this.status = { delivery: details.delivery, consumption: details.consumption };
|
|
95
|
+
cards.add(new WeakRef(this));
|
|
96
|
+
watch();
|
|
97
|
+
void this.refresh();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Read the message's own row: delivery and consumption are facts, not a guess from the card. */
|
|
101
|
+
async refresh(): Promise<void> {
|
|
102
|
+
const runtime = getRuntime();
|
|
103
|
+
if (runtime === undefined || this.refreshing) return;
|
|
104
|
+
this.refreshing = true;
|
|
105
|
+
let next: CardStatus;
|
|
106
|
+
try {
|
|
107
|
+
const row: HistoryMessage = await runtime.node.history.message(runtime.node.auth, this.details.message_ref);
|
|
108
|
+
next = { delivery: row.delivery, ...(row.consumption === undefined ? {} : { consumption: row.consumption }) };
|
|
109
|
+
} catch (error) {
|
|
110
|
+
// A failed read is reported as unknown; claiming a state the card cannot see is worse.
|
|
111
|
+
next = { delivery: "unknown", error: error instanceof Error ? error.message : String(error) };
|
|
112
|
+
} finally {
|
|
113
|
+
this.refreshing = false;
|
|
114
|
+
}
|
|
115
|
+
if (next.delivery === this.status.delivery && next.consumption === this.status.consumption && next.error === this.status.error) return;
|
|
116
|
+
this.status = next;
|
|
117
|
+
this.native = undefined;
|
|
118
|
+
this.headers = undefined;
|
|
119
|
+
this.footer = undefined;
|
|
120
|
+
this.rendered = undefined;
|
|
121
|
+
repaint();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
describe(): NativeNode {
|
|
125
|
+
this.dropLocaleCaches();
|
|
126
|
+
return this.native ??= this.describeCard();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
render(width: number): readonly string[] {
|
|
130
|
+
this.dropLocaleCaches();
|
|
131
|
+
if (this.rendered !== undefined) return this.rendered;
|
|
132
|
+
const mail = this.details.mail;
|
|
133
|
+
if (mail === undefined) return this.rendered = [];
|
|
134
|
+
const rule = "─".repeat(Math.max(0, Math.min(width, 72)));
|
|
135
|
+
this.headers ??= new Text(this.headerLines(), 0, 0);
|
|
136
|
+
this.footer ??= new Text(this.theme.fg("muted", `${this.label()} · #${this.details.seq}\n${messagePath(this.details.message_ref)}`), 0, 0);
|
|
137
|
+
return this.rendered = [...this.headers.render(width), rule, "", ...this.body.render(width), "", rule, ...this.footer.render(width)];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
invalidate(): void {
|
|
141
|
+
this.native = undefined;
|
|
142
|
+
this.headers = undefined;
|
|
143
|
+
this.footer = undefined;
|
|
144
|
+
this.rendered = undefined;
|
|
145
|
+
this.body.invalidate();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private label(): string {
|
|
149
|
+
return this.kind === "onlyne.supplement"
|
|
150
|
+
? t("mail.supplement")
|
|
151
|
+
: this.kind === "onlyne.routing"
|
|
152
|
+
? t("mail.routing")
|
|
153
|
+
: t("mail.letter");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private heading(): string {
|
|
157
|
+
const subject = (this.details.mail?.subject ?? "").replace(/[\r\n]+/g, " ");
|
|
158
|
+
return `${this.details.mail?.relation === "reply" ? "Re: " : ""}${subject}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private headerLines(): string {
|
|
162
|
+
const mail = this.details.mail;
|
|
163
|
+
if (mail === undefined) return "";
|
|
164
|
+
const fields = [
|
|
165
|
+
`${this.theme.fg("muted", `${t("mail.from")}:`)} ${shortNodeId(mail.from)} <${mail.from}>`,
|
|
166
|
+
`${this.theme.fg("muted", `${t("mail.date")}:`)} ${mail.at}`,
|
|
167
|
+
`${this.theme.fg("muted", `${t("mail.thread")}:`)} ${this.details.thread_ref.owner}/${this.details.thread_ref.id} #${this.details.seq}`,
|
|
168
|
+
...(mail.relation === undefined ? [] : [`${this.theme.fg("muted", `${t("mail.relation")}:`)} ${mail.relation}`]),
|
|
169
|
+
...(mail.in_reply_to === undefined
|
|
170
|
+
? []
|
|
171
|
+
: [`${this.theme.fg("muted", `${t("mail.inReplyTo")}:`)} ${mail.in_reply_to.sender}/${mail.in_reply_to.id}`]),
|
|
172
|
+
`${this.theme.fg("muted", `${t("mail.delivery")}:`)} ${this.status.delivery}${
|
|
173
|
+
this.status.consumption === undefined ? "" : t("mail.consumed", { state: this.status.consumption })
|
|
174
|
+
}`,
|
|
175
|
+
...(this.status.error === undefined
|
|
176
|
+
? []
|
|
177
|
+
: [`${this.theme.fg("muted", `${t("mail.status")}:`)} unknown (${this.status.error})`]),
|
|
178
|
+
];
|
|
179
|
+
return [this.theme.fg("accent", this.theme.bold(this.heading())), ...fields].join("\n");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The mailing-list card: the header block a letter carries, the body, then the two ways to
|
|
184
|
+
* read this message again. Every link names a route this node serves, and one that does
|
|
185
|
+
* not resolve is not offered.
|
|
186
|
+
*/
|
|
187
|
+
private describeCard(): NativeNode {
|
|
188
|
+
const mail = this.details.mail;
|
|
189
|
+
if (mail === undefined) return text([span("onlyne", "muted mono")]);
|
|
190
|
+
const messageHref = messagePath(this.details.message_ref);
|
|
191
|
+
const replyHref = mail.in_reply_to === undefined ? undefined : messagePath(mail.in_reply_to);
|
|
192
|
+
const fields: Array<{ k: string; v: TspSpan[] }> = [
|
|
193
|
+
{
|
|
194
|
+
k: t("mail.from"),
|
|
195
|
+
v: [
|
|
196
|
+
span(shortNodeId(mail.from), "link mono", { href: `onlyne://neighbors/${encodeURIComponent(mail.from)}` }),
|
|
197
|
+
span(` <${mail.from}>`, "muted mono"),
|
|
198
|
+
],
|
|
199
|
+
},
|
|
200
|
+
{ k: t("mail.date"), v: [span(mail.at, "mono")] },
|
|
201
|
+
{
|
|
202
|
+
k: t("mail.thread"),
|
|
203
|
+
// Both segments are encoded, so a URN owner stays one segment of the route.
|
|
204
|
+
v: [
|
|
205
|
+
span(`${this.details.thread_ref.owner}/${this.details.thread_ref.id}`, "link mono", {
|
|
206
|
+
href: `onlyne://threads/${encodeURIComponent(this.details.thread_ref.owner)}/${encodeURIComponent(this.details.thread_ref.id)}`,
|
|
207
|
+
}),
|
|
208
|
+
span(` #${this.details.seq}`, "muted mono"),
|
|
209
|
+
],
|
|
210
|
+
},
|
|
211
|
+
...(mail.relation === undefined ? [] : [{ k: t("mail.relation"), v: [span(mail.relation, "mono")] }]),
|
|
212
|
+
...(mail.in_reply_to === undefined || replyHref === undefined
|
|
213
|
+
? []
|
|
214
|
+
: [{
|
|
215
|
+
k: t("mail.inReplyTo"),
|
|
216
|
+
v: [span(`${mail.in_reply_to.sender}/${mail.in_reply_to.id}`, "link mono", { href: replyHref })],
|
|
217
|
+
}]),
|
|
218
|
+
{
|
|
219
|
+
k: t("mail.delivery"),
|
|
220
|
+
v: [
|
|
221
|
+
span(this.status.delivery, this.status.delivery === "received" ? "mono" : "warning mono"),
|
|
222
|
+
...(this.status.consumption === undefined
|
|
223
|
+
? []
|
|
224
|
+
: [span(t("mail.consumed", { state: this.status.consumption }), "mono")]),
|
|
225
|
+
],
|
|
226
|
+
},
|
|
227
|
+
...(this.status.error === undefined
|
|
228
|
+
? []
|
|
229
|
+
: [{ k: t("mail.status"), v: [span(`unknown (${this.status.error})`, "warning mono")] }]),
|
|
230
|
+
];
|
|
231
|
+
return node("card", {
|
|
232
|
+
role: "onlyne.mail",
|
|
233
|
+
head: [span("[onlyne] ", "muted mono"), span(this.heading(), "strong")],
|
|
234
|
+
}, [
|
|
235
|
+
node("kv", { layout: "grid", items: fields.map(field => ({ k: [span(`${field.k}:`, "muted mono")], v: field.v })) }),
|
|
236
|
+
node("rule"),
|
|
237
|
+
node("md", { text: mail.body }),
|
|
238
|
+
node("rule"),
|
|
239
|
+
text([
|
|
240
|
+
span(`${this.label()} · #${this.details.seq} · `, "muted mono"),
|
|
241
|
+
span(t("mail.rawRecord"), "link mono", { href: messageHref }),
|
|
242
|
+
span(" · ", "muted mono"),
|
|
243
|
+
span(t("mail.wholeThread"), "link mono", { href: conversationPath(this.details.message_ref) }),
|
|
244
|
+
], { wrap: "word" }),
|
|
245
|
+
]);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** A locale switch rewrites every label, so the frame caches of the old locale are dropped. */
|
|
249
|
+
private dropLocaleCaches(): void {
|
|
250
|
+
const locale = getLocale();
|
|
251
|
+
if (locale === this.locale) return;
|
|
252
|
+
this.locale = locale;
|
|
253
|
+
this.native = undefined;
|
|
254
|
+
this.headers = undefined;
|
|
255
|
+
this.footer = undefined;
|
|
256
|
+
this.rendered = undefined;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export const renderMailMessage: MessageRenderer<OnlyneMessageDetails> = (message, _options, theme) => {
|
|
261
|
+
if (message.details?.mail === undefined) return undefined;
|
|
262
|
+
return new MailMessage(message.details, message.customType, theme);
|
|
263
|
+
};
|
package/src/ui/status.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@oh-my-pi/pi-coding-agent";
|
|
2
|
+
import { getLocale } from "@onlyne/node-host/i18n";
|
|
3
|
+
import {
|
|
4
|
+
type Component,
|
|
5
|
+
type NativeNode,
|
|
6
|
+
node,
|
|
7
|
+
span,
|
|
8
|
+
text as textNode,
|
|
9
|
+
type Theme,
|
|
10
|
+
} from "@oh-my-pi/pi-tui";
|
|
11
|
+
import { getRuntime, type PluginRuntime } from "../runtime.ts";
|
|
12
|
+
import type { OnlyneStatus as OnlyneStatusSnapshot } from "../types.ts";
|
|
13
|
+
import { shortNodeId } from "../config.ts";
|
|
14
|
+
import { t } from "./bootstrap-text.ts";
|
|
15
|
+
import { fit, oneLine } from "./format.ts";
|
|
16
|
+
|
|
17
|
+
const REGION_PREFIX = "urn:onlyne:region:";
|
|
18
|
+
const REFRESH_MS = 2_000;
|
|
19
|
+
const WIDGET_KEY = "onlyne";
|
|
20
|
+
|
|
21
|
+
/** A region URI is stored for stability; the row shows the short name the user typed. */
|
|
22
|
+
function regionLabel(region: string): string {
|
|
23
|
+
if (!region.startsWith(REGION_PREFIX)) return region;
|
|
24
|
+
const name = region.slice(REGION_PREFIX.length);
|
|
25
|
+
try {
|
|
26
|
+
return decodeURIComponent(name);
|
|
27
|
+
} catch {
|
|
28
|
+
return name;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One `label value` module of the status row. */
|
|
33
|
+
interface Module {
|
|
34
|
+
label: string;
|
|
35
|
+
value: string;
|
|
36
|
+
tone?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One fixed row above the editor: region, node and neighbor counts, pending
|
|
41
|
+
* alerts and unread messages. The modules are laid out across the full width
|
|
42
|
+
* (`justify: between`), never left-aligned; the separators are layout gaps, not
|
|
43
|
+
* drawn characters. `render` and `describe` project the same modules, so the
|
|
44
|
+
* ANSI row and the Tern card cannot disagree, and neither repeats the line.
|
|
45
|
+
*/
|
|
46
|
+
export class OnlyneStatus implements Component {
|
|
47
|
+
private readonly theme: Theme;
|
|
48
|
+
private readonly requestRender: () => void;
|
|
49
|
+
private runtime: PluginRuntime | undefined;
|
|
50
|
+
private snapshot: OnlyneStatusSnapshot | undefined;
|
|
51
|
+
private unsubscribe: (() => void) | undefined;
|
|
52
|
+
private timer: Timer | undefined;
|
|
53
|
+
private disposed = false;
|
|
54
|
+
private refreshing = false;
|
|
55
|
+
private row: string | undefined;
|
|
56
|
+
private rows: readonly string[] | undefined;
|
|
57
|
+
private described: NativeNode | undefined;
|
|
58
|
+
private locale = getLocale();
|
|
59
|
+
|
|
60
|
+
constructor(theme: Theme, requestRender: () => void) {
|
|
61
|
+
this.theme = theme;
|
|
62
|
+
this.requestRender = requestRender;
|
|
63
|
+
void this.refresh();
|
|
64
|
+
this.timer = setInterval(() => void this.refresh(), REFRESH_MS);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Re-read the runtime and its status; a restart is a new runtime object and a new subscription. */
|
|
68
|
+
private async refresh(): Promise<void> {
|
|
69
|
+
if (this.disposed || this.refreshing) return;
|
|
70
|
+
this.refreshing = true;
|
|
71
|
+
try {
|
|
72
|
+
const runtime = getRuntime();
|
|
73
|
+
if (runtime !== this.runtime) {
|
|
74
|
+
this.unsubscribe?.();
|
|
75
|
+
this.unsubscribe = undefined;
|
|
76
|
+
this.runtime = runtime;
|
|
77
|
+
if (runtime !== undefined) this.unsubscribe = runtime.onChange(() => void this.refresh());
|
|
78
|
+
}
|
|
79
|
+
const next = await runtime?.status();
|
|
80
|
+
if (this.disposed) return;
|
|
81
|
+
// The rendered row is the whole observable state, so an identical row needs no repaint.
|
|
82
|
+
const before = this.modules();
|
|
83
|
+
this.snapshot = next;
|
|
84
|
+
if (JSON.stringify(before) !== JSON.stringify(this.modules())) this.changed();
|
|
85
|
+
} catch {
|
|
86
|
+
// The status read is best effort: keep the last good row instead of throwing into the host.
|
|
87
|
+
} finally {
|
|
88
|
+
this.refreshing = false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private changed(): void {
|
|
93
|
+
if (this.disposed) return;
|
|
94
|
+
this.row = undefined;
|
|
95
|
+
this.rows = undefined;
|
|
96
|
+
this.described = undefined;
|
|
97
|
+
this.requestRender();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private modules(): Module[] {
|
|
101
|
+
const snapshot = this.snapshot;
|
|
102
|
+
if (snapshot === undefined) {
|
|
103
|
+
return this.runtime === undefined
|
|
104
|
+
? [{ label: "onlyne", value: t("status.unconfigured"), tone: "warning" }]
|
|
105
|
+
: [{ label: "onlyne", value: t("status.starting"), tone: "muted" }];
|
|
106
|
+
}
|
|
107
|
+
const modules: Module[] = [
|
|
108
|
+
{ label: t("status.nodes"), value: String(snapshot.nodes) },
|
|
109
|
+
{ label: t("status.neighbors"), value: String(snapshot.neighbors) },
|
|
110
|
+
{ label: t("status.alert"), value: String(snapshot.alerts) },
|
|
111
|
+
{ label: t("status.unread"), value: String(snapshot.unread) },
|
|
112
|
+
];
|
|
113
|
+
if (snapshot.discovery_error !== undefined) {
|
|
114
|
+
modules.push({ label: t("status.discovery"), value: oneLine(snapshot.discovery_error), tone: "warning" });
|
|
115
|
+
}
|
|
116
|
+
return modules;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The node id and region lead the row; without a runtime there is nothing to lead with. */
|
|
120
|
+
private lead(): { text: string; tone: string } {
|
|
121
|
+
const snapshot = this.snapshot;
|
|
122
|
+
if (snapshot === undefined) return { text: "", tone: "muted" };
|
|
123
|
+
const parts = [shortNodeId(snapshot.node_id), regionLabel(snapshot.region)].filter(part => part !== "");
|
|
124
|
+
return { text: parts.join(" · "), tone: "accent strong" };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** ANSI: one row with the modules spread across the width, region pinned left. */
|
|
128
|
+
render(width: number): readonly string[] {
|
|
129
|
+
this.dropLocaleCaches();
|
|
130
|
+
const lead = this.lead();
|
|
131
|
+
const cells = this.modules().map(module => `${module.label} ${module.value}`);
|
|
132
|
+
const head = lead.text === "" ? "" : `${lead.text} `;
|
|
133
|
+
// Even columns over the remaining width, so the modules are spread out rather than left-aligned.
|
|
134
|
+
const each = cells.length === 0 ? 0 : Math.max(1, Math.floor(Math.max(0, width - head.length) / cells.length));
|
|
135
|
+
let row = head;
|
|
136
|
+
for (const [index, cell] of cells.entries()) {
|
|
137
|
+
row += " ".repeat(index === 0 ? 0 : Math.max(0, each - cells[index - 1]!.length)) + cell;
|
|
138
|
+
}
|
|
139
|
+
row = fit(row, width);
|
|
140
|
+
if (row === this.row && this.rows !== undefined) return this.rows;
|
|
141
|
+
this.row = row;
|
|
142
|
+
this.rows = [row];
|
|
143
|
+
return this.rows;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
describe(): NativeNode {
|
|
147
|
+
this.dropLocaleCaches();
|
|
148
|
+
if (this.described !== undefined) return this.described;
|
|
149
|
+
const lead = this.lead();
|
|
150
|
+
const children: NativeNode[] = [];
|
|
151
|
+
if (lead.text !== "") {
|
|
152
|
+
children.push(textNode([span(lead.text, lead.tone)], { truncate: "end" }));
|
|
153
|
+
}
|
|
154
|
+
for (const module of this.modules()) {
|
|
155
|
+
children.push(
|
|
156
|
+
textNode([span(`${module.label} `, "muted"), span(module.value, module.tone ?? "strong")], { truncate: "end" }),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
// A plain row, not a card: a card adds head/body chrome and a second visual row,
|
|
160
|
+
// which pushed the widget taller than the one line it is meant to occupy.
|
|
161
|
+
this.described = node(
|
|
162
|
+
"row",
|
|
163
|
+
{ role: "omp.widget.onlyne", justify: "between", align: "center", gap: "md" },
|
|
164
|
+
children,
|
|
165
|
+
"status",
|
|
166
|
+
);
|
|
167
|
+
return this.described;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** A locale switch rewrites every label, so the frame caches of the old locale are dropped. */
|
|
171
|
+
private dropLocaleCaches(): void {
|
|
172
|
+
const locale = getLocale();
|
|
173
|
+
if (locale === this.locale) return;
|
|
174
|
+
this.locale = locale;
|
|
175
|
+
this.row = undefined;
|
|
176
|
+
this.rows = undefined;
|
|
177
|
+
this.described = undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
dispose(): void {
|
|
181
|
+
if (this.disposed) return;
|
|
182
|
+
this.disposed = true;
|
|
183
|
+
if (this.timer !== undefined) {
|
|
184
|
+
clearInterval(this.timer);
|
|
185
|
+
this.timer = undefined;
|
|
186
|
+
}
|
|
187
|
+
this.unsubscribe?.();
|
|
188
|
+
this.unsubscribe = undefined;
|
|
189
|
+
this.described = undefined;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Mount the status row above the editor. The returned cleanup removes the
|
|
195
|
+
* widget and stops the component's timer and runtime subscription.
|
|
196
|
+
*/
|
|
197
|
+
export function attachStatus(ctx: ExtensionContext): () => void {
|
|
198
|
+
let component: OnlyneStatus | undefined;
|
|
199
|
+
ctx.ui.setWidget(
|
|
200
|
+
WIDGET_KEY,
|
|
201
|
+
(tui, theme) => {
|
|
202
|
+
component?.dispose();
|
|
203
|
+
component = new OnlyneStatus(theme, () => tui.requestRender());
|
|
204
|
+
return component;
|
|
205
|
+
},
|
|
206
|
+
{ placement: "aboveEditor" },
|
|
207
|
+
);
|
|
208
|
+
return () => {
|
|
209
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
210
|
+
component?.dispose();
|
|
211
|
+
component = undefined;
|
|
212
|
+
};
|
|
213
|
+
}
|