plasalid 0.8.2 → 0.9.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 +4 -0
- package/dist/ai/personas.js +29 -6
- package/dist/ai/prompt-sections.d.ts +10 -0
- package/dist/ai/prompt-sections.js +29 -0
- package/dist/ai/system-prompt.js +10 -6
- package/dist/ai/tools/clarify.js +35 -0
- package/dist/ai/tools/common.js +3 -2
- package/dist/ai/tools/index.js +6 -3
- package/dist/ai/tools/ingest.js +47 -35
- package/dist/ai/tools/mutate.d.ts +2 -0
- package/dist/ai/tools/mutate.js +81 -0
- package/dist/cli/commands/accounts.d.ts +1 -4
- package/dist/cli/commands/accounts.js +12 -101
- package/dist/cli/commands/files.d.ts +7 -0
- package/dist/cli/commands/files.js +24 -0
- package/dist/cli/commands/rules.d.ts +4 -12
- package/dist/cli/commands/rules.js +33 -67
- package/dist/cli/commands/scan.js +14 -12
- package/dist/cli/commands/status.js +5 -3
- package/dist/cli/commands/transactions.d.ts +0 -2
- package/dist/cli/commands/transactions.js +10 -63
- package/dist/cli/format.js +22 -32
- package/dist/cli/helper.d.ts +9 -1
- package/dist/cli/helper.js +17 -2
- package/dist/cli/index.js +37 -32
- package/dist/cli/ink/FilesBrowser.d.ts +7 -0
- package/dist/cli/ink/FilesBrowser.js +103 -0
- package/dist/cli/ink/ListBrowser.d.ts +16 -1
- package/dist/cli/ink/ListBrowser.js +36 -49
- package/dist/cli/ink/PromptFrame.js +1 -1
- package/dist/cli/ink/RulesBrowser.d.ts +7 -0
- package/dist/cli/ink/RulesBrowser.js +67 -0
- package/dist/cli/ink/ScanDashboard.js +90 -68
- package/dist/cli/ink/hooks/useFooterText.js +14 -22
- package/dist/cli/ink/keys.d.ts +2 -0
- package/dist/cli/ink/keys.js +19 -0
- package/dist/db/queries/files.d.ts +29 -0
- package/dist/db/queries/files.js +34 -0
- package/dist/db/queries/questions.d.ts +17 -0
- package/dist/db/queries/questions.js +47 -9
- package/dist/db/queries/rules.d.ts +31 -0
- package/dist/db/queries/rules.js +55 -0
- package/dist/db/queries/transactions.d.ts +34 -0
- package/dist/db/queries/transactions.js +86 -0
- package/dist/db/schema.js +17 -0
- package/dist/scanner/clarifier-memory.d.ts +15 -3
- package/dist/scanner/clarifier-memory.js +38 -17
- package/dist/scanner/clarifier.d.ts +2 -1
- package/dist/scanner/clarifier.js +40 -26
- package/dist/scanner/commit-pipeline.d.ts +56 -0
- package/dist/scanner/commit-pipeline.js +204 -0
- package/dist/scanner/committer.d.ts +56 -0
- package/dist/scanner/committer.js +204 -0
- package/dist/scanner/parse.js +27 -7
- package/dist/scanner/recurrence-pipeline.d.ts +28 -0
- package/dist/scanner/recurrence-pipeline.js +126 -0
- package/dist/scanner/recurrence.d.ts +28 -0
- package/dist/scanner/recurrence.js +155 -0
- package/dist/scanner/rule-keys.d.ts +13 -0
- package/dist/scanner/rule-keys.js +28 -0
- package/dist/scanner/rules.d.ts +13 -0
- package/dist/scanner/rules.js +28 -0
- package/dist/scanner/worker.js +4 -2
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -13,13 +13,7 @@ function ensureConfigured() {
|
|
|
13
13
|
process.exit(1);
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
|
-
|
|
17
|
-
.name("plasalid")
|
|
18
|
-
.description("The Harness Layer for Personal Finance")
|
|
19
|
-
.version(version)
|
|
20
|
-
.addHelpCommand(false)
|
|
21
|
-
.showHelpAfterError(`Run ${chalk.cyan("plasalid --help")} for the list of commands.`)
|
|
22
|
-
.action(async () => {
|
|
16
|
+
async function runChatOrSetup() {
|
|
23
17
|
if (!isConfigured()) {
|
|
24
18
|
console.log("Plasalid is not configured yet. Running setup...\n");
|
|
25
19
|
const { runSetup } = await import("./setup.js");
|
|
@@ -28,7 +22,18 @@ program
|
|
|
28
22
|
}
|
|
29
23
|
const { startChat } = await import("./chat.js");
|
|
30
24
|
await startChat();
|
|
31
|
-
}
|
|
25
|
+
}
|
|
26
|
+
program
|
|
27
|
+
.name("plasalid")
|
|
28
|
+
.description("The Harness Layer for Personal Finance")
|
|
29
|
+
.version(version)
|
|
30
|
+
.addHelpCommand(false)
|
|
31
|
+
.showHelpAfterError(`Run ${chalk.cyan("plasalid --help")} for the list of commands.`)
|
|
32
|
+
.action(runChatOrSetup);
|
|
33
|
+
program
|
|
34
|
+
.command("chat")
|
|
35
|
+
.description("Open the chat TUI (the default action when running `plasalid`)")
|
|
36
|
+
.action(runChatOrSetup);
|
|
32
37
|
program
|
|
33
38
|
.command("setup")
|
|
34
39
|
.description("Configure Plasalid (API key, encryption, data directory)")
|
|
@@ -46,12 +51,11 @@ program
|
|
|
46
51
|
});
|
|
47
52
|
program
|
|
48
53
|
.command("accounts")
|
|
49
|
-
.description("Browse the chart of accounts with balances
|
|
50
|
-
.
|
|
51
|
-
.action(async (opts) => {
|
|
54
|
+
.description("Browse the chart of accounts with balances")
|
|
55
|
+
.action(async () => {
|
|
52
56
|
ensureConfigured();
|
|
53
57
|
const { showAccounts } = await import("./commands/accounts.js");
|
|
54
|
-
await showAccounts(
|
|
58
|
+
await showAccounts();
|
|
55
59
|
});
|
|
56
60
|
program
|
|
57
61
|
.command("status")
|
|
@@ -63,13 +67,12 @@ program
|
|
|
63
67
|
});
|
|
64
68
|
program
|
|
65
69
|
.command("transactions")
|
|
66
|
-
.description("Browse transactions
|
|
70
|
+
.description("Browse transactions")
|
|
67
71
|
.option("-a, --account <id>", "Filter by account id")
|
|
68
72
|
.option("--from <date>", "From date YYYY-MM-DD")
|
|
69
73
|
.option("--to <date>", "To date YYYY-MM-DD")
|
|
70
74
|
.option("-q, --query <text>", "Free-text search on description / memo")
|
|
71
|
-
.option("-n, --limit <number>", "Max results (default 1000
|
|
72
|
-
.option("--no-interactive", "Force plain-print output instead of the Ink browser")
|
|
75
|
+
.option("-n, --limit <number>", "Max results (default 1000)")
|
|
73
76
|
.action(async (opts) => {
|
|
74
77
|
ensureConfigured();
|
|
75
78
|
const { showTransactions } = await import("./commands/transactions.js");
|
|
@@ -79,8 +82,6 @@ program
|
|
|
79
82
|
to: opts.to,
|
|
80
83
|
query: opts.query,
|
|
81
84
|
limit: opts.limit != null ? Number(opts.limit) : undefined,
|
|
82
|
-
// commander inverts --no-foo to `opts.foo = false`
|
|
83
|
-
noInteractive: opts.interactive === false,
|
|
84
85
|
});
|
|
85
86
|
});
|
|
86
87
|
program
|
|
@@ -119,20 +120,20 @@ program
|
|
|
119
120
|
await runScanCommand({ regex: regexes[0], force: !!opts.force, parallel });
|
|
120
121
|
});
|
|
121
122
|
program
|
|
122
|
-
.command("
|
|
123
|
-
.description("
|
|
123
|
+
.command("files")
|
|
124
|
+
.description("Browse scanned files; press d to drop one and cascade-remove its data")
|
|
124
125
|
.action(async () => {
|
|
125
126
|
ensureConfigured();
|
|
126
|
-
const {
|
|
127
|
-
|
|
127
|
+
const { showFiles } = await import("./commands/files.js");
|
|
128
|
+
await showFiles();
|
|
128
129
|
});
|
|
129
130
|
program
|
|
130
|
-
.command("
|
|
131
|
-
.description("
|
|
132
|
-
.action(async (
|
|
131
|
+
.command("rules")
|
|
132
|
+
.description("Browse the rules the system has learned (press d to delete)")
|
|
133
|
+
.action(async () => {
|
|
133
134
|
ensureConfigured();
|
|
134
|
-
const {
|
|
135
|
-
|
|
135
|
+
const { showRules } = await import("./commands/rules.js");
|
|
136
|
+
await showRules();
|
|
136
137
|
});
|
|
137
138
|
program
|
|
138
139
|
.command("clarify")
|
|
@@ -144,6 +145,10 @@ program
|
|
|
144
145
|
});
|
|
145
146
|
program.configureHelp({
|
|
146
147
|
formatHelp: () => helpScreen([
|
|
148
|
+
{
|
|
149
|
+
name: "chat",
|
|
150
|
+
desc: "Open the chat TUI (default when running `plasalid`)",
|
|
151
|
+
},
|
|
147
152
|
{
|
|
148
153
|
name: "setup",
|
|
149
154
|
desc: "Configure Plasalid (API key, encryption, data dir)",
|
|
@@ -152,11 +157,11 @@ program.configureHelp({
|
|
|
152
157
|
name: "data",
|
|
153
158
|
desc: "Open the data folder in your OS file explorer (alias: open)",
|
|
154
159
|
},
|
|
155
|
-
{ name: "accounts", desc: "Browse the chart of accounts
|
|
160
|
+
{ name: "accounts", desc: "Browse the chart of accounts with balances" },
|
|
156
161
|
{ name: "status", desc: "Show financial and system status (net worth, recurring, questions)" },
|
|
157
162
|
{
|
|
158
163
|
name: "transactions",
|
|
159
|
-
desc: "Browse transactions (
|
|
164
|
+
desc: "Browse transactions (with optional filters)",
|
|
160
165
|
},
|
|
161
166
|
{
|
|
162
167
|
name: "record",
|
|
@@ -167,12 +172,12 @@ program.configureHelp({
|
|
|
167
172
|
desc: "Scan new PDFs (optionally by regex; --force to re-scan)",
|
|
168
173
|
},
|
|
169
174
|
{
|
|
170
|
-
name: "
|
|
171
|
-
desc: "
|
|
175
|
+
name: "files",
|
|
176
|
+
desc: "Browse scanned files; press d to drop one and cascade-remove its data",
|
|
172
177
|
},
|
|
173
178
|
{
|
|
174
|
-
name: "
|
|
175
|
-
desc: "
|
|
179
|
+
name: "rules",
|
|
180
|
+
desc: "Browse the rules the system has learned (press d to delete)",
|
|
176
181
|
},
|
|
177
182
|
{
|
|
178
183
|
name: "clarify",
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type Database from "libsql";
|
|
2
|
+
import { type ScannedFileRow } from "../../db/queries/files.js";
|
|
3
|
+
export interface FilesBrowserProps {
|
|
4
|
+
files: ScannedFileRow[];
|
|
5
|
+
db: Database.Database;
|
|
6
|
+
}
|
|
7
|
+
export declare function FilesBrowser({ files: initialFiles, db }: FilesBrowserProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
import { Text } from "ink";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import { padRight, truncateMiddle } from "../helper.js";
|
|
6
|
+
import { ListBrowser } from "./ListBrowser.js";
|
|
7
|
+
import { keyOf } from "./keys.js";
|
|
8
|
+
import { getDataDir } from "../../config.js";
|
|
9
|
+
import { deleteScannedFile, } from "../../db/queries/files.js";
|
|
10
|
+
import { countTransactionsBySourceFile } from "../../db/queries/transactions.js";
|
|
11
|
+
import { countQuestions } from "../../db/queries/questions.js";
|
|
12
|
+
const COL = {
|
|
13
|
+
status: 9,
|
|
14
|
+
provenance: 28,
|
|
15
|
+
scannedAt: 20,
|
|
16
|
+
};
|
|
17
|
+
const MIN_PATH_WIDTH = 16;
|
|
18
|
+
const MAX_PATH_WIDTH = 50;
|
|
19
|
+
const STATUS_COLOR = {
|
|
20
|
+
scanned: chalk.green,
|
|
21
|
+
failed: chalk.red,
|
|
22
|
+
pending: chalk.gray,
|
|
23
|
+
};
|
|
24
|
+
export function FilesBrowser({ files: initialFiles, db }) {
|
|
25
|
+
const [files, setFiles] = useState(initialFiles);
|
|
26
|
+
const [confirm, setConfirm] = useState(null);
|
|
27
|
+
const adapter = useMemo(() => {
|
|
28
|
+
const commitDelete = () => {
|
|
29
|
+
if (!confirm)
|
|
30
|
+
return;
|
|
31
|
+
deleteScannedFile(db, confirm.fileId);
|
|
32
|
+
setFiles(prev => prev.filter(f => f.id !== confirm.fileId));
|
|
33
|
+
setConfirm(null);
|
|
34
|
+
};
|
|
35
|
+
const cancelConfirm = () => setConfirm(null);
|
|
36
|
+
const CONFIRM_KEYS = {
|
|
37
|
+
y: commitDelete,
|
|
38
|
+
n: cancelConfirm,
|
|
39
|
+
escape: cancelConfirm,
|
|
40
|
+
};
|
|
41
|
+
const BROWSE_KEYS = {
|
|
42
|
+
d: (cursorItem) => {
|
|
43
|
+
if (!cursorItem)
|
|
44
|
+
return false;
|
|
45
|
+
setConfirm({
|
|
46
|
+
fileId: cursorItem.id,
|
|
47
|
+
path: cursorItem.path,
|
|
48
|
+
cascadeTx: countTransactionsBySourceFile(db, cursorItem.id),
|
|
49
|
+
// includeDeferred: true so the count matches what ON DELETE CASCADE will actually drop.
|
|
50
|
+
cascadeQuestions: countQuestions(db, { file_id: cursorItem.id, includeDeferred: true }),
|
|
51
|
+
});
|
|
52
|
+
return true;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
title: "Scanned files",
|
|
57
|
+
items: files,
|
|
58
|
+
getId: f => f.id,
|
|
59
|
+
renderRow: (f, ctx) => renderFileRow(f, ctx.isCursor, ctx.cols),
|
|
60
|
+
matches: (f, needle) => f.path.toLowerCase().includes(needle) ||
|
|
61
|
+
(f.provider ?? "").toLowerCase().includes(needle) ||
|
|
62
|
+
(f.model ?? "").toLowerCase().includes(needle),
|
|
63
|
+
emptyMessage: "No scanned files match the current filter.",
|
|
64
|
+
summary: confirm ? (_jsx(Text, { color: "yellow", children: `Delete ${truncateMiddle(confirm.path, 60)}? (y/n) Cascade removes ${confirm.cascadeTx} transaction(s) and ${confirm.cascadeQuestions} question(s).` })) : undefined,
|
|
65
|
+
onKey: (input, key, { cursorItem }) => {
|
|
66
|
+
const k = keyOf(input, key).toLowerCase();
|
|
67
|
+
if (confirm !== null) {
|
|
68
|
+
CONFIRM_KEYS[k]?.();
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
return BROWSE_KEYS[k]?.(cursorItem) ?? false;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}, [files, confirm, db]);
|
|
75
|
+
return _jsx(ListBrowser, { adapter: adapter });
|
|
76
|
+
}
|
|
77
|
+
function renderFileRow(f, isCursor, cols) {
|
|
78
|
+
const marker = isCursor ? "▸" : " ";
|
|
79
|
+
const status = STATUS_COLOR[f.status](padRight(f.status, COL.status));
|
|
80
|
+
const provenanceRaw = f.provider && f.model ? `${f.provider}/${f.model}` : f.status === "failed" ? "(failed)" : "(not stamped)";
|
|
81
|
+
const provenance = chalk.dim(padRight(truncateMiddle(provenanceRaw, COL.provenance), COL.provenance));
|
|
82
|
+
const scannedAtRaw = f.scanned_at ?? "—";
|
|
83
|
+
const scannedAt = chalk.dim(padRight(scannedAtRaw, COL.scannedAt));
|
|
84
|
+
// Layout: "M status(9) path(flex) provenance(28) scannedAt(20)"
|
|
85
|
+
const fixedWidth = 1 + 1 + COL.status + 2 + 2 + COL.provenance + 2 + COL.scannedAt;
|
|
86
|
+
const pathBudget = Math.max(MIN_PATH_WIDTH, Math.min(MAX_PATH_WIDTH, cols - fixedWidth - 2));
|
|
87
|
+
const pathRaw = truncateMiddle(relativeFromDataDir(f.path), pathBudget);
|
|
88
|
+
const pathPadded = padRight(pathRaw, pathBudget);
|
|
89
|
+
const path = isCursor ? chalk.cyan.bold(pathPadded) : pathPadded;
|
|
90
|
+
return `${marker} ${status} ${path} ${provenance} ${scannedAt}`;
|
|
91
|
+
}
|
|
92
|
+
/** Strip the configured data-dir prefix so the path column shows just the
|
|
93
|
+
* meaningful tail (subdirs + filename). Falls back to the absolute path
|
|
94
|
+
* for files that somehow live outside the data dir. */
|
|
95
|
+
function relativeFromDataDir(absolutePath) {
|
|
96
|
+
const dataDir = getDataDir().replace(/\/+$/, "");
|
|
97
|
+
if (absolutePath === dataDir)
|
|
98
|
+
return absolutePath;
|
|
99
|
+
const prefix = dataDir + "/";
|
|
100
|
+
return absolutePath.startsWith(prefix)
|
|
101
|
+
? absolutePath.slice(prefix.length)
|
|
102
|
+
: absolutePath;
|
|
103
|
+
}
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
|
+
import { type Key } from "ink";
|
|
2
3
|
export interface ListBrowserAdapter<T> {
|
|
3
|
-
title
|
|
4
|
+
/** Title row "{title} · N results · {filterSummary} · cursor/N". Omit to
|
|
5
|
+
* hide the title row and its separator rule entirely — useful when the
|
|
6
|
+
* adapter supplies its own `headerNode` and the auto-title would be
|
|
7
|
+
* redundant (e.g. ScanDashboard). */
|
|
8
|
+
title?: string;
|
|
4
9
|
filterSummary?: string;
|
|
10
|
+
/** Optional fixed chrome rendered above the title row. Use this when the
|
|
11
|
+
* list needs a multi-line header (e.g. a pipeline status indicator plus
|
|
12
|
+
* column labels) that should not scroll with the items. */
|
|
13
|
+
headerNode?: ReactNode;
|
|
5
14
|
items: T[];
|
|
6
15
|
getId: (item: T) => string;
|
|
7
16
|
/** Returns the row as a single ANSI-colored string for the given context. */
|
|
@@ -22,6 +31,12 @@ export interface ListBrowserAdapter<T> {
|
|
|
22
31
|
summary?: ReactNode;
|
|
23
32
|
/** Optional override for the "no results" empty state. */
|
|
24
33
|
emptyMessage?: string;
|
|
34
|
+
/** Adapter-owned key handler. Runs after search-mode and before built-in
|
|
35
|
+
* navigation; return true to mark the key consumed so default handling
|
|
36
|
+
* (q/arrows/g/G/return/etc.) is skipped. */
|
|
37
|
+
onKey?: (input: string, key: Key, ctx: {
|
|
38
|
+
cursorItem: T | null;
|
|
39
|
+
}) => boolean;
|
|
25
40
|
}
|
|
26
41
|
/**
|
|
27
42
|
* Alternate-screen list browser shell. The type-specific behavior lives in the
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { memo, useEffect, useMemo, useState } from "react";
|
|
3
3
|
import { Box, Text, useApp, useInput, useStdout } from "ink";
|
|
4
|
+
import { keyOf } from "./keys.js";
|
|
4
5
|
const HEADER_LINES = 2; // title + rule
|
|
5
6
|
const FOOTER_LINES = 2; // rule + hint
|
|
6
7
|
const SUMMARY_LINES = 1; // optional aggregate footer
|
|
@@ -73,71 +74,57 @@ export function ListBrowser({ adapter }) {
|
|
|
73
74
|
});
|
|
74
75
|
}, [cursor, effectiveViewportSize, filtered.length]);
|
|
75
76
|
useInput((input, key) => {
|
|
77
|
+
const k = keyOf(input, key);
|
|
78
|
+
const last = Math.max(0, filtered.length - 1);
|
|
76
79
|
if (searchMode) {
|
|
77
|
-
|
|
78
|
-
setSearchMode(false)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
const SEARCH_KEYS = {
|
|
81
|
+
return: () => setSearchMode(false),
|
|
82
|
+
escape: () => setSearchMode(false),
|
|
83
|
+
backspace: () => setSearch(prev => prev.slice(0, -1)),
|
|
84
|
+
delete: () => setSearch(prev => prev.slice(0, -1)),
|
|
85
|
+
};
|
|
86
|
+
const handler = SEARCH_KEYS[k];
|
|
87
|
+
if (handler) {
|
|
88
|
+
handler();
|
|
83
89
|
return;
|
|
84
90
|
}
|
|
85
91
|
if (input && !key.ctrl && !key.meta)
|
|
86
92
|
setSearch(prev => prev + input);
|
|
87
93
|
return;
|
|
88
94
|
}
|
|
89
|
-
if (input
|
|
90
|
-
exit();
|
|
95
|
+
if (adapter.onKey?.(input, key, { cursorItem: filtered[cursor] ?? null }))
|
|
91
96
|
return;
|
|
92
|
-
}
|
|
93
|
-
if (input === "/") {
|
|
94
|
-
setSearchMode(true);
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
const last = Math.max(0, filtered.length - 1);
|
|
98
97
|
const move = (delta) => {
|
|
99
98
|
setExpandedId(null);
|
|
100
99
|
setCursor(c => Math.max(0, Math.min(last, c + delta)));
|
|
101
100
|
};
|
|
102
|
-
|
|
103
|
-
move(-1);
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
if (key.downArrow || input === "j") {
|
|
107
|
-
move(1);
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
if (key.pageUp) {
|
|
111
|
-
move(-viewportSize);
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
if (key.pageDown) {
|
|
115
|
-
move(viewportSize);
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
if (input === "g") {
|
|
119
|
-
setExpandedId(null);
|
|
120
|
-
setCursor(0);
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
if (input === "G") {
|
|
124
|
-
setExpandedId(null);
|
|
125
|
-
setCursor(last);
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
if (key.return) {
|
|
101
|
+
const toggleExpand = () => {
|
|
129
102
|
const item = filtered[cursor];
|
|
130
|
-
if (item)
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
103
|
+
if (!item)
|
|
104
|
+
return;
|
|
105
|
+
const id = adapter.getId(item);
|
|
106
|
+
setExpandedId(prev => prev === id ? null : id);
|
|
107
|
+
};
|
|
108
|
+
const NAV_KEYS = {
|
|
109
|
+
q: exit,
|
|
110
|
+
escape: exit,
|
|
111
|
+
"/": () => setSearchMode(true),
|
|
112
|
+
k: () => move(-1),
|
|
113
|
+
upArrow: () => move(-1),
|
|
114
|
+
j: () => move(1),
|
|
115
|
+
downArrow: () => move(1),
|
|
116
|
+
pageUp: () => move(-viewportSize),
|
|
117
|
+
pageDown: () => move(viewportSize),
|
|
118
|
+
g: () => { setExpandedId(null); setCursor(0); },
|
|
119
|
+
G: () => { setExpandedId(null); setCursor(last); },
|
|
120
|
+
return: toggleExpand,
|
|
121
|
+
};
|
|
122
|
+
NAV_KEYS[k]?.();
|
|
136
123
|
});
|
|
137
124
|
const ruleWidth = Math.min(cols, 120);
|
|
138
125
|
const visibleEnd = Math.min(filtered.length, scrollOffset + effectiveViewportSize);
|
|
139
126
|
const visible = filtered.slice(scrollOffset, visibleEnd);
|
|
140
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: adapter.title }), _jsx(Text, { dimColor: true, children: ` · ${filtered.length} results` }), adapter.filterSummary ? _jsx(Text, { dimColor: true, children: ` · ${adapter.filterSummary}` }) : null, filtered.length > viewportSize ? (_jsx(Text, { dimColor: true, children: ` · ${Math.min(cursor + 1, filtered.length)}/${filtered.length}` })) : null] }), _jsx(Text, { dimColor: true, children: "─".repeat(ruleWidth) }), filtered.length === 0 ? (_jsx(Text, { color: "yellow", children: adapter.emptyMessage ?? "No results match the current filter." })) : (_jsx(Box, { flexDirection: "column", children: visible.map((item, i) => {
|
|
127
|
+
return (_jsxs(Box, { flexDirection: "column", children: [adapter.headerNode, adapter.title ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: adapter.title }), _jsx(Text, { dimColor: true, children: ` · ${filtered.length} results` }), adapter.filterSummary ? _jsx(Text, { dimColor: true, children: ` · ${adapter.filterSummary}` }) : null, filtered.length > viewportSize ? (_jsx(Text, { dimColor: true, children: ` · ${Math.min(cursor + 1, filtered.length)}/${filtered.length}` })) : null] }), _jsx(Text, { dimColor: true, children: "─".repeat(ruleWidth) })] })) : null, filtered.length === 0 ? (_jsx(Text, { color: "yellow", children: adapter.emptyMessage ?? "No results match the current filter." })) : (_jsx(Box, { flexDirection: "column", children: visible.map((item, i) => {
|
|
141
128
|
const idx = scrollOffset + i;
|
|
142
129
|
const isCursor = idx === cursor;
|
|
143
130
|
const id = adapter.getId(item);
|
|
@@ -7,5 +7,5 @@ export function PromptFrame({ buffer, footerText, showCaret, banner }) {
|
|
|
7
7
|
const { stdout } = useStdout();
|
|
8
8
|
const cols = stdout?.columns || 80;
|
|
9
9
|
const rule = chalk.dim("─".repeat(cols));
|
|
10
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: rule }), _jsx(TextInput, { buffer: buffer, prompt: chalk.dim("❯ "), showCaret: showCaret }), _jsx(Text, { children: rule }), _jsx(Text, { children:
|
|
10
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: rule }), _jsx(TextInput, { buffer: buffer, prompt: chalk.dim("❯ "), showCaret: showCaret }), _jsx(Text, { children: rule }), _jsx(Text, { children: ` ${footerText}` }), banner ? _jsx(Text, { children: banner }) : null] }));
|
|
11
11
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type Database from "libsql";
|
|
2
|
+
import type { RuleEntry } from "../commands/rules.js";
|
|
3
|
+
export interface RulesBrowserProps {
|
|
4
|
+
rules: RuleEntry[];
|
|
5
|
+
db: Database.Database;
|
|
6
|
+
}
|
|
7
|
+
export declare function RulesBrowser({ rules: initialRules, db }: RulesBrowserProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
import { Text } from "ink";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import { padRight, truncateMiddle } from "../helper.js";
|
|
6
|
+
import { ListBrowser } from "./ListBrowser.js";
|
|
7
|
+
import { keyOf } from "./keys.js";
|
|
8
|
+
const MIN_TEXT_WIDTH = 16;
|
|
9
|
+
export function RulesBrowser({ rules: initialRules, db }) {
|
|
10
|
+
const [rules, setRules] = useState(initialRules);
|
|
11
|
+
const [confirmId, setConfirmId] = useState(null);
|
|
12
|
+
const idWidth = useMemo(() => (rules.length === 0 ? 0 : Math.max(...rules.map((r) => r.displayId.length))), [rules]);
|
|
13
|
+
const adapter = useMemo(() => {
|
|
14
|
+
const commitDelete = () => {
|
|
15
|
+
const target = rules.find((r) => r.displayId === confirmId);
|
|
16
|
+
if (target) {
|
|
17
|
+
target.forget(db);
|
|
18
|
+
setRules((prev) => prev.filter((r) => r.displayId !== confirmId));
|
|
19
|
+
}
|
|
20
|
+
setConfirmId(null);
|
|
21
|
+
};
|
|
22
|
+
const cancelConfirm = () => setConfirmId(null);
|
|
23
|
+
const CONFIRM_KEYS = {
|
|
24
|
+
y: commitDelete,
|
|
25
|
+
n: cancelConfirm,
|
|
26
|
+
escape: cancelConfirm,
|
|
27
|
+
};
|
|
28
|
+
const BROWSE_KEYS = {
|
|
29
|
+
d: (cursorItem) => {
|
|
30
|
+
if (!cursorItem)
|
|
31
|
+
return false;
|
|
32
|
+
setConfirmId(cursorItem.displayId);
|
|
33
|
+
return true;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
title: "Rules",
|
|
38
|
+
items: rules,
|
|
39
|
+
getId: (r) => r.displayId,
|
|
40
|
+
renderRow: (r, ctx) => renderRuleRow(r, ctx.isCursor, ctx.cols, idWidth),
|
|
41
|
+
matches: (r, needle) => r.displayId.toLowerCase().includes(needle) ||
|
|
42
|
+
r.text.toLowerCase().includes(needle),
|
|
43
|
+
emptyMessage: "No rules yet. Rules accumulate as you clarify questions. Run `plasalid clarify` after a scan.",
|
|
44
|
+
summary: confirmId ? (_jsx(Text, { color: "yellow", children: `Delete ${confirmId}? (y/n)` })) : undefined,
|
|
45
|
+
onKey: (input, key, { cursorItem }) => {
|
|
46
|
+
const k = keyOf(input, key).toLowerCase();
|
|
47
|
+
if (confirmId !== null) {
|
|
48
|
+
CONFIRM_KEYS[k]?.();
|
|
49
|
+
return true; // confirm mode swallows everything else
|
|
50
|
+
}
|
|
51
|
+
return BROWSE_KEYS[k]?.(cursorItem) ?? false;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}, [rules, idWidth, confirmId, db]);
|
|
55
|
+
return _jsx(ListBrowser, { adapter: adapter });
|
|
56
|
+
}
|
|
57
|
+
function renderRuleRow(r, isCursor, cols, idWidth) {
|
|
58
|
+
const marker = isCursor ? "▸" : " ";
|
|
59
|
+
const idPadded = padRight(r.displayId, idWidth);
|
|
60
|
+
const id = isCursor ? chalk.cyan.bold(idPadded) : chalk.cyan(idPadded);
|
|
61
|
+
// Layout: "M idPadded text" → marker(1) + space + idPadded + 2 + text
|
|
62
|
+
const fixedWidth = 1 + 1 + idWidth + 2;
|
|
63
|
+
const textBudget = Math.max(MIN_TEXT_WIDTH, cols - fixedWidth - 2);
|
|
64
|
+
const textRaw = truncateMiddle(r.text, textBudget);
|
|
65
|
+
const text = isCursor ? chalk.bold(textRaw) : textRaw;
|
|
66
|
+
return `${marker} ${id} ${text}`;
|
|
67
|
+
}
|