plasalid 0.6.9 → 0.7.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 (56) hide show
  1. package/README.md +3 -5
  2. package/dist/accounts/taxonomy.d.ts +0 -23
  3. package/dist/accounts/taxonomy.js +15 -15
  4. package/dist/ai/agent.d.ts +4 -4
  5. package/dist/ai/agent.js +9 -8
  6. package/dist/ai/context.d.ts +0 -2
  7. package/dist/ai/context.js +2 -2
  8. package/dist/ai/memory.d.ts +1 -0
  9. package/dist/ai/memory.js +4 -0
  10. package/dist/ai/personas.js +3 -6
  11. package/dist/ai/provider.d.ts +1 -0
  12. package/dist/ai/thinking.d.ts +0 -6
  13. package/dist/ai/thinking.js +29 -4
  14. package/dist/ai/tools/index.d.ts +5 -1
  15. package/dist/ai/tools/index.js +21 -15
  16. package/dist/ai/tools/ingest.js +94 -110
  17. package/dist/ai/tools/resolve.js +15 -44
  18. package/dist/cli/commands/accounts.d.ts +4 -1
  19. package/dist/cli/commands/accounts.js +39 -20
  20. package/dist/cli/commands/scan.js +47 -47
  21. package/dist/cli/commands/status.js +81 -14
  22. package/dist/cli/commands/transactions.d.ts +3 -1
  23. package/dist/cli/commands/transactions.js +37 -34
  24. package/dist/cli/format.d.ts +0 -1
  25. package/dist/cli/format.js +1 -1
  26. package/dist/cli/helper.d.ts +11 -0
  27. package/dist/cli/helper.js +24 -0
  28. package/dist/cli/index.js +14 -10
  29. package/dist/cli/ink/AccountsBrowser.d.ts +7 -0
  30. package/dist/cli/ink/AccountsBrowser.js +149 -0
  31. package/dist/cli/ink/ListBrowser.d.ts +38 -0
  32. package/dist/cli/ink/ListBrowser.js +154 -0
  33. package/dist/cli/ink/TransactionsBrowser.d.ts +6 -0
  34. package/dist/cli/ink/TransactionsBrowser.js +87 -0
  35. package/dist/cli/ink/hooks/useFooterText.js +30 -11
  36. package/dist/cli/ink/runBrowser.d.ts +7 -0
  37. package/dist/cli/ink/runBrowser.js +24 -0
  38. package/dist/cli/ux.d.ts +4 -5
  39. package/dist/cli/ux.js +87 -66
  40. package/dist/db/connection.d.ts +0 -2
  41. package/dist/db/connection.js +0 -5
  42. package/dist/db/queries/files.d.ts +11 -0
  43. package/dist/db/queries/files.js +16 -0
  44. package/dist/db/queries/recurrences.d.ts +7 -0
  45. package/dist/db/queries/recurrences.js +21 -0
  46. package/dist/db/queries/transactions.d.ts +28 -4
  47. package/dist/db/queries/transactions.js +68 -15
  48. package/dist/db/queries/unknowns.d.ts +3 -5
  49. package/dist/db/queries/unknowns.js +4 -4
  50. package/dist/db/schema.js +8 -0
  51. package/dist/lib/runPasses.d.ts +30 -0
  52. package/dist/lib/runPasses.js +15 -0
  53. package/dist/resolver/pipeline.d.ts +6 -6
  54. package/dist/resolver/pipeline.js +50 -22
  55. package/dist/scanner/inspectors/similarities.js +14 -16
  56. package/package.json +2 -2
@@ -1,21 +1,88 @@
1
1
  import chalk from "chalk";
2
2
  import { getDb } from "../../db/connection.js";
3
- import { getNetWorth, getPeriodTotals, } from "../../db/queries/account-balance.js";
3
+ import { getNetWorth } from "../../db/queries/account-balance.js";
4
+ import { countTransactions } from "../../db/queries/transactions.js";
5
+ import { getRecurringSummary } from "../../db/queries/recurrences.js";
6
+ import { countScannedFiles } from "../../db/queries/files.js";
7
+ import { countOpenUnknowns } from "../../db/queries/unknowns.js";
8
+ import { countMemories } from "../../ai/memory.js";
4
9
  import { formatAmount } from "../../currency.js";
