Compare commits

..

7 commits

Author SHA1 Message Date
020fb2db77
suppress output on tests
All checks were successful
Generic zig build / build (push) Successful in 5m39s
Generic zig build / publish-macos (push) Successful in 11s
Generic zig build / deploy (push) Successful in 18s
2026-06-28 08:09:18 -07:00
821c084f70
update ack hint 2026-06-28 08:06:10 -07:00
9f47d46d9a
vaxis has its own handling of this - use that 2026-06-28 07:56:24 -07:00
8b85dcb9ea
handle upper case in input buffers 2026-06-28 07:27:34 -07:00
8986537d93
fix visuals on review tab 2026-06-28 07:20:19 -07:00
f1f665758c
implement per-account audit large lot threshold overrides 2026-06-27 14:01:29 -07:00
354a7e7799
remove unnecessary todo items 2026-06-27 12:38:11 -07:00
11 changed files with 815 additions and 209 deletions

94
TODO.md
View file

@ -39,9 +39,6 @@ ranking; unlabeled items are "someday, if the mood strikes."
market return vs contributions" annotation would clarify how
much of the trajectory was the model being right vs new money
arriving on schedule.
- Mosaic mode: overlay multiple as-of starting points on one chart
("show me 1Y, 3Y, 5Y, 10Y projections all at once") so the user
can see how the projection envelope tightened as data came in.
- **Better composition basis for imported-only as-of.** Today
the imported-only path uses today's allocations scaled by
`imported_liquid / today_total_liquid`. That's the simplest
@ -70,38 +67,6 @@ ranking; unlabeled items are "someday, if the mood strikes."
faithfulness one notch. Pick whichever has the highest
payoff vs. complexity when this gets revisited.
## `--export-chart` follow-ups - priority LOW
V1 of `--export-chart <PATH>` shipped for `quote`, `projections`
(bands, `--convergence`, and `--return-backtest` modes), and
`history`. Two adjacent surfaces still don't have PNG export:
- **`projections --vs <DATE>`.** No chart at all in this mode
(text-only delta table); `--export-chart` rejected at parse
time. Could grow a side-by-side bands comparison chart, but
that's a feature of its own - not just an export plumbing job.
- **Theme overrides at export time.** Today the export always
uses `theme.default_theme`. A `--theme <PATH>` flag at export
time would let users render with their configured theme or a
presentation-friendly one. Out of scope for V1; gate when
someone asks for it.
## Refactor: trim `src/format.zig` once Money / Date have absorbed their helpers - priority LOW
`src/format.zig` is still a ~1600-line grab-bag, but the money- and
date-shaped helpers that used to live there have been moved out:
money formatting now lives in `src/Money.zig` (with `{f}` /
`whole()` / `trim()` / `signed()` / `padRight(N)` / `padLeft(N)`),
date formatting lives in `src/Date.zig` (with `{f}` /
`padRight(N)` / `padLeft(N)`), and the braille sparkline chart now
lives in `src/charts/braille.zig`. What's left in `format.zig` is
the genuinely-format-domain stuff: return formatters, allocation
notes, signed-percent rendering.
If the file ever grows enough to be annoying again, consider
renaming to `src/render.zig` to better describe what's left.
Not blocking - file it as cleanup if and when it bites.
## Investigate: detailed 401(k) contributions data source
Found a more detailed contributions screen on at least one
@ -130,46 +95,6 @@ opts ESPP/HSA accounts into cash-based attribution.
Related: ESPP-style accrual blind spot in the "Audit: manual-check
accounts mechanism" section above.
## Torn SRF files from server sync (root cause unknown)
**Status:** Root cause still unidentified. We have mitigations and
diagnostics in place that keep torn responses from corrupting the
cache, but we don't yet know *why* responses arrive torn. Until we
have a root cause, this is not resolved - it's mitigated.
Mitigations landed so far:
- `syncFromServer` (`src/service.zig`) validates responses via
`cache.Store.looksCompleteSrf` before `writeRaw`. Torn HTTP bodies
(empty, missing `#!srfv1` header, or no trailing newline) are
rejected with a warn-level log and NOT written to cache.
- HTTP responses are checked for an `ETag` sha256 header; on mismatch
we retry the request once before giving up and falling back to the
provider.
- Read-path self-heal: on SRF parse failure during read, the cache
entry is invalidated so a subsequent refresh can repair without
user intervention.
- Diagnostics: richer error capture around the sync path. So far,
HTTP transit is the dominant source of torn responses - but that's
an observation, not a root cause.
**Remaining work:**
- Identify root cause. Candidates to investigate: proxy/load-balancer
behavior, HTTP keepalive reuse, partial reads on the server side,
client-side buffer handling. The etag retry tells us whether the
problem is per-request or persistent; dig into the diagnostics
output when the next occurrence is captured.
- Once root cause is known, decide whether the current mitigations
are sufficient or whether a targeted fix is needed. The
mitigations may end up being the whole answer, but we can't
conclude that without understanding the underlying cause.
(Content-Length validation was considered and rejected: once the
server starts compressing response bodies, Content-Length reflects
the compressed byte count, not the decoded payload, so it's not a
reliable integrity check.)
## On-demand server-side fetch for new symbols
Currently the server's SRF endpoints (`/candles`, `/dividends`, etc.) are pure
@ -330,25 +255,6 @@ taxonomy.
The following items are acknowledged but not prioritized. Listed here
so they don't get lost; pick up opportunistically.
### UX
- **CLI options command UX.** The `options` command auto-expands only
the nearest monthly expiration and lists others collapsed. Reconsider
the interaction model - e.g. allow specifying an expiration date,
showing all monthlies expanded by default, or filtering by strategy
(covered calls, spreads).
### Audit
- **Audit large-lot threshold tuning.** `src/commands/audit.zig` uses
`audit_large_lot_threshold: f64 = 10_000.0` as the cutoff for
"surface this new lot for confirmation." Revisit if $10k proves too
aggressive (ESPP accruals spam the report) or too permissive (large
DRIP confirmations slip past). If runtime tuning becomes necessary,
a `--large-lot <amount>` flag or a global
`audit_large_lot_threshold` field on `accounts.srf` would be
reasonable extensions.
### Infra / performance
- **HTTP connection pooling.** Parallel server sync in `loadAllPrices`

View file

@ -67,8 +67,8 @@ account::Old Rollover,tax_type::traditional,update_cadence::none
## 4. Advanced flags
Two flags change how analysis treats an account. Both are optional --
see the reference for details:
Three optional fields change how analysis and the audit treat an
account -- see the reference for details:
- **`shielded:bool:false`** -- mark a pre-tax account that is *not*
judgment-protected (deferred comp, a weak-state IRA) so it counts
@ -79,6 +79,15 @@ see the reference for details:
account as real external contributions in
[`zfin contributions`](track-contributions.md), instead of internal
noise.
- **`audit_large_lot_threshold:num:50000`** -- raise (or lower) the
dollar cutoff at which a flagless [`zfin audit`](../reference/cli/audit.md)
nudges you to confirm a **new lot**'s source. The default is $10,000;
bump it on a noisy ESPP/payroll account so routine accruals stop
spamming the report, while leaving quieter accounts at the default:
```srf
account::Sample ESPP,tax_type::taxable,audit_large_lot_threshold:num:50000
```
## Example (from `examples/pre-retirement-both`)

View file

