@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/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
+ }
@@ -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
+ }