scripter-x 1.0.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/ui/App.js ADDED
@@ -0,0 +1,101 @@
1
+ // App — the root ink component that hosts the interactive shell and runs commands
2
+ // inside the same ink tree (so prompts/menus are all cursor-driven).
3
+ //
4
+ // Screen state machine:
5
+ // 'shell' → the REPL prompt + command palette
6
+ // 'prompts' → a sequence of TextField/SelectList/Confirm questions a command asks
7
+ // 'run' → the live extraction view (RunView)
8
+ // 'output' → scrollback lines a command printed (tables, results)
9
+ import React, { useState, useEffect, useRef } from 'react';
10
+ import { Box, Text, useApp } from 'ink';
11
+ import { Shell } from './Shell.js';
12
+ import { SelectList, Confirm, TextField } from './components.js';
13
+ import { RunView } from './RunView.js';
14
+ import { COLORS } from '../theme.js';
15
+ import { useMouse } from './mouse.js';
16
+
17
+ const h = React.createElement;
18
+
19
+ export function App({ ctx }) {
20
+ const { exit } = useApp();
21
+ const [screen, setScreen] = useState('shell');
22
+ const [lines, setLines] = useState([]); // scrollback (printed output)
23
+ const [prompt, setPrompt] = useState(null); // active question
24
+ const [runController, setRunController] = useState(null);
25
+ const [busy, setBusy] = useState(false);
26
+ const [me, setMe] = useState({ username: ctx.username, server: ctx.server });
27
+ const [, forceTick] = useState(0);
28
+ const frameRef = useRef();
29
+ useMouse(); // enable click selection across the app
30
+
31
+ // track whether a live run is active, so prompts shown mid-run return to the run view
32
+ const runActive = useRef(false);
33
+ const afterPrompt = () => (runActive.current ? 'run' : 'shell');
34
+
35
+ // expose an "io" object commands use to print + ask, all rendering in-tree
36
+ const io = useRef(null);
37
+ if (!io.current) {
38
+ io.current = {
39
+ print: (line, color) => setLines((ls) => [...ls, { text: line, color }].slice(-200)),
40
+ clear: () => setLines([]),
41
+ ask: (message, opts = {}) => new Promise((resolve) => {
42
+ setScreen('prompts');
43
+ setPrompt({ kind: opts.mask ? 'secret' : 'text', message, mask: opts.mask, dflt: opts.dflt || '',
44
+ done: (v) => { setPrompt(null); setScreen(afterPrompt()); resolve(v || opts.dflt || ''); } });
45
+ }),
46
+ select: (message, items, opts = {}) => new Promise((resolve) => {
47
+ const norm = items.map((it) => (typeof it === 'string' ? { label: it, value: it } : it));
48
+ setScreen('prompts');
49
+ setPrompt({ kind: 'select', message, items: norm,
50
+ done: (v) => { setPrompt(null); setScreen(afterPrompt()); resolve(v); } });
51
+ }),
52
+ confirm: (message, dflt = true) => new Promise((resolve) => {
53
+ setScreen('prompts');
54
+ setPrompt({ kind: 'confirm', message, dflt,
55
+ done: (v) => { setPrompt(null); setScreen(afterPrompt()); resolve(v); } });
56
+ }),
57
+ // live run view: returns a controller; call .endRun() when done
58
+ startRun: (controller) => { runActive.current = true; setRunController(controller); setScreen('run'); forceTick((t) => t + 1); },
59
+ endRun: () => { runActive.current = false; setRunController(null); setScreen('shell'); },
60
+ setUser: (u) => setMe((m) => ({ ...m, username: u })),
61
+ exitApp: () => exit(),
62
+ };
63
+ }
64
+
65
+ async function runCommand(name) {
66
+ setBusy(true);
67
+ try {
68
+ await ctx.dispatch(name, io.current);
69
+ } catch (e) {
70
+ io.current.print(` ✗ ${e.message}`, COLORS.danger);
71
+ } finally {
72
+ setBusy(false);
73
+ setScreen((s) => (s === 'run' ? s : 'shell'));
74
+ }
75
+ }
76
+
77
+ // scrollback (map semantic color names → theme hexes)
78
+ const colorMap = { accent: COLORS.accent, danger: COLORS.danger, success: COLORS.success, warn: COLORS.warn };
79
+ const scroll = h(Box, { flexDirection: 'column' },
80
+ lines.map((l, i) => h(Text, { key: i, color: colorMap[l.color] || l.color || undefined }, l.text)));
81
+
82
+ return h(Box, { flexDirection: 'column', ref: frameRef },
83
+ scroll,
84
+ screen === 'run' && runController ? h(RunView, { controller: runController, embedded: true }) : null,
85
+ screen === 'prompts' && prompt ? h(PromptView, { prompt, frameRef }) : null,
86
+ screen === 'shell' ? h(Shell, { username: me.username, server: me.server, busy, onRun: runCommand, frameRef }) : null,
87
+ );
88
+ }
89
+
90
+ function PromptView({ prompt, frameRef }) {
91
+ if (prompt.kind === 'select') {
92
+ return h(Box, { flexDirection: 'column' },
93
+ h(Text, { color: COLORS.accent }, '? ' + prompt.message),
94
+ h(SelectList, { items: prompt.items, frameRef, onSelect: (it) => prompt.done(it) }));
95
+ }
96
+ if (prompt.kind === 'confirm') {
97
+ return h(Confirm, { message: prompt.message, defaultValue: prompt.dflt, frameRef, onAnswer: (v) => prompt.done(v) });
98
+ }
99
+ return h(TextField, { message: prompt.message, mask: prompt.kind === 'secret', defaultValue: prompt.dflt,
100
+ onSubmit: (v) => prompt.done(v) });
101
+ }
@@ -0,0 +1,91 @@
1
+ // The live run view — Claude-Code-style ink TUI: header, live slots, results, stats.
2
+ // PROPERLY REACTIVE: the controller is an EventEmitter-like object; this component
3
+ // subscribes and updates React state on every event, so slots/stats actually re-render.
4
+ import React, { useState, useEffect } from 'react';
5
+ import { Box, Text } from 'ink';
6
+ import Spinner from 'ink-spinner';
7
+ import { COLORS, STATUS, PHASE_LABEL, gradientChars } from '../theme.js';
8
+
9
+ const h = React.createElement;
10
+
11
+ function Header({ name, provider, succeeded, total }) {
12
+ return h(Box, { borderStyle: 'round', borderColor: COLORS.accent, paddingX: 1 },
13
+ h(Text, { color: COLORS.accent }, '◉ '),
14
+ h(Text, { bold: true }, name),
15
+ h(Text, { color: 'gray' }, ` ${provider}`),
16
+ h(Text, { color: COLORS.accent }, ` ${succeeded}/${total} JSONs`),
17
+ );
18
+ }
19
+
20
+ function SlotRow({ slot, mobile, phase, detail, wait }) {
21
+ const label = PHASE_LABEL[phase] || phase;
22
+ const color = phase === 'done' ? COLORS.success : (String(phase).includes('cancel') ? COLORS.warn : COLORS.accent);
23
+ const mob = mobile ? mobile.slice(-10) : '—';
24
+ return h(Box, null,
25
+ h(Box, { width: 5 }, h(Text, { color: 'gray' }, `#${slot}`)),
26
+ h(Box, { width: 13 }, h(Text, null, mob)),
27
+ h(Box, { width: 2 }, h(Text, { color }, h(Spinner, { type: 'dots' }))),
28
+ h(Box, { flexGrow: 1 }, h(Text, { color }, ` ${label}`), detail ? h(Text, { color: 'gray' }, ` ${detail}`) : null),
29
+ h(Box, { width: 7, justifyContent: 'flex-end' }, h(Text, { color: COLORS.warn }, wait ? `${wait}s` : '')),
30
+ );
31
+ }
32
+
33
+ function ResultRow({ status, mobile, detail, coupon, cost }) {
34
+ const st = STATUS[status] || STATUS.pending;
35
+ const mob = (mobile || '—').slice(-10) || '—';
36
+ const text = status === 'success' ? (coupon ? '₹100 coupon' : 'extracted') : (detail || '');
37
+ return h(Box, null,
38
+ h(Box, { width: 13 }, h(Text, null, mob)),
39
+ h(Box, { width: 13 }, h(Text, { color: st.color }, `${st.glyph} ${status}`)),
40
+ h(Box, { flexGrow: 1 }, h(Text, { color: 'gray' }, text)),
41
+ h(Box, { width: 7, justifyContent: 'flex-end' }, h(Text, { color: 'gray' }, cost ? `₹${cost}` : '')),
42
+ );
43
+ }
44
+
45
+ function Panel({ title, children, borderColor = 'gray' }) {
46
+ return h(Box, { flexDirection: 'column', borderStyle: 'round', borderColor, paddingX: 1 },
47
+ title ? h(Text, { bold: true }, title) : null, children);
48
+ }
49
+
50
+ export function RunView({ controller }) {
51
+ // mirror the controller's mutable state into React state via subscription
52
+ const [stats, setStats] = useState({ ...controller.stats });
53
+ const [slots, setSlots] = useState({ ...controller.slots });
54
+ const [rows, setRows] = useState([...controller.rows]);
55
+
56
+ useEffect(() => {
57
+ const onEvent = (kind) => {
58
+ // copy the latest controller state on EVERY event so React re-renders
59
+ if (kind === 'slot') setSlots({ ...controller.slots });
60
+ else if (kind === 'progress' || kind === 'done') setStats({ ...controller.stats });
61
+ else if (kind === 'row') setRows([...controller.rows]);
62
+ };
63
+ controller.subscribe(onEvent);
64
+ // also poll lightly so the spinner + countdown stay smooth between events
65
+ const id = setInterval(() => { setStats({ ...controller.stats }); setSlots({ ...controller.slots }); }, 200);
66
+ return () => { controller.unsubscribe(onEvent); clearInterval(id); };
67
+ }, [controller]);
68
+
69
+ const { name, provider, requested } = controller;
70
+ const slotList = Object.values(slots).sort((a, b) => a.slot - b.slot);
71
+ const lastRows = rows.slice(-8);
72
+
73
+ return h(Box, { flexDirection: 'column' },
74
+ h(Header, { name, provider, succeeded: stats.succeeded, total: requested }),
75
+ h(Panel, { title: 'live' },
76
+ slotList.length ? slotList.map((sl) => h(SlotRow, { key: sl.slot, ...sl }))
77
+ : h(Text, { color: 'gray' }, 'starting…')),
78
+ h(Panel, { title: 'results' },
79
+ lastRows.length ? lastRows.map((r, i) => h(ResultRow, { key: i, status: r.status, mobile: r.mobile, detail: r.detail, coupon: r.coupon_eligible, cost: r.cost }))
80
+ : h(Text, { color: 'gray' }, 'waiting for first result…')),
81
+ h(Box, { borderStyle: 'round', borderColor: COLORS.accent, paddingX: 1 },
82
+ h(Text, { color: COLORS.success }, `✓ ${stats.succeeded}`),
83
+ h(Text, { color: COLORS.danger }, ` ✗ ${stats.failed}`),
84
+ h(Text, { color: COLORS.warn }, ` ⊘ ${stats.cancelled}`),
85
+ h(Text, { color: 'gray' }, ` ◍ ${stats.generated} numbers`),
86
+ h(Text, { bold: true }, ` ₹${stats.charges} spent`),
87
+ ),
88
+ );
89
+ }
90
+
91
+ export function bannerChars() { return gradientChars('ScripterX'); }
@@ -0,0 +1,114 @@
1
+ // The interactive shell — Claude-Code-style REPL.
2
+ // • a welcome banner box
3
+ // • a `›` prompt; type to filter, `/` opens the command palette
4
+ // • ↑/↓ to move the cursor through commands, Enter/click-row to run, Esc to dismiss
5
+ // Each command runs its own flow (which may itself use SelectList/Confirm/TextField),
6
+ // then control returns to the prompt.
7
+ import React, { useState } from 'react';
8
+ import { Box, Text, useInput, useApp } from 'ink';
9
+ import { COLORS, gradientChars } from '../theme.js';
10
+ import { Clickable, isMouse, parseMouse } from './mouse.js';
11
+
12
+ const h = React.createElement;
13
+
14
+ export const COMMANDS = [
15
+ { name: 'run', desc: 'run an extraction' },
16
+ { name: 'campaigns', desc: 'list your campaigns' },
17
+ { name: 'export', desc: "download a campaign's sessions" },
18
+ { name: 'balance', desc: 'check provider balance' },
19
+ { name: 'creds', desc: 'manage saved provider credentials' },
20
+ { name: 'stop', desc: 'stop a running campaign' },
21
+ { name: 'delete', desc: 'delete a campaign' },
22
+ { name: 'whoami', desc: 'show the current user' },
23
+ { name: 'login', desc: 'sign in / switch account' },
24
+ { name: 'logout', desc: 'sign out' },
25
+ { name: 'config', desc: 'view / change settings' },
26
+ { name: 'help', desc: 'show all commands' },
27
+ { name: 'exit', desc: 'quit ScripterX' },
28
+ ];
29
+
30
+ function WelcomeBox({ username, server, frameRef, onLogout }) {
31
+ const chars = gradientChars('ScripterX');
32
+ return h(Box, { borderStyle: 'round', borderColor: COLORS.accent, paddingX: 2, paddingY: 0, flexDirection: 'column' },
33
+ h(Box, null, chars.map((c, i) => h(Text, { key: i, color: c.color, bold: true }, c.char)),
34
+ h(Text, { color: 'gray' }, ' Flipkart session extractor')),
35
+ h(Box, null,
36
+ h(Text, { color: 'gray' }, username ? `◉ ${username}` : '○ not signed in'),
37
+ h(Text, { color: 'gray' }, ` · ${server}`),
38
+ // clickable logout affordance when signed in
39
+ username ? h(Clickable, { frameRef, onClick: onLogout },
40
+ h(Text, { color: COLORS.accent }, ' [ logout ]')) : null),
41
+ h(Text, { color: 'gray' }, "type a command, or / to browse — press 1-9 or ↑↓+enter to pick · 'exit' to quit"),
42
+ );
43
+ }
44
+
45
+ // The shell is a state machine: 'prompt' (typing) ↔ 'palette' (browsing commands).
46
+ // When a command is picked it calls `onRun(name)`; the parent runs it (suspending the
47
+ // ink tree), then calls back to resume the prompt.
48
+ export function Shell({ username, server, onRun, busy, frameRef }) {
49
+ const { exit } = useApp();
50
+ const [query, setQuery] = useState('');
51
+ const [idx, setIdx] = useState(0);
52
+ const [paletteOpen, setPaletteOpen] = useState(false);
53
+
54
+ // commands filtered by what's typed after `/` (or the raw query)
55
+ const filterStr = query.startsWith('/') ? query.slice(1) : query;
56
+ const matches = COMMANDS.filter((c) => c.name.startsWith(filterStr.toLowerCase()) || c.desc.includes(filterStr.toLowerCase()));
57
+ const showPalette = (paletteOpen || query.startsWith('/')) && matches.length > 0;
58
+
59
+ useInput((input, key) => {
60
+ if (busy) return;
61
+ // Mouse clicks arrive here as input (ink strips the ESC). Handle them as clicks —
62
+ // parseMouse fires the clicked row's onClick via the registry — and NEVER type them.
63
+ if (isMouse(input)) { parseMouse(input); return; }
64
+ if (showPalette) {
65
+ // Number 1-9 = pick that visible row (no command name has a digit, so safe).
66
+ if (/^[1-9]$/.test(input)) {
67
+ const shown = matches.slice(0, 8);
68
+ const n = +input - 1;
69
+ if (n < shown.length) { setQuery(''); setPaletteOpen(false); setIdx(0); run(shown[n].name); }
70
+ return;
71
+ }
72
+ if (key.upArrow) { setIdx((i) => (i - 1 + matches.length) % matches.length); return; }
73
+ if (key.downArrow) { setIdx((i) => (i + 1) % matches.length); return; }
74
+ if (key.return) { const cmd = matches[Math.min(idx, matches.length - 1)]; setQuery(''); setPaletteOpen(false); setIdx(0); if (cmd) run(cmd.name); return; }
75
+ if (key.escape) { setPaletteOpen(false); setQuery(''); setIdx(0); return; }
76
+ }
77
+ if (key.return) {
78
+ const q = query.trim().replace(/^\//, '');
79
+ setQuery(''); setPaletteOpen(false); setIdx(0);
80
+ if (q) run(q);
81
+ return;
82
+ }
83
+ if (key.escape) { setQuery(''); setPaletteOpen(false); return; }
84
+ if (key.backspace || key.delete) { setQuery((v) => v.slice(0, -1)); setIdx(0); return; }
85
+ if (input === '/' && query === '') { setQuery('/'); setPaletteOpen(true); setIdx(0); return; }
86
+ if (input && !key.ctrl && !key.meta) { setQuery((v) => v + input); setIdx(0); }
87
+ });
88
+
89
+ function run(name) {
90
+ if (name === 'exit' || name === 'quit') { exit(); return; }
91
+ onRun(name);
92
+ }
93
+
94
+ return h(Box, { flexDirection: 'column' },
95
+ h(WelcomeBox, { username, server, frameRef, onLogout: () => run('logout') }),
96
+ h(Box, { marginTop: 1 },
97
+ h(Text, { color: COLORS.accent }, '› '),
98
+ h(Text, null, query || ''),
99
+ busy ? h(Text, { color: 'gray' }, ' …') : h(Text, { color: COLORS.accent }, '▏')),
100
+ showPalette ? h(Box, { flexDirection: 'column', marginTop: 0, marginLeft: 2 },
101
+ matches.slice(0, 8).map((c, i) => {
102
+ const active = i === Math.min(idx, matches.length - 1);
103
+ const row = h(Box, null,
104
+ h(Text, { color: active ? COLORS.accent : 'gray' }, active ? '▸ ' : ' '),
105
+ h(Text, { color: active ? COLORS.accent : 'gray', dimColor: !active }, `${i + 1} `),
106
+ h(Text, { color: active ? COLORS.accent : 'white', bold: active }, ('/' + c.name).padEnd(14)),
107
+ h(Text, { color: 'gray' }, ' ' + c.desc));
108
+ // hover → highlight this row; click → select + run it
109
+ return h(Clickable, { key: c.name, frameRef,
110
+ onHover: () => setIdx(i),
111
+ onClick: () => { setQuery(''); setPaletteOpen(false); setIdx(0); run(c.name); } }, row);
112
+ })) : null,
113
+ );
114
+ }
@@ -0,0 +1,84 @@
1
+ // Reusable ink components: a cursor/arrow-key SelectList, a Confirm toggle, and a
2
+ // TextField — the building blocks for the Claude-Code-style interactive shell.
3
+ import React, { useState } from 'react';
4
+ import { Box, Text, useInput } from 'ink';
5
+ import { COLORS } from '../theme.js';
6
+ import { Clickable, isMouse, parseMouse } from './mouse.js';
7
+
8
+ const h = React.createElement;
9
+
10
+ // SelectList — pick a row by NUMBER (1-9, the reliable fast path), or ↑/↓ (or j/k) +
11
+ // Enter, or Esc to cancel. Click is best-effort (works when nothing has scrolled).
12
+ export function SelectList({ items, onSelect, onCancel, label, frameRef }) {
13
+ const [idx, setIdx] = useState(0);
14
+ useInput((input, key) => {
15
+ if (isMouse(input)) { parseMouse(input); return; } // best-effort click
16
+ if (/^[1-9]$/.test(input)) { const n = +input - 1; if (n < items.length) onSelect(items[n], n); return; }
17
+ if (key.upArrow || input === 'k') setIdx((i) => (i - 1 + items.length) % items.length);
18
+ else if (key.downArrow || input === 'j') setIdx((i) => (i + 1) % items.length);
19
+ else if (key.return) onSelect(items[idx], idx);
20
+ else if (key.escape && onCancel) onCancel();
21
+ });
22
+ return h(Box, { flexDirection: 'column' },
23
+ label ? h(Text, { color: 'gray' }, label) : null,
24
+ items.map((it, i) => {
25
+ const active = i === idx;
26
+ const title = typeof it === 'string' ? it : it.label;
27
+ const desc = typeof it === 'string' ? '' : (it.description || '');
28
+ const num = i < 9 ? `${i + 1}` : ' ';
29
+ const row = h(Box, null,
30
+ h(Text, { color: active ? COLORS.accent : 'gray' }, active ? ' ▸ ' : ' '),
31
+ h(Text, { color: active ? COLORS.accent : 'gray', dimColor: !active }, num + ' '),
32
+ h(Text, { color: active ? COLORS.accent : 'white', bold: active }, title.padEnd(14)),
33
+ desc ? h(Text, { color: 'gray' }, ' ' + desc) : null);
34
+ return h(Clickable, { key: i, frameRef,
35
+ onHover: () => setIdx((cur) => (cur === i ? cur : i)),
36
+ onClick: () => onSelect(it, i) }, row);
37
+ }),
38
+ );
39
+ }
40
+
41
+ // Confirm — Yes/No rendered as a two-row cursor menu so it's hover/click/number-
42
+ // selectable like every other prompt (also y/n shortcut keys + ←/→/Enter).
43
+ export function Confirm({ message, defaultValue = true, onAnswer, frameRef }) {
44
+ const [idx, setIdx] = useState(defaultValue ? 0 : 1); // 0=Yes, 1=No
45
+ useInput((input, key) => {
46
+ if (isMouse(input)) { parseMouse(input); return; }
47
+ if (input === 'y') { onAnswer(true); return; }
48
+ if (input === 'n') { onAnswer(false); return; }
49
+ if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) setIdx((i) => (i === 0 ? 1 : 0));
50
+ else if (/^[12]$/.test(input)) onAnswer(input === '1');
51
+ else if (key.return) onAnswer(idx === 0);
52
+ });
53
+ const opts = [{ label: 'Yes', v: true }, { label: 'No', v: false }];
54
+ return h(Box, { flexDirection: 'column' },
55
+ h(Text, { color: COLORS.accent }, '? ' + message),
56
+ opts.map((o, i) => {
57
+ const active = i === idx;
58
+ const row = h(Box, null,
59
+ h(Text, { color: active ? COLORS.accent : 'gray' }, active ? ' ▸ ' : ' '),
60
+ h(Text, { color: active ? COLORS.accent : 'gray', dimColor: !active }, `${i + 1} `),
61
+ h(Text, { color: active ? COLORS.accent : 'white', bold: active }, o.label));
62
+ return h(Clickable, { key: i, frameRef, onHover: () => setIdx(i), onClick: () => onAnswer(o.v) }, row);
63
+ }),
64
+ );
65
+ }
66
+
67
+ // TextField — a single-line input with a prompt. Enter submits, Esc cancels.
68
+ export function TextField({ message, mask = false, defaultValue = '', onSubmit, onCancel }) {
69
+ const [value, setValue] = useState(defaultValue);
70
+ useInput((input, key) => {
71
+ if (isMouse(input)) { parseMouse(input); return; } // swallow mouse, don't type it
72
+ if (key.return) onSubmit(value);
73
+ else if (key.escape && onCancel) onCancel();
74
+ else if (key.backspace || key.delete) setValue((v) => v.slice(0, -1));
75
+ else if (input && !key.ctrl && !key.meta) setValue((v) => v + input);
76
+ });
77
+ const shown = mask ? '•'.repeat(value.length) : value;
78
+ return h(Box, null,
79
+ h(Text, { color: COLORS.accent }, '? '),
80
+ h(Text, { bold: true }, message + ': '),
81
+ h(Text, null, shown),
82
+ h(Text, { color: COLORS.accent }, '▏'),
83
+ );
84
+ }
@@ -0,0 +1,95 @@
1
+ // Mouse support for ink.
2
+ //
3
+ // THE KEY INSIGHT: ink owns stdin via its own `useInput`. A separate stdin listener
4
+ // competes with it and loses (ink strips the ESC and the leftover `[<0;9;21M` leaks in
5
+ // as typed text — the bug we saw). So instead we:
6
+ // 1. enable SGR mouse mode (and re-enable it after every render, since ink's frame
7
+ // redraw can reset terminal modes),
8
+ // 2. parse clicks INSIDE useInput via parseMouse(), where the sequence arrives as the
9
+ // `input` string (ESC already stripped by ink → it looks like `[<0;9;21M`).
10
+ // A registry maps absolute terminal-Y → onClick. Clickable computes its own Y from the
11
+ // ink/yoga layout (frame is bottom-anchored).
12
+ import React, { useEffect, useRef, useLayoutEffect } from 'react';
13
+ import { Box, useStdin } from 'ink';
14
+
15
+ const h = React.createElement;
16
+ const registry = new Map(); // absolute Y (1-based) → onClick fn
17
+ const hoverRegistry = new Map(); // absolute Y (1-based) → onHover fn
18
+
19
+ // Matches an SGR mouse event with OR without the leading ESC (ink strips it):
20
+ // (ESC)[<button;x;y(M|m)
21
+ const MOUSE = /\x1b?\[<(\d+);(\d+);(\d+)([Mm])/g;
22
+
23
+ // Is this input string (from useInput) a mouse sequence? Used to swallow it as non-text.
24
+ export function isMouse(input) {
25
+ return !!input && /\x1b?\[<\d+;\d+;\d+[Mm]/.test(input);
26
+ }
27
+
28
+ // Parse mouse events from a useInput `input` string. Fires onClick on a left-button
29
+ // release, and onHover for movement (the 32 bit = motion) so the row under the cursor
30
+ // highlights. Returns true if any mouse event was handled.
31
+ export function parseMouse(input) {
32
+ if (!input) return false;
33
+ let m, handled = false;
34
+ MOUSE.lastIndex = 0;
35
+ while ((m = MOUSE.exec(input)) !== null) {
36
+ handled = true;
37
+ const btn = parseInt(m[1], 10);
38
+ const y = parseInt(m[3], 10);
39
+ if (btn & 32) { const hf = hoverRegistry.get(y); if (hf) hf(); } // motion → hover
40
+ else if (btn === 0 && m[4] === 'm') { const fn = registry.get(y); if (fn) fn(); } // click
41
+ }
42
+ return handled;
43
+ }
44
+
45
+ // Enable SGR mouse mode and keep it enabled (ink redraws can reset it).
46
+ export function useMouse() {
47
+ const { setRawMode, isRawModeSupported } = useStdin();
48
+ useEffect(() => {
49
+ if (!isRawModeSupported) return undefined;
50
+ try { setRawMode(true); } catch { /* */ }
51
+ // 1000=button events, 1003=any-motion (for hover), 1006=SGR extended coords
52
+ const enable = () => process.stdout.write('\x1b[?1000h\x1b[?1003h\x1b[?1006h');
53
+ enable();
54
+ const id = setInterval(enable, 1000); // re-arm in case a redraw cleared it
55
+ return () => { clearInterval(id); process.stdout.write('\x1b[?1000l\x1b[?1003l\x1b[?1006l'); };
56
+ }, []);
57
+ }
58
+
59
+ function offsetWithinFrame(el, frameEl) {
60
+ let acc = 0;
61
+ let cur = el;
62
+ while (cur && cur !== frameEl && cur.yogaNode) {
63
+ acc += cur.yogaNode.getComputedTop();
64
+ cur = cur.parentNode;
65
+ }
66
+ return acc;
67
+ }
68
+
69
+ // Clickable — registers its absolute terminal Y so a click fires onClick and (optionally)
70
+ // a hover over its line fires onHover (used to highlight the row under the cursor).
71
+ export function Clickable({ frameRef, onClick, onHover, children }) {
72
+ const ref = useRef();
73
+ const claimed = useRef(null);
74
+ useLayoutEffect(() => {
75
+ const el = ref.current, frameEl = frameRef && frameRef.current;
76
+ if (!el || !frameEl || !frameEl.yogaNode || !el.yogaNode) return undefined;
77
+ try {
78
+ const frameH = frameEl.yogaNode.getComputedHeight();
79
+ const off = offsetWithinFrame(el, frameEl);
80
+ const termRows = process.stdout.rows || 40;
81
+ const y = termRows - (frameH - off);
82
+ if (claimed.current != null && claimed.current !== y) {
83
+ registry.delete(claimed.current); hoverRegistry.delete(claimed.current);
84
+ }
85
+ claimed.current = y;
86
+ registry.set(y, onClick);
87
+ if (onHover) hoverRegistry.set(y, onHover);
88
+ } catch { /* */ }
89
+ return undefined;
90
+ });
91
+ useEffect(() => () => {
92
+ if (claimed.current != null) { registry.delete(claimed.current); hoverRegistry.delete(claimed.current); }
93
+ }, []);
94
+ return h(Box, { ref }, children);
95
+ }
package/src/util.js ADDED
@@ -0,0 +1,54 @@
1
+ // Small shared helpers: colored console output + session export-to-file.
2
+ import { homedir } from 'node:os';
3
+ import { join, dirname } from 'node:path';
4
+ import { mkdirSync, writeFileSync, existsSync, statSync } from 'node:fs';
5
+ import { COLORS } from './theme.js';
6
+
7
+ // minimal ANSI for the non-TUI commands (login, lists, etc.)
8
+ const c = (hex, s) => {
9
+ // map our hex accents to the nearest 256-color for plain stdout
10
+ const codes = { [COLORS.accent]: 38, [COLORS.success]: 78, [COLORS.warn]: 221, [COLORS.danger]: 210, gray: 245 };
11
+ const code = codes[hex] || 245;
12
+ return `\x1b[38;5;${code}m${s}\x1b[0m`;
13
+ };
14
+
15
+ export const paint = {
16
+ accent: (s) => c(COLORS.accent, s),
17
+ ok: (s) => c(COLORS.success, s),
18
+ warn: (s) => c(COLORS.warn, s),
19
+ err: (s) => c(COLORS.danger, s),
20
+ dim: (s) => c('gray', s),
21
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
22
+ };
23
+
24
+ export const log = {
25
+ info: (m) => console.log(` ${paint.accent('◉')} ${m}`),
26
+ ok: (m) => console.log(` ${paint.ok('✓')} ${m}`),
27
+ warn: (m) => console.log(` ${paint.warn('!')} ${m}`),
28
+ err: (m) => console.log(` ${paint.err('✗')} ${m}`),
29
+ };
30
+
31
+ // Resolve + write a campaign's sessions to a file. Returns {path, count} or null
32
+ // (null when there are no extracted sessions). Mirrors the Python CLI's behavior:
33
+ // out=<file> → that file; out=<dir>/ → default name inside; no out → ~/Downloads/scripterx/
34
+ export async function saveSessions(api, cid, { campaignName, out } = {}) {
35
+ let name = (campaignName || cid.slice(0, 8)).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || cid.slice(0, 8);
36
+ const accounts = await api.exportCampaign(cid);
37
+ const sessions = (accounts || []).map((a) => a.session).filter(Boolean);
38
+ if (!sessions.length) return null;
39
+
40
+ const defaultName = `${name}-${cid.slice(0, 8)}.json`;
41
+ let dest;
42
+ if (out) {
43
+ const expanded = out.startsWith('~') ? join(homedir(), out.slice(1)) : out;
44
+ const isDir = (existsSync(expanded) && statSync(expanded).isDirectory()) || /[/\\]$/.test(out);
45
+ dest = isDir ? join(expanded, defaultName) : expanded;
46
+ } else {
47
+ const downloads = join(homedir(), 'Downloads');
48
+ const base = existsSync(downloads) ? downloads : homedir();
49
+ dest = join(base, 'scripterx', defaultName);
50
+ }
51
+ mkdirSync(dirname(dest), { recursive: true });
52
+ writeFileSync(dest, JSON.stringify(sessions, null, 2));
53
+ return { path: dest, count: sessions.length };
54
+ }