@ -27,6 +27,13 @@ Reconciliation matches export accounts to yours via `institution::` and
`account_number::` in [`accounts.srf`](../config/accounts-srf.md); an
unmatched account is reported as "unmapped."
The hygiene check also flags newly-appeared lots worth at least
$10,000 in a **Large new lots - confirm source** section, so you can
confirm whether each is a real contribution or an unrecorded transfer.
The cutoff is per account -- raise or lower it on an account's record
via [`audit_large_lot_threshold`](../config/accounts-srf.md#audit_large_lot_threshold)
in `accounts.srf` (e.g. to silence a noisy ESPP account).
## Example (hygiene check)
```bash

View file

@ -24,16 +24,17 @@ account::Joint taxable,tax_type::taxable,institution::schwab,account_number::JT0
## Fields
| Field | Type | Required | Default | Description |
|------------------------|--------|----------|-----------|----------------------------------------------------------------------------------------------------------------------------------|
| `account` | string | Yes | -- | Account name; must match `account::` on lots exactly. |
| `tax_type` | string | Yes | -- | `taxable`, `roth`, `traditional`, or `hsa`. |
| `institution` | string | No | -- | Broker key, e.g. `fidelity`, `schwab`, `vanguard`, `wells_fargo`. Used by [`zfin audit`](../cli/audit.md) to match export files. |
| `account_number` | string | No | -- | Account identifier used with `institution` for audit matching. Use a placeholder, not a full real number. |
| `update_cadence` | string | No | `weekly` | How often you refresh this account's manual data: `weekly`, `monthly`, `quarterly`, or `none`. Drives the audit staleness nag. |
| `cash_is_contribution` | bool | No | `false` | When `true`, raw cash-balance increases on this account count as real external contributions (see below). |
| `direct_indexing` | bool | No | `false` | Marks an account whose lots track a benchmark with tracking-error drift (loosens contribution/audit tolerances). |
| `shielded` | bool | No | (derived) | Umbrella-exposure override (see below). |
| Field | Type | Required | Default | Description |
|-----------------------------|--------|----------|-----------|----------------------------------------------------------------------------------------------------------------------------------|
| `account` | string | Yes | -- | Account name; must match `account::` on lots exactly. |
| `tax_type` | string | Yes | -- | `taxable`, `roth`, `traditional`, or `hsa`. |
| `institution` | string | No | -- | Broker key, e.g. `fidelity`, `schwab`, `vanguard`, `wells_fargo`. Used by [`zfin audit`](../cli/audit.md) to match export files. |
| `account_number` | string | No | -- | Account identifier used with `institution` for audit matching. Use a placeholder, not a full real number. |
| `update_cadence` | string | No | `weekly` | How often you refresh this account's manual data: `weekly`, `monthly`, `quarterly`, or `none`. Drives the audit staleness nag. |
| `cash_is_contribution` | bool | No | `false` | When `true`, raw cash-balance increases on this account count as real external contributions (see below). |
| `direct_indexing` | bool | No | `false` | Marks an account whose lots track a benchmark with tracking-error drift (loosens contribution/audit tolerances). |
| `shielded` | bool | No | (derived) | Umbrella-exposure override (see below). |
| `audit_large_lot_threshold` | num | No | `10000` | Per-account dollar cutoff for the audit "Large new lots" nudge (see below). Must be positive. |
## Tax types
@ -47,6 +48,33 @@ account::Joint taxable,tax_type::taxable,institution::schwab,account_number::JT0
Any other value is shown as-is. Accounts missing from `accounts.srf`
appear as "Unknown".
## `audit_large_lot_threshold`
When [`zfin audit`](../cli/audit.md) runs flagless, its **Large new
lots - confirm source** section flags any newly-appeared lot worth at
least this many dollars, nudging you to confirm whether it's a real
external contribution or an unrecorded internal transfer. Smaller new
lots pass silently so routine payroll/ESPP accruals and weekly deposits
don't spam the report.
The threshold is **per account**, because the noise it fights is
account-specific: an ESPP or payroll account that accrues routine large
lots wants a high bar, while a taxable brokerage where any sizeable new
lot deserves a look wants the default (or lower). Set it on the
account's own record:
```srf
#!srfv1
account::Sample ESPP,tax_type::taxable,audit_large_lot_threshold:num:50000
account::Sample Brokerage,tax_type::taxable
```
Here the ESPP account stays quiet until a new lot tops $50k, while
`Sample Brokerage` (no override) uses the built-in `$10,000` default.
Accounts you don't list, or list without the field, use that default.
The value must be **positive** -- zero or a negative number is rejected
at load time and the account falls back to the default.
## `update_cadence` and the audit nag
[`zfin audit`](../cli/audit.md) (run flagless) flags accounts you

View file

@ -3,6 +3,7 @@
/// Takes portfolio allocations (with market values) and classification metadata,
/// produces breakdowns by asset class, sector, geographic region, account, and tax type.
const std = @import("std");
const builtin = @import("builtin");
const srf = @import("srf");
const Allocation = @import("valuation.zig").Allocation;
const ClassificationMap = @import("../models/classification.zig").ClassificationMap;
@ -10,6 +11,8 @@ const ClassificationEntry = @import("../models/classification.zig").Classificati
const Portfolio = @import("../models/portfolio.zig").Portfolio;
const Date = @import("../Date.zig");
const log = std.log.scoped(.accounts);
/// A single slice of a breakdown (e.g., "Technology" -> 25.3%)
pub const BreakdownItem = struct {
label: []const u8,
@ -94,6 +97,22 @@ pub const AccountTaxEntry = struct {
/// `shielded:bool:false` on their IRA accounts to get a
/// correct umbrella-exposure number.
shielded: ?bool = null,
/// Optional per-account override for the dollar threshold above
/// which `zfin audit` flags a new lot in its "Large new lots -
/// confirm source" section. Null means "use the audit's built-in
/// default" (`contributions.default_audit_large_lot_threshold`, $10k).
///
/// The right knob is per-account because the noise this nudge
/// fights is account-specific: an ESPP/payroll account that
/// accrues routine large lots wants a HIGH threshold to stay
/// quiet, while a taxable brokerage where any sizeable new lot is
/// worth a look wants the default (or lower). Set it higher to cut
/// ESPP spam, lower to catch smaller movements.
///
/// Must be positive; a zero or negative value is rejected at parse
/// time (warned + treated as unset) since zero would flag every
/// new lot and negative is meaningless.
audit_large_lot_threshold: ?f64 = null,
};
/// Update cadence for manual account maintenance. Parsed from accounts.srf.
@ -196,10 +215,26 @@ pub const AccountMap = struct {
}
return false;
}
/// Per-account override for the audit "Large new lots" dollar
/// threshold. Returns the account's configured value, or null to
/// fall back to the audit's built-in default
/// (`contributions.default_audit_large_lot_threshold`). Null both when
/// the account isn't in the map and when its entry omits the
/// field. Parse guarantees any non-null result is positive.
pub fn largeLotThresholdFor(self: AccountMap, account: []const u8) ?f64 {
for (self.entries) |e| {
if (std.mem.eql(u8, e.account, account)) {
return e.audit_large_lot_threshold;
}
}
return null;
}
};
/// Parse an accounts.srf file into an AccountMap.
/// Each record has: account::<NAME>,tax_type::<TYPE>[,institution::<INST>][,account_number::<NUM>]
/// Each record has: account::<NAME>,tax_type::<TYPE>[,institution::<INST>][,account_number::<NUM>][,<flags>]
/// where the optional flags include `audit_large_lot_threshold:num:<DOLLARS>`.
pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !AccountMap {
var entries = std.ArrayList(AccountTaxEntry).empty;
errdefer {
@ -217,6 +252,20 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun
while (try it.next()) |fields| {
const entry = fields.to(AccountTaxEntry, .{}) catch continue;
// A zero/negative large-lot threshold is nonsensical (zero
// flags every new lot; negative is meaningless). Reject it and
// treat the account as unset so the audit uses its default.
const lot_threshold: ?f64 = if (entry.audit_large_lot_threshold) |t| blk: {
if (t > 0) break :blk t;
// No-op under `zig build test`: the parser's own tests feed
// invalid thresholds (0, negative) on purpose to verify they
// are rejected, and the warn spam pollutes test output.
if (!builtin.is_test)
log.warn("accounts.srf: account '{s}': audit_large_lot_threshold must be > 0 (got {d}); ignoring", .{ entry.account, t });
break :blk null;
} else null;
try entries.append(allocator, .{
.account = try allocator.dupe(u8, entry.account),
.tax_type = entry.tax_type,
@ -226,6 +275,7 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun
.cash_is_contribution = entry.cash_is_contribution,
.direct_indexing = entry.direct_indexing,
.shielded = entry.shielded,
.audit_large_lot_threshold = lot_threshold,
});
}
@ -945,6 +995,71 @@ test "parseAccountsFile: shielded:bool:true override (rare, e.g. asset-protectio
try std.testing.expect(am.entries[0].shielded.?);
}
test "parseAccountsFile: audit_large_lot_threshold omitted -> null (use audit default)" {
const data =
\\#!srfv1
\\account::Sample Roth,tax_type::roth
\\account::Sample Brokerage,tax_type::taxable
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
// No override on either account -> lookup returns null so the
// audit falls back to its built-in default.
try std.testing.expect(am.entries[0].audit_large_lot_threshold == null);
try std.testing.expect(am.largeLotThresholdFor("Sample Roth") == null);
try std.testing.expect(am.largeLotThresholdFor("Sample Brokerage") == null);
}
test "parseAccountsFile: per-account audit_large_lot_threshold parses and is looked up by account" {
// Mixed: one account raises its threshold (e.g. a noisy ESPP
// account), a sibling leaves it default.
const data =
\\#!srfv1
\\account::Sample ESPP,tax_type::taxable,audit_large_lot_threshold:num:50000
\\account::Sample Brokerage,tax_type::taxable
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
try std.testing.expectApproxEqAbs(@as(f64, 50000.0), am.largeLotThresholdFor("Sample ESPP").?, 0.01);
// Sibling account has no override.
try std.testing.expect(am.largeLotThresholdFor("Sample Brokerage") == null);
// Unknown account -> null (falls back to default downstream).
try std.testing.expect(am.largeLotThresholdFor("Nonexistent") == null);
}
test "parseAccountsFile: audit_large_lot_threshold accepts a fractional value" {
const data =
\\#!srfv1
\\account::Sample Brokerage,tax_type::taxable,audit_large_lot_threshold:num:7500.5
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expectApproxEqAbs(@as(f64, 7500.5), am.largeLotThresholdFor("Sample Brokerage").?, 0.001);
}
test "parseAccountsFile: non-positive audit_large_lot_threshold is rejected -> null" {
// Zero and negative thresholds are nonsensical (zero flags every
// new lot; negative is meaningless). They're dropped at parse
// time so the audit falls back to its built-in default. The
// surrounding account still parses.
inline for (.{ "0", "-5000" }) |bad| {
const data = "#!srfv1\naccount::Sample Brokerage,tax_type::taxable,audit_large_lot_threshold:num:" ++ bad ++ "\n";
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expectEqual(@as(usize, 1), am.entries.len);
try std.testing.expect(am.largeLotThresholdFor("Sample Brokerage") == null);
}
}
// umbrellaExposure
/// Helper: build an in-memory AccountMap from a literal SRF

View file

@ -37,20 +37,6 @@ const audit_file_max_size_non_csv = 512 * 1024; // 512KB, for non-CSV files only
pub const default_stale_days: u32 = 3;
const stale_warning_multiplier: u32 = 2; // yellow -> red at 2× threshold
/// Dollar threshold above which a new lot (new_stock / new_drip_lot /
/// new_cash / new_cd / cash_contribution) gets flagged in the
/// "Large new lots - confirm source" hygiene section. Below this
/// threshold new lots pass silently - the audit's goal is to catch
/// unconfirmed six-figure movements, not flag every payroll
/// contribution.
///
/// $10k is a judgment call: high enough to ignore routine payroll
/// ESPP accruals and $1-$2k weekly deposits, low enough to surface
/// a typical IRA contribution or a genuine transfer. Tunable here,
/// per the plan's "revisit if the threshold proves wrong" note in
/// TODO.md.
const audit_large_lot_threshold: f64 = 10_000.0;
/// Type of a discovered brokerage file.
const BrokerFileKind = enum {
fidelity_csv,
@ -992,9 +978,10 @@ pub fn runHygieneCheck(
//
// Silent when every large lot matched a transfer record, when
// there are no new lots at all, or when the pipeline can't run
// (not in a git repo). Threshold is a judgment call; see
// `audit_large_lot_threshold`.
if (contributions.findUnmatchedLargeLots(io, allocator, env, svc, portfolio_path, audit_large_lot_threshold, as_of, color, refresh)) |found| {
// (not in a git repo). Threshold is per-account: an account's
// `audit_large_lot_threshold` in accounts.srf wins, otherwise the
// filter's built-in default applies.
if (contributions.findUnmatchedLargeLots(io, allocator, env, svc, portfolio_path, &account_map, as_of, color, refresh)) |found| {
var found_mut = found;
defer found_mut.deinit();

View file

@ -933,9 +933,25 @@ pub fn computeAttributionSpec(
// Public audit hook
/// Default dollar threshold above which a new lot (new_stock /
/// new_drip_lot / new_cash / new_cd / cash_contribution) gets flagged
/// in the audit "Large new lots - confirm source" section. Below this
/// threshold new lots pass silently - the goal is to catch unconfirmed
/// six-figure movements, not flag every payroll contribution.
///
/// $10k is a judgment call: high enough to ignore routine payroll ESPP
/// accruals and $1-$2k weekly deposits, low enough to surface a typical
/// IRA contribution or a genuine transfer. This is only the fallback -
/// an account can override it per-account with an
/// `audit_large_lot_threshold:num:<DOLLARS>` field on its accounts.srf
/// record (see `AccountTaxEntry.audit_large_lot_threshold` /
/// `AccountMap.largeLotThresholdFor`), so a noisy ESPP account can
/// raise its bar without going blind on a quiet brokerage account.
const default_audit_large_lot_threshold: f64 = 10_000.0;
/// Descriptor of a "large new lot" the audit command may want to
/// surface. Emitted by `findUnmatchedLargeLots` for any new-side
/// Change whose `value()` meets the caller's threshold and which
/// Change whose `value()` meets the resolved threshold and which
/// was NOT reclassified by the transfer-log matcher. All string
/// fields are caller-arena-owned through the `UnmatchedLargeLotSet`
/// wrapper; the caller frees everything at once via `deinit`.
@ -966,12 +982,18 @@ pub const UnmatchedLargeLotSet = struct {
};
/// Find new-side lots (new_stock / new_drip_lot / new_cash / new_cd
/// / cash_contribution) with `value() >= threshold` that weren't
/// matched to a record in `transaction_log.srf` over the HEAD ->
/// working-copy window. Mirrors the `zfin contributions` zero-flag
/// path - uses `prepareReport`'s shared git + portfolio + transfer
/// plumbing so the classification is identical. Returns null if the
/// pipeline can't resolve a window (not in a git repo, etc.).
/// / cash_contribution) whose unattributed value meets the audit
/// large-lot threshold but weren't matched to a record in
/// `transaction_log.srf` over the HEAD -> working-copy window.
/// Mirrors the `zfin contributions` zero-flag path - uses
/// `prepareReport`'s shared git + portfolio + transfer plumbing so
/// the classification is identical. Returns null if the pipeline
/// can't resolve a window (not in a git repo, etc.).
///
/// The threshold is resolved per lot: an account's
/// `audit_large_lot_threshold` (from `account_map`) wins, otherwise
/// `default_audit_large_lot_threshold` applies. Pass `account_map =
/// null` to use the default for everything.
///
/// Consumed by `zfin audit` to prompt the user to either confirm
/// the lot as an external contribution or add a transfer record
@ -984,7 +1006,7 @@ pub fn findUnmatchedLargeLots(
env: *const std.process.Environ.Map,
svc: *zfin.DataService,
portfolio_path: []const u8,
threshold: f64,
account_map: ?*const analysis.AccountMap,
as_of: Date,
color: bool,
refresh: framework.RefreshPolicy,
@ -1007,7 +1029,7 @@ pub fn findUnmatchedLargeLots(
};
defer ctx.deinit();
const lots = collectUnmatchedLargeLots(arena, ctx.report.changes, threshold) catch {
const lots = collectUnmatchedLargeLots(arena, ctx.report.changes, account_map) catch {
arena_state.deinit();
return null;
};
@ -1061,7 +1083,7 @@ pub fn findUnmatchedLargeLots(
fn collectUnmatchedLargeLots(
arena: std.mem.Allocator,
changes: []const Change,
threshold: f64,
account_map: ?*const analysis.AccountMap,
) ![]UnmatchedLargeLot {
var out: std.ArrayList(UnmatchedLargeLot) = .empty;
for (changes) |c| {
@ -1071,6 +1093,14 @@ fn collectUnmatchedLargeLots(
};
if (!is_new_side) continue;
// Per-account threshold: the lot's account can raise/lower its
// own cutoff (e.g. a noisy ESPP account); otherwise the
// built-in default applies.
const threshold = if (account_map) |am|
(am.largeLotThresholdFor(c.account) orelse default_audit_large_lot_threshold)
else
default_audit_large_lot_threshold;
// Use attributedValue() so a fully-attributed cash lot
// (whose `transfer_attributed` covers the whole `value()`)
// drops out, and a partially-attributed cash lot surfaces
@ -5684,7 +5714,7 @@ test "collectUnmatchedLargeLots: below threshold is silent" {
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 0), lots.len);
}
@ -5704,7 +5734,7 @@ test "collectUnmatchedLargeLots: unmatched large stock lot surfaces" {
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 1), lots.len);
try std.testing.expectEqualStrings("Acct A", lots[0].account);
@ -5714,6 +5744,46 @@ test "collectUnmatchedLargeLots: unmatched large stock lot surfaces" {
try std.testing.expectEqual(Date.fromYmd(2026, 5, 3).days, lots[0].open_date.days);
}
test "collectUnmatchedLargeLots: per-account threshold suppresses one account, default flags the other" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("ESPPSYM", 300.0);
try prices.put("BRKSYM", 300.0);
const before = [_]Lot{};
// Two new $30k lots in different accounts.
const after = [_]Lot{
.{ .symbol = "ESPPSYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 300, .account = "Sample ESPP" },
.{ .symbol = "BRKSYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 300, .account = "Sample Brokerage" },
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
// Sanity: with no account_map, both $30k lots clear the $10k
// default and surface. This isolates the override as the cause of
// the difference below.
const both = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 2), both.len);
// ESPP raises its own threshold to $50k (routine large accruals);
// Sample Brokerage leaves it at the default. Now only the
// brokerage lot surfaces - the $30k ESPP lot is below its $50k bar.
var am = try analysis.parseAccountsFile(allocator,
\\#!srfv1
\\account::Sample ESPP,tax_type::taxable,audit_large_lot_threshold:num:50000
\\account::Sample Brokerage,tax_type::taxable
);
defer am.deinit();
const lots = try collectUnmatchedLargeLots(allocator, report.changes, &am);
try std.testing.expectEqual(@as(usize, 1), lots.len);
try std.testing.expectEqualStrings("Sample Brokerage", lots[0].account);
try std.testing.expectEqualStrings("BRKSYM", lots[0].symbol);
try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), lots[0].value, 0.01);
}
test "collectUnmatchedLargeLots: unmatched large cash lot surfaces" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
@ -5727,7 +5797,7 @@ test "collectUnmatchedLargeLots: unmatched large cash lot surfaces" {
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 11), .{});
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 1), lots.len);
try std.testing.expectEqual(LotType.cash, lots[0].security_type);
@ -5761,7 +5831,7 @@ test "collectUnmatchedLargeLots: matched via transfer log is silent" {
// Sanity: the lot should have been reclassified.
try std.testing.expectEqual(ChangeKind.transfer_in, report.changes[0].kind);
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 0), lots.len);
}
@ -5811,7 +5881,7 @@ test "collectUnmatchedLargeLots: cash-destination matched is silent" {
try std.testing.expectEqual(@as(f64, 73158.33), attributed);
// The audit filter must subtract the attribution and stay quiet.
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 0), lots.len);
}
@ -5841,7 +5911,7 @@ test "collectUnmatchedLargeLots: cash-destination partial match surfaces residua
.transfer_log = tlog.transfers,
});
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 1), lots.len);
try std.testing.expectEqual(@as(f64, 20000.0), lots[0].value);
}
@ -5870,7 +5940,7 @@ test "collectUnmatchedLargeLots: cash-destination partial below threshold is sil
.transfer_log = tlog.transfers,
});
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 0), lots.len);
}
@ -5885,7 +5955,7 @@ test "collectUnmatchedLargeLots: no new lots -> empty result" {
const after = [_]Lot{};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 0), lots.len);
}
@ -5909,7 +5979,7 @@ test "collectUnmatchedLargeLots: buy funded by same-account cash is silent" {
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 0), lots.len);
}
@ -5930,7 +6000,7 @@ test "collectUnmatchedLargeLots: partly cash-funded buy surfaces residual only"
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 1), lots.len);
try std.testing.expectApproxEqAbs(@as(f64, 20_000.0), lots[0].value, 0.01);
}
@ -5952,7 +6022,7 @@ test "collectUnmatchedLargeLots: partial cash-funded residual below threshold is
};
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 0), lots.len);
}
@ -5991,7 +6061,7 @@ test "collectUnmatchedLargeLots: partial transfer still flags residual? No - ful
try std.testing.expectEqual(ChangeKind.partial_transfer_in, report.changes[0].kind);
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
try std.testing.expectEqual(@as(usize, 0), lots.len);
}

