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.
Files changed (64) hide show
  1. package/README.md +4 -0
  2. package/dist/ai/personas.js +29 -6
  3. package/dist/ai/prompt-sections.d.ts +10 -0
  4. package/dist/ai/prompt-sections.js +29 -0
  5. package/dist/ai/system-prompt.js +10 -6
  6. package/dist/ai/tools/clarify.js +35 -0
  7. package/dist/ai/tools/common.js +3 -2
  8. package/dist/ai/tools/index.js +6 -3
  9. package/dist/ai/tools/ingest.js +47 -35
  10. package/dist/ai/tools/mutate.d.ts +2 -0
  11. package/dist/ai/tools/mutate.js +81 -0
  12. package/dist/cli/commands/accounts.d.ts +1 -4
  13. package/dist/cli/commands/accounts.js +12 -101
  14. package/dist/cli/commands/files.d.ts +7 -0
  15. package/dist/cli/commands/files.js +24 -0
  16. package/dist/cli/commands/rules.d.ts +4 -12
  17. package/dist/cli/commands/rules.js +33 -67
  18. package/dist/cli/commands/scan.js +14 -12
  19. package/dist/cli/commands/status.js +5 -3
  20. package/dist/cli/commands/transactions.d.ts +0 -2
  21. package/dist/cli/commands/transactions.js +10 -63
  22. package/dist/cli/format.js +22 -32
  23. package/dist/cli/helper.d.ts +9 -1
  24. package/dist/cli/helper.js +17 -2
  25. package/dist/cli/index.js +37 -32
  26. package/dist/cli/ink/FilesBrowser.d.ts +7 -0
  27. package/dist/cli/ink/FilesBrowser.js +103 -0
  28. package/dist/cli/ink/ListBrowser.d.ts +16 -1
  29. package/dist/cli/ink/ListBrowser.js +36 -49
  30. package/dist/cli/ink/PromptFrame.js +1 -1
  31. package/dist/cli/ink/RulesBrowser.d.ts +7 -0
  32. package/dist/cli/ink/RulesBrowser.js +67 -0
  33. package/dist/cli/ink/ScanDashboard.js +90 -68
  34. package/dist/cli/ink/hooks/useFooterText.js +14 -22
  35. package/dist/cli/ink/keys.d.ts +2 -0
  36. package/dist/cli/ink/keys.js +19 -0
  37. package/dist/db/queries/files.d.ts +29 -0
  38. package/dist/db/queries/files.js +34 -0
  39. package/dist/db/queries/questions.d.ts +17 -0
  40. package/dist/db/queries/questions.js +47 -9
  41. package/dist/db/queries/rules.d.ts +31 -0
  42. package/dist/db/queries/rules.js +55 -0
  43. package/dist/db/queries/transactions.d.ts +34 -0
  44. package/dist/db/queries/transactions.js +86 -0
  45. package/dist/db/schema.js +17 -0
  46. package/dist/scanner/clarifier-memory.d.ts +15 -3
  47. package/dist/scanner/clarifier-memory.js +38 -17
  48. package/dist/scanner/clarifier.d.ts +2 -1
  49. package/dist/scanner/clarifier.js +40 -26
  50. package/dist/scanner/commit-pipeline.d.ts +56 -0
  51. package/dist/scanner/commit-pipeline.js +204 -0
  52. package/dist/scanner/committer.d.ts +56 -0
  53. package/dist/scanner/committer.js +204 -0
  54. package/dist/scanner/parse.js +27 -7
  55. package/dist/scanner/recurrence-pipeline.d.ts +28 -0
  56. package/dist/scanner/recurrence-pipeline.js +126 -0
  57. package/dist/scanner/recurrence.d.ts +28 -0
  58. package/dist/scanner/recurrence.js +155 -0
  59. package/dist/scanner/rule-keys.d.ts +13 -0
  60. package/dist/scanner/rule-keys.js +28 -0
  61. package/dist/scanner/rules.d.ts +13 -0
  62. package/dist/scanner/rules.js +28 -0
  63. package/dist/scanner/worker.js +4 -2
  64. 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
- program
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 (interactive TTY) or print them (piped)")
50
- .option("--no-interactive", "Force plain-print output instead of the Ink browser")
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({ noInteractive: opts.interactive === false });
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 (interactive TTY) or print them (piped)")
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 interactive, 100 piped)")
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("rules")
123
- .description("List rules the system has learned")
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 { showRules } = await import("./commands/rules.js");
127
- showRules();
127
+ const { showFiles } = await import("./commands/files.js");
128
+ await showFiles();
128
129
  });
129
130
  program
130
- .command("forget <regex>")
131
- .description("Delete every learned rule whose id matches <regex> (anchored). Run `plasalid rules` to list ids.")
132
- .action(async (regex) => {
131
+ .command("rules")
132
+ .description("Browse the rules the system has learned (press d to delete)")
133
+ .action(async () => {
133
134
  ensureConfigured();
134
- const { forgetRule } = await import("./commands/rules.js");
135
- forgetRule(regex);
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 (interactive TTY) or list them (piped)" },
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 (interactive TTY) or list them (piped/--no-interactive)",
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: "rules",
171
- desc: "List rules the system has learned",
175
+ name: "files",
176
+ desc: "Browse scanned files; press d to drop one and cascade-remove its data",
172
177
  },
173
178
  {
174
- name: "forget",
175
- desc: "Delete learned rules whose ids match <regex> (anchored)",
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: string;
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
- if (key.return || key.escape) {
78
- setSearchMode(false);
79
- return;
80
- }
81
- if (key.backspace || key.delete) {
82
- setSearch(prev => prev.slice(0, -1));
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 === "q" || key.escape) {
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
- if (key.upArrow || input === "k") {
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
- const id = adapter.getId(item);
132
- setExpandedId(prev => prev === id ? null : id);
133
- }
134
- return;
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: chalk.dim(` ${footerText}`) }), banner ? _jsx(Text, { children: banner }) : null] }));
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
+ }