@onlyne/node-host 3.0.0 → 3.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/README.md +22 -0
- package/dist/acquire.d.ts +62 -0
- package/dist/acquire.d.ts.map +1 -0
- package/dist/acquire.js +146 -0
- package/dist/acquire.js.map +1 -0
- package/dist/host-text.d.ts +2 -0
- package/dist/host-text.d.ts.map +1 -0
- package/dist/host-text.js +103 -0
- package/dist/host-text.js.map +1 -0
- package/dist/i18n.d.ts +9 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +19 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +74 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +20 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +144 -0
- package/dist/keys.js.map +1 -0
- package/dist/log.d.ts +43 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +77 -0
- package/dist/log.js.map +1 -0
- package/dist/network.d.ts +27 -0
- package/dist/network.d.ts.map +1 -0
- package/dist/network.js +350 -0
- package/dist/network.js.map +1 -0
- package/dist/node.d.ts +20 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +153 -0
- package/dist/node.js.map +1 -0
- package/dist/store.d.ts +44 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +219 -0
- package/dist/store.js.map +1 -0
- package/dist/tui-text.d.ts +37 -0
- package/dist/tui-text.d.ts.map +1 -0
- package/dist/tui-text.js +69 -0
- package/dist/tui-text.js.map +1 -0
- package/dist/tui.d.ts +19 -0
- package/dist/tui.d.ts.map +1 -0
- package/dist/tui.js +400 -0
- package/dist/tui.js.map +1 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +46 -10
- package/src/i18n.ts +0 -22
- package/src/index.ts +0 -80
- package/src/keys.ts +0 -158
- package/src/log.ts +0 -52
- package/src/network.ts +0 -382
- package/src/tui-text.ts +0 -73
- package/src/tui.ts +0 -318
package/src/tui-text.ts
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { createTranslator } from './i18n.js';
|
|
2
|
-
|
|
3
|
-
const en = {
|
|
4
|
-
'title': 'Onlyne gateway · {actor} · {endpoint}',
|
|
5
|
-
'peers.none': 'No nodes discovered yet.',
|
|
6
|
-
'peers.count': 'Nodes ({count})',
|
|
7
|
-
'column.delivery': 'deliver',
|
|
8
|
-
'hint.idle': 'Press [c] to enter an address, or wait for a peer to appear over mDNS.',
|
|
9
|
-
'label.fingerprint': 'fingerprint',
|
|
10
|
-
'label.endpoint': 'endpoint',
|
|
11
|
-
'label.delivery': 'delivery',
|
|
12
|
-
'value.notExchanged': 'not exchanged',
|
|
13
|
-
'value.unknown': 'unknown',
|
|
14
|
-
'confirm.title': 'Confirm the fingerprint of {node}?',
|
|
15
|
-
'confirm.keys': ' [y] confirm [n] cancel',
|
|
16
|
-
'input.prompt': 'address> {value}▏',
|
|
17
|
-
'help.keys': '[t] request exchange [a] accept request [r] revoke [c] connect address [q] quit',
|
|
18
|
-
'notice.cancelled': 'Cancelled.',
|
|
19
|
-
'notice.enterAddress': 'Enter a node service address, then press Enter to connect; Esc cancels.',
|
|
20
|
-
'notice.refreshFailed': 'Refresh failed: {error}',
|
|
21
|
-
'notice.alreadyExchanged': '{node} has already exchanged keys.',
|
|
22
|
-
'notice.noRequest': '{node} has no request waiting for your confirmation.',
|
|
23
|
-
'notice.accepted': 'Approved the key of {node}.',
|
|
24
|
-
'notice.noTrust': '{node} has no trust record.',
|
|
25
|
-
'notice.revoked': 'Revoked trust for {node}.',
|
|
26
|
-
'notice.noAddress': 'No address entered.',
|
|
27
|
-
'notice.connected': 'Connected to {node}.',
|
|
28
|
-
'notice.submitted': '{node} submitted, current stage {phase}.',
|
|
29
|
-
'notice.operationFailed': 'Operation failed: {error}',
|
|
30
|
-
'phase.exchanged': 'verified',
|
|
31
|
-
'phase.requested': 'awaiting peer',
|
|
32
|
-
'phase.invited': 'needs approval',
|
|
33
|
-
'phase.revoked': 'revoked',
|
|
34
|
-
'list.separator': ', ',
|
|
35
|
-
} as const;
|
|
36
|
-
|
|
37
|
-
const zh: Record<keyof typeof en, string> = {
|
|
38
|
-
'title': 'Onlyne 网关 · {actor} · {endpoint}',
|
|
39
|
-
'peers.none': '还没有发现任何节点。',
|
|
40
|
-
'peers.count': '节点 ({count})',
|
|
41
|
-
'column.delivery': '投送',
|
|
42
|
-
'hint.idle': '用 [c] 输入一个地址,或用 mDNS 等对端出现。',
|
|
43
|
-
'label.fingerprint': '指纹',
|
|
44
|
-
'label.endpoint': '端点',
|
|
45
|
-
'label.delivery': '投送',
|
|
46
|
-
'value.notExchanged': '未交换',
|
|
47
|
-
'value.unknown': '未知',
|
|
48
|
-
'confirm.title': '确认 {node} 的指纹?',
|
|
49
|
-
'confirm.keys': ' [y] 确认 [n] 取消',
|
|
50
|
-
'input.prompt': '地址> {value}▏',
|
|
51
|
-
'help.keys': '[t] 请求交换 [a] 接受申请 [r] 撤销 [c] 连接地址 [q] 退出界面',
|
|
52
|
-
'notice.cancelled': '已取消。',
|
|
53
|
-
'notice.enterAddress': '输入节点服务地址,回车连接,Esc 取消。',
|
|
54
|
-
'notice.refreshFailed': '刷新失败:{error}',
|
|
55
|
-
'notice.alreadyExchanged': '{node} 已经完成交换。',
|
|
56
|
-
'notice.noRequest': '{node} 没有等待你确认的申请。',
|
|
57
|
-
'notice.accepted': '已批准 {node} 的密钥。',
|
|
58
|
-
'notice.noTrust': '{node} 没有信任记录。',
|
|
59
|
-
'notice.revoked': '已撤销 {node} 的信任。',
|
|
60
|
-
'notice.noAddress': '没有输入地址。',
|
|
61
|
-
'notice.connected': '已连接 {node}。',
|
|
62
|
-
'notice.submitted': '{node} 已提交,当前阶段 {phase}。',
|
|
63
|
-
'notice.operationFailed': '操作失败:{error}',
|
|
64
|
-
'phase.exchanged': '已认证',
|
|
65
|
-
'phase.requested': '等对方',
|
|
66
|
-
'phase.invited': '待确认',
|
|
67
|
-
'phase.revoked': '已撤销',
|
|
68
|
-
'list.separator': '、',
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
export type TuiTextKey = keyof typeof en;
|
|
72
|
-
|
|
73
|
-
export const t = createTranslator({ en, 'zh-CN': zh });
|
package/src/tui.ts
DELETED
|
@@ -1,318 +0,0 @@
|
|
|
1
|
-
import { trustPhase, type KeyExchange, type OnlyneNode, type TrustPhase } from '@onlyne/lib';
|
|
2
|
-
import type { LogRing } from './log.js';
|
|
3
|
-
import { t, type TuiTextKey } from './tui-text.js';
|
|
4
|
-
|
|
5
|
-
/** One peer as the terminal shows it: a discovery address plus whatever the trust table knows. */
|
|
6
|
-
interface Row {
|
|
7
|
-
node: string;
|
|
8
|
-
endpoint?: string;
|
|
9
|
-
phase?: TrustPhase;
|
|
10
|
-
fingerprint?: string;
|
|
11
|
-
/** Resources this gateway serves whose binding points at this node: where events go now. */
|
|
12
|
-
delivery?: string[];
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const PHASE_KEY: Record<TrustPhase, TuiTextKey> = {
|
|
16
|
-
exchanged: 'phase.exchanged',
|
|
17
|
-
requested: 'phase.requested',
|
|
18
|
-
invited: 'phase.invited',
|
|
19
|
-
revoked: 'phase.revoked',
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
const NO_PHASE = '—';
|
|
23
|
-
const SHORT = (node: string): string => node.split(':').pop() ?? node;
|
|
24
|
-
|
|
25
|
-
function failureText(error: unknown): string {
|
|
26
|
-
if (error instanceof Error && 'error' in error) {
|
|
27
|
-
const inner = (error as { error?: { message?: string } }).error;
|
|
28
|
-
if (typeof inner?.message === 'string') return inner.message;
|
|
29
|
-
}
|
|
30
|
-
return String(error);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
type Mode = { kind: 'list' } | { kind: 'confirm'; exchange: KeyExchange } | { kind: 'input' };
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* The gateway's foreground surface: one screen for the peers it discovered, the trust stage
|
|
37
|
-
* of each, and the four actions that change one. Every confirmation shows the fingerprint
|
|
38
|
-
* first, because that string — not the node id — is what the two operators compare.
|
|
39
|
-
*/
|
|
40
|
-
export class GatewayTui {
|
|
41
|
-
#rows: Row[] = [];
|
|
42
|
-
#index = 0;
|
|
43
|
-
#mode: Mode = { kind: 'list' };
|
|
44
|
-
#input = '';
|
|
45
|
-
#message = '';
|
|
46
|
-
#busy = false;
|
|
47
|
-
#closed = false;
|
|
48
|
-
#refreshTimer?: ReturnType<typeof setInterval>;
|
|
49
|
-
|
|
50
|
-
constructor(
|
|
51
|
-
private readonly node: OnlyneNode,
|
|
52
|
-
private readonly ring: LogRing,
|
|
53
|
-
private readonly endpoint: string,
|
|
54
|
-
private readonly onChange: () => void = () => undefined,
|
|
55
|
-
) {}
|
|
56
|
-
|
|
57
|
-
async start(): Promise<void> {
|
|
58
|
-
process.stdout.write('\x1b[?1049h\x1b[?25l');
|
|
59
|
-
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
60
|
-
process.stdin.resume();
|
|
61
|
-
process.stdin.setEncoding('utf8');
|
|
62
|
-
process.stdin.on('data', this.#onData);
|
|
63
|
-
process.stdout.on('resize', this.#onResize);
|
|
64
|
-
await this.#refresh();
|
|
65
|
-
this.#refreshTimer = setInterval(() => { void this.#refresh(); }, 1000);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async close(): Promise<void> {
|
|
69
|
-
if (this.#closed) return;
|
|
70
|
-
this.#closed = true;
|
|
71
|
-
clearInterval(this.#refreshTimer);
|
|
72
|
-
process.stdin.off('data', this.#onData);
|
|
73
|
-
process.stdout.off('resize', this.#onResize);
|
|
74
|
-
process.stdin.pause();
|
|
75
|
-
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
76
|
-
process.stdout.write('\x1b[?25h\x1b[?1049l');
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
#onResize = (): void => { this.#draw(); };
|
|
80
|
-
|
|
81
|
-
#onData = (chunk: string): void => {
|
|
82
|
-
if (this.#closed) return;
|
|
83
|
-
// One `data` event can carry a whole paste or a burst of keystrokes, so it is split into
|
|
84
|
-
// keys rather than treated as one. Each key runs in order; `#busy` still gates actions.
|
|
85
|
-
for (const token of tokens(chunk)) void this.#handle(token);
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
async #handle(token: Token): Promise<void> {
|
|
89
|
-
if (this.#busy) return;
|
|
90
|
-
if (this.#mode.kind === 'confirm') {
|
|
91
|
-
if (token === 'y') await this.#settle(true);
|
|
92
|
-
else if (token === 'n' || token === 'esc') { this.#mode = { kind: 'list' }; this.#message = t('notice.cancelled'); this.#draw(); }
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
if (this.#mode.kind === 'input') {
|
|
96
|
-
if (token === 'esc') { this.#mode = { kind: 'list' }; this.#input = ''; this.#draw(); return; }
|
|
97
|
-
if (token === 'enter') { await this.#connect(); return; }
|
|
98
|
-
if (token === 'backspace') { this.#input = this.#input.slice(0, -1); this.#draw(); return; }
|
|
99
|
-
if (typeof token === 'string' && token.length === 1 && token >= ' ') { this.#input += token; this.#draw(); }
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
if (token === 'q') { await this.close(); this.onChange(); return; }
|
|
103
|
-
if (token === 'up') { this.#move(-1); return; }
|
|
104
|
-
if (token === 'down') { this.#move(1); return; }
|
|
105
|
-
if (token === 'c') { this.#mode = { kind: 'input' }; this.#input = ''; this.#message = t('notice.enterAddress'); this.#draw(); return; }
|
|
106
|
-
const row = this.#rows[this.#index];
|
|
107
|
-
if (row === undefined) return;
|
|
108
|
-
if (token === 't') { await this.#begin(row); return; }
|
|
109
|
-
if (token === 'a') { await this.#accept(row); return; }
|
|
110
|
-
if (token === 'r') { await this.#revoke(row); return; }
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
#move(delta: number): void {
|
|
114
|
-
if (this.#rows.length === 0) return;
|
|
115
|
-
this.#index = (this.#index + delta + this.#rows.length) % this.#rows.length;
|
|
116
|
-
this.#draw();
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
async #refresh(): Promise<void> {
|
|
120
|
-
if (this.#closed) return;
|
|
121
|
-
try {
|
|
122
|
-
const [trust, records] = await Promise.all([
|
|
123
|
-
this.node.trust.list(this.node.auth),
|
|
124
|
-
this.node.discovery.listRecords(),
|
|
125
|
-
]);
|
|
126
|
-
const handlers = new Map<string, string[]>();
|
|
127
|
-
const resources = await this.node.resources.list(this.node.auth, { page_size: 100 });
|
|
128
|
-
for (const resource of resources.items) {
|
|
129
|
-
const list = handlers.get(resource.binding.handler) ?? [];
|
|
130
|
-
list.push(resource.resource_ref.id);
|
|
131
|
-
handlers.set(resource.binding.handler, list);
|
|
132
|
-
}
|
|
133
|
-
const rows = new Map<string, Row>();
|
|
134
|
-
for (const record of records) {
|
|
135
|
-
if (record.node_id === this.node.auth.actor) continue;
|
|
136
|
-
rows.set(record.node_id, { node: record.node_id, endpoint: record.base_url });
|
|
137
|
-
}
|
|
138
|
-
for (const entry of trust) {
|
|
139
|
-
if (entry.node_id === this.node.auth.actor) continue;
|
|
140
|
-
const row = rows.get(entry.node_id) ?? { node: entry.node_id };
|
|
141
|
-
rows.set(entry.node_id, { ...row, phase: trustPhase(entry), fingerprint: entry.fingerprint, endpoint: row.endpoint ?? entry.base_url });
|
|
142
|
-
}
|
|
143
|
-
for (const [handler, ids] of handlers) {
|
|
144
|
-
if (handler === this.node.auth.actor) continue;
|
|
145
|
-
const row = rows.get(handler) ?? { node: handler };
|
|
146
|
-
rows.set(handler, { ...row, delivery: ids });
|
|
147
|
-
}
|
|
148
|
-
this.#rows = [...rows.values()].sort((a, b) => (a.node < b.node ? -1 : a.node > b.node ? 1 : 0));
|
|
149
|
-
if (this.#index >= this.#rows.length) this.#index = Math.max(0, this.#rows.length - 1);
|
|
150
|
-
this.#draw();
|
|
151
|
-
} catch (error) {
|
|
152
|
-
this.#message = t('notice.refreshFailed', { error: failureText(error) });
|
|
153
|
-
this.#draw();
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
async #begin(row: Row): Promise<void> {
|
|
158
|
-
if (row.phase === 'exchanged') { this.#message = t('notice.alreadyExchanged', { node: SHORT(row.node) }); this.#draw(); return; }
|
|
159
|
-
await this.#run(async () => {
|
|
160
|
-
const exchange = await this.node.trust.begin(this.node.auth, row.node);
|
|
161
|
-
this.#mode = { kind: 'confirm', exchange };
|
|
162
|
-
this.#message = '';
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
async #accept(row: Row): Promise<void> {
|
|
167
|
-
if (row.phase !== 'invited' || row.fingerprint === undefined) {
|
|
168
|
-
this.#message = t('notice.noRequest', { node: SHORT(row.node) });
|
|
169
|
-
this.#draw();
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
const fingerprint = row.fingerprint;
|
|
173
|
-
await this.#run(async () => {
|
|
174
|
-
await this.node.trust.accept(this.node.auth, row.node, fingerprint);
|
|
175
|
-
this.#message = t('notice.accepted', { node: SHORT(row.node) });
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
async #revoke(row: Row): Promise<void> {
|
|
180
|
-
if (row.phase === undefined) { this.#message = t('notice.noTrust', { node: SHORT(row.node) }); this.#draw(); return; }
|
|
181
|
-
await this.#run(async () => {
|
|
182
|
-
await this.node.trust.revoke(this.node.auth, row.node);
|
|
183
|
-
this.#message = t('notice.revoked', { node: SHORT(row.node) });
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
async #connect(): Promise<void> {
|
|
188
|
-
const value = this.#input.trim();
|
|
189
|
-
this.#mode = { kind: 'list' };
|
|
190
|
-
this.#input = '';
|
|
191
|
-
if (value === '') { this.#message = t('notice.noAddress'); this.#draw(); return; }
|
|
192
|
-
await this.#run(async () => {
|
|
193
|
-
const url = new URL(value.includes('://') ? value : `http://${value}`);
|
|
194
|
-
url.pathname = url.pathname.replace(/\/+$/, '') || '/onlyne/v3';
|
|
195
|
-
const record = await this.node.discovery.observe(url.href, 'manual');
|
|
196
|
-
this.#message = t('notice.connected', { node: record.node_id });
|
|
197
|
-
});
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
async #settle(accept: boolean): Promise<void> {
|
|
201
|
-
const mode = this.#mode;
|
|
202
|
-
if (mode.kind !== 'confirm') return;
|
|
203
|
-
if (!accept) { this.#mode = { kind: 'list' }; this.#message = t('notice.cancelled'); this.#draw(); return; }
|
|
204
|
-
this.#mode = { kind: 'list' };
|
|
205
|
-
await this.#run(async () => {
|
|
206
|
-
const entry = await mode.exchange.confirm(mode.exchange.fingerprint);
|
|
207
|
-
this.#message = t('notice.submitted', { node: SHORT(entry.node_id), phase: t(PHASE_KEY[trustPhase(entry)]) });
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
async #run(work: () => Promise<void>): Promise<void> {
|
|
212
|
-
this.#busy = true;
|
|
213
|
-
this.#draw();
|
|
214
|
-
try { await work(); }
|
|
215
|
-
catch (error) { this.#message = t('notice.operationFailed', { error: failureText(error) }); }
|
|
216
|
-
finally {
|
|
217
|
-
this.#busy = false;
|
|
218
|
-
await this.#refresh();
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
#draw(): void {
|
|
223
|
-
if (this.#closed) return;
|
|
224
|
-
// One cell short of the terminal width: writing the last cell auto-wraps, which would
|
|
225
|
-
// consume a second row per line and push the frame upward.
|
|
226
|
-
const width = Math.max(48, (process.stdout.columns ?? 80) - 1);
|
|
227
|
-
const height = Math.max(16, process.stdout.rows ?? 24);
|
|
228
|
-
const lines: string[] = [];
|
|
229
|
-
const push = (text: string): void => { lines.push(fit(text, width)); };
|
|
230
|
-
|
|
231
|
-
push(t('title', { actor: this.node.auth.actor, endpoint: this.endpoint }));
|
|
232
|
-
push(rule(width));
|
|
233
|
-
|
|
234
|
-
const peerHeader = this.#rows.length === 0 ? t('peers.none') : t('peers.count', { count: this.#rows.length });
|
|
235
|
-
push(peerHeader);
|
|
236
|
-
for (const [index, row] of this.#rows.entries()) {
|
|
237
|
-
const marker = index === this.#index ? '▸' : ' ';
|
|
238
|
-
const phase = row.phase === undefined ? NO_PHASE : t(PHASE_KEY[row.phase]);
|
|
239
|
-
// Delivery is the binding's answer, not the trust stage's: a node can be exchanged and
|
|
240
|
-
// still receive nothing, and the handler can be a node with no key exchange at all.
|
|
241
|
-
const delivery = row.delivery === undefined ? '' : t('column.delivery');
|
|
242
|
-
push(`${marker} ${SHORT(row.node).padEnd(14)} ${phase.padEnd(14)} ${delivery.padEnd(8)} ${row.endpoint ?? ''}`);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
push(rule(width));
|
|
246
|
-
const selected = this.#rows[this.#index];
|
|
247
|
-
if (selected === undefined) {
|
|
248
|
-
push(t('hint.idle'));
|
|
249
|
-
} else {
|
|
250
|
-
push(selected.node);
|
|
251
|
-
push(`${t('label.fingerprint').padEnd(6)}${selected.fingerprint ?? t('value.notExchanged')}`);
|
|
252
|
-
push(`${t('label.endpoint').padEnd(6)}${selected.endpoint ?? t('value.unknown')}`);
|
|
253
|
-
if (selected.delivery !== undefined) push(`${t('label.delivery').padEnd(6)}${selected.delivery.join(t('list.separator'))}`);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
push(rule(width));
|
|
257
|
-
if (this.#mode.kind === 'confirm') {
|
|
258
|
-
const exchange = this.#mode.exchange;
|
|
259
|
-
push(t('confirm.title', { node: SHORT(exchange.node_id) }));
|
|
260
|
-
push(` ${exchange.fingerprint}`);
|
|
261
|
-
push(t('confirm.keys'));
|
|
262
|
-
} else if (this.#mode.kind === 'input') {
|
|
263
|
-
push(t('input.prompt', { value: this.#input }));
|
|
264
|
-
} else {
|
|
265
|
-
push(t('help.keys'));
|
|
266
|
-
}
|
|
267
|
-
if (this.#message !== '') push(this.#message);
|
|
268
|
-
if (this.#busy) push('…');
|
|
269
|
-
|
|
270
|
-
const logLines = Math.max(0, height - lines.length - 1);
|
|
271
|
-
if (logLines > 0) {
|
|
272
|
-
push(rule(width));
|
|
273
|
-
for (const line of this.ring.tail(logLines)) push(line);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
let frame = '\x1b[H';
|
|
277
|
-
for (let index = 0; index < height; index++) {
|
|
278
|
-
frame += `${lines[index] ?? ''}\x1b[K${index === height - 1 ? '' : '\r\n'}`;
|
|
279
|
-
}
|
|
280
|
-
frame += '\x1b[J';
|
|
281
|
-
process.stdout.write(frame);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
type Token = 'up' | 'down' | 'enter' | 'esc' | 'backspace' | 'y' | 'n' | 'q' | 't' | 'a' | 'r' | 'c' | string;
|
|
286
|
-
|
|
287
|
-
/** Splits one stdin chunk into keys: CSI arrows, a bare escape, and every printable character. */
|
|
288
|
-
function tokens(chunk: string): Token[] {
|
|
289
|
-
const out: Token[] = [];
|
|
290
|
-
let rest = chunk.replace(/\x1b\[20[01]~/g, '');
|
|
291
|
-
while (rest.length > 0) {
|
|
292
|
-
if (rest.startsWith('\x1b[A')) { out.push('up'); rest = rest.slice(3); continue; }
|
|
293
|
-
if (rest.startsWith('\x1b[B')) { out.push('down'); rest = rest.slice(3); continue; }
|
|
294
|
-
if (rest.startsWith('\x1b[')) {
|
|
295
|
-
// Any other control sequence is not a key this surface uses; drop it whole.
|
|
296
|
-
const end = rest.slice(2).search(/[@-~]/);
|
|
297
|
-
rest = end < 0 ? '' : rest.slice(end + 3);
|
|
298
|
-
continue;
|
|
299
|
-
}
|
|
300
|
-
if (rest.startsWith('\x1b')) { out.push('esc'); rest = rest.slice(1); continue; }
|
|
301
|
-
const character = rest[0]!;
|
|
302
|
-
if (character === '\r' || character === '\n') out.push('enter');
|
|
303
|
-
else if (character === '\x7f' || character === '\b') out.push('backspace');
|
|
304
|
-
else if (character === '\x03') out.push('q');
|
|
305
|
-
else if (character >= ' ') out.push(character);
|
|
306
|
-
rest = rest.slice(1);
|
|
307
|
-
}
|
|
308
|
-
return out;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
function fit(text: string, width: number): string {
|
|
312
|
-
if (text.length <= width) return text;
|
|
313
|
-
return `${text.slice(0, width - 1)}…`;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function rule(width: number): string {
|
|
317
|
-
return '─'.repeat(width);
|
|
318
|
-
}
|