plasalid 0.8.2 → 0.8.3

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.
@@ -1,4 +1 @@
1
- export interface ShowAccountsOptions {
2
- noInteractive?: boolean;
3
- }
4
- export declare function showAccounts(opts?: ShowAccountsOptions): Promise<void>;
1
+ export declare function showAccounts(): Promise<void>;
@@ -1,113 +1,24 @@
1
1
  import chalk from "chalk";
2
2
  import { getDb } from "../../db/connection.js";
3
3
  import { getAccountBalances } from "../../db/queries/account-balance.js";
4
- import { visibleLength } from "../format.js";
5
- import { formatSignedAmount } from "../../currency.js";
6
- const TYPE_TAG = {
7
- asset: "asset",
8
- liability: "liab",
9
- income: "income",
10
- expense: "expense",
11
- equity: "equity",
12
- };
13
- const TYPE_TAG_WIDTH = 8;
14
- const TYPE_RANK = {
15
- asset: 0,
16
- liability: 1,
17
- income: 2,
18
- expense: 3,
19
- equity: 4,
20
- };
21
- export async function showAccounts(opts = {}) {
4
+ export async function showAccounts() {
22
5
  const db = getDb();
23
6
  const accounts = getAccountBalances(db);
24
7
  if (accounts.length === 0) {
25
8
  console.log(chalk.yellow("No accounts yet. Drop your bank/credit card statements into ~/.plasalid/data/ and run `plasalid scan`."));
26
9
  return;
27
10
  }
28
- const interactive = !opts.noInteractive && Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY);
29
- if (interactive) {
30
- const [{ runBrowser }, { AccountsBrowser }, { createElement }, { listPostings },] = await Promise.all([
31
- import("../ink/runBrowser.js"),
32
- import("../ink/AccountsBrowser.js"),
33
- import("react"),
34
- import("../../db/queries/transactions.js"),
35
- ]);
36
- const recentTransactionsByAccount = new Map();
37
- for (const a of accounts) {
38
- const rows = listPostings(db, { account_id: a.id, limit: 10 });
39
- if (rows.length > 0)
40
- recentTransactionsByAccount.set(a.id, rows);
41
- }
42
- await runBrowser(createElement(AccountsBrowser, { accounts, recentTransactionsByAccount }));
43
- return;
44
- }
45
- printAccountsPlain(accounts);
46
- }
47
- function printAccountsPlain(raw) {
48
- const byId = new Map(raw.map((a) => [a.id, a]));
49
- const depthCache = new Map();
50
- const depthOf = (id) => {
51
- if (depthCache.has(id))
52
- return depthCache.get(id);
53
- const node = byId.get(id);
54
- if (!node || !node.parent_id) {
55
- depthCache.set(id, 0);
56
- return 0;
57
- }
58
- const d = depthOf(node.parent_id) + 1;
59
- depthCache.set(id, d);
60
- return d;
61
- };
62
- const accounts = [...raw].sort((a, b) => {
63
- const t = TYPE_RANK[a.type] - TYPE_RANK[b.type];
64
- if (t !== 0)
65
- return t;
66
- return a.id.localeCompare(b.id);
67
- });
68
- const balanceWidth = Math.max(...accounts.map((a) => formatSignedAmount(a.balance).length));
69
- const nameWidth = Math.max(...accounts.map((a) => a.name.length + depthOf(a.id) * 2));
11
+ const [{ runBrowser }, { AccountsBrowser }, { createElement }, { listPostings },] = await Promise.all([
12
+ import("../ink/runBrowser.js"),
13
+ import("../ink/AccountsBrowser.js"),
14
+ import("react"),
15
+ import("../../db/queries/transactions.js"),
16
+ ]);
17
+ const recentTransactionsByAccount = new Map();
70
18
  for (const a of accounts) {
71
- const tag = chalk.dim(TYPE_TAG[a.type].padEnd(TYPE_TAG_WIDTH));
72
- const indent = " ".repeat(depthOf(a.id));
73
- const displayName = indent + a.name;
74
- const name = chalk.bold(displayName) + " ".repeat(nameWidth - displayName.length);
75
- const rawBalance = formatSignedAmount(a.balance);
76
- const coloredBalance = a.balance < 0 ? chalk.red(rawBalance) : rawBalance;
77
- const paddedBalance = " ".repeat(balanceWidth - visibleLength(coloredBalance)) + coloredBalance;
78
- const meta = compactMeta(a);
79
- const metaStr = meta.length ? ` ${chalk.dim(meta.join(" · "))}` : "";
80
- console.log(` ${tag} ${name} ${paddedBalance}${metaStr}`);
19
+ const rows = listPostings(db, { account_id: a.id, limit: 10 });
20
+ if (rows.length > 0)
21
+ recentTransactionsByAccount.set(a.id, rows);
81
22
  }
82
- let assets = 0, liabilities = 0;
83
- for (const a of accounts) {
84
- if (a.type === "asset")
85
- assets += a.balance;
86
- else if (a.type === "liability")
87
- liabilities += a.balance;
88
- }
89
- const netWorth = assets - liabilities;
90
- console.log("");
91
- console.log(" " +
92
- chalk.dim(`Assets ${formatSignedAmount(assets)}`) +
93
- chalk.dim(" · ") +
94
- chalk.dim(`Liabilities ${formatSignedAmount(liabilities)}`) +
95
- chalk.dim(" · ") +
96
- chalk.bold(`Net worth ${formatSignedAmount(netWorth)}`));
97
- }
98
- function compactMeta(a) {
99
- const meta = [];
100
- if (a.bank_name)
101
- meta.push(a.bank_name);
102
- if (a.due_day)
103
- meta.push(`due ${a.due_day}`);
104
- if (a.statement_day)
105
- meta.push(`stmt ${a.statement_day}`);
106
- if (a.points_balance)
107
- meta.push(`${a.points_balance.toLocaleString()} pts`);
108
- if (a.currency && a.currency !== "THB")
109
- meta.push(a.currency);
110
- if (meta.length === 0 && a.subtype)
111
- meta.push(a.subtype);
112
- return meta;
23
+ await runBrowser(createElement(AccountsBrowser, { accounts, recentTransactionsByAccount }));
113
24
  }
