ai-usage-analyzer 0.3.2 → 0.5.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 +30 -15
- package/bin/ai-usage.js +66 -35
- package/package.json +8 -6
- package/src/aggregate.js +48 -21
- package/src/live.js +132 -0
- package/src/loaders.js +259 -114
- package/src/markdown.js +133 -70
- package/src/pricing.js +49 -0
- package/src/quota.js +194 -0
- package/src/render.js +512 -189
- package/src/server.js +67 -0
- package/src/tools.js +5 -5
- package/src/tui.js +326 -0
- package/web/app.js +952 -0
- package/web/favicon.svg +1 -0
- package/web/fonts/OFL.txt +93 -0
- package/web/fonts/manrope.woff2 +0 -0
- package/web/icons/claude.svg +1 -0
- package/web/icons/openai.svg +1 -0
- package/web/icons/opencode.svg +1 -0
- package/web/index.html +199 -0
- package/web/style.css +324 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { detectAll } from './detectors.js';
|
|
5
|
+
import { loadAll } from './loaders.js';
|
|
6
|
+
|
|
7
|
+
const assets = new Map([
|
|
8
|
+
['/', ['../web/index.html', 'text/html; charset=utf-8']],
|
|
9
|
+
['/app.js', ['../web/app.js', 'text/javascript; charset=utf-8']],
|
|
10
|
+
['/aggregate.js', ['./aggregate.js', 'text/javascript; charset=utf-8']],
|
|
11
|
+
['/style.css', ['../web/style.css', 'text/css; charset=utf-8']],
|
|
12
|
+
['/favicon.svg', ['../web/favicon.svg', 'image/svg+xml']],
|
|
13
|
+
['/gsap.js', [import.meta.resolve('gsap/dist/gsap.min.js'), 'text/javascript; charset=utf-8']],
|
|
14
|
+
['/fonts/manrope.woff2', ['../web/fonts/manrope.woff2', 'font/woff2']],
|
|
15
|
+
['/icons/claude.svg', ['../web/icons/claude.svg', 'image/svg+xml']],
|
|
16
|
+
['/icons/openai.svg', ['../web/icons/openai.svg', 'image/svg+xml']],
|
|
17
|
+
['/icons/opencode.svg', ['../web/icons/opencode.svg', 'image/svg+xml']],
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
export function createDashboardServer() {
|
|
21
|
+
return createServer(async (req, res) => {
|
|
22
|
+
const host = req.headers.host || '';
|
|
23
|
+
if (!/^127\.0\.0\.1:\d+$/.test(host) || (req.headers.origin && req.headers.origin !== `http://${host}`) || req.headers['sec-fetch-site'] === 'cross-site') {
|
|
24
|
+
res.writeHead(403).end('Forbidden');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
28
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
29
|
+
res.setHeader('Content-Security-Policy', "default-src 'self'; style-src 'self'; script-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'");
|
|
30
|
+
if (req.method !== 'GET') {
|
|
31
|
+
res.writeHead(405, { Allow: 'GET' }).end();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const path = new URL(req.url, `http://${host}`).pathname;
|
|
36
|
+
if (path === '/api/usage') {
|
|
37
|
+
const detections = detectAll();
|
|
38
|
+
const { records, errors } = await loadAll(detections);
|
|
39
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }).end(JSON.stringify({ detections, records, errors, generatedAt: new Date().toISOString() }));
|
|
40
|
+
} else if (assets.has(path)) {
|
|
41
|
+
const [file, type] = assets.get(path);
|
|
42
|
+
const content = await readFile(new URL(file, import.meta.url));
|
|
43
|
+
res.writeHead(200, { 'Content-Type': type }).end(content);
|
|
44
|
+
} else {
|
|
45
|
+
res.writeHead(404).end('Not found');
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ error: 'Could not load local usage data.' }));
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function startDashboard() {
|
|
54
|
+
const server = createDashboardServer();
|
|
55
|
+
await new Promise((resolve, reject) => {
|
|
56
|
+
server.once('error', reject);
|
|
57
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
58
|
+
});
|
|
59
|
+
const url = `http://127.0.0.1:${server.address().port}/`;
|
|
60
|
+
console.log(`AI Usage Analyzer is running at ${url}\nPress Ctrl+C to stop.`);
|
|
61
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
62
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
63
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
|
|
64
|
+
child.on('error', () => {});
|
|
65
|
+
child.unref();
|
|
66
|
+
return server;
|
|
67
|
+
}
|
package/src/tools.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// data, also wire a loader branch in src/loaders.js.
|
|
9
9
|
|
|
10
10
|
import { statSync, readdirSync, existsSync } from 'node:fs';
|
|
11
|
-
import { join } from 'node:path';
|
|
11
|
+
import { basename, dirname, join } from 'node:path';
|
|
12
12
|
import { homedir, platform } from 'node:os';
|
|
13
13
|
import { env } from 'node:process';
|
|
14
14
|
import { createRequire } from 'node:module';
|
|
@@ -112,10 +112,10 @@ export const TOOLS = [
|
|
|
112
112
|
barChar: '▓',
|
|
113
113
|
kind: 'jsonl-rollout',
|
|
114
114
|
envVar: 'CODEX_HOME',
|
|
115
|
-
candidatePaths: () =>
|
|
116
|
-
env.CODEX_HOME
|
|
117
|
-
|
|
118
|
-
count: countJsonlRecursive,
|
|
115
|
+
candidatePaths: () => env.CODEX_HOME
|
|
116
|
+
? [env.CODEX_HOME]
|
|
117
|
+
: [join(HOME, '.codex', 'sessions'), join(HOME, '.codex', 'archived_sessions')],
|
|
118
|
+
count: path => countJsonlRecursive(path) + (basename(path) === 'sessions' ? countJsonlRecursive(join(dirname(path), 'archived_sessions')) : 0),
|
|
119
119
|
hasTokens: true,
|
|
120
120
|
description: '~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl',
|
|
121
121
|
},
|
package/src/tui.js
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// Interactive TUI: tab bar + scrollable content + keyboard navigation.
|
|
2
|
+
// Falls back gracefully when stdout is not a TTY.
|
|
3
|
+
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import readline from "node:readline";
|
|
7
|
+
import { perProject } from "./aggregate.js";
|
|
8
|
+
import { renderQuota } from "./quota.js";
|
|
9
|
+
import {
|
|
10
|
+
CURSOR_MARK,
|
|
11
|
+
renderDetections,
|
|
12
|
+
renderNotes,
|
|
13
|
+
renderOverview,
|
|
14
|
+
renderPerMonth,
|
|
15
|
+
renderPerProjectCursor,
|
|
16
|
+
renderPerTool,
|
|
17
|
+
renderPerToolPerMonth,
|
|
18
|
+
renderPerWeek,
|
|
19
|
+
renderProjectSessions,
|
|
20
|
+
renderTopSessions,
|
|
21
|
+
} from "./render.js";
|
|
22
|
+
|
|
23
|
+
// Strip ANSI escape codes to measure visible string length.
|
|
24
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
25
|
+
function visLen(s) {
|
|
26
|
+
return s.replace(ANSI_RE, "").length;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function runInteractive({ records, detections, errors, topN, quotaPromise, quotaController, liveTailer }) {
|
|
30
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) return false;
|
|
31
|
+
|
|
32
|
+
const PROJECTS_VIEW = 1; // index into VIEWS
|
|
33
|
+
|
|
34
|
+
let liveRate = liveTailer ? liveTailer.tick() : null;
|
|
35
|
+
let quotas = null;
|
|
36
|
+
let active = true;
|
|
37
|
+
const VIEWS = [
|
|
38
|
+
{
|
|
39
|
+
key: "1",
|
|
40
|
+
label: "Overview",
|
|
41
|
+
fn: () => renderOverview(records, detections),
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
key: "2",
|
|
45
|
+
label: "Projects",
|
|
46
|
+
// rendered specially via proj state — fn used only for non-interactive fallback
|
|
47
|
+
fn: () => renderPerProjectCursor(records, proj.cursor),
|
|
48
|
+
},
|
|
49
|
+
{ key: "3", label: "Per Tool", fn: () => renderPerTool(records) },
|
|
50
|
+
{ key: "4", label: "Tool/Month", fn: () => renderPerToolPerMonth(records) },
|
|
51
|
+
{ key: "5", label: "Per Month", fn: () => renderPerMonth(records) },
|
|
52
|
+
{ key: "6", label: "Per Week", fn: () => renderPerWeek(records) },
|
|
53
|
+
{
|
|
54
|
+
key: "7",
|
|
55
|
+
label: `Top ${topN}`,
|
|
56
|
+
fn: () => renderTopSessions(records, topN),
|
|
57
|
+
},
|
|
58
|
+
{ key: "8", label: "Detections", fn: () => renderDetections(detections) },
|
|
59
|
+
{ key: "9", label: "Notes", fn: () => renderNotes(detections, errors) },
|
|
60
|
+
{ key: "0", label: "Quota", fn: () => renderQuota(quotas) },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
// Project drill-down state (only active when current === PROJECTS_VIEW).
|
|
64
|
+
const proj = { mode: "list", cursor: 0, name: null };
|
|
65
|
+
const projectItems = () => perProject(records);
|
|
66
|
+
|
|
67
|
+
// One reserved row each for tab bar and status bar.
|
|
68
|
+
const CHROME = 2;
|
|
69
|
+
|
|
70
|
+
function usableRows() {
|
|
71
|
+
return Math.max(1, (process.stdout.rows || 24) - CHROME);
|
|
72
|
+
}
|
|
73
|
+
function termWidth() {
|
|
74
|
+
return process.stdout.columns || 100;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let current = 0;
|
|
78
|
+
let scrollY = 0;
|
|
79
|
+
let lines = []; // raw rendered lines of the current view
|
|
80
|
+
let dirty = true; // full redraw needed (e.g. after resize)
|
|
81
|
+
|
|
82
|
+
function loadView() {
|
|
83
|
+
let raw;
|
|
84
|
+
if (current === PROJECTS_VIEW) {
|
|
85
|
+
raw =
|
|
86
|
+
proj.mode === "detail"
|
|
87
|
+
? renderProjectSessions(records, proj.name)
|
|
88
|
+
: renderPerProjectCursor(records, proj.cursor);
|
|
89
|
+
} else {
|
|
90
|
+
raw = VIEWS[current].fn() || chalk.dim(" (no data)");
|
|
91
|
+
}
|
|
92
|
+
lines = raw.split("\n");
|
|
93
|
+
// Auto-scroll so the cursor row stays visible in list mode.
|
|
94
|
+
if (current === PROJECTS_VIEW && proj.mode === "list") {
|
|
95
|
+
const markerLine = lines.findIndex((l) => l.includes(CURSOR_MARK));
|
|
96
|
+
if (markerLine >= 0) {
|
|
97
|
+
const usable = usableRows();
|
|
98
|
+
if (markerLine < scrollY) scrollY = markerLine;
|
|
99
|
+
else if (markerLine >= scrollY + usable)
|
|
100
|
+
scrollY = markerLine - usable + 1;
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
scrollY = 0;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function tabBar() {
|
|
108
|
+
const w = termWidth();
|
|
109
|
+
const tabs = VIEWS.map((v, i) => {
|
|
110
|
+
const label = ` ${v.key}:${v.label} `;
|
|
111
|
+
return i === current ? chalk.bgCyan.black.bold(label) : chalk.dim(label);
|
|
112
|
+
}).join(chalk.dim("│"));
|
|
113
|
+
const pad = Math.max(0, w - visLen(tabs));
|
|
114
|
+
return tabs + " ".repeat(pad);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function statusBar() {
|
|
118
|
+
const w = termWidth();
|
|
119
|
+
const usable = usableRows();
|
|
120
|
+
const total = lines.length;
|
|
121
|
+
const pct =
|
|
122
|
+
total <= usable
|
|
123
|
+
? chalk.dim(" all ")
|
|
124
|
+
: chalk.dim(` ${Math.round(((scrollY + usable) / total) * 100)}% `);
|
|
125
|
+
|
|
126
|
+
let hint;
|
|
127
|
+
if (current === PROJECTS_VIEW && proj.mode === "list") {
|
|
128
|
+
hint = chalk.dim(" ↑/↓ navigate Enter select ←/→ switch view q quit");
|
|
129
|
+
} else if (current === PROJECTS_VIEW && proj.mode === "detail") {
|
|
130
|
+
hint = chalk.dim(" ↑/↓ j/k scroll PgUp/PgDn Esc back q quit");
|
|
131
|
+
} else {
|
|
132
|
+
hint = chalk.dim(
|
|
133
|
+
" ←/→ h/l switch ↑/↓ j/k scroll PgUp/PgDn g/G top/bot q quit",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (liveRate !== null) hint = chalk.dim(` ${Math.round(liveRate)} tokens/min (10m) `) + hint;
|
|
137
|
+
const gap = Math.max(1, w - visLen(hint) - visLen(pct));
|
|
138
|
+
return chalk.bgBlack(hint + " ".repeat(gap) + pct);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Clip a line to w visible chars, preserving ANSI sequences.
|
|
142
|
+
function clipLine(line, w) {
|
|
143
|
+
const visible = line.replace(ANSI_RE, "");
|
|
144
|
+
if (visible.length <= w) return line;
|
|
145
|
+
let kept = 0,
|
|
146
|
+
out = "";
|
|
147
|
+
for (let i = 0; i < line.length; ) {
|
|
148
|
+
if (line[i] === "\x1b") {
|
|
149
|
+
const end = line.indexOf("m", i);
|
|
150
|
+
if (end !== -1) {
|
|
151
|
+
out += line.slice(i, end + 1);
|
|
152
|
+
i = end + 1;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (kept < w) {
|
|
157
|
+
out += line[i];
|
|
158
|
+
kept++;
|
|
159
|
+
}
|
|
160
|
+
i++;
|
|
161
|
+
}
|
|
162
|
+
return out + "\x1b[0m";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function draw(forceFullClear) {
|
|
166
|
+
const rows = process.stdout.rows || 24;
|
|
167
|
+
const usable = Math.max(1, rows - CHROME);
|
|
168
|
+
const w = termWidth();
|
|
169
|
+
const maxScroll = Math.max(0, lines.length - usable);
|
|
170
|
+
if (scrollY > maxScroll) scrollY = maxScroll;
|
|
171
|
+
|
|
172
|
+
// Full clear on first draw or resize; partial rewrite otherwise.
|
|
173
|
+
if (forceFullClear) process.stdout.write("\x1b[2J");
|
|
174
|
+
|
|
175
|
+
// Tab bar pinned to row 1.
|
|
176
|
+
process.stdout.write("\x1b[1;1H" + clipLine(tabBar(), w) + "\x1b[K");
|
|
177
|
+
|
|
178
|
+
// Content rows 2 … rows-1.
|
|
179
|
+
const slice = lines.slice(scrollY, scrollY + usable);
|
|
180
|
+
while (slice.length < usable) slice.push("");
|
|
181
|
+
for (let i = 0; i < slice.length; i++) {
|
|
182
|
+
process.stdout.write(
|
|
183
|
+
`\x1b[${i + 2};1H` + clipLine(slice[i], w) + "\x1b[K",
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Status bar pinned to last row.
|
|
188
|
+
process.stdout.write(
|
|
189
|
+
`\x1b[${rows};1H` + clipLine(statusBar(), w) + "\x1b[K",
|
|
190
|
+
);
|
|
191
|
+
dirty = false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
readline.emitKeypressEvents(process.stdin);
|
|
195
|
+
process.stdin.setRawMode(true);
|
|
196
|
+
process.stdin.resume();
|
|
197
|
+
process.stdin.setEncoding("utf8");
|
|
198
|
+
|
|
199
|
+
loadView();
|
|
200
|
+
// Enter alternate screen buffer so the TUI never clobbers terminal scrollback.
|
|
201
|
+
process.stdout.write("\x1b[?1049h\x1b[?25l");
|
|
202
|
+
draw(true);
|
|
203
|
+
|
|
204
|
+
const liveTimer = liveTailer ? setInterval(() => { liveRate = liveTailer.tick(); if (active) draw(false); }, 5000) : null;
|
|
205
|
+
const onResize = () => draw(true);
|
|
206
|
+
process.stdout.on("resize", onResize);
|
|
207
|
+
if (quotaPromise) quotaPromise.then((result) => {
|
|
208
|
+
quotas = result;
|
|
209
|
+
if (active && current === 9) { loadView(); draw(false); }
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
return new Promise((resolve) => {
|
|
213
|
+
function quit() {
|
|
214
|
+
// Leave alternate screen — original terminal content is restored automatically.
|
|
215
|
+
active = false;
|
|
216
|
+
if (liveTimer) clearInterval(liveTimer);
|
|
217
|
+
quotaController?.abort();
|
|
218
|
+
process.stdout.removeListener("resize", onResize);
|
|
219
|
+
process.stdin.removeListener("keypress", onKeypress);
|
|
220
|
+
process.stdout.write("\x1b[?25h\x1b[?1049l");
|
|
221
|
+
process.stdin.setRawMode(false);
|
|
222
|
+
process.stdin.pause();
|
|
223
|
+
resolve(true);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function onKeypress(_str, key) {
|
|
227
|
+
if (!key) return;
|
|
228
|
+
const usable = usableRows();
|
|
229
|
+
const maxScroll = Math.max(0, lines.length - usable);
|
|
230
|
+
|
|
231
|
+
if (key.name === "q" || (key.ctrl && key.name === "c")) {
|
|
232
|
+
quit();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// --- Projects view special handling ---
|
|
237
|
+
if (current === PROJECTS_VIEW) {
|
|
238
|
+
if (proj.mode === "list") {
|
|
239
|
+
const items = projectItems();
|
|
240
|
+
if (key.name === "down" || key.name === "j") {
|
|
241
|
+
proj.cursor = Math.min(proj.cursor + 1, items.length - 1);
|
|
242
|
+
loadView();
|
|
243
|
+
draw(false);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (key.name === "up" || key.name === "k") {
|
|
247
|
+
proj.cursor = Math.max(0, proj.cursor - 1);
|
|
248
|
+
loadView();
|
|
249
|
+
draw(false);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (key.name === "return") {
|
|
253
|
+
if (items.length > 0) {
|
|
254
|
+
proj.name = items[proj.cursor].project;
|
|
255
|
+
proj.mode = "detail";
|
|
256
|
+
loadView();
|
|
257
|
+
draw(false);
|
|
258
|
+
}
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
// detail mode — Esc goes back to list
|
|
263
|
+
if (key.name === "escape" || key.name === "backspace") {
|
|
264
|
+
proj.mode = "list";
|
|
265
|
+
loadView();
|
|
266
|
+
draw(false);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// --- Global view switching (not available in Projects detail mode) ---
|
|
273
|
+
if (!(current === PROJECTS_VIEW && proj.mode === "detail")) {
|
|
274
|
+
if (key.name === "right" || key.name === "l" || key.name === "tab") {
|
|
275
|
+
current = (current + 1) % VIEWS.length;
|
|
276
|
+
if (current === PROJECTS_VIEW) proj.mode = "list";
|
|
277
|
+
loadView();
|
|
278
|
+
draw(false);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (key.name === "left" || key.name === "h") {
|
|
282
|
+
current = (current - 1 + VIEWS.length) % VIEWS.length;
|
|
283
|
+
if (current === PROJECTS_VIEW) proj.mode = "list";
|
|
284
|
+
loadView();
|
|
285
|
+
draw(false);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (_str && /^[0-9]$/.test(_str)) {
|
|
289
|
+
const idx = _str === "0" ? 9 : parseInt(_str, 10) - 1;
|
|
290
|
+
if (idx < VIEWS.length) {
|
|
291
|
+
current = idx;
|
|
292
|
+
if (current === PROJECTS_VIEW) proj.mode = "list";
|
|
293
|
+
loadView();
|
|
294
|
+
draw(false);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Scrolling
|
|
301
|
+
let moved = false;
|
|
302
|
+
if (key.name === "down" || key.name === "j") {
|
|
303
|
+
scrollY = Math.min(scrollY + 1, maxScroll);
|
|
304
|
+
moved = true;
|
|
305
|
+
} else if (key.name === "up" || key.name === "k") {
|
|
306
|
+
scrollY = Math.max(0, scrollY - 1);
|
|
307
|
+
moved = true;
|
|
308
|
+
} else if (key.name === "pagedown" || key.name === "space") {
|
|
309
|
+
scrollY = Math.min(scrollY + usable, maxScroll);
|
|
310
|
+
moved = true;
|
|
311
|
+
} else if (key.name === "pageup") {
|
|
312
|
+
scrollY = Math.max(0, scrollY - usable);
|
|
313
|
+
moved = true;
|
|
314
|
+
} else if (key.name === "home" || key.name === "g") {
|
|
315
|
+
scrollY = 0;
|
|
316
|
+
moved = true;
|
|
317
|
+
} else if (key.name === "end" || key.name === "G") {
|
|
318
|
+
scrollY = maxScroll;
|
|
319
|
+
moved = true;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (moved) draw(false);
|
|
323
|
+
}
|
|
324
|
+
process.stdin.on("keypress", onKeypress);
|
|
325
|
+
});
|
|
326
|
+
}
|