10
+ import { visibleLength } from "../format.js";
11
+ const LABEL_WIDTH = 18;
5
12
  export function showStatus() {
6
13
  const db = getDb();
7
- const nw = getNetWorth(db);
8
- console.log(chalk.bold("Net worth: ") + formatAmount(nw.net_worth));
9
- console.log(chalk.dim(`Assets ${formatAmount(nw.assets)} − Liabilities ${formatAmount(nw.liabilities)}`));
10
- const now = new Date();
11
- const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
12
- .toISOString()
13
- .slice(0, 10);
14
- const today = now.toISOString().slice(0, 10);
15
- const totals = getPeriodTotals(db, monthStart, today);
14
+ printSection("Financial", financialRows(db));
16
15
  console.log("");
17
- console.log(chalk.bold(`This month (${monthStart} → ${today})`));
18
- console.log(` Income: ${formatAmount(totals.income)}`);
19
- console.log(` Expenses: ${formatAmount(totals.expenses)}`);
20
- console.log(` Net: ${formatAmount(totals.income - totals.expenses)}`);
16
+ printSection("System", systemRows(db));
17
+ }
18
+ function financialRows(db) {
19
+ const nw = getNetWorth(db);
20
+ const rows = [
21
+ { label: "Net worth", value: formatAmount(nw.net_worth) },
22
+ { label: "Assets", value: chalk.dim(formatAmount(nw.assets)) },
23
+ { label: "Liabilities", value: chalk.dim(formatAmount(nw.liabilities)) },
24
+ ];
25
+ const recurring = getRecurringSummary(db);
26
+ if (recurring.count > 0) {
27
+ const monthly = recurring.monthly_estimate > 0
28
+ ? ` · ${formatAmount(recurring.monthly_estimate)} / month (est.)`
29
+ : "";
30
+ rows.push({
31
+ label: "Recurring",
32
+ value: `${recurring.count} active${chalk.dim(monthly)}`,
33
+ });
34
+ }
35
+ return rows;
36
+ }
37
+ function systemRows(db) {
38
+ const tx = countTransactions(db);
39
+ const files = countScannedFiles(db);
40
+ const memories = countMemories(db);
41
+ const unknowns = countOpenUnknowns(db);
42
+ const rows = [
43
+ {
44
+ label: "Transactions",
45
+ value: formatInteger(tx.transactions),
46
+ suffix: tx.postings > 0
47
+ ? chalk.dim(`(${formatInteger(tx.postings)} postings)`)
48
+ : undefined,
49
+ },
50
+ ];
51
+ if (files.scanned + files.pending + files.failed > 0) {
52
+ const extras = [];
53
+ if (files.pending > 0)
54
+ extras.push(`${files.pending} pending`);
55
+ if (files.failed > 0)
56
+ extras.push(chalk.red(`${files.failed} failed`));
57
+ rows.push({
58
+ label: "Scanned",
59
+ value: formatInteger(files.scanned),
60
+ suffix: extras.length > 0 ? chalk.dim(`(${extras.join(", ")})`) : undefined,
61
+ });
62
+ }
63
+ if (memories > 0) {
64
+ rows.push({ label: "Memories", value: formatInteger(memories) });
65
+ }
66
+ if (unknowns > 0) {
67
+ rows.push({
68
+ label: "Unknowns",
69
+ value: chalk.yellow(formatInteger(unknowns)),
70
+ suffix: chalk.dim("run `plasalid resolve`"),
71
+ });
72
+ }
73
+ return rows;
74
+ }
75
+ function printSection(title, rows) {
76
+ console.log(chalk.bold(title));
77
+ console.log(chalk.dim("─".repeat(title.length)));
78
+ const valueWidth = Math.max(0, ...rows.map((r) => visibleLength(r.value)));
79
+ for (const row of rows) {
80
+ const label = row.label.padEnd(LABEL_WIDTH);
81
+ const valuePad = " ".repeat(Math.max(0, valueWidth - visibleLength(row.value)));
82
+ const suffix = row.suffix ? ` ${row.suffix}` : "";
83
+ console.log(` ${label}${valuePad}${row.value}${suffix}`);
84
+ }
85
+ }
86
+ function formatInteger(n) {
87
+ return n.toLocaleString("en-US");
21
88
  }