View file

@ -430,6 +430,32 @@ test "parse: single ack with two notes round-trips" {
try testing.expectEqualStrings("Will trim by Q3 2026.", entry.notes[1]);
}
test "round-trip: a note with emoji and accented text survives format + parse" {
// UTF-8 note content (emoji, accented letters) must survive the
// SRF format -> parse cycle byte-for-byte. Multi-byte sequences
// can't collide with SRF's ASCII delimiters (',' '::' newline), so
// this is safe; the test pins that guarantee for the ack-note flow.
const a = testing.allocator;
const records = [_]JournalRecord{
.{ .acknowledgment = .{
.observation = "position_concentration",
.target = "NVDA",
.acknowledged_at = Date.fromYmd(2026, 6, 12),
.state = .acknowledged,
} },
.{ .note = .{ .line = "Trim by Q3 \u{1F389} - café conviction" } },
};
var buf: std.Io.Writer.Allocating = .init(a);
defer buf.deinit();
try buf.writer.print("{f}", .{srf.fmt(JournalRecord, &records, .{})});
var journal = try parse(a, buf.writer.buffered());
defer journal.deinit();
try testing.expectEqual(@as(usize, 1), journal.entries.len);
try testing.expectEqual(@as(usize, 1), journal.entries[0].notes.len);
try testing.expectEqualStrings("Trim by Q3 \u{1F389} - café conviction", journal.entries[0].notes[0]);
}
test "parse: notes attach to the most-recent preceding ack" {
const data =
\\#!srfv1

View file

@ -747,8 +747,7 @@ pub const App = struct {
if (mouse.col >= col and mouse.col < col + lbl_len) {
if (self.isDisabled(t)) return;
self.active_tab = t;
self.scroll_offset = 0;
self.loadTabData();
self.switchedToTab();
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
return ctx.consumeAndRedraw();
}
@ -1048,7 +1047,7 @@ pub const App = struct {
if (self.input_len > 0) {
self.setActiveSymbol(self.input_buf[0..self.input_len]);
self.active_tab = .quote;
self.loadTabData();
self.switchedToTab();
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
}
self.mode = .normal;
@ -1061,6 +1060,10 @@ pub const App = struct {
fn handleNormalKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
// Ctrl+L: full screen redraw (standard TUI convention, not configurable)
if (key.codepoint == 'l' and key.mods.ctrl) {
// Also drop any status override so the bar returns to
// the active tab's default hint - "redraw" should make
// the screen pristine, status line included.
self.resetStatus();
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
return ctx.consumeAndRedraw();
}
@ -1100,15 +1103,13 @@ pub const App = struct {
},
.prev_tab => {
self.prevTab();
self.scroll_offset = 0;
self.loadTabData();
self.switchedToTab();
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
return ctx.consumeAndRedraw();
},
.next_tab => {
self.nextTab();
self.scroll_offset = 0;
self.loadTabData();
self.switchedToTab();
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
return ctx.consumeAndRedraw();
},
@ -1118,8 +1119,7 @@ pub const App = struct {
const target = tabs[idx];
if (self.isDisabled(target)) return;
self.active_tab = target;
self.scroll_offset = 0;
self.loadTabData();
self.switchedToTab();
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
return ctx.consumeAndRedraw();
}
@ -1374,6 +1374,27 @@ pub const App = struct {
self.status_len = len;
}
/// Drop any active status override so the next draw falls back
/// to the active tab's default hint (globals + `status_hints`).
/// Called on navigation events - tab switches and the Ctrl+L
/// redraw - so a stale message set on one screen can't linger
/// past the point where it stops being relevant.
pub fn resetStatus(self: *App) void {
self.status_len = 0;
}
/// Common tail for every "the active tab changed" path: reset
/// the viewport scroll, drop any stale status override (so the
/// new tab shows its own default hint unless its `activate`
/// sets something), and load the new tab's data. Centralized so
/// no switch site can forget a step. Callers set `active_tab`
/// (directly or via `nextTab`/`prevTab`) before calling this.
fn switchedToTab(self: *App) void {
self.scroll_offset = 0;
self.resetStatus();
self.loadTabData();
}
/// Cell pixel size for the active terminal, used by tabs that
/// render bitmap charts via the Kitty graphics protocol. Falls
/// back to (8, 16) when vaxis hasn't reported pixel dimensions
@ -1677,7 +1698,8 @@ pub const App = struct {
}
}
pub fn drawStyledContent(_: *App, _: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16, lines: []const StyledLine) !void {
pub fn drawStyledContent(self: *App, _: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16, lines: []const StyledLine) !void {
const method = self.gwidthMethod();
for (lines, 0..) |line, row| {
if (row >= height) break;
// Fill row with style bg
@ -1692,10 +1714,41 @@ pub const App = struct {
buf[row * width + ci] = .{ .char = .{ .grapheme = graphemes[ci] }, .style = s };
}
} else {
// UTF-8 aware rendering: byte index and column index tracked separately
// Grapheme-cluster aware rendering. Segment `line.text`
// into grapheme clusters and measure each cluster's
// display width with the SAME method vaxis uses at render
// time (`caps.unicode`), then set the cell width
// explicitly and advance the display column by that width.
// That keeps three things in lockstep - our buffer
// columns, vaxis's per-cell cursor advance (it steps its
// buffer index by the cell's width, skipping the covered
// columns), and the terminal - so wide glyphs (CJK and any
// emoji: VS16, ZWJ, skin-tone, or flag sequences) don't
// desync and smear as the content scrolls. A wide cluster
// leaves its trailing column(s) as the row-fill blank;
// vaxis never emits those because the prior cell is wide.
var col: usize = 0;
var bi: usize = 0;
while (bi < line.text.len and col < width) {
var giter = vaxis.unicode.graphemeIterator(line.text);
while (giter.next()) |g| {
if (col >= width) break;
const cluster = g.bytes(line.text);
// Fast path: a single-byte cluster below 0x80 is a
// standalone ASCII char (combining marks are
// multi-byte, so they'd make the cluster longer) -
// always width 1. Skips the gwidth state machine for
// the overwhelmingly common case of ASCII table text.
const gw: u16 = if (g.len == 1 and cluster[0] < 0x80)
1
else
vaxis.gwidth.gwidth(cluster, method);
// Zero-width cluster (e.g. a leading combining mark
// with no base): skip it rather than let the next
// glyph overwrite this cell or the column desync.
if (gw == 0) continue;
// A wide cluster that would overrun the row edge is
// dropped (matches the prior truncation behavior).
if (col + gw > width) break;
var s = line.style;
// `spans` (if present) takes precedence over `alt_*`.
// Iterate forward; the LAST span that contains `col`
@ -1723,24 +1776,27 @@ pub const App = struct {
} else if (line.alt_style) |alt| {
if (col >= line.alt_start and col < line.alt_end) s = alt;
}
const byte = line.text[bi];
if (byte < 0x80) {
// ASCII: single byte, single column
buf[row * width + col] = .{ .char = .{ .grapheme = ascii_g[byte] }, .style = s };
bi += 1;
} else {
// Multi-byte UTF-8: determine sequence length
const seq_len: usize = if (byte >= 0xF0) 4 else if (byte >= 0xE0) 3 else if (byte >= 0xC0) 2 else 1;
const end = @min(bi + seq_len, line.text.len);
buf[row * width + col] = .{ .char = .{ .grapheme = line.text[bi..end] }, .style = s };
bi = end;
}
col += 1;
buf[row * width + col] = .{ .char = .{ .grapheme = cluster, .width = @intCast(gw) }, .style = s };
col += gw;
}
}
}
}
/// The grapheme-width method vaxis will use when it paints this
/// frame's cells. Reading it from the live `caps.unicode` (instead
/// of hardcoding) is what keeps `drawStyledContent`'s column
/// advance in agreement with vaxis's own per-cell measurement: on a
/// Unicode-width terminal both count an emoji as 2 columns; on a
/// legacy wcwidth terminal both count it the same narrower way.
/// Falls back to `.unicode` only before `vx_app` is wired (early
/// startup / tests), when nothing is actually being painted.
fn gwidthMethod(self: *const App) vaxis.gwidth.Method {
const va = self.vx_app orelse return .unicode;
return va.vx.caps.unicode;
}
/// Render a prompt + live input buffer + blinking cursor + right-
/// aligned hint into the status-bar cell buffer. Shared between
/// `.symbol_input` and `.date_input` modes - only the prompt and
@ -2843,6 +2899,112 @@ test "formatStatusHint: single fragment has no separator" {
try testing.expectEqualStrings("ctrl+s save", out);
}
test "resetStatus: clears an active status override" {
var app: App = undefined;
app.setStatus("stuck message");
try testing.expect(app.status_len > 0);
app.resetStatus();
try testing.expectEqual(@as(usize, 0), app.status_len);
}
// drawStyledContent: grapheme-width handling
// vx_app is null in these tests, so `gwidthMethod` falls back to
// `.unicode` - the modern terminal interpretation, where emoji are
// 2 columns. A real session uses the terminal's detected method.
test "drawStyledContent: a 2-column emoji occupies one wide cell" {
var app: App = undefined;
app.vx_app = null;
const w: u16 = 8;
var buf: [w]vaxis.Cell = undefined;
const lines = [_]StyledLine{
.{ .text = "a\u{26A0}\u{FE0F}b", .style = .{} }, // a + warning-emoji + b
};
try app.drawStyledContent(undefined, &buf, w, 1, &lines);
// col 0: plain ASCII, width 1.
try testing.expectEqualStrings("a", buf[0].char.grapheme);
try testing.expectEqual(@as(u8, 1), buf[0].char.width);
// col 1: the whole grapheme cluster (base + FE0F) in one cell,
// measured 2 columns wide.
try testing.expectEqualStrings("\u{26A0}\u{FE0F}", buf[1].char.grapheme);
try testing.expectEqual(@as(u8, 2), buf[1].char.width);
// col 2: the emoji's second column - left as the row-fill blank;
// vaxis skips it because the prior cell reports width 2.
try testing.expectEqualStrings(" ", buf[2].char.grapheme);
// col 3: the next glyph lands after the 2-wide emoji, not at col 2.
try testing.expectEqualStrings("b", buf[3].char.grapheme);
}
test "drawStyledContent: a default-presentation emoji (no FE0F) is still 2 wide" {
// The whole point of generalizing past the FE0F special-case: a
// user-typed emoji like the party popper carries no variation
// selector but is still 2 columns. The renderer must measure it.
var app: App = undefined;
app.vx_app = null;
const w: u16 = 8;
var buf: [w]vaxis.Cell = undefined;
const lines = [_]StyledLine{
.{ .text = "x\u{1F389}y", .style = .{} }, // x + party-popper + y
};
try app.drawStyledContent(undefined, &buf, w, 1, &lines);
try testing.expectEqualStrings("x", buf[0].char.grapheme);
try testing.expectEqualStrings("\u{1F389}", buf[1].char.grapheme);
try testing.expectEqual(@as(u8, 2), buf[1].char.width);
try testing.expectEqualStrings(" ", buf[2].char.grapheme); // covered column
try testing.expectEqualStrings("y", buf[3].char.grapheme); // after the emoji
}
test "drawStyledContent: a ZWJ emoji sequence is one cluster in one cell" {
// Woman astronaut = woman + ZWJ + rocket; a single grapheme
// cluster, width 2 under the unicode method.
var app: App = undefined;
app.vx_app = null;
const w: u16 = 8;
var buf: [w]vaxis.Cell = undefined;
const lines = [_]StyledLine{
.{ .text = "\u{1F469}\u{200D}\u{1F680}z", .style = .{} },
};
try app.drawStyledContent(undefined, &buf, w, 1, &lines);
try testing.expectEqualStrings("\u{1F469}\u{200D}\u{1F680}", buf[0].char.grapheme);
try testing.expectEqual(@as(u8, 2), buf[0].char.width);
try testing.expectEqualStrings("z", buf[2].char.grapheme); // after the 2-wide cluster
}
test "drawStyledContent: a multibyte glyph that is 1 column stays one column" {
var app: App = undefined;
app.vx_app = null;
const w: u16 = 4;
var buf: [w]vaxis.Cell = undefined;
const lines = [_]StyledLine{
.{ .text = "\u{2014}x", .style = .{} }, // em-dash (1 col) + x
};
try app.drawStyledContent(undefined, &buf, w, 1, &lines);
try testing.expectEqualStrings("\u{2014}", buf[0].char.grapheme);
try testing.expectEqual(@as(u8, 1), buf[0].char.width);
try testing.expectEqualStrings("x", buf[1].char.grapheme);
}
test "drawStyledContent: a wide emoji with no room at the edge is dropped" {
// width 2: 'a' takes col 0, leaving only col 1 - not enough room
// for a 2-wide cell, so the emoji is truncated at the edge and
// col 1 stays the row-fill blank.
var app: App = undefined;
app.vx_app = null;
const w: u16 = 2;
var buf: [w]vaxis.Cell = undefined;
const lines = [_]StyledLine{
.{ .text = "a\u{26A0}\u{FE0F}", .style = .{} },
};
try app.drawStyledContent(undefined, &buf, w, 1, &lines);
try testing.expectEqualStrings("a", buf[0].char.grapheme);
try testing.expectEqualStrings(" ", buf[1].char.grapheme);
}
// symbol toggle helpers
test "shouldStashSymbol: stashes a differing non-empty symbol" {

View file

@ -72,7 +72,7 @@ pub fn handleKey(buf: []u8, len: *usize, key: vaxis.Key) Result {
return .committed;
}
if (key.codepoint == vaxis.Key.backspace) {
if (len.* > 0) len.* -= 1;
len.* = graphemeBackspaceLen(buf[0..len.*]);
return .edited;
}
// Ctrl+U: clear entire input (readline convention)
@ -80,12 +80,9 @@ pub fn handleKey(buf: []u8, len: *usize, key: vaxis.Key) Result {
len.* = 0;
return .edited;
}
// Accept printable ASCII (letters, digits, common punctuation).
if (key.codepoint < std.math.maxInt(u7) and std.ascii.isPrint(@intCast(key.codepoint)) and len.* < buf.len) {
buf[len.*] = @intCast(key.codepoint);
len.* += 1;
return .edited;
}
// Printable input. Prefer the terminal-resolved text so Shift /
// Caps Lock produce the right case (see `appendPrintable`).
if (appendPrintable(buf, len, key)) return .edited;
return .ignored;
}
@ -123,7 +120,8 @@ pub const MultiResult = enum {
/// => `.committed`. `len.*` unchanged so the caller can flush
/// any final unfinished fragment before joining all fragments
/// and writing the journal record.
/// - **Backspace, Ctrl+U, printable ASCII**: same as `handleKey`.
/// - **Backspace** (deletes the last grapheme cluster), **Ctrl+U**,
/// and printable text input: same as `handleKey`.
pub fn handleKeyMulti(buf: []u8, len: *usize, key: vaxis.Key) MultiResult {
if (key.codepoint == vaxis.Key.escape) {
len.* = 0;
@ -148,19 +146,70 @@ pub fn handleKeyMulti(buf: []u8, len: *usize, key: vaxis.Key) MultiResult {
return .fragment;
}
if (key.codepoint == vaxis.Key.backspace) {
if (len.* > 0) len.* -= 1;
len.* = graphemeBackspaceLen(buf[0..len.*]);
return .edited;
}
if (key.matches('u', .{ .ctrl = true })) {
len.* = 0;
return .edited;
}
if (appendPrintable(buf, len, key)) return .edited;
return .ignored;
}
/// Append the character a key produced to `buf`, returning true on
/// success. Callers handle the special keys (Esc, Enter, Backspace,
/// Ctrl+*) before this runs, so anything reaching here is ordinary
/// text input.
///
/// We prefer `key.text` - the terminal's resolved text for the
/// event - over the raw codepoint. Under the Kitty keyboard protocol
/// (which vaxis turns on), a Shift+a press reports `codepoint = 'a'`
/// (the base-layout key) with `text = "A"`; keying off the codepoint
/// alone silently downcases everything and turns shifted symbols
/// (`!@#`) back into their digits. We take a single printable-ASCII
/// byte or any well-formed multi-byte UTF-8 grapheme (accented
/// letters, CJK, emoji), rejecting lone control bytes - so notes can
/// hold whatever the user types or pastes. When no text is reported
/// (legacy terminal, no Kitty protocol) we fall back to the codepoint,
/// which carries the shifted value in that mode (ASCII only).
fn appendPrintable(buf: []u8, len: *usize, key: vaxis.Key) bool {
if (key.text) |text| {
// Accept the terminal-resolved text when it's typed content: a
// single printable ASCII byte, or any well-formed multi-byte
// UTF-8 grapheme (accented letters, CJK, emoji - including
// pasted ones, which arrive as key events under bracketed
// paste). Reject a lone control byte and anything that won't
// fit. When text is present we never fall through to the
// codepoint path - that would re-append a downcased/duplicate
// byte.
if (text.len == 0) return false;
if (text.len == 1 and !std.ascii.isPrint(text[0])) return false;
if (text.len > 1 and !std.unicode.utf8ValidateSlice(text)) return false;
if (len.* + text.len > buf.len) return false;
@memcpy(buf[len.*..][0..text.len], text);
len.* += text.len;
return true;
}
if (key.codepoint < std.math.maxInt(u7) and std.ascii.isPrint(@intCast(key.codepoint)) and len.* < buf.len) {
buf[len.*] = @intCast(key.codepoint);
len.* += 1;
return .edited;
return true;
}
return .ignored;
return false;
}
/// New buffer length after deleting the final grapheme cluster - the
/// result of one Backspace. Cluster-aware so backspacing an emoji
/// (which can be several codepoints: ZWJ joins, skin-tone modifiers,
/// a trailing FE0F) removes the whole glyph instead of leaving a
/// mangled partial UTF-8 sequence behind. For pure ASCII this removes
/// exactly one byte.
fn graphemeBackspaceLen(text: []const u8) usize {
var iter = vaxis.unicode.graphemeIterator(text);
var last_start: usize = 0;
while (iter.next()) |g| last_start = g.start;
return last_start;
}
// Tests
@ -194,14 +243,93 @@ test "handleKey: printable ASCII appends and increments len" {
try testing.expectEqual(@as(u8, 'x'), buf[0]);
}
test "handleKey: Shift+letter appends the uppercase text, not the base codepoint" {
// Kitty keyboard protocol reports the base-layout key in
// `codepoint` and the resolved character in `text`. Shift+a must
// land 'A', not 'a'.
var buf: [16]u8 = undefined;
var len: usize = 0;
const result = handleKey(&buf, &len, .{ .codepoint = 'a', .text = "A", .mods = .{ .shift = true } });
try testing.expectEqual(Result.edited, result);
try testing.expectEqual(@as(usize, 1), len);
try testing.expectEqual(@as(u8, 'A'), buf[0]);
}
test "handleKey: shifted symbol appends the symbol, not the digit" {
// Shift+1 => '!' : codepoint stays '1', text is "!".
var buf: [16]u8 = undefined;
var len: usize = 0;
const result = handleKey(&buf, &len, .{ .codepoint = '1', .text = "!", .mods = .{ .shift = true } });
try testing.expectEqual(Result.edited, result);
try testing.expectEqual(@as(u8, '!'), buf[0]);
}
test "handleKey: legacy terminal (no text) falls back to the codepoint" {
// Without the Kitty protocol, vaxis reports the shifted value
// directly in `codepoint` and leaves `text` null.
var buf: [16]u8 = undefined;
var len: usize = 0;
const result = handleKey(&buf, &len, .{ .codepoint = 'A' });
try testing.expectEqual(Result.edited, result);
try testing.expectEqual(@as(u8, 'A'), buf[0]);
}
test "handleKey: a multibyte UTF-8 grapheme is appended whole" {
// Em-dash (3 bytes). Multi-byte text is accepted now; the whole
// grapheme lands in the buffer.
var buf: [16]u8 = undefined;
var len: usize = 0;
const result = handleKey(&buf, &len, .{ .codepoint = 0x2014, .text = "\u{2014}" });
try testing.expectEqual(Result.edited, result);
try testing.expectEqual(@as(usize, 3), len);
try testing.expectEqualStrings("\u{2014}", buf[0..len]);
}
test "handleKey: an emoji grapheme is appended whole" {
// Party popper, 4 bytes.
var buf: [16]u8 = undefined;
var len: usize = 0;
const result = handleKey(&buf, &len, .{ .codepoint = 0x1F389, .text = "\u{1F389}" });
try testing.expectEqual(Result.edited, result);
try testing.expectEqual(@as(usize, 4), len);
try testing.expectEqualStrings("\u{1F389}", buf[0..len]);
}
test "handleKey: a lone control byte in text is rejected" {
var buf: [16]u8 = undefined;
var len: usize = 0;
try testing.expectEqual(Result.ignored, handleKey(&buf, &len, .{ .codepoint = vaxis.Key.tab, .text = "\t" }));
try testing.expectEqual(@as(usize, 0), len);
}
test "handleKey: multibyte append respects buffer capacity" {
// 4-byte emoji into a 3-byte buffer: doesn't fit, nothing appended.
var buf: [3]u8 = undefined;
var len: usize = 0;
try testing.expectEqual(Result.ignored, handleKey(&buf, &len, .{ .codepoint = 0x1F389, .text = "\u{1F389}" }));
try testing.expectEqual(@as(usize, 0), len);
}
test "handleKey: backspace decrements len" {
var buf: [16]u8 = undefined;
@memcpy(buf[0..3], "abc");
var len: usize = 3;
const result = handleKey(&buf, &len, .{ .codepoint = vaxis.Key.backspace });
try testing.expectEqual(Result.edited, result);
try testing.expectEqual(@as(usize, 2), len);
}
test "handleKey: backspace removes a whole emoji grapheme cluster" {
// Wave + skin-tone modifier is one cluster (8 bytes); backspace
// must remove all of it, not leave a mangled partial sequence.
var buf: [16]u8 = undefined;
@memcpy(buf[0..8], "\u{1F44B}\u{1F3FF}");
var len: usize = 8;
const result = handleKey(&buf, &len, .{ .codepoint = vaxis.Key.backspace });
try testing.expectEqual(Result.edited, result);
try testing.expectEqual(@as(usize, 0), len);
}
test "handleKey: backspace at len=0 stays at 0" {
var buf: [16]u8 = undefined;
var len: usize = 0;
@ -296,14 +424,44 @@ test "handleKeyMulti: printable ASCII appends" {
try testing.expectEqual(@as(u8, 'x'), buf[0]);
}
test "handleKeyMulti: Shift+letter appends uppercase text (the ack-note bug)" {
var buf: [64]u8 = undefined;
var len: usize = 0;
const result = handleKeyMulti(&buf, &len, .{ .codepoint = 'a', .text = "A", .mods = .{ .shift = true } });
try testing.expectEqual(MultiResult.edited, result);
try testing.expectEqual(@as(u8, 'A'), buf[0]);
}
test "handleKeyMulti: legacy terminal (no text) falls back to the codepoint" {
var buf: [64]u8 = undefined;
var len: usize = 0;
const result = handleKeyMulti(&buf, &len, .{ .codepoint = 'Z' });
try testing.expectEqual(MultiResult.edited, result);
try testing.expectEqual(@as(u8, 'Z'), buf[0]);
}
test "handleKeyMulti: backspace decrements len" {
var buf: [64]u8 = undefined;
@memcpy(buf[0..3], "abc");
var len: usize = 3;
const result = handleKeyMulti(&buf, &len, .{ .codepoint = vaxis.Key.backspace });
try testing.expectEqual(MultiResult.edited, result);
try testing.expectEqual(@as(usize, 2), len);
}
test "handleKeyMulti: Shift+letter and an emoji both append (notes accept any grapheme)" {
var buf: [64]u8 = undefined;
var len: usize = 0;
// Shift+a -> "A"
_ = handleKeyMulti(&buf, &len, .{ .codepoint = 'a', .text = "A", .mods = .{ .shift = true } });
// a multi-codepoint emoji (woman astronaut: woman + ZWJ + rocket)
_ = handleKeyMulti(&buf, &len, .{ .codepoint = 0x1F469, .text = "\u{1F469}\u{200D}\u{1F680}" });
try testing.expectEqualStrings("A\u{1F469}\u{200D}\u{1F680}", buf[0..len]);
// Backspace removes the whole emoji cluster, leaving just "A".
_ = handleKeyMulti(&buf, &len, .{ .codepoint = vaxis.Key.backspace });
try testing.expectEqualStrings("A", buf[0..len]);
}
test "handleKeyMulti: ctrl+U clears buffer" {
var buf: [64]u8 = undefined;
var len: usize = 5;

View file

@ -269,6 +269,22 @@ pub const tab = struct {
pub const deactivate = framework.noopDeactivate(State);
/// Status-bar override. While collecting an ack note, render the
/// note-entry key hints full-width; otherwise the App-level
/// default status applies. Driving the prompt off `input_mode`
/// (instead of a one-shot `setStatus`) means it appears exactly
/// when the modal is open and the App restores the tab's default
/// hint the instant we leave it - no frozen prompt after Esc or
/// commit. It also marks the tab modal, so a stray tab-bar click
/// can't switch tabs out from under a half-typed note.
pub fn statusOverride(state: *State, app: *App) ?framework.StatusOverride {
_ = app;
return switch (state.input_mode) {
.normal => null,
.ack_note => .{ .hint = "Type reasoning. Enter = next line. Ctrl+Enter/Ctrl+D = save. Esc = cancel." },
};
}
/// Framework poll-tick hook: true while the observation panel
/// has async checks in flight. Drives the App's poll timer so
/// the `tick` hook below gets called without user input. The
@ -416,6 +432,11 @@ pub const tab = struct {
/// Wheel events fall through to App's scroll handling via
/// `onWheelMove` returning false (see below).
pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool {
// While collecting an ack note the tab is modal (statusOverride
// is non-null, so the App routes every mouse event here).
// Swallow them all: a click that moved the cursor mid-note
// would make commitAckNote write to a different finding.
if (state.input_mode == .ack_note) return true;
if (mouse.button != .left) return false;
if (mouse.type != .press) return false;
const view = state.view orelse return false;
@ -542,6 +563,9 @@ pub const tab = struct {
return true;
};
state.note_len = 0;
// The bar just grew by a line; keep its bottom on
// screen so the next line the user types is visible.
ensureAckInputVisible(state, app);
return true;
},
.committed => {
@ -667,15 +691,37 @@ fn wrapCursor(current: usize, delta: isize, total: usize) usize {
/// (acceptable - the cursor IS at row zero anyway).
fn ensureCursorVisible(state: *const State, scroll_offset: *usize, visible_height: usize) void {
const visual = cursorVisualRow(state) orelse return;
if (visual < scroll_offset.*) {
scroll_offset.* = visual;
return;
}
if (visible_height > 0 and visual >= scroll_offset.* + visible_height) {
scroll_offset.* = visual - visible_height + 1;
ensureRowVisible(visual, scroll_offset, visible_height);
}
/// Scroll `scroll_offset` the minimum amount so content row
/// `target` lands inside the `[scroll_offset, scroll_offset +
/// visible_height)` viewport. Pure over the offset pointer;
/// shared by the cursor-follow path and the ack-input-follow path.
fn ensureRowVisible(target: usize, scroll_offset: *usize, visible_height: usize) void {
if (target < scroll_offset.*) {
scroll_offset.* = target;
} else if (visible_height > 0 and target >= scroll_offset.* + visible_height) {
scroll_offset.* = target - visible_height + 1;
}
}
/// While the inline note-input bar is open, keep its bottom line
/// (the hint row, below the in-progress input line) on screen.
/// Without this, acknowledging a finding that sits on the last
/// visible row opens the whole input bar below the viewport and
/// the user types blind. Called when entering ack mode and after
/// each committed fragment (which grows the bar by one line).
fn ensureAckInputVisible(state: *State, app: *App) void {
if (state.input_mode != .ack_note) return;
const cv = cursorVisualRow(state) orelse return;
const fv = state.findings_view orelse return;
const local = cursorLocalIndex(state);
if (local >= fv.rows.len) return;
const bottom = cv + expansionLineCount(fv.rows[local]) + inputBarLineCount(state);
ensureRowVisible(bottom, &app.scroll_offset, app.visible_height);
}
/// Compute the cursor's content-area row index (0 = first
/// rendered line). Returns null when neither view is loaded or
/// when the section indices haven't been populated yet (first
@ -765,7 +811,13 @@ fn ackCurrentFinding(state: *State, app: *App) void {
// input mode from `.normal`, and `commitAckNote`/`cancelAckNote`
// both clear it on exit). Defensive: clear anyway.
clearNoteFragments(state, app.allocator);
app.setStatus("Type reasoning. Enter = next line. Ctrl+Enter = save. Esc = cancel.");
// No setStatus: the `statusOverride` hook renders the input
// prompt while `input_mode == .ack_note` and the App restores
// the tab's default hint automatically when we exit the mode
// (on Esc or commit) - no stale prompt left frozen on the bar.
// Scroll so the freshly-opened input bar is actually on screen
// even when the finding sat on the last visible row.
ensureAckInputVisible(state, app);
}
/// Un-acknowledge the cursor-selected finding. Flips the journal
@ -876,7 +928,9 @@ fn cancelAckNote(state: *State, app: *App) void {
state.input_mode = .normal;
state.note_len = 0;
clearNoteFragments(state, app.allocator);
// No setStatus: the disappearing input bar is its own feedback.
// No setStatus needed: flipping `input_mode` back to `.normal`
// makes `statusOverride` return null, so the App falls straight
// back to the tab's default hint - the bar visibly resets.
}
/// Commit the in-progress ack note: dupe any final unflushed
@ -943,10 +997,11 @@ fn commitAckNote(state: *State, app: *App) void {
};
rebuildFindingsView(state, app);
// No setStatus on success: the visible removal of the row
// from the findings list (or the "[acked]" prefix when
// show_acked is on) is feedback enough. Leaving the prior
// help/hint visible is the user's preference.
// No setStatus on success: the `defer` above flips `input_mode`
// back to `.normal`, so `statusOverride` returns null and the
// bar falls back to the tab's default hint. The row leaving the
// findings list (or gaining an "[acked]" prefix when show_acked
// is on) is the confirmation.
}
// Data loading
@ -1416,19 +1471,14 @@ pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]c
return lines.toOwnedSlice(arena);
}
/// Glyph used in the findings table's severity column. Each is two
/// display columns wide (emoji-presentation) so renderers don't have
/// to width-correct.
/// Per-finding-row glyph indicating severity. Each glyph string
/// includes a trailing U+FE0F variation selector to force emoji
/// presentation. The variation selector also serves a critical
/// rendering role here: `drawStyledContent` allocates one buffer
/// cell per UTF-8 sequence (advancing col by 1), so a single-
/// codepoint emoji like takes 1 buffer cell while terminals
/// render it as 2 visual cols, desyncing the col counter from
/// terminal display. Appending FE0F gives it a second codepoint
/// (which gets its own cell) so buffer-col advancement matches
/// terminal-col advancement at exactly 2 per emoji.
/// (two-display-column) presentation - without it some terminals
/// draw a narrow text-style glyph. The renderer (`drawStyledContent`)
/// measures every grapheme's width with `gwidth` and sizes the cell
/// accordingly, so it handles these correctly along with any other
/// wide glyph; the FE0F here is purely about presentation, not a
/// renderer hint.
fn severityGlyph(sev: observations.Severity) []const u8 {
return switch (sev) {
.warn => "⚠️", // U+26A0 + FE0F
@ -1447,9 +1497,8 @@ fn severityGlyph(sev: observations.Severity) []const u8 {
/// in the renderer means the renderer is ready when the async
/// path lands.
/// Per-check status-grid glyph. See `severityGlyph` for the
/// FE0F-trailing convention - it forces emoji presentation and
/// gives the renderer a second cell to track so buffer-col
/// advancement matches terminal-col advancement.
/// FE0F-trailing convention (emoji presentation; the renderer
/// measures width generically via gwidth).
fn checkStatusGlyph(result: observations.CheckResult) []const u8 {
return switch (result) {
.pass => "\u{FE0F}",
@ -2916,6 +2965,95 @@ test "onCursorMove: empty table returns false" {
state.findings_view = null;
}
// ensureRowVisible / ack-input scroll-into-view
test "ensureRowVisible: scrolls down so a below-viewport row is last visible" {
var off: usize = 0;
ensureRowVisible(23, &off, 10);
try testing.expectEqual(@as(usize, 14), off); // 23 - 10 + 1
}
test "ensureRowVisible: scrolls up so an above-viewport row is first visible" {
var off: usize = 30;
ensureRowVisible(5, &off, 10);
try testing.expectEqual(@as(usize, 5), off);
}
test "ensureRowVisible: no change when the row is already on screen" {
var off: usize = 10;
ensureRowVisible(12, &off, 10);
try testing.expectEqual(@as(usize, 10), off);
}
test "ensureAckInputVisible: pulls a bottom-row ack input bar onto screen" {
// One finding, no holdings -> unified cursor 0 is that finding.
// Pretend it renders at content row 20 (findings_first_row) and
// the viewport is only 10 rows tall starting at offset 0, so the
// freshly-opened input bar (detail + in-progress + hint = 3 rows
// below the finding) sits entirely below the fold.
var findings = [_]observations_view.FindingRow{
.{ .severity = .warn, .kind = "k", .target = "t", .text = "x", .is_acked = false },
};
const empty_view: review_view.ReviewView = .{
.rows = &.{},
.totals = std.mem.zeroes(review_view.ReviewTotals),
.as_of = zfin.Date.fromYmd(2026, 6, 8),
.total_liquid = 0,
.portfolio_path = "x",
};
var state: State = .{
.view = empty_view,
.cursor = 0,
.expanded_finding = 0,
.input_mode = .ack_note,
.findings_first_row = 20,
.findings_view = .{
.rows = &findings,
.total_active = 1,
.total_acked = 0,
.total_resolved = 0,
},
};
var app: App = undefined;
app.scroll_offset = 0;
app.visible_height = 10;
ensureAckInputVisible(&state, &app);
// finding at row 20; bottom of the bar at 20 + 1 + 2 = 23; the
// offset advances so row 23 is the last visible: 23 - 10 + 1 = 14.
try testing.expectEqual(@as(usize, 14), app.scroll_offset);
state.view = null;
state.findings_view = null;
}
test "ensureAckInputVisible: no-op when not collecting a note" {
var state: State = .{ .input_mode = .normal, .findings_first_row = 20 };
var app: App = undefined;
app.scroll_offset = 7;
app.visible_height = 10;
ensureAckInputVisible(&state, &app);
try testing.expectEqual(@as(usize, 7), app.scroll_offset);
}
// statusOverride (ack-note prompt)
test "statusOverride: null in normal mode, key hints while collecting a note" {
var state: State = .{};
var app: App = undefined;
try testing.expect(tab.statusOverride(&state, &app) == null);
state.input_mode = .ack_note;
const ov = tab.statusOverride(&state, &app) orelse return error.TestUnexpectedResult;
switch (ov) {
.hint => |hint| {
try testing.expect(std.mem.indexOf(u8, hint, "Ctrl+Enter") != null);
try testing.expect(std.mem.indexOf(u8, hint, "Esc") != null);
},
else => return error.TestUnexpectedResult,
}
}
test "onCursorMove: wheel-sized delta still moves by one row" {
// Wheel events arrive as ±3 per detent. The cursor should
// step by ±1 regardless - wheel == j/k, not "skip three rows".