becki 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.
@@ -0,0 +1,406 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * watch-folders.tsx — manage registered project paths in cache.db.
4
+ *
5
+ * becki-mcp owns the cache.db at ${BECKI_HOME}/cache.db (better-sqlite3). We
6
+ * open it read-write here too. SQLite is safe with multiple processes via WAL
7
+ * (which becki-mcp turns on at create time). If the daemon happens to be
8
+ * scanning when we write, the write just queues briefly.
9
+ *
10
+ * Add flow: text input → expand ~ → validate dir exists → optional git check
11
+ * (warn but allow) → insert row. No file picker — typing the path is faster
12
+ * for power users, who are the only ones running a CLI admin TUI anyway.
13
+ */
14
+ import { useEffect, useState } from 'react';
15
+ import { Box, Text, useInput } from 'ink';
16
+ import { existsSync, statSync, readdirSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { homedir } from 'node:os';
19
+ import { randomUUID } from 'node:crypto';
20
+ import { PATHS } from '../client.js';
21
+ import { ScreenHeader, BackFooter, ErrorLine, OkLine, Loading } from './_shared.js';
22
+ import { theme } from '../theme.js';
23
+ function expandHome(p) {
24
+ if (p === '~')
25
+ return homedir();
26
+ if (p.startsWith('~/'))
27
+ return join(homedir(), p.slice(2));
28
+ return p;
29
+ }
30
+ function projectNameFromPath(p) {
31
+ const parts = p.split('/').filter(Boolean);
32
+ return parts[parts.length - 1] ?? 'unnamed';
33
+ }
34
+ /** Common roots Becki users keep their git repos under. Same set
35
+ * becki-mcp init scans. Missing dirs are silently skipped. */
36
+ const DEFAULT_SCAN_ROOTS = [
37
+ join(homedir(), 'Documents'),
38
+ join(homedir(), 'Repos'),
39
+ join(homedir(), 'Code'),
40
+ join(homedir(), 'src'),
41
+ join(homedir(), 'Projects'),
42
+ join(homedir(), 'Developer'),
43
+ join(homedir(), 'work'),
44
+ join(homedir(), 'dev'),
45
+ ];
46
+ const SCAN_MAX_DEPTH = 3;
47
+ function isGitRepo(path) {
48
+ try {
49
+ const s = statSync(join(path, '.git'));
50
+ return s.isDirectory() || s.isFile(); // worktrees use a .git file
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ /** Walk roots looking for git repos. Depth-limited; stops descending
57
+ * once a repo is found (no nested repo enumeration). */
58
+ function findRepos(roots, skip) {
59
+ const seen = new Set();
60
+ function walk(dir, depth) {
61
+ if (depth > SCAN_MAX_DEPTH)
62
+ return;
63
+ if (seen.has(dir))
64
+ return;
65
+ if (skip.has(dir))
66
+ return;
67
+ if (isGitRepo(dir)) {
68
+ seen.add(dir);
69
+ return;
70
+ }
71
+ // `encoding: 'utf8'` forces the string-typed Dirent overload (without it
72
+ // TS picks the Buffer-typed default in newer @types/node).
73
+ let entries;
74
+ try {
75
+ entries = readdirSync(dir, { withFileTypes: true, encoding: 'utf8' });
76
+ }
77
+ catch {
78
+ return;
79
+ }
80
+ for (const ent of entries) {
81
+ if (!ent.isDirectory())
82
+ continue;
83
+ if (ent.name.startsWith('.'))
84
+ continue; // skip dotdirs
85
+ walk(join(dir, ent.name), depth + 1);
86
+ }
87
+ }
88
+ for (const root of roots) {
89
+ if (existsSync(root))
90
+ walk(root, 0);
91
+ }
92
+ return [...seen].sort();
93
+ }
94
+ function openCache() {
95
+ try {
96
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
97
+ const Database = require('better-sqlite3');
98
+ const dbPath = join(PATHS.beckiHome, 'cache.db');
99
+ if (!existsSync(dbPath))
100
+ return null;
101
+ return new Database(dbPath);
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
107
+ function loadProjects() {
108
+ const db = openCache();
109
+ if (!db)
110
+ return [];
111
+ try {
112
+ const rows = db.prepare('SELECT id, name, path, active FROM projects ORDER BY name').all();
113
+ db.close();
114
+ return rows;
115
+ }
116
+ catch {
117
+ try {
118
+ db.close();
119
+ }
120
+ catch { /* ignore */ }
121
+ return [];
122
+ }
123
+ }
124
+ export function WatchFoldersScreen({ onBack }) {
125
+ const [projects, setProjects] = useState(null);
126
+ const [cursor, setCursor] = useState(0);
127
+ const [mode, setMode] = useState('list');
128
+ const [pathInput, setPathInput] = useState('');
129
+ const [msg, setMsg] = useState(null);
130
+ const [removeTarget, setRemoveTarget] = useState(null);
131
+ // Scan mode state
132
+ const [scanResults, setScanResults] = useState(null);
133
+ const [scanCursor, setScanCursor] = useState(0);
134
+ const [selected, setSelected] = useState(new Set());
135
+ const [filter, setFilter] = useState('');
136
+ // Initial load
137
+ useEffect(() => {
138
+ setProjects(loadProjects());
139
+ }, []);
140
+ // When entering scan mode, kick off the (synchronous-ish but fast) walk
141
+ // and reset selection state.
142
+ useEffect(() => {
143
+ if (mode !== 'scan')
144
+ return;
145
+ setScanResults(null);
146
+ setSelected(new Set());
147
+ setFilter('');
148
+ setScanCursor(0);
149
+ // setTimeout(0) so the "scanning…" frame renders before we block.
150
+ const t = setTimeout(() => {
151
+ const registered = new Set((projects ?? []).map((p) => p.path));
152
+ const found = findRepos(DEFAULT_SCAN_ROOTS, registered);
153
+ setScanResults(found);
154
+ }, 0);
155
+ return () => clearTimeout(t);
156
+ }, [mode, projects]);
157
+ const filteredScanResults = (scanResults ?? []).filter((p) => filter ? p.toLowerCase().includes(filter.toLowerCase()) : true);
158
+ const refresh = () => {
159
+ setProjects(loadProjects());
160
+ setCursor((c) => Math.max(0, Math.min(c, (projects?.length ?? 1) - 1)));
161
+ };
162
+ const addProject = (rawPath) => {
163
+ const abs = expandHome(rawPath.trim());
164
+ if (!abs) {
165
+ setMsg({ kind: 'err', text: 'empty path' });
166
+ return;
167
+ }
168
+ if (!existsSync(abs)) {
169
+ setMsg({ kind: 'err', text: `not found: ${abs}` });
170
+ return;
171
+ }
172
+ if (!statSync(abs).isDirectory()) {
173
+ setMsg({ kind: 'err', text: `not a directory: ${abs}` });
174
+ return;
175
+ }
176
+ const db = openCache();
177
+ if (!db) {
178
+ setMsg({ kind: 'err', text: `cache.db not found at ${PATHS.beckiHome} — run becki-mcp init first` });
179
+ return;
180
+ }
181
+ try {
182
+ const name = projectNameFromPath(abs);
183
+ const id = randomUUID();
184
+ db.prepare(`INSERT INTO projects(id, name, path, active, created_at) VALUES (?, ?, ?, 1, ?)
185
+ ON CONFLICT(path) DO UPDATE SET active = 1, name = excluded.name`).run(id, name, abs, Date.now());
186
+ db.close();
187
+ setMsg({ kind: 'ok', text: `added: ${name} (${abs})` });
188
+ refresh();
189
+ }
190
+ catch (err) {
191
+ try {
192
+ db.close();
193
+ }
194
+ catch { /* ignore */ }
195
+ setMsg({ kind: 'err', text: err.message });
196
+ }
197
+ };
198
+ const togglePause = (p) => {
199
+ const db = openCache();
200
+ if (!db)
201
+ return;
202
+ try {
203
+ db.prepare('UPDATE projects SET active = ? WHERE id = ?').run(p.active ? 0 : 1, p.id);
204
+ db.close();
205
+ refresh();
206
+ }
207
+ catch (err) {
208
+ try {
209
+ db.close();
210
+ }
211
+ catch { /* ignore */ }
212
+ setMsg({ kind: 'err', text: err.message });
213
+ }
214
+ };
215
+ const removeProject = (p) => {
216
+ const db = openCache();
217
+ if (!db)
218
+ return;
219
+ try {
220
+ db.prepare('DELETE FROM projects WHERE id = ?').run(p.id);
221
+ db.close();
222
+ setMsg({ kind: 'ok', text: `removed: ${p.name}` });
223
+ refresh();
224
+ }
225
+ catch (err) {
226
+ try {
227
+ db.close();
228
+ }
229
+ catch { /* ignore */ }
230
+ setMsg({ kind: 'err', text: err.message });
231
+ }
232
+ };
233
+ /** Insert all selected paths from scan mode. Reuses the same upsert
234
+ * semantics as addProject — ON CONFLICT path → reactivate. */
235
+ const addBatch = (paths) => {
236
+ if (paths.length === 0)
237
+ return;
238
+ const db = openCache();
239
+ if (!db) {
240
+ setMsg({ kind: 'err', text: `cache.db not found at ${PATHS.beckiHome} — run becki-mcp init first` });
241
+ return;
242
+ }
243
+ try {
244
+ const stmt = db.prepare(`INSERT INTO projects(id, name, path, active, created_at) VALUES (?, ?, ?, 1, ?)
245
+ ON CONFLICT(path) DO UPDATE SET active = 1, name = excluded.name`);
246
+ const insertMany = db.transaction((items) => {
247
+ for (const p of items) {
248
+ stmt.run(randomUUID(), projectNameFromPath(p), p, Date.now());
249
+ }
250
+ });
251
+ insertMany(paths);
252
+ db.close();
253
+ setMsg({ kind: 'ok', text: `added ${paths.length} folder${paths.length === 1 ? '' : 's'}` });
254
+ refresh();
255
+ }
256
+ catch (err) {
257
+ try {
258
+ db.close();
259
+ }
260
+ catch { /* ignore */ }
261
+ setMsg({ kind: 'err', text: err.message });
262
+ }
263
+ };
264
+ useInput((input, key) => {
265
+ if (mode === 'add') {
266
+ if (key.escape) {
267
+ setMode('list');
268
+ setPathInput('');
269
+ return;
270
+ }
271
+ if (key.return) {
272
+ addProject(pathInput);
273
+ setMode('list');
274
+ setPathInput('');
275
+ return;
276
+ }
277
+ if (key.backspace || key.delete) {
278
+ setPathInput((s) => s.slice(0, -1));
279
+ return;
280
+ }
281
+ if (input && !key.ctrl && !key.meta)
282
+ setPathInput((s) => s + input);
283
+ return;
284
+ }
285
+ if (mode === 'scan') {
286
+ if (key.escape) {
287
+ setMode('list');
288
+ return;
289
+ }
290
+ if (input === 'c') {
291
+ setMode('add');
292
+ setPathInput('');
293
+ return;
294
+ }
295
+ // not loaded yet → ignore everything else
296
+ if (scanResults === null)
297
+ return;
298
+ const list = filteredScanResults;
299
+ if (key.return) {
300
+ addBatch([...selected]);
301
+ setMode('list');
302
+ return;
303
+ }
304
+ if (input === ' ') {
305
+ const target = list[scanCursor];
306
+ if (target) {
307
+ const next = new Set(selected);
308
+ if (next.has(target))
309
+ next.delete(target);
310
+ else
311
+ next.add(target);
312
+ setSelected(next);
313
+ }
314
+ return;
315
+ }
316
+ // Shift-A / Shift-N for batch toggles (uppercase since space is "toggle one")
317
+ if (input === 'A') {
318
+ setSelected(new Set(list));
319
+ return;
320
+ }
321
+ if (input === 'N') {
322
+ setSelected(new Set());
323
+ return;
324
+ }
325
+ if (key.upArrow || input === 'k') {
326
+ setScanCursor((c) => Math.max(0, c - 1));
327
+ return;
328
+ }
329
+ if (key.downArrow || input === 'j') {
330
+ setScanCursor((c) => Math.min(list.length - 1, c + 1));
331
+ return;
332
+ }
333
+ // Fuzzy-filter: typing letters/digits/dashes/dots updates the filter.
334
+ if (key.backspace || key.delete) {
335
+ setFilter((f) => f.slice(0, -1));
336
+ setScanCursor(0);
337
+ return;
338
+ }
339
+ if (input && !key.ctrl && !key.meta && input.length === 1 && /[a-zA-Z0-9._\-/]/.test(input)) {
340
+ setFilter((f) => f + input);
341
+ setScanCursor(0);
342
+ return;
343
+ }
344
+ return;
345
+ }
346
+ if (mode === 'confirm-remove') {
347
+ if (input === 'y' && removeTarget) {
348
+ removeProject(removeTarget);
349
+ setMode('list');
350
+ setRemoveTarget(null);
351
+ return;
352
+ }
353
+ if (input === 'n' || key.escape) {
354
+ setMode('list');
355
+ setRemoveTarget(null);
356
+ return;
357
+ }
358
+ return;
359
+ }
360
+ // list mode
361
+ if (key.escape || key.leftArrow) {
362
+ onBack();
363
+ return;
364
+ }
365
+ if (key.upArrow || input === 'k')
366
+ setCursor((c) => Math.max(0, c - 1));
367
+ else if (key.downArrow || input === 'j')
368
+ setCursor((c) => Math.min((projects?.length ?? 1) - 1, c + 1));
369
+ else if (input === 'a')
370
+ setMode('scan');
371
+ else if (input === 't') {
372
+ setMode('add');
373
+ setPathInput('');
374
+ }
375
+ else if (input === 'p' && projects && projects[cursor])
376
+ togglePause(projects[cursor]);
377
+ else if (input === 'd' && projects && projects[cursor]) {
378
+ setRemoveTarget(projects[cursor]);
379
+ setMode('confirm-remove');
380
+ }
381
+ else if (input === 'r')
382
+ refresh();
383
+ });
384
+ if (projects === null) {
385
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(ScreenHeader, { title: "Watch folders" }), _jsx(Loading, {}), _jsx(BackFooter, {})] }));
386
+ }
387
+ if (mode === 'scan') {
388
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(ScreenHeader, { title: "Pick folders to watch", subtitle: scanResults === null
389
+ ? `scanning ${DEFAULT_SCAN_ROOTS.filter((r) => existsSync(r)).length} root(s)…`
390
+ : `${filteredScanResults.length} of ${scanResults.length} unregistered git repo${scanResults.length === 1 ? '' : 's'} · ${selected.size} selected` }), scanResults === null && _jsx(Loading, {}), scanResults !== null && scanResults.length === 0 && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.gray, children: "(no new git repos found under default roots)" }), _jsxs(Text, { color: theme.gray, dimColor: true, children: ["Roots checked: ", DEFAULT_SCAN_ROOTS.join(', ')] }), _jsx(Text, { color: theme.gray, dimColor: true, children: "Press 'c' to type a custom path instead." })] })), scanResults !== null && scanResults.length > 0 && (_jsxs(Box, { flexDirection: "column", children: [filter && (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: theme.gray, children: "filter: " }), _jsx(Text, { color: theme.gold, children: filter }), _jsx(Text, { color: theme.gray, children: "_ " }), _jsx(Text, { color: theme.gray, dimColor: true, children: "(backspace to clear)" })] })), filteredScanResults.length === 0 && (_jsx(Text, { color: "yellow", children: "no matches \u2014 backspace to clear filter" })), filteredScanResults.slice(0, 20).map((p, i) => {
391
+ const sel = i === scanCursor;
392
+ const checked = selected.has(p);
393
+ return (_jsxs(Box, { children: [_jsx(Box, { width: 3, children: _jsx(Text, { color: sel ? theme.gold : theme.gray, children: sel ? '▸ ' : ' ' }) }), _jsx(Box, { width: 4, children: _jsx(Text, { color: checked ? 'green' : theme.gray, children: checked ? '[x]' : '[ ]' }) }), _jsx(Box, { width: 22, children: _jsx(Text, { color: sel ? theme.gold : theme.text, bold: sel, children: projectNameFromPath(p) }) }), _jsx(Text, { color: theme.gray, children: p.replace(homedir(), '~') })] }, p));
394
+ }), filteredScanResults.length > 20 && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.gray, dimColor: true, children: ["\u2026 ", filteredScanResults.length - 20, " more (type to filter)"] }) }))] })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.gray, dimColor: true, children: ["\u2191/\u2193 pick \u00B7 space toggle \u00B7 A all \u00B7 N none \u00B7 type to filter \u00B7 enter add (", selected.size, ") \u00B7 c custom path \u00B7 esc cancel"] }) })] }));
395
+ }
396
+ if (mode === 'add') {
397
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(ScreenHeader, { title: "Add folder (custom path)", subtitle: "absolute path or ~/relative-to-home" }), _jsxs(Box, { children: [_jsx(Text, { color: theme.gold, children: "\u25B8 " }), _jsx(Text, { color: theme.text, children: pathInput }), _jsx(Text, { color: theme.gold, children: "_" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.gray, dimColor: true, children: "enter add \u00B7 esc cancel" }) })] }));
398
+ }
399
+ if (mode === 'confirm-remove' && removeTarget) {
400
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(ScreenHeader, { title: "Remove folder" }), _jsxs(Text, { children: ["Remove ", removeTarget.name, " (", removeTarget.path, ")?"] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "yellow", children: "y confirm \u00B7 n cancel" }) })] }));
401
+ }
402
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(ScreenHeader, { title: "Watch folders", subtitle: `${projects.length} registered · ${PATHS.beckiHome}/cache.db` }), projects.length === 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.gray, children: "(none registered yet)" }), _jsx(Text, { color: theme.gray, children: "Press 'a' to add your first folder." }), _jsx(Text, { color: theme.gray, dimColor: true, children: "If cache.db is missing, run: becki-mcp init" })] })) : (projects.map((p, i) => {
403
+ const sel = i === cursor;
404
+ return (_jsxs(Box, { children: [_jsx(Box, { width: 3, children: _jsx(Text, { color: sel ? theme.gold : theme.gray, children: sel ? '▸ ' : ' ' }) }), _jsx(Box, { width: 3, children: _jsx(Text, { color: p.active ? 'green' : 'yellow', children: p.active ? '●' : '◌' }) }), _jsx(Box, { width: 20, children: _jsx(Text, { color: sel ? theme.gold : theme.text, bold: sel, children: p.name }) }), _jsx(Text, { color: theme.gray, children: p.path })] }, p.id));
405
+ })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: msg && (msg.kind === 'ok' ? _jsx(OkLine, { message: msg.text }) : _jsx(ErrorLine, { message: msg.text })) }), _jsx(BackFooter, { extra: "a scan & pick \u00B7 t type path \u00B7 p pause/resume \u00B7 d delete \u00B7 r refresh" })] }));
406
+ }
package/dist/setup.js ADDED
@@ -0,0 +1,71 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * setup.tsx — first-run CLI picker.
4
+ *
5
+ * Shown once, only when ~/.becki/config.json did not exist yet. becki launches
6
+ * an AI CLI; this screen is where the user says which one. Known CLIs are
7
+ * listed and annotated with whether they were actually found on PATH; the
8
+ * user picks by arrow/number, or types any other command. The chosen command
9
+ * is persisted as `cli` in the config by the caller.
10
+ *
11
+ * Same input model as the focus prompt: arrow/number selects a listed CLI,
12
+ * typing free text overrides with a custom command, Enter commits.
13
+ */
14
+ import { useState } from 'react';
15
+ import { Box, Text, useApp, useInput } from 'ink';
16
+ import { theme } from './theme.js';
17
+ /** The Becki mark, tagged "first run". */
18
+ function Mark() {
19
+ return (_jsxs(Text, { color: theme.gold, bold: true, children: ['▁▃▅▇▅▃▁', ' ', '◆', ' ', _jsx(Text, { children: "BECKI" }), ' ', _jsx(Text, { color: theme.gray, bold: false, children: "first run" })] }));
20
+ }
21
+ export function SetupApp({ candidates, defaultIndex, initialCustom, onComplete, }) {
22
+ const { exit } = useApp();
23
+ const [highlight, setHighlight] = useState(defaultIndex);
24
+ const [custom, setCustom] = useState(initialCustom ?? '');
25
+ const [done, setDone] = useState(null);
26
+ useInput((input, key) => {
27
+ if (done)
28
+ return; // committed — ignore further input until unmount
29
+ // --- Arrow navigation -------------------------------------------------
30
+ if (key.upArrow) {
31
+ setCustom('');
32
+ setHighlight((h) => (h <= 0 ? candidates.length - 1 : h - 1));
33
+ return;
34
+ }
35
+ if (key.downArrow) {
36
+ setCustom('');
37
+ setHighlight((h) => (h >= candidates.length - 1 ? 0 : h + 1));
38
+ return;
39
+ }
40
+ // --- Commit -----------------------------------------------------------
41
+ if (key.return) {
42
+ const chosen = custom.trim() !== '' ? custom.trim() : candidates[highlight].name;
43
+ setDone(chosen);
44
+ onComplete(chosen);
45
+ setTimeout(() => exit(), 450);
46
+ return;
47
+ }
48
+ // --- Editing ----------------------------------------------------------
49
+ if (key.backspace || key.delete) {
50
+ setCustom((c) => c.slice(0, -1));
51
+ return;
52
+ }
53
+ // A digit as the first keystroke jumps to that listed CLI.
54
+ if (custom === '' && /^[1-9]$/.test(input)) {
55
+ const idx = Number(input) - 1;
56
+ if (idx < candidates.length) {
57
+ setHighlight(idx);
58
+ return;
59
+ }
60
+ }
61
+ // Anything else is a custom command typed by hand.
62
+ if (input && !key.ctrl && !key.meta && !key.escape && !key.tab) {
63
+ setCustom((c) => c + input);
64
+ }
65
+ });
66
+ const usingCustom = custom.trim() !== '';
67
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsx(Mark, {}), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.text, children: "Becki launches your AI CLI. Which one should it open?" }) }), _jsx(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, children: candidates.map((c, i) => {
68
+ const hl = !usingCustom && i === highlight;
69
+ return (_jsxs(Box, { children: [_jsx(Text, { color: hl ? theme.gold : theme.gray, children: String(i + 1) }), _jsx(Text, { color: hl ? theme.gold : theme.gray, children: hl ? ' ▸ ' : ' · ' }), _jsx(Box, { width: 12, children: _jsx(Text, { color: hl ? theme.gold : theme.text, bold: hl, children: c.name }) }), _jsx(Text, { color: c.detected ? theme.gold : theme.gray, children: c.detected ? 'detected' : 'not found' })] }, c.name));
70
+ }) }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: theme.gold, children: ["Pick your CLI", ' ', _jsx(Text, { color: theme.gray, children: "(\u2191\u2193 or number \u00B7 or type any command \u00B7 enter)" })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.gold, children: '> ' }), usingCustom ? (_jsxs(Text, { color: theme.text, children: [custom, _jsx(Text, { inverse: true, children: " " })] })) : (_jsxs(Text, { color: theme.gold, children: [highlight + 1, " \u00B7", ' ', _jsx(Text, { color: theme.text, children: candidates[highlight].name })] }))] })] }), done ? (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: theme.gray, children: "becki will launch " }), _jsx(Text, { color: theme.gold, children: done }), _jsxs(Text, { color: theme.gray, children: [' ', "\u2014 change it anytime in ~/.becki/config.json"] })] })) : null] }));
71
+ }
package/dist/splash.js ADDED
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { theme } from './theme.js';
4
+ import { relativeAge } from './vault.js';
5
+ /** The Becki mark — a small waveform-to-nodes glyph row. */
6
+ function Mark() {
7
+ return (_jsxs(Text, { color: theme.gold, bold: true, children: ['◆ BECKI', ' ', _jsx(Text, { color: theme.gray, bold: false, children: "your memory layer" })] }));
8
+ }
9
+ /** One "Label Value Detail" stat row, column-aligned. */
10
+ function StatRow({ label, value, detail, }) {
11
+ return (_jsxs(Box, { children: [_jsx(Box, { width: 20, children: _jsx(Text, { color: theme.gray, children: label }) }), _jsx(Box, { width: 20, children: _jsx(Text, { color: theme.text, children: value }) }), detail ? _jsx(Text, { color: theme.gray, children: detail }) : null] }));
12
+ }
13
+ export function Splash({ vault, config, warnings, highlightIndex = null, }) {
14
+ const vaultLocation = `local · ${config.vault_path}`;
15
+ const entryValue = vault.ok ? `${vault.entryCount} entries` : 'unavailable';
16
+ const lastValue = vault.ok && vault.lastEntryAt ? relativeAge(vault.lastEntryAt) : '—';
17
+ const lastDetail = vault.ok && vault.lastEntryTitle ? `"${vault.lastEntryTitle}"` : undefined;
18
+ const threadCount = vault.ok ? String(vault.openThreadsTotal) : '—';
19
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsx(Mark, {}), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(StatRow, { label: "Vault", value: entryValue, detail: vaultLocation }), _jsx(StatRow, { label: "Last ingested", value: lastValue, detail: lastDetail }), config.show_open_threads ? (_jsx(StatRow, { label: "Open threads", value: threadCount })) : null] }), config.show_open_threads && vault.openThreads.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, children: [vault.openThreads.map((t, i) => {
20
+ const age = t.ageDays === null
21
+ ? ''
22
+ : `open ${t.ageDays === 0 ? 'today' : `${t.ageDays}d`}`;
23
+ const hl = i === highlightIndex;
24
+ const marker = hl ? ' ▸ ' : t.flagged ? ' ↑ ' : ' · ';
25
+ return (_jsxs(Box, { children: [_jsx(Text, { color: hl ? theme.gold : theme.gray, children: String(i + 1).padStart(2, ' ') }), _jsx(Text, { color: hl || t.flagged ? theme.gold : theme.gray, children: marker }), _jsx(Box, { width: 46, paddingRight: 2, children: _jsx(Text, { color: hl ? theme.gold : theme.text, bold: hl, wrap: "truncate-end", children: t.title }) }), age ? _jsx(Text, { color: theme.gray, children: age }) : null] }, i));
26
+ }), vault.openThreadsTotal > vault.openThreads.length ? (_jsxs(Text, { color: theme.gray, children: [' ', "+", vault.openThreadsTotal - vault.openThreads.length, " more"] })) : null] })) : null, config.show_open_threads && vault.ok && vault.openThreadsTotal === 0 ? (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(Text, { color: theme.gray, children: "no open threads \u2014 clear slate" }) })) : null, warnings.length > 0 ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: warnings.map((w, i) => (_jsxs(Text, { color: theme.gray, children: ["! ", w] }, i))) })) : null] }));
27
+ }
package/dist/theme.js ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * theme.ts — the becki palette, theme-aware.
3
+ *
4
+ * A light terminal needs dark text; a dark one needs light. The Becki Studio
5
+ * terminal exports BECKI_TERM_THEME=light|dark when it spawns the shell.
6
+ * Outside Becki we fall back to COLORFGBG (set by many terminals) and
7
+ * otherwise assume a dark terminal — the historical default.
8
+ */
9
+ function detectMode() {
10
+ const explicit = process.env.BECKI_TERM_THEME;
11
+ if (explicit === 'light' || explicit === 'dark')
12
+ return explicit;
13
+ // COLORFGBG is "fg;bg" (occasionally "fg;default;bg"). A trailing bg of
14
+ // 7 or 15 (or any high index) means the terminal has a light background.
15
+ const cfb = process.env.COLORFGBG;
16
+ if (cfb) {
17
+ const parts = cfb.split(';');
18
+ const bg = parseInt(parts[parts.length - 1], 10);
19
+ if (!Number.isNaN(bg))
20
+ return bg >= 7 ? 'light' : 'dark';
21
+ }
22
+ return 'dark';
23
+ }
24
+ /** Dark terminal — warm gold on near-black, muted gray secondary (original). */
25
+ const dark = {
26
+ gold: '#C9983A',
27
+ gray: '#4A4A52',
28
+ black: '#0D0D0F',
29
+ text: '#E8E8EA',
30
+ };
31
+ /** Light terminal — deeper amber + darker grays so text reads on white. */
32
+ const light = {
33
+ gold: '#9A6F1A',
34
+ gray: '#5F5F66',
35
+ black: '#FFFFFF',
36
+ text: '#1B1B1D',
37
+ };
38
+ export const theme = detectMode() === 'light' ? light : dark;