@@ -4,5 +4,7 @@ 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;
7
9
  }
8
- export declare function showTransactions(opts: ShowTransactionsOptions): void;
10
+ export declare function showTransactions(opts: ShowTransactionsOptions): Promise<void>;
@@ -1,51 +1,53 @@
1
1
  import chalk from "chalk";
2
2
  import { getDb } from "../../db/connection.js";
3
- import { listPostings } from "../../db/queries/transactions.js";
3
+ import { groupByTransaction, listPostings, } from "../../db/queries/transactions.js";
4
4
  import { visibleLength } from "../format.js";
5
5
  import { formatAmount } from "../../currency.js";
6
- function truncateMiddle(s, max) {
7
- if (s.length <= max)
8
- return s;
9
- if (max < 5)
10
- return s.slice(0, max);
11
- const keep = max - 1;
12
- const head = Math.ceil(keep / 2);
13
- const tail = Math.floor(keep / 2);
14
- return `${s.slice(0, head)}…${s.slice(s.length - tail)}`;
15
- }
6
+ import { truncateMiddle } from "../helper.js";
16
7
  const ACCOUNT_CAP = 32;
17
8
  const MEMO_CAP = 40;
18
- function groupByTransaction(postings) {
19
- const groups = [];
20
- let current = null;
21
- for (const p of postings) {
22
- if (!current || current.transaction_id !== p.transaction_id) {
23
- current = {
24
- transaction_id: p.transaction_id,
25
- date: p.transaction_date ?? "",
26
- description: p.transaction_description ?? "",
27
- merchant: p.merchant_name ?? null,
28
- postings: [],
29
- };
30
- groups.push(current);
31
- }
32
- current.postings.push(p);
33
- }
34
- return groups;
35
- }
36
- export function showTransactions(opts) {
9
+ const INTERACTIVE_LIMIT = 1000;
10
+ const RECURRING_MARKER = "[R]";
11
+ export async function showTransactions(opts) {
37
12
  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);
38
15
  const postings = listPostings(db, {
39
16
  account_id: opts.account,
40
17
  from: opts.from,
41
18
  to: opts.to,
42
19
  q: opts.query,
43
- limit: opts.limit ?? 100,
20
+ limit: requestedLimit,
44
21
  });
45
22
  if (postings.length === 0) {
46
23
  console.log(chalk.yellow("No postings match those filters."));
47
24
  return;
48
25
  }
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);
37
+ }
38
+ function buildFilterSummary(opts) {
39
+ const parts = [];
40
+ if (opts.account)
41
+ parts.push(`account=${opts.account}`);
42
+ if (opts.from)
43
+ parts.push(`from=${opts.from}`);
44
+ if (opts.to)
45
+ parts.push(`to=${opts.to}`);
46
+ if (opts.query)
47
+ parts.push(`query="${opts.query}"`);
48
+ return parts.join(" · ");
49
+ }
50
+ function printTransactionsPlain(postings) {
49
51
  const truncatedAccount = new Map();
50
52
  const truncatedMemo = new Map();
51
53
  for (const p of postings) {
@@ -58,7 +60,7 @@ export function showTransactions(opts) {
58
60
  const amountWidth = Math.max(...postings.map((p) => {
59
61
  const side = p.debit > 0 ? "DR" : "CR";
60
62
  const amt = p.debit > 0 ? p.debit : p.credit;
61
- return `${side} ${formatAmount(amt)}`.length;
63
+ return `${side} ${formatAmount(amt, p.currency)}`.length;
62
64
  }));
63
65
  const cols = process.stdout.columns || 100;
64
66
  const descMax = Math.max(20, cols - 14);
@@ -66,13 +68,14 @@ export function showTransactions(opts) {
66
68
  for (const g of groups) {
67
69
  const desc = truncateMiddle(g.description, descMax);
68
70
  const merchant = g.merchant ? chalk.green(` · ${g.merchant}`) : "";
69
- console.log(`${chalk.dim(g.date)} ${chalk.bold(desc)}${merchant}`);
71
+ const recurring = g.recurrence_id ? chalk.dim(` ${RECURRING_MARKER}`) : "";
72
+ console.log(`${chalk.dim(g.date)} ${chalk.bold(desc)}${merchant}${recurring}`);
70
73
  for (const p of g.postings) {
71
74
  const acct = truncatedAccount.get(p.id);
72
75
  const acctPadded = acct + " ".repeat(accountWidth - acct.length);
73
76
  const side = p.debit > 0 ? "DR" : "CR";
74
77
  const amt = p.debit > 0 ? p.debit : p.credit;
75
- const rawAmount = `${side} ${formatAmount(amt)}`;
78
+ const rawAmount = `${side} ${formatAmount(amt, p.currency)}`;
76
79
  const colored = p.debit > 0 ? chalk.cyan(rawAmount) : chalk.magenta(rawAmount);
77
80
  const amountPadded = " ".repeat(amountWidth - visibleLength(colored)) + colored;
78
81
  const memo = truncatedMemo.get(p.id);
@@ -1,4 +1,3 @@
1
- export declare const ANSI_RE: RegExp;
2
1
  export declare function visibleLength(s: string): number;
3
2
  export declare function formatDuration(ms: number): string;
4
3
  export declare function formatError(error: any, context?: string): string;
@@ -1,6 +1,6 @@
1
1
  import chalk from "chalk";
2
2
  // eslint-disable-next-line no-control-regex
3
- export const ANSI_RE = /\x1b\[[0-9;]*m/g;
3
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
4
4
  export function visibleLength(s) {
5
5
  return s.replace(ANSI_RE, "").length;
6
6
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Trim `s` to `max` characters, keeping the head and tail and inserting `…`
3
+ * in the middle. Returns `s` unchanged when already short enough.
4
+ */
5
+ export declare function truncateMiddle(s: string, max: number): string;
6
+ /**
7
+ * Right-pad to a fixed visible width. Assumes `s` has no ANSI codes — callers
8
+ * working with colored strings should compose color around the padded value,
9
+ * not inside it.
10
+ */
11
+ export declare function padRight(s: string, width: number): string;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Trim `s` to `max` characters, keeping the head and tail and inserting `…`
3
+ * in the middle. Returns `s` unchanged when already short enough.
4
+ */
5
+ export function truncateMiddle(s, max) {
6
+ if (max <= 0)
7
+ return "";
8
+ if (s.length <= max)
9
+ return s;
10
+ if (max < 5)
11
+ return s.slice(0, max);
12
+ const keep = max - 1;
13
+ const head = Math.ceil(keep / 2);
14
+ const tail = Math.floor(keep / 2);
15
+ return `${s.slice(0, head)}…${s.slice(s.length - tail)}`;
16
+ }
17
+ /**
18
+ * Right-pad to a fixed visible width. Assumes `s` has no ANSI codes — callers
19
+ * working with colored strings should compose color around the padded value,
20
+ * not inside it.
21
+ */
22
+ export function padRight(s, width) {
23
+ return s.length >= width ? s : s + " ".repeat(width - s.length);
24
+ }
package/dist/cli/index.js CHANGED
@@ -46,11 +46,12 @@ program
46
46
  });
47
47
  program
48
48
  .command("accounts")
49
- .description("Show the chart of accounts with balances")
50
- .action(async () => {
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) => {
51
52
  ensureConfigured();
52
53
  const { showAccounts } = await import("./commands/accounts.js");
53
- showAccounts();
54
+ await showAccounts({ noInteractive: opts.interactive === false });
54
55
  });
55
56
  program
56
57
  .command("status")
@@ -62,21 +63,24 @@ program
62
63
  });
63
64
  program
64
65
  .command("transactions")
65
- .description("List transactions and their postings")
66
+ .description("Browse transactions (interactive TTY) or print them (piped)")
66
67
  .option("-a, --account <id>", "Filter by account id")
67
68
  .option("--from <date>", "From date YYYY-MM-DD")
68
69
  .option("--to <date>", "To date YYYY-MM-DD")
69
70
  .option("-q, --query <text>", "Free-text search on description / memo")
70
- .option("-n, --limit <number>", "Max results", "100")
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")
71
73
  .action(async (opts) => {
72
74
  ensureConfigured();
73
75
  const { showTransactions } = await import("./commands/transactions.js");
74
- showTransactions({
76
+ await showTransactions({
75
77
  account: opts.account,
76
78
  from: opts.from,
77
79
  to: opts.to,
78
80
  query: opts.query,
79
- limit: Number(opts.limit),
81
+ limit: opts.limit != null ? Number(opts.limit) : undefined,
82
+ // commander inverts --no-foo to `opts.foo = false`
83
+ noInteractive: opts.interactive === false,
80
84
  });
81
85
  });
82
86
  program
@@ -165,11 +169,11 @@ program.configureHelp({
165
169
  name: "data",
166
170
  desc: "Open the data folder in your OS file explorer (alias: open)",
167
171
  },
168
- { name: "accounts", desc: "Show the chart of accounts with balances" },
169
- { name: "status", desc: "Show net worth and this-month totals" },
172
+ { name: "accounts", desc: "Browse the chart of accounts (interactive TTY) or list them (piped)" },
173
+ { name: "status", desc: "Show financial and system status (net worth, recurring, unknowns)" },
170
174
  {
171
175
  name: "transactions",
172
- desc: "List transactions and their postings (filter by account/date/text)",
176
+ desc: "Browse transactions (interactive TTY) or list them (piped/--no-interactive)",
173
177
  },
174
178
  {
175
179
  name: "record",
@@ -0,0 +1,7 @@
1
+ import type { AccountBalance } from "../../db/queries/account-balance.js";
2
+ import type { PostingRow } from "../../db/queries/transactions.js";
3
+ export interface AccountsBrowserProps {
4
+ accounts: AccountBalance[];
5
+ recentTransactionsByAccount: Map<string, PostingRow[]>;
6
+ }
7
+ export declare function AccountsBrowser({ accounts, recentTransactionsByAccount }: AccountsBrowserProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,149 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { memo, useMemo } from "react";
3
+ import { Box, Text } from "ink";
4
+ import chalk from "chalk";
5
+ import { formatAmount, formatSignedAmount } from "../../currency.js";
6
+ import { padRight, truncateMiddle } from "../helper.js";
7
+ import { ListBrowser } from "./ListBrowser.js";
8
+ const TYPE_TAG = {
9
+ asset: "asset",
10
+ liability: "liab",
11
+ income: "income",
12
+ expense: "expense",
13
+ equity: "equity",
14
+ };
15
+ const TYPE_TAG_WIDTH = 8;
16
+ const TYPE_RANK = {
17
+ asset: 0,
18
+ liability: 1,
19
+ income: 2,
20
+ expense: 3,
21
+ equity: 4,
22
+ };
23
+ const MIN_NAME_WIDTH = 12;
24
+ export function AccountsBrowser({ accounts, recentTransactionsByAccount }) {
25
+ const sorted = useMemo(() => {
26
+ return [...accounts].sort((a, b) => {
27
+ const t = TYPE_RANK[a.type] - TYPE_RANK[b.type];
28
+ if (t !== 0)
29
+ return t;
30
+ return a.id.localeCompare(b.id);
31
+ });
32
+ }, [accounts]);
33
+ const precomputed = useMemo(() => {
34
+ const byId = new Map(sorted.map(a => [a.id, a]));
35
+ const depthCache = new Map();
36
+ const depthOf = (id) => {
37
+ const cached = depthCache.get(id);
38
+ if (cached !== undefined)
39
+ return cached;
40
+ const node = byId.get(id);
41
+ if (!node || !node.parent_id) {
42
+ depthCache.set(id, 0);
43
+ return 0;
44
+ }
45
+ const d = depthOf(node.parent_id) + 1;
46
+ depthCache.set(id, d);
47
+ return d;
48
+ };
49
+ return sorted.map(a => ({
50
+ item: a,
51
+ indent: " ".repeat(depthOf(a.id)),
52
+ displayName: a.name,
53
+ balanceText: formatSignedAmount(a.balance),
54
+ meta: compactMeta(a),
55
+ }));
56
+ }, [sorted]);
57
+ const adapter = useMemo(() => ({
58
+ title: "Accounts",
59
+ items: precomputed,
60
+ getId: p => p.item.id,
61
+ renderRow: (p, ctx) => renderAccountRow(p, ctx.isCursor, ctx.isExpanded, ctx.cols),
62
+ renderExpanded: p => (_jsx(RecentTransactionsView, { postings: recentTransactionsByAccount.get(p.item.id) ?? [] })),
63
+ getExpandedHeight: p => {
64
+ const n = recentTransactionsByAccount.get(p.item.id)?.length ?? 0;
65
+ return n > 0 ? n : 1; // 1 for the empty-state line
66
+ },
67
+ matches: (p, needle) => accountMatches(p.item, needle),
68
+ emptyMessage: "No accounts yet. Run `plasalid scan` to ingest statements.",
69
+ }), [precomputed, recentTransactionsByAccount]);
70
+ return _jsx(ListBrowser, { adapter: adapter });
71
+ }
72
+ function renderAccountRow(p, isCursor, isExpanded, cols) {
73
+ const a = p.item;
74
+ const marker = isExpanded ? "▾" : isCursor ? "▸" : " ";
75
+ const tag = chalk.dim(padRight(TYPE_TAG[a.type], TYPE_TAG_WIDTH));
76
+ const balanceRaw = p.balanceText;
77
+ const metaRaw = p.meta.join(" · ");
78
+ // Layout: "M tag(8) name balance[ meta…]"
79
+ // Just spacing — no column alignment. Balance follows the name directly,
80
+ // meta (if any) follows the balance. Truncate name to whatever room remains.
81
+ const fixedWidth = 1 + 1 + TYPE_TAG_WIDTH + 2 + 2 + balanceRaw.length + (metaRaw ? 2 + metaRaw.length : 0);
82
+ const nameBudget = Math.max(MIN_NAME_WIDTH, cols - fixedWidth - 2);
83
+ const nameRaw = truncateMiddle(p.indent + p.displayName, nameBudget);
84
+ const name = isCursor ? chalk.cyan.bold(nameRaw) : chalk.bold(nameRaw);
85
+ const balance = isCursor
86
+ ? chalk.cyan(balanceRaw)
87
+ : a.balance < 0
88
+ ? chalk.red(balanceRaw)
89
+ : balanceRaw;
90
+ const meta = metaRaw ? ` ${chalk.dim(metaRaw)}` : "";
91
+ return `${marker} ${tag} ${name} ${balance}${meta}`;
92
+ }
93
+ function compactMeta(a) {
94
+ const meta = [];
95
+ if (a.bank_name)
96
+ meta.push(a.bank_name);
97
+ if (a.due_day)
98
+ meta.push(`due ${a.due_day}`);
99
+ if (a.statement_day)
100
+ meta.push(`stmt ${a.statement_day}`);
101
+ if (a.points_balance)
102
+ meta.push(`${a.points_balance.toLocaleString()} pts`);
103
+ if (a.currency && a.currency !== "THB")
104
+ meta.push(a.currency);
105
+ if (meta.length === 0 && a.subtype)
106
+ meta.push(a.subtype);
107
+ return meta;
108
+ }
109
+ function accountMatches(a, needle) {
110
+ if (a.name.toLowerCase().includes(needle))
111
+ return true;
112
+ if (a.id.toLowerCase().includes(needle))
113
+ return true;
114
+ if (a.bank_name && a.bank_name.toLowerCase().includes(needle))
115
+ return true;
116
+ if (a.subtype && a.subtype.toLowerCase().includes(needle))
117
+ return true;
118
+ return false;
119
+ }
120
+ const DATE_WIDTH = 10;
121
+ const MIN_DESC_WIDTH_DETAIL = 16;
122
+ const RecentTransactionsView = memo(function RecentTransactionsView({ postings, }) {
123
+ if (postings.length === 0) {
124
+ return (_jsx(Box, { flexDirection: "column", marginLeft: 6, children: _jsx(Text, { dimColor: true, children: "No recent activity on this account." }) }));
125
+ }
126
+ // Pre-compute the amount column width so amounts line up.
127
+ const amountColumn = postings.map(p => {
128
+ const side = p.debit > 0 ? "DR" : "CR";
129
+ const amount = p.debit > 0 ? p.debit : p.credit;
130
+ return `${side} ${formatAmount(amount, p.currency)}`;
131
+ });
132
+ const amountWidth = Math.max(...amountColumn.map(s => s.length));
133
+ return (_jsx(Box, { flexDirection: "column", marginLeft: 6, children: postings.map((p, i) => {
134
+ const date = chalk.dim(padRight(p.transaction_date ?? "", DATE_WIDTH));
135
+ const merchantText = p.merchant_name ? ` · ${p.merchant_name}` : "";
136
+ const description = p.transaction_description ?? "";
137
+ const memoText = p.memo ? ` ${chalk.dim(p.memo)}` : "";
138
+ // Pad amount to its column, then color.
139
+ const amountRaw = amountColumn[i];
140
+ const amountPadded = padRight(amountRaw, amountWidth);
141
+ const color = p.debit > 0 ? chalk.cyan : chalk.magenta;
142
+ const amount = color(amountPadded);
143
+ // Truncate description+merchant to whatever room remains.
144
+ const descBudget = Math.max(MIN_DESC_WIDTH_DETAIL, 80 - merchantText.length);
145
+ const desc = truncateMiddle(description, descBudget);
146
+ const merchant = merchantText ? chalk.green(merchantText) : "";
147
+ return (_jsx(Text, { children: `${date} ${desc}${merchant} ${amount}${memoText}` }, p.id));
148
+ }) }));
149
+ });
@@ -0,0 +1,38 @@
1
+ import { type ReactNode } from "react";
2
+ export interface ListBrowserAdapter<T> {
3
+ title: string;
4
+ filterSummary?: string;
5
+ items: T[];
6
+ getId: (item: T) => string;
7
+ /** Returns the row as a single ANSI-colored string for the given context. */
8
+ renderRow: (item: T, ctx: {
9
+ isCursor: boolean;
10
+ isExpanded: boolean;
11
+ cols: number;
12
+ }) => string;
13
+ /** Optional expanded body rendered below the row when isExpanded is true. */
14
+ renderExpanded?: (item: T) => ReactNode;
15
+ /** Lines the expanded body will occupy. The shell budgets viewport space
16
+ * with this so the expanded row + body never push the header off-screen.
17
+ * Default 0 — implement when `renderExpanded` is set. */
18
+ getExpandedHeight?: (item: T) => number;
19
+ /** In-app search predicate. */
20
+ matches: (item: T, needle: string) => boolean;
21
+ /** Optional aggregate footer rendered above the keybindings hint. */
22
+ summary?: ReactNode;
23
+ /** Optional override for the "no results" empty state. */
24
+ emptyMessage?: string;
25
+ }
26
+ /**
27
+ * Alternate-screen list browser shell. The type-specific behavior lives in the
28
+ * `adapter` — this component owns terminal dimensions, the edge-scroll window,
29
+ * cursor / search / expand state, key dispatch, and the header/footer chrome.
30
+ *
31
+ * Render strategy: a memoized `Row` short-circuits when its props (a single
32
+ * pre-composed string + an optional expanded node) are unchanged. Combined
33
+ * with the edge-scroll window, most cursor moves only invalidate the two
34
+ * rows whose `isCursor` flag flipped.
35
+ */
36
+ export declare function ListBrowser<T>({ adapter }: {
37
+ adapter: ListBrowserAdapter<T>;
38
+ }): import("react/jsx-runtime").JSX.Element;