@@ -1,16 +1,8 @@
1
1
  import type Database from "libsql";
2
- export interface ForgetMatch {
2
+ export interface RuleEntry {
3
3
  displayId: string;
4
4
  text: string;
5
+ forget(db: Database.Database): void;
5
6
  }
6
- export type ForgetOutcome = {
7
- ok: true;
8
- matched: ForgetMatch[];
9
- } | {
10
- ok: false;
11
- error: string;
12
- };
13
- export declare function renderRules(db: Database.Database): string;
14
- export declare function forgetRules(db: Database.Database, pattern: string): ForgetOutcome;
15
- export declare function showRules(): void;
16
- export declare function forgetRule(pattern: string): void;
7
+ export declare function collectRules(db: Database.Database): RuleEntry[];
8
+ export declare function showRules(): Promise<void>;
@@ -2,7 +2,7 @@ import chalk from "chalk";
2
2
  import { getDb } from "../../db/connection.js";
3
3
  import { getMemories, deleteMemory } from "../../ai/memory.js";
4
4
  import { listMerchants, clearMerchantDefaultAccount, } from "../../db/queries/merchants.js";
5
- function collectRules(db) {
5
+ export function collectRules(db) {
6
6
  const out = [];
7
7
  for (const m of getMemories(db)) {
8
8
  out.push({
@@ -25,55 +25,18 @@ function collectRules(db) {
25
25
  });
26
26
  return out;
27
27
  }
28
- export function renderRules(db) {
28
+ export async function showRules() {
29
+ const db = getDb();
29
30
  const rules = collectRules(db);
30
31
  if (rules.length === 0) {
31
- return ("No rules yet.\n\n" +
32
+ console.log("No rules yet.\n\n" +
32
33
  chalk.dim("Rules accumulate as you clarify questions. Run `plasalid clarify` after a scan."));
33
- }
34
- const width = Math.max(...rules.map((r) => r.displayId.length));
35
- const lines = [chalk.bold(`Rules (${rules.length}):`)];
36
- for (const r of rules) {
37
- lines.push(` ${chalk.cyan(r.displayId.padEnd(width))} ${r.text}`);
38
- }
39
- lines.push("");
40
- lines.push(chalk.dim("To remove: plasalid forget <regex>"));
41
- return lines.join("\n");
42
- }
43
- export function forgetRules(db, pattern) {
44
- let re;
45
- try {
46
- re = new RegExp(`^${pattern}$`);
47
- }
48
- catch (err) {
49
- return { ok: false, error: `Invalid regex /${pattern}/: ${err instanceof Error ? err.message : String(err)}` };
50
- }
51
- const snapshot = collectRules(db);
52
- const hits = snapshot.filter((r) => re.test(r.displayId));
53
- if (!hits.length) {
54
- return {
55
- ok: false,
56
- error: `No rule matches /${pattern}/. Run \`plasalid rules\` to list ids.`,
57
- };
58
- }
59
- const matched = hits.map((r) => {
60
- r.forget(db);
61
- return { displayId: r.displayId, text: r.text };
62
- });
63
- return { ok: true, matched };
64
- }
65
- export function showRules() {
66
- console.log(renderRules(getDb()));
67
- }
68
- export function forgetRule(pattern) {
69
- const outcome = forgetRules(getDb(), pattern);
70
- if (!outcome.ok) {
71
- console.error(chalk.red(outcome.error));
72
- process.exitCode = 1;
73
34
  return;
74
35
  }
75
- const width = Math.max(...outcome.matched.map((m) => m.displayId.length));
76
- for (const m of outcome.matched) {
77
- console.log(`Forgot ${chalk.cyan(m.displayId.padEnd(width))} ${m.text}`);
78
- }
36
+ const [{ runBrowser }, { RulesBrowser }, { createElement }] = await Promise.all([
37
+ import("../ink/runBrowser.js"),
38
+ import("../ink/RulesBrowser.js"),
39
+ import("react"),
40
+ ]);
41
+ await runBrowser(createElement(RulesBrowser, { rules, db }));
79
42
  }
@@ -69,10 +69,12 @@ async function buildTtyHooks(signal) {
69
69
  let inkInstance = null;
70
70
  let unsubscribeProgress = null;
71
71
  const chunkLookup = new Map();
72
- // Surface cancellation through Ink's controller, not raw stdout — writing
73
- // to stdout while Ink is rendering corrupts its frame tracking and leaves
74
- // a phantom copy of the header in scrollback. once:true so the listener
75
- // self-removes without leaking past the scan run.
72
+ /**
73
+ * Surface cancellation through Ink's controller, not raw stdout writing
74
+ * to stdout while Ink is rendering corrupts its frame tracking and leaves
75
+ * a phantom copy of the header in scrollback. once:true so the listener
76
+ * self-removes without leaking past the scan run.
77
+ */
76
78
  const onAbortEvt = () => {
77
79
  controller.publish({ type: "phase-set", phase: "cancelling" });
78
80
  };
@@ -86,11 +88,6 @@ async function buildTtyHooks(signal) {
86
88
  }
87
89
  console.log(chalk.dim(`Decrypted ${s.decrypted.length}, skipped ${s.skipped.length}, failed ${s.failed.length}.`));
88
90
  },
89
- afterChunk: (s) => {
90
- if (s.chunks.length === 0)
91
- return;
92
- console.log(chalk.dim(`Chunked into ${s.chunks.length} page(s). Mounting dashboard…`));
93
- },
94
91
  beforeParse: (s) => {
95
92
  for (const c of s.chunks)
96
93
  chunkLookup.set(c.chunkId, {
@@ -8,10 +8,13 @@ import { countQuestions } from "../../db/queries/questions.js";
8
8
  import { countMemories } from "../../ai/memory.js";
9
9
  import { config, getActiveModel } from "../../config.js";
10
10
  import { formatAmount } from "../../currency.js";
11
- import { visibleLength } from "../format.js";
11
+ import { banner, visibleLength } from "../format.js";
12
12
  const LABEL_WIDTH = 18;
13
13
  export function showStatus() {
14
14
  const db = getDb();
15
+ console.log("");
16
+ console.log(banner());
17
+ console.log("");
15
18
  printSection("Financial", financialRows(db));
16
19
  console.log("");
17
20
  printSection("System", systemRows(db));
@@ -83,8 +86,7 @@ function modelRows() {
83
86
  }
84
87
  function printSection(title, rows, opts) {
85
88
  const align = opts?.align ?? "right";
86
- console.log(chalk.bold(title));
87
- console.log(chalk.dim("─".repeat(title.length)));
89
+ console.log(chalk.bold.yellow(title));
88
90
  const valueWidth = Math.max(0, ...rows.map((r) => visibleLength(r.value)));
89
91
  for (const row of rows) {
90
92
  const label = row.label.padEnd(LABEL_WIDTH);
@@ -4,7 +4,5 @@ export interface ShowTransactionsOptions {
4
4
  to?: string;
5
5
  query?: string;
6
6
  limit?: number;
7
- /** Force the plain-print path even when stdout is a TTY. */
8
- noInteractive?: boolean;
9
7
  }
10
8
  export declare function showTransactions(opts: ShowTransactionsOptions): Promise<void>;
@@ -1,39 +1,27 @@
1
1
  import chalk from "chalk";
2
2
  import { getDb } from "../../db/connection.js";
3
- import { groupByTransaction, listPostings, } from "../../db/queries/transactions.js";
4
- import { visibleLength } from "../format.js";
5
- import { formatAmount } from "../../currency.js";
6
- import { truncateMiddle } from "../helper.js";
7
- const ACCOUNT_CAP = 32;
8
- const MEMO_CAP = 40;
9
- const INTERACTIVE_LIMIT = 1000;
10
- const RECURRING_MARKER = "[R]";
3
+ import { listPostings } from "../../db/queries/transactions.js";
4
+ const DEFAULT_LIMIT = 1000;
11
5
  export async function showTransactions(opts) {
12
6
  const db = getDb();
13
- const interactive = !opts.noInteractive && Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY);
14
- const requestedLimit = opts.limit ?? (interactive ? INTERACTIVE_LIMIT : 100);
15
7
  const postings = listPostings(db, {
16
8
  account_id: opts.account,
17
9
  from: opts.from,
18
10
  to: opts.to,
19
11
  q: opts.query,
20
- limit: requestedLimit,
12
+ limit: opts.limit ?? DEFAULT_LIMIT,
21
13
  });
22
14
  if (postings.length === 0) {
23
15
  console.log(chalk.yellow("No postings match those filters."));
24
16
  return;
25
17
  }
26
- if (interactive) {
27
- const filterSummary = buildFilterSummary(opts);
28
- const [{ runBrowser }, { TransactionsBrowser }, { createElement }] = await Promise.all([
29
- import("../ink/runBrowser.js"),
30
- import("../ink/TransactionsBrowser.js"),
31
- import("react"),
32
- ]);
33
- await runBrowser(createElement(TransactionsBrowser, { postings, filterSummary }));
34
- return;
35
- }
36
- printTransactionsPlain(postings);
18
+ const filterSummary = buildFilterSummary(opts);
19
+ const [{ runBrowser }, { TransactionsBrowser }, { createElement }] = await Promise.all([
20
+ import("../ink/runBrowser.js"),
21
+ import("../ink/TransactionsBrowser.js"),
22
+ import("react"),
23
+ ]);
24
+ await runBrowser(createElement(TransactionsBrowser, { postings, filterSummary }));
37
25
  }
38
26
  function buildFilterSummary(opts) {
39
27
  const parts = [];
@@ -47,44 +35,3 @@ function buildFilterSummary(opts) {
47
35
  parts.push(`query="${opts.query}"`);
48
36
  return parts.join(" · ");
49
37
  }
50
- function printTransactionsPlain(postings) {
51
- const truncatedAccount = new Map();
52
- const truncatedMemo = new Map();
53
- for (const p of postings) {
54
- const acct = p.account_name ?? p.account_id;
55
- truncatedAccount.set(p.id, truncateMiddle(acct, ACCOUNT_CAP));
56
- if (p.memo)
57
- truncatedMemo.set(p.id, truncateMiddle(p.memo, MEMO_CAP));
58
- }
59
- const accountWidth = Math.max(...postings.map((p) => truncatedAccount.get(p.id).length));
60
- const amountWidth = Math.max(...postings.map((p) => {
61
- const side = p.debit > 0 ? "DR" : "CR";
62
- const amt = p.debit > 0 ? p.debit : p.credit;
63
- return `${side} ${formatAmount(amt, p.currency)}`.length;
64
- }));
65
- const cols = process.stdout.columns || 100;
66
- const descMax = Math.max(20, cols - 14);
67
- const groups = groupByTransaction(postings);
68
- for (const g of groups) {
69
- const desc = truncateMiddle(g.description, descMax);
70
- const merchant = g.merchant ? chalk.green(` · ${g.merchant}`) : "";
71
- const recurring = g.recurrence_id ? chalk.dim(` ${RECURRING_MARKER}`) : "";
72
- console.log(`${chalk.dim(g.date)} ${chalk.bold(desc)}${merchant}${recurring}`);
73
- for (const p of g.postings) {
74
- const acct = truncatedAccount.get(p.id);
75
- const acctPadded = acct + " ".repeat(accountWidth - acct.length);
76
- const side = p.debit > 0 ? "DR" : "CR";
77
- const amt = p.debit > 0 ? p.debit : p.credit;
78
- const rawAmount = `${side} ${formatAmount(amt, p.currency)}`;
79
- const colored = p.debit > 0 ? chalk.cyan(rawAmount) : chalk.magenta(rawAmount);
80
- const amountPadded = " ".repeat(amountWidth - visibleLength(colored)) + colored;
81
- const memo = truncatedMemo.get(p.id);
82
- const memoStr = memo ? ` ${chalk.dim(memo)}` : "";
83
- console.log(` ${acctPadded} ${amountPadded}${memoStr}`);
84
- }
85
- }
86
- if (groups.length > 1) {
87
- console.log("");
88
- console.log(chalk.dim(` ${groups.length} transactions · ${postings.length} postings`));
89
- }
90
- }
@@ -58,43 +58,33 @@ export function banner() {
58
58
  chalk.bold("Plasalid") +
59
59
  chalk.dim(" · The Harness Layer for Personal Finance"));
60
60
  }
61
- function stripAnsi(str) {
62
- return str.replace(ANSI_RE, "");
63
- }
64
- function box(label, lines) {
65
- const cols = process.stdout.columns || 100;
66
- const inner = cols - 4;
67
- const top = `┌─── ${label} ${"─".repeat(Math.max(0, inner - label.length - 5))}┐`;
68
- const bot = `└${"─".repeat(inner + 2)}┘`;
69
- const pad = `│${" ".repeat(inner + 2)}│`;
70
- const body = lines.map((l) => {
71
- const vis = stripAnsi(l).length;
72
- return `│ ${l}${" ".repeat(Math.max(0, inner - vis))}│`;
73
- });
74
- return [top, pad, ...body, pad, bot].join("\n");
75
- }
76
61
  const DISCLAIMER = "Plasalid is an assistant, not a financial advisor. It only summarizes financial statements — verify amounts against your statements before relying on them.";
62
+ function section(label, lines) {
63
+ return [chalk.bold.yellow(label), ...lines.map((l) => ` ${l}`)].join("\n");
64
+ }
77
65
  export function helpScreen(commands) {
78
- const sections = [
66
+ const options = [
67
+ { name: "--version", desc: "Show the version and exit" },
68
+ { name: "--help", desc: "Show this help screen" },
69
+ ];
70
+ const nameWidth = Math.max(...commands.map((c) => c.name.length), ...options.map((o) => o.name.length));
71
+ const row = (name, desc) => `${chalk.cyan(name.padEnd(nameWidth))} ${chalk.dim(desc)}`;
72
+ const usageLines = [
73
+ `${chalk.cyan("plasalid")} ${chalk.dim("<command> [OPTIONS]")}`,
74
+ row("plasalid", "Start the chat session"),
75
+ ];
76
+ return [
77
+ "",
79
78
  banner(),
80
79
  "",
81
- box("Usage", [
82
- "plasalid <command> [OPTIONS]",
83
- "plasalid Start the chat session",
84
- ]),
80
+ section("Usage", usageLines),
85
81
  "",
86
- ];
87
- const nameWidth = Math.max(...commands.map((c) => c.name.length));
88
- const cmdLines = commands.map((c) => `${chalk.white(c.name.padEnd(nameWidth))} ${chalk.dim(c.desc)}`);
89
- sections.push(box("Commands", cmdLines));
90
- sections.push("");
91
- sections.push(box("Options", [
92
- `${chalk.white("--version".padEnd(nameWidth))} ${chalk.dim("Show the version and exit")}`,
93
- `${chalk.white("--help".padEnd(nameWidth))} ${chalk.dim("Show this help screen")}`,
94
- ]));
95
- sections.push("");
96
- sections.push(chalk.dim(DISCLAIMER));
97
- return sections.join("\n");
82
+ section("Commands", commands.map((c) => row(c.name, c.desc))),
83
+ "",
84
+ section("Options", options.map((o) => row(o.name, o.desc))),
85
+ "",
86
+ chalk.dim(DISCLAIMER),
87
+ ].join("\n");
98
88
  }
99
89
  export function formatResponse(text) {
100
90
  return text
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
@@ -120,19 +121,11 @@ program
120
121
  });
121
122
  program
122
123
  .command("rules")
123
- .description("List rules the system has learned")
124
+ .description("Browse the rules the system has learned (press d to delete)")
124
125
  .action(async () => {
125
126
  ensureConfigured();
126
127
  const { showRules } = await import("./commands/rules.js");
127
- showRules();
128
- });
129
- 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) => {
133
- ensureConfigured();
134
- const { forgetRule } = await import("./commands/rules.js");
135
- forgetRule(regex);
128
+ await showRules();
136
129
  });
137
130
  program
138
131
  .command("clarify")
@@ -144,6 +137,10 @@ program
144
137
  });
145
138
  program.configureHelp({
146
139
  formatHelp: () => helpScreen([
140
+ {
141
+ name: "chat",
142
+ desc: "Open the chat TUI (default when running `plasalid`)",
143
+ },
147
144
  {
148
145
  name: "setup",
149
146
  desc: "Configure Plasalid (API key, encryption, data dir)",
@@ -152,11 +149,11 @@ program.configureHelp({
152
149
  name: "data",
153
150
  desc: "Open the data folder in your OS file explorer (alias: open)",
154
151
  },
155
- { name: "accounts", desc: "Browse the chart of accounts (interactive TTY) or list them (piped)" },
152
+ { name: "accounts", desc: "Browse the chart of accounts with balances" },
156
153
  { name: "status", desc: "Show financial and system status (net worth, recurring, questions)" },
157
154
  {
158
155
  name: "transactions",
159
- desc: "Browse transactions (interactive TTY) or list them (piped/--no-interactive)",
156
+ desc: "Browse transactions (with optional filters)",
160
157
  },
161
158
  {
162
159
  name: "record",
@@ -168,11 +165,7 @@ program.configureHelp({
168
165
  },
169
166
  {
170
167
  name: "rules",
171
- desc: "List rules the system has learned",
172
- },
173
- {
174
- name: "forget",
175
- desc: "Delete learned rules whose ids match <regex> (anchored)",
168
+ desc: "Browse the rules the system has learned (press d to delete)",
176
169
  },
177
170
  {
178
171
  name: "clarify",
@@ -1,4 +1,5 @@
1
1
  import { type ReactNode } from "react";
2
+ import { type Key } from "ink";
2
3
  export interface ListBrowserAdapter<T> {
3
4
  title: string;
4
5
  filterSummary?: string;
@@ -22,6 +23,12 @@ export interface ListBrowserAdapter<T> {
22
23
  summary?: ReactNode;
23
24
  /** Optional override for the "no results" empty state. */
24
25
  emptyMessage?: string;
26
+ /** Adapter-owned key handler. Runs after search-mode and before built-in
27
+ * navigation; return true to mark the key consumed so default handling
28
+ * (q/arrows/g/G/return/etc.) is skipped. */
29
+ onKey?: (input: string, key: Key, ctx: {
30
+ cursorItem: T | null;
31
+ }) => boolean;
25
32
  }
26
33
  /**
27
34
  * Alternate-screen list browser shell. The type-specific behavior lives in the
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } 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,66 +74,52 @@ 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);
@@ -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
+ }
@@ -37,7 +37,7 @@ export function ScanDashboard(props) {
37
37
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { phase: phase }), _jsx(AttachmentLine, { info: props.attachment }), _jsx(Box, { marginTop: 1, children: _jsx(ColumnHeader, {}) }), _jsx(Divider, { width: ruleWidth }), Array.from(rows.entries()).map(([fileId, group]) => (_jsx(FileGroupView, { group: group }, fileId))), _jsx(Divider, { width: ruleWidth }), phase !== "done" && _jsx(Footnote, {})] }));
38
38
  }
39
39
  function Footnote() {
40
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "output accuracy depends on the model's VL capability." }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "we also provide " }), _jsx(Text, { color: "cyan", children: "clarify" }), _jsx(Text, { dimColor: true, children: ", " }), _jsx(Text, { color: "cyan", children: "record" }), _jsx(Text, { dimColor: true, children: ", and " }), _jsx(Text, { color: "cyan", children: "chat" }), _jsx(Text, { dimColor: true, children: " to rectify the data later." })] })] }));
40
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "output accuracy depends on the model's vision capability." }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "by the way, user can run" }), _jsx(Text, { color: "cyan", children: "clarify" }), _jsx(Text, { dimColor: true, children: ", " }), _jsx(Text, { color: "cyan", children: "record" }), _jsx(Text, { dimColor: true, children: ", and " }), _jsx(Text, { color: "cyan", children: "chat" }), _jsx(Text, { dimColor: true, children: " to correct the data later." })] })] }));
41
41
  }
42
42
  function AttachmentLine({ info }) {
43
43
  const detail = info.format === "pdf" ? "pdf (native)" : "png (rasterized)";
@@ -81,9 +81,6 @@ function phaseStateOf(label, current) {
81
81
  return "pending";
82
82
  }
83
83
  function Header({ phase }) {
84
- // Cancellation collapses the parse/clarify segments — neither is still
85
- // running once the user hits Ctrl+C, and showing them as "pending" would
86
- // be misleading. The single "cancelling…" label communicates the wind-down.
87
84
  if (phase === "cancelling") {
88
85
  return (_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Scanner" }), _jsx(Text, { dimColor: true, children: " · " }), _jsx(Text, { color: "green", children: "\u2713 decrypt" }), _jsx(Text, { dimColor: true, children: " -> " }), _jsx(Text, { color: "green", children: "\u2713 chunk" }), _jsx(Text, { dimColor: true, children: " -> " }), _jsxs(Text, { color: "red", children: [_jsx(Spinner, { type: "dots" }), " cancelling\u2026"] })] }));
89
86
  }
@@ -0,0 +1,2 @@
1
+ import type { Key } from "ink";
2
+ export declare function keyOf(input: string, key: Key): string;
@@ -0,0 +1,19 @@
1
+ const SPECIAL = [
2
+ [(k) => k.escape, "escape"],
3
+ [(k) => k.return, "return"],
4
+ [(k) => k.backspace, "backspace"],
5
+ [(k) => k.delete, "delete"],
6
+ [(k) => k.upArrow, "upArrow"],
7
+ [(k) => k.downArrow, "downArrow"],
8
+ [(k) => k.leftArrow, "leftArrow"],
9
+ [(k) => k.rightArrow, "rightArrow"],
10
+ [(k) => k.pageUp, "pageUp"],
11
+ [(k) => k.pageDown, "pageDown"],
12
+ [(k) => k.tab, "tab"],
13
+ ];
14
+ export function keyOf(input, key) {
15
+ for (const [pred, name] of SPECIAL)
16
+ if (pred(key))
17
+ return name;
18
+ return input;
19
+ }
@@ -43,10 +43,12 @@ export async function parsePhase(db, state, hooks) {
43
43
  error: errorMessage(r.error),
44
44
  });
45
45
  }
46
- // Only flip files to "scanned" for groups that actually completed. On abort
47
- // the pool leaves later groups unclaimed (their settled slot is undefined);
48
- // those rows stay `pending` so a future re-scan can pick them up. Partial
49
- // transactions already committed during the run stay (scanner is DB-direct).
46
+ /**
47
+ * Only flip files to "scanned" for groups that actually completed. On abort
48
+ * the pool leaves later groups unclaimed (their settled slot is undefined);
49
+ * those rows stay `pending` so a future re-scan can pick them up. Partial
50
+ * transactions already committed during the run stay (scanner is DB-direct).
51
+ */
50
52
  for (let i = 0; i < fileGroups.length; i++) {
51
53
  if (!settled[i])
52
54
  continue;
@@ -43,8 +43,10 @@ export async function runScanWorker(deps, hooks) {
43
43
  }));
44
44
  hooks.onWorkerEnd?.(workerId, deps.chunk, outcome.ok);
45
45
  if (!outcome.ok) {
46
- // A worker whose in-flight call was cancelled by Ctrl+C is not a real
47
- // failure don't pollute the questions table with chunk_failed rows.
46
+ /**
47
+ * A worker whose in-flight call was cancelled by Ctrl+C is not a real
48
+ * failure — don't pollute the questions table with chunk_failed rows.
49
+ */
48
50
  if (deps.signal.aborted)
49
51
  return;
50
52
  recordChunkFailure(deps, outcome.error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plasalid",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "description": "Plasalid — The Harness Layer for Personal Finance",
5
5
  "keywords": [
6
6
  "finance",