@akta/dao-cli 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.
- package/README.md +137 -0
- package/images/dao_view_styled.png +0 -0
- package/images/fees_view_styled.png +0 -0
- package/images/proposals_view_styled.png +0 -0
- package/images/wallet_view_styled.png +0 -0
- package/install.sh +19 -0
- package/package.json +33 -0
- package/src/commands/info.ts +33 -0
- package/src/commands/proposals.ts +133 -0
- package/src/commands/state.ts +167 -0
- package/src/commands/wallet.ts +356 -0
- package/src/formatting.ts +659 -0
- package/src/index.ts +188 -0
- package/src/output.ts +232 -0
- package/src/sdk.ts +37 -0
- package/src/theme.ts +73 -0
- package/src/tui/app.ts +366 -0
- package/src/tui/input.ts +85 -0
- package/src/tui/panels.ts +133 -0
- package/src/tui/renderer.ts +126 -0
- package/src/tui/terminal.ts +66 -0
- package/src/tui/types.ts +74 -0
- package/src/tui/views/dao.ts +338 -0
- package/src/tui/views/fees.ts +164 -0
- package/src/tui/views/proposal-detail.ts +331 -0
- package/src/tui/views/proposals-list.ts +213 -0
- package/src/tui/views/wallet.ts +560 -0
package/src/tui/app.ts
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import type { AkitaNetwork } from "@akta/sdk";
|
|
2
|
+
import { createDAO } from "../sdk";
|
|
3
|
+
import { jsonReplacer } from "../output";
|
|
4
|
+
import { enterRawMode, exitRawMode, writeFrame, getTermSize, onResize } from "./terminal";
|
|
5
|
+
import { startKeyListener } from "./input";
|
|
6
|
+
import { renderFrame } from "./renderer";
|
|
7
|
+
import type { AppState, KeyAction, LoadResult, View, ViewContext, ViewId } from "./types";
|
|
8
|
+
import { TABS } from "./types";
|
|
9
|
+
|
|
10
|
+
// ── Views ───────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
import { daoView } from "./views/dao";
|
|
13
|
+
import { feesView } from "./views/fees";
|
|
14
|
+
import { walletView, cycleWalletAccount, getSelectedAccountLine, invalidateWalletCache } from "./views/wallet";
|
|
15
|
+
import { proposalsListView, cycleProposalCursor, getProposalCursor, invalidateProposalsCache } from "./views/proposals-list";
|
|
16
|
+
|
|
17
|
+
// ── View cache ──────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
interface CacheEntry {
|
|
20
|
+
lines: string[];
|
|
21
|
+
fixedRight?: string[];
|
|
22
|
+
ts: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const CACHE_TTL = 30_000; // 30 seconds
|
|
26
|
+
|
|
27
|
+
// ── Main TUI ────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export async function startTUI(network: AkitaNetwork): Promise<void> {
|
|
30
|
+
const dao = createDAO(network);
|
|
31
|
+
|
|
32
|
+
const state: AppState = {
|
|
33
|
+
tab: "dao",
|
|
34
|
+
walletAccountIdx: 0,
|
|
35
|
+
scrollOffset: 0,
|
|
36
|
+
cursor: 0,
|
|
37
|
+
viewStack: [],
|
|
38
|
+
jsonMode: false,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const cache = new Map<string, CacheEntry>();
|
|
42
|
+
let currentLines: string[] = [];
|
|
43
|
+
let currentFixedRight: string[] | undefined;
|
|
44
|
+
let loading = false;
|
|
45
|
+
let loadGeneration = 0; // guards against stale async renders
|
|
46
|
+
let stopKeyListener: (() => void) | null = null;
|
|
47
|
+
let removeResizeListener: (() => void) | null = null;
|
|
48
|
+
let pendingScrollTo: (() => number) | null = null;
|
|
49
|
+
|
|
50
|
+
// ── Helpers ─────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
function viewKey(vid: ViewId): string {
|
|
53
|
+
return vid.tab;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function currentViewId(): ViewId {
|
|
57
|
+
if (state.viewStack.length > 0) {
|
|
58
|
+
return state.viewStack[state.viewStack.length - 1];
|
|
59
|
+
}
|
|
60
|
+
return { tab: state.tab } as ViewId;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getView(vid: ViewId): View {
|
|
64
|
+
switch (vid.tab) {
|
|
65
|
+
case "dao": return daoView;
|
|
66
|
+
case "fees": return feesView;
|
|
67
|
+
case "wallet": return walletView;
|
|
68
|
+
case "proposals": return proposalsListView;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function viewportHeight(): number {
|
|
73
|
+
return getTermSize().rows - 3;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function clampScroll(): void {
|
|
77
|
+
const totalLines = Math.max(currentLines.length, currentFixedRight?.length ?? 0);
|
|
78
|
+
const maxScroll = Math.max(0, totalLines - viewportHeight());
|
|
79
|
+
state.scrollOffset = Math.max(0, Math.min(state.scrollOffset, maxScroll));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Ensure the selected proposal row is visible in the viewport. */
|
|
83
|
+
function scrollToProposalCursor(): void {
|
|
84
|
+
// Left panel structure: "" + panel-border + header-row + data-rows + border
|
|
85
|
+
// The cursor item is at line 3 + cursorIndex
|
|
86
|
+
const line = 3 + getProposalCursor();
|
|
87
|
+
ensureLineVisible(line);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function ensureLineVisible(line: number): void {
|
|
91
|
+
const vh = viewportHeight();
|
|
92
|
+
if (line < state.scrollOffset) {
|
|
93
|
+
state.scrollOffset = line;
|
|
94
|
+
} else if (line >= state.scrollOffset + vh) {
|
|
95
|
+
state.scrollOffset = line - vh + 1;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Render cycle ────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
function render(): void {
|
|
102
|
+
const { rows, cols } = getTermSize();
|
|
103
|
+
const frame = renderFrame(state, currentLines, rows, cols, loading, currentFixedRight);
|
|
104
|
+
writeFrame(frame);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function loadAndRender(forceRefresh = false): Promise<void> {
|
|
108
|
+
const vid = currentViewId();
|
|
109
|
+
const key = viewKey(vid);
|
|
110
|
+
const { cols } = getTermSize();
|
|
111
|
+
|
|
112
|
+
// Check cache
|
|
113
|
+
if (!forceRefresh && !state.jsonMode) {
|
|
114
|
+
const cached = cache.get(key);
|
|
115
|
+
if (cached && Date.now() - cached.ts < CACHE_TTL) {
|
|
116
|
+
currentLines = cached.lines;
|
|
117
|
+
currentFixedRight = cached.fixedRight;
|
|
118
|
+
clampScroll();
|
|
119
|
+
render();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Show loading state
|
|
125
|
+
const gen = ++loadGeneration;
|
|
126
|
+
loading = true;
|
|
127
|
+
render();
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const view = getView(vid);
|
|
131
|
+
const ctx: ViewContext = {
|
|
132
|
+
width: cols,
|
|
133
|
+
height: viewportHeight(),
|
|
134
|
+
network,
|
|
135
|
+
dao,
|
|
136
|
+
navigate: (target: ViewId) => {
|
|
137
|
+
state.viewStack.push(target);
|
|
138
|
+
state.scrollOffset = 0;
|
|
139
|
+
state.cursor = 0;
|
|
140
|
+
loadAndRender();
|
|
141
|
+
},
|
|
142
|
+
refresh: () => loadAndRender(true),
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const result = await view.load(ctx);
|
|
146
|
+
|
|
147
|
+
// Discard result if user navigated away during the async load
|
|
148
|
+
if (gen !== loadGeneration) return;
|
|
149
|
+
|
|
150
|
+
// Normalize result — views can return string[] or LoadResult
|
|
151
|
+
let lines: string[];
|
|
152
|
+
let fixedRight: string[] | undefined;
|
|
153
|
+
if (Array.isArray(result)) {
|
|
154
|
+
lines = result;
|
|
155
|
+
} else {
|
|
156
|
+
lines = result.lines;
|
|
157
|
+
fixedRight = result.fixedRight;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// JSON mode: re-render as JSON (flatten both panels)
|
|
161
|
+
if (state.jsonMode) {
|
|
162
|
+
const jsonStr = JSON.stringify(lines, jsonReplacer, 2);
|
|
163
|
+
lines = jsonStr.split("\n");
|
|
164
|
+
fixedRight = undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Update cache (not for JSON mode)
|
|
168
|
+
if (!state.jsonMode) {
|
|
169
|
+
cache.set(key, { lines, fixedRight, ts: Date.now() });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
currentLines = lines;
|
|
173
|
+
currentFixedRight = fixedRight;
|
|
174
|
+
} catch (err: any) {
|
|
175
|
+
// Discard error if user navigated away
|
|
176
|
+
if (gen !== loadGeneration) return;
|
|
177
|
+
|
|
178
|
+
currentLines = [
|
|
179
|
+
"",
|
|
180
|
+
` Error: ${err.message ?? err}`,
|
|
181
|
+
"",
|
|
182
|
+
" Press 'r' to retry.",
|
|
183
|
+
];
|
|
184
|
+
currentFixedRight = undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
loading = false;
|
|
188
|
+
|
|
189
|
+
// Apply pending scroll-to (set before loadAndRender, resolved after load)
|
|
190
|
+
if (pendingScrollTo) {
|
|
191
|
+
ensureLineVisible(pendingScrollTo());
|
|
192
|
+
pendingScrollTo = null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
clampScroll();
|
|
196
|
+
render();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Input handling ──────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
function handleKey(action: KeyAction): void {
|
|
202
|
+
switch (action.type) {
|
|
203
|
+
case "quit":
|
|
204
|
+
cleanup();
|
|
205
|
+
process.exit(0);
|
|
206
|
+
break;
|
|
207
|
+
|
|
208
|
+
case "tab-next": {
|
|
209
|
+
if (state.viewStack.length > 0) {
|
|
210
|
+
state.viewStack = [];
|
|
211
|
+
}
|
|
212
|
+
const tabIdx = TABS.indexOf(state.tab);
|
|
213
|
+
state.tab = TABS[(tabIdx + 1) % TABS.length];
|
|
214
|
+
state.scrollOffset = 0;
|
|
215
|
+
state.cursor = 0;
|
|
216
|
+
loadAndRender();
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
case "tab-prev": {
|
|
221
|
+
if (state.viewStack.length > 0) {
|
|
222
|
+
state.viewStack = [];
|
|
223
|
+
}
|
|
224
|
+
const tabIdx = TABS.indexOf(state.tab);
|
|
225
|
+
state.tab = TABS[(tabIdx - 1 + TABS.length) % TABS.length];
|
|
226
|
+
state.scrollOffset = 0;
|
|
227
|
+
state.cursor = 0;
|
|
228
|
+
loadAndRender();
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
case "sub-next": {
|
|
233
|
+
if (state.tab === "wallet") {
|
|
234
|
+
cycleWalletAccount(1);
|
|
235
|
+
cache.delete("wallet");
|
|
236
|
+
pendingScrollTo = () => getSelectedAccountLine() + 1;
|
|
237
|
+
loadAndRender();
|
|
238
|
+
} else if (state.tab === "proposals" && state.viewStack.length === 0) {
|
|
239
|
+
cycleProposalCursor(1);
|
|
240
|
+
cache.delete("proposals");
|
|
241
|
+
scrollToProposalCursor();
|
|
242
|
+
loadAndRender();
|
|
243
|
+
} else {
|
|
244
|
+
handleKey({ type: "tab-next" });
|
|
245
|
+
}
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
case "sub-prev": {
|
|
250
|
+
if (state.tab === "wallet") {
|
|
251
|
+
cycleWalletAccount(-1);
|
|
252
|
+
cache.delete("wallet");
|
|
253
|
+
pendingScrollTo = () => getSelectedAccountLine() + 1;
|
|
254
|
+
loadAndRender();
|
|
255
|
+
} else if (state.tab === "proposals" && state.viewStack.length === 0) {
|
|
256
|
+
cycleProposalCursor(-1);
|
|
257
|
+
cache.delete("proposals");
|
|
258
|
+
scrollToProposalCursor();
|
|
259
|
+
loadAndRender();
|
|
260
|
+
} else {
|
|
261
|
+
handleKey({ type: "tab-prev" });
|
|
262
|
+
}
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
case "up":
|
|
267
|
+
if (state.scrollOffset > 0) {
|
|
268
|
+
state.scrollOffset--;
|
|
269
|
+
render();
|
|
270
|
+
}
|
|
271
|
+
break;
|
|
272
|
+
|
|
273
|
+
case "down": {
|
|
274
|
+
const totalLines = Math.max(currentLines.length, currentFixedRight?.length ?? 0);
|
|
275
|
+
const maxScroll = Math.max(0, totalLines - viewportHeight());
|
|
276
|
+
if (state.scrollOffset < maxScroll) {
|
|
277
|
+
state.scrollOffset++;
|
|
278
|
+
render();
|
|
279
|
+
}
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
case "page-up":
|
|
284
|
+
state.scrollOffset = Math.max(0, state.scrollOffset - 10);
|
|
285
|
+
render();
|
|
286
|
+
break;
|
|
287
|
+
|
|
288
|
+
case "page-down": {
|
|
289
|
+
const totalLines = Math.max(currentLines.length, currentFixedRight?.length ?? 0);
|
|
290
|
+
const maxScroll = Math.max(0, totalLines - viewportHeight());
|
|
291
|
+
state.scrollOffset = Math.min(maxScroll, state.scrollOffset + 10);
|
|
292
|
+
render();
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
case "top":
|
|
297
|
+
state.scrollOffset = 0;
|
|
298
|
+
render();
|
|
299
|
+
break;
|
|
300
|
+
|
|
301
|
+
case "bottom": {
|
|
302
|
+
const totalLines = Math.max(currentLines.length, currentFixedRight?.length ?? 0);
|
|
303
|
+
const maxScroll = Math.max(0, totalLines - viewportHeight());
|
|
304
|
+
state.scrollOffset = maxScroll;
|
|
305
|
+
render();
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
case "back":
|
|
310
|
+
if (state.viewStack.length > 0) {
|
|
311
|
+
state.viewStack.pop();
|
|
312
|
+
state.scrollOffset = 0;
|
|
313
|
+
state.cursor = 0;
|
|
314
|
+
loadAndRender();
|
|
315
|
+
}
|
|
316
|
+
break;
|
|
317
|
+
|
|
318
|
+
case "refresh":
|
|
319
|
+
// Invalidate cache for current view
|
|
320
|
+
cache.delete(viewKey(currentViewId()));
|
|
321
|
+
if (state.tab === "wallet") {
|
|
322
|
+
invalidateWalletCache();
|
|
323
|
+
} else if (state.tab === "proposals") {
|
|
324
|
+
invalidateProposalsCache();
|
|
325
|
+
}
|
|
326
|
+
loadAndRender(true);
|
|
327
|
+
break;
|
|
328
|
+
|
|
329
|
+
case "json":
|
|
330
|
+
state.jsonMode = !state.jsonMode;
|
|
331
|
+
state.scrollOffset = 0;
|
|
332
|
+
loadAndRender();
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── Cleanup ─────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
function cleanup(): void {
|
|
340
|
+
if (stopKeyListener) stopKeyListener();
|
|
341
|
+
if (removeResizeListener) removeResizeListener();
|
|
342
|
+
exitRawMode();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ── Startup ─────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
// Graceful exit handlers
|
|
348
|
+
process.on("SIGINT", () => { cleanup(); process.exit(0); });
|
|
349
|
+
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
|
|
350
|
+
process.on("uncaughtException", (err) => {
|
|
351
|
+
cleanup();
|
|
352
|
+
console.error("Uncaught exception:", err);
|
|
353
|
+
process.exit(1);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
enterRawMode();
|
|
357
|
+
stopKeyListener = startKeyListener(handleKey);
|
|
358
|
+
removeResizeListener = onResize(() => {
|
|
359
|
+
// Invalidate all cache (widths changed)
|
|
360
|
+
cache.clear();
|
|
361
|
+
loadAndRender();
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// Initial load
|
|
365
|
+
await loadAndRender();
|
|
366
|
+
}
|
package/src/tui/input.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { KeyAction } from "./types";
|
|
2
|
+
|
|
3
|
+
type KeyHandler = (action: KeyAction) => void;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Start listening for keypresses on stdin (must be in raw mode).
|
|
7
|
+
* Returns a cleanup function to stop listening.
|
|
8
|
+
*/
|
|
9
|
+
export function startKeyListener(handler: KeyHandler): () => void {
|
|
10
|
+
let escTimer: ReturnType<typeof setTimeout> | null = null;
|
|
11
|
+
|
|
12
|
+
function onData(buf: Buffer) {
|
|
13
|
+
const seq = buf.toString("utf8");
|
|
14
|
+
|
|
15
|
+
// Ctrl+C
|
|
16
|
+
if (seq === "\x03") return handler({ type: "quit" });
|
|
17
|
+
|
|
18
|
+
// Escape sequences (arrows, page up/down, etc.)
|
|
19
|
+
if (seq.startsWith("\x1b")) {
|
|
20
|
+
// Clear any pending standalone Esc
|
|
21
|
+
if (escTimer) {
|
|
22
|
+
clearTimeout(escTimer);
|
|
23
|
+
escTimer = null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Known escape sequences
|
|
27
|
+
const mapped = mapEscapeSequence(seq);
|
|
28
|
+
if (mapped) return handler(mapped);
|
|
29
|
+
|
|
30
|
+
// Could be standalone Esc — wait 50ms to distinguish
|
|
31
|
+
if (seq === "\x1b") {
|
|
32
|
+
escTimer = setTimeout(() => {
|
|
33
|
+
escTimer = null;
|
|
34
|
+
handler({ type: "back" });
|
|
35
|
+
}, 50);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Unknown escape sequence, ignore
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Single character keys
|
|
44
|
+
const action = mapCharKey(seq);
|
|
45
|
+
if (action) handler(action);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
process.stdin.on("data", onData);
|
|
49
|
+
|
|
50
|
+
return () => {
|
|
51
|
+
process.stdin.off("data", onData);
|
|
52
|
+
if (escTimer) {
|
|
53
|
+
clearTimeout(escTimer);
|
|
54
|
+
escTimer = null;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function mapEscapeSequence(seq: string): KeyAction | null {
|
|
60
|
+
switch (seq) {
|
|
61
|
+
case "\x1b[A": return { type: "up" };
|
|
62
|
+
case "\x1b[B": return { type: "down" };
|
|
63
|
+
case "\x1b[5~": return { type: "page-up" };
|
|
64
|
+
case "\x1b[6~": return { type: "page-down" };
|
|
65
|
+
case "\x1b[Z": return { type: "tab-prev" }; // Shift+Tab
|
|
66
|
+
default: return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function mapCharKey(seq: string): KeyAction | null {
|
|
71
|
+
switch (seq) {
|
|
72
|
+
case "q": return { type: "quit" };
|
|
73
|
+
case "\t": return { type: "tab-next" };
|
|
74
|
+
case "[": return { type: "sub-prev" };
|
|
75
|
+
case "]": return { type: "sub-next" };
|
|
76
|
+
case "k": return { type: "up" };
|
|
77
|
+
case "j": return { type: "json" };
|
|
78
|
+
case "g": return { type: "top" };
|
|
79
|
+
case "G": return { type: "bottom" };
|
|
80
|
+
case "\r": return { type: "enter" };
|
|
81
|
+
case "\x7f": return { type: "back" }; // Backspace
|
|
82
|
+
case "r": return { type: "refresh" };
|
|
83
|
+
default: return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { visibleLength, padEndVisible, truncateAnsi } from "../output";
|
|
2
|
+
import theme from "../theme";
|
|
3
|
+
|
|
4
|
+
// ── Types ──────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export interface PanelOptions {
|
|
7
|
+
title?: string;
|
|
8
|
+
width: number;
|
|
9
|
+
padding?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// ── Panel rendering ────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Wrap content lines in box-drawing borders.
|
|
16
|
+
*
|
|
17
|
+
* ┌─ Title ─────┐
|
|
18
|
+
* │ content │
|
|
19
|
+
* └─────────────┘
|
|
20
|
+
*/
|
|
21
|
+
export function renderPanel(content: string[], opts: PanelOptions): string[] {
|
|
22
|
+
const { width, title, padding = 1 } = opts;
|
|
23
|
+
if (width < 4) return content;
|
|
24
|
+
|
|
25
|
+
const innerWidth = width - 2; // subtract left + right border chars
|
|
26
|
+
const padStr = " ".repeat(padding);
|
|
27
|
+
const contentWidth = innerWidth - padding * 2;
|
|
28
|
+
|
|
29
|
+
// Top border
|
|
30
|
+
let top: string;
|
|
31
|
+
if (title) {
|
|
32
|
+
const titleText = ` ${title} `;
|
|
33
|
+
const ruleAfter = innerWidth - 1 - visibleLength(titleText); // 1 for the leading ─
|
|
34
|
+
top =
|
|
35
|
+
theme.border("┌─") +
|
|
36
|
+
theme.panelTitle(titleText) +
|
|
37
|
+
theme.border("─".repeat(Math.max(0, ruleAfter)) + "┐");
|
|
38
|
+
} else {
|
|
39
|
+
top = theme.border("┌" + "─".repeat(innerWidth) + "┐");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Bottom border
|
|
43
|
+
const bottom = theme.border("└" + "─".repeat(innerWidth) + "┘");
|
|
44
|
+
|
|
45
|
+
// Content lines — truncate/pad to fit
|
|
46
|
+
const border = theme.border("│");
|
|
47
|
+
const lines: string[] = [top];
|
|
48
|
+
|
|
49
|
+
for (const line of content) {
|
|
50
|
+
const vLen = visibleLength(line);
|
|
51
|
+
let fitted: string;
|
|
52
|
+
if (vLen > contentWidth) {
|
|
53
|
+
fitted = truncateAnsi(line, contentWidth);
|
|
54
|
+
} else {
|
|
55
|
+
fitted = padEndVisible(line, contentWidth);
|
|
56
|
+
}
|
|
57
|
+
lines.push(border + padStr + fitted + padStr + border);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
lines.push(bottom);
|
|
61
|
+
return lines;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Place rendered panels side-by-side, padding shorter panels to match the tallest.
|
|
66
|
+
*/
|
|
67
|
+
export function renderPanelRow(panels: string[][], gap = 2): string[] {
|
|
68
|
+
if (panels.length === 0) return [];
|
|
69
|
+
if (panels.length === 1) return panels[0];
|
|
70
|
+
|
|
71
|
+
const maxHeight = Math.max(...panels.map((p) => p.length));
|
|
72
|
+
const gapStr = " ".repeat(gap);
|
|
73
|
+
const lines: string[] = [];
|
|
74
|
+
|
|
75
|
+
// Measure each panel's visible width from its first line
|
|
76
|
+
const panelWidths = panels.map((p) => (p.length > 0 ? visibleLength(p[0]) : 0));
|
|
77
|
+
|
|
78
|
+
for (let i = 0; i < maxHeight; i++) {
|
|
79
|
+
const parts: string[] = [];
|
|
80
|
+
for (let p = 0; p < panels.length; p++) {
|
|
81
|
+
const line = panels[p][i];
|
|
82
|
+
if (line !== undefined) {
|
|
83
|
+
// Pad to panel width for alignment
|
|
84
|
+
parts.push(padEndVisible(line, panelWidths[p]));
|
|
85
|
+
} else {
|
|
86
|
+
parts.push(" ".repeat(panelWidths[p]));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
lines.push(parts.join(gapStr));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return lines;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Stack panel-rows vertically with optional gaps.
|
|
97
|
+
*/
|
|
98
|
+
export function renderPanelGrid(
|
|
99
|
+
rows: string[][][],
|
|
100
|
+
opts?: { rowGap?: number; colGap?: number },
|
|
101
|
+
): string[] {
|
|
102
|
+
const { rowGap = 0, colGap = 2 } = opts ?? {};
|
|
103
|
+
const lines: string[] = [];
|
|
104
|
+
|
|
105
|
+
for (let r = 0; r < rows.length; r++) {
|
|
106
|
+
if (r > 0 && rowGap > 0) {
|
|
107
|
+
for (let g = 0; g < rowGap; g++) lines.push("");
|
|
108
|
+
}
|
|
109
|
+
lines.push(...renderPanelRow(rows[r], colGap));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return lines;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Divide total width among N panels accounting for gaps between them.
|
|
117
|
+
* Returns an array of widths. Any remainder pixels go to earlier panels.
|
|
118
|
+
*/
|
|
119
|
+
export function splitWidth(totalWidth: number, count: number, gap = 2): number[] {
|
|
120
|
+
if (count <= 0) return [];
|
|
121
|
+
if (count === 1) return [totalWidth];
|
|
122
|
+
|
|
123
|
+
const totalGap = gap * (count - 1);
|
|
124
|
+
const available = totalWidth - totalGap;
|
|
125
|
+
const base = Math.floor(available / count);
|
|
126
|
+
const remainder = available - base * count;
|
|
127
|
+
|
|
128
|
+
const widths: number[] = [];
|
|
129
|
+
for (let i = 0; i < count; i++) {
|
|
130
|
+
widths.push(base + (i < remainder ? 1 : 0));
|
|
131
|
+
}
|
|
132
|
+
return widths;
|
|
133
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { AppState, TabId } from "./types";
|
|
2
|
+
import { TABS, TAB_LABELS } from "./types";
|
|
3
|
+
import { visibleLength, padEndVisible, truncateAnsi } from "../output";
|
|
4
|
+
import theme from "../theme";
|
|
5
|
+
|
|
6
|
+
// ── Tab bar ─────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
function renderTabBar(activeTab: TabId, width: number): string {
|
|
9
|
+
const prefix = theme.appName(" Akita DAO") + " ";
|
|
10
|
+
const tabs = TABS.map((t) => {
|
|
11
|
+
const label = TAB_LABELS[t];
|
|
12
|
+
return t === activeTab
|
|
13
|
+
? theme.activeTab(` ${label} `)
|
|
14
|
+
: theme.inactiveTab(` ${label} `);
|
|
15
|
+
}).join(theme.tabSeparator("│"));
|
|
16
|
+
|
|
17
|
+
return padEndVisible(prefix + tabs, width);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ── Separator line ──────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
function renderSeparator(width: number): string {
|
|
23
|
+
return theme.separator("─".repeat(width));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Status bar ──────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
function renderStatusBar(
|
|
29
|
+
state: AppState,
|
|
30
|
+
totalLines: number,
|
|
31
|
+
viewportHeight: number,
|
|
32
|
+
width: number,
|
|
33
|
+
loading: boolean,
|
|
34
|
+
): string {
|
|
35
|
+
const hints: string[] = [
|
|
36
|
+
"q:quit",
|
|
37
|
+
"Tab:nav",
|
|
38
|
+
"↑↓:scroll",
|
|
39
|
+
"r:refresh",
|
|
40
|
+
"j:json",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
if (state.tab === "wallet") {
|
|
44
|
+
hints.splice(2, 0, "[]:acct");
|
|
45
|
+
} else if (state.tab === "proposals") {
|
|
46
|
+
hints.splice(2, 0, "[]:select");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const left = " " + hints.join(" ");
|
|
50
|
+
|
|
51
|
+
let right: string;
|
|
52
|
+
if (loading) {
|
|
53
|
+
right = "loading... ";
|
|
54
|
+
} else if (totalLines <= viewportHeight) {
|
|
55
|
+
right = `${totalLines} ln `;
|
|
56
|
+
} else {
|
|
57
|
+
const pct = totalLines > 0
|
|
58
|
+
? Math.round(((state.scrollOffset + viewportHeight) / totalLines) * 100)
|
|
59
|
+
: 100;
|
|
60
|
+
right = `${Math.min(pct, 100)}% (${totalLines} ln) `;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const middle = width - visibleLength(left) - visibleLength(right);
|
|
64
|
+
const pad = middle > 0 ? " ".repeat(middle) : " ";
|
|
65
|
+
|
|
66
|
+
return theme.statusBar(left + pad + right);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Full frame compositor ───────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
export function renderFrame(
|
|
72
|
+
state: AppState,
|
|
73
|
+
contentLines: string[],
|
|
74
|
+
termRows: number,
|
|
75
|
+
termCols: number,
|
|
76
|
+
loading: boolean,
|
|
77
|
+
fixedRight?: string[],
|
|
78
|
+
): string[] {
|
|
79
|
+
const lines: string[] = [];
|
|
80
|
+
|
|
81
|
+
// Line 1: Tab bar
|
|
82
|
+
lines.push(renderTabBar(state.tab, termCols));
|
|
83
|
+
|
|
84
|
+
// Line 2: Separator
|
|
85
|
+
lines.push(renderSeparator(termCols));
|
|
86
|
+
|
|
87
|
+
// Lines 3..N-1: Scrollable viewport
|
|
88
|
+
const viewportHeight = termRows - 3; // tab bar + separator + status bar
|
|
89
|
+
const start = state.scrollOffset;
|
|
90
|
+
|
|
91
|
+
if (fixedRight && fixedRight.length > 0) {
|
|
92
|
+
// Two-panel mode: both panels scroll together
|
|
93
|
+
const rightWidth = Math.max(...fixedRight.map((l) => visibleLength(l)));
|
|
94
|
+
const leftWidth = termCols - rightWidth - 1;
|
|
95
|
+
|
|
96
|
+
for (let i = 0; i < viewportHeight; i++) {
|
|
97
|
+
const idx = start + i;
|
|
98
|
+
const leftLine = idx < contentLines.length ? contentLines[idx] : "";
|
|
99
|
+
const rightLine = idx < fixedRight.length ? fixedRight[idx] : "";
|
|
100
|
+
|
|
101
|
+
const left = padEndVisible(truncateAnsi(leftLine, leftWidth), leftWidth);
|
|
102
|
+
lines.push(left + " " + rightLine);
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
// Normal mode: everything scrolls together
|
|
106
|
+
const end = start + viewportHeight;
|
|
107
|
+
for (let i = start; i < end; i++) {
|
|
108
|
+
if (i < contentLines.length) {
|
|
109
|
+
const line = contentLines[i];
|
|
110
|
+
if (visibleLength(line) > termCols) {
|
|
111
|
+
lines.push(truncateAnsi(line, termCols));
|
|
112
|
+
} else {
|
|
113
|
+
lines.push(line);
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
lines.push("");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Last line: Status bar
|
|
122
|
+
const totalLineCount = fixedRight ? Math.max(contentLines.length, fixedRight.length) : contentLines.length;
|
|
123
|
+
lines.push(renderStatusBar(state, totalLineCount, viewportHeight, termCols, loading));
|
|
124
|
+
|
|
125
|
+
return lines;
|
|
126
|
+
}
|