ai: provide fetch progress info when tui starts

This commit is contained in:
Emil Lerch 2026-03-03 12:00:41 -08:00
parent bfeac82c51
commit 089e81df54
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 160 additions and 73 deletions

View file

@ -79,6 +79,50 @@ pub fn stderrRateLimitWait(wait_seconds: u64, color: bool) !void {
try out.flush(); try out.flush();
} }
/// Progress callback for loadPrices that prints to stderr.
/// Shared between the CLI portfolio command and TUI pre-fetch.
pub const LoadProgress = struct {
svc: *zfin.DataService,
color: bool,
/// Offset added to index for display (e.g. stock count when loading watch symbols).
index_offset: usize,
/// Grand total across all loadPrices calls (stocks + watch).
grand_total: usize,
fn onProgress(ctx: *anyopaque, index: usize, _: usize, symbol: []const u8, status: zfin.DataService.SymbolStatus) void {
const self: *LoadProgress = @ptrCast(@alignCast(ctx));
const display_idx = self.index_offset + index + 1;
switch (status) {
.fetching => {
// Show rate-limit wait before the fetch
if (self.svc.estimateWaitSeconds()) |w| {
if (w > 0) stderrRateLimitWait(w, self.color) catch {};
}
stderrProgress(symbol, " (fetching)", display_idx, self.grand_total, self.color) catch {};
},
.cached => {
stderrProgress(symbol, " (cached)", display_idx, self.grand_total, self.color) catch {};
},
.fetched => {
// Already showed "(fetching)" no extra line needed
},
.failed_used_stale => {
stderrProgress(symbol, " FAILED (using cached)", display_idx, self.grand_total, self.color) catch {};
},
.failed => {
stderrProgress(symbol, " FAILED", display_idx, self.grand_total, self.color) catch {};
},
}
}
pub fn callback(self: *LoadProgress) zfin.DataService.ProgressCallback {
return .{
.context = @ptrCast(self),
.on_progress = onProgress,
};
}
};
// Tests // Tests
test "setFg emits ANSI when color enabled" { test "setFg emits ANSI when color enabled" {

View file

@ -3,49 +3,6 @@ const zfin = @import("../root.zig");
const cli = @import("common.zig"); const cli = @import("common.zig");
const fmt = cli.fmt; const fmt = cli.fmt;
/// CLI progress context for loadPrices callback.
const CliProgress = struct {
svc: *zfin.DataService,
color: bool,
/// Offset added to index for display (e.g. stock count when loading watch symbols).
index_offset: usize,
/// Grand total across all loadPrices calls (stocks + watch).
grand_total: usize,
fn onProgress(ctx: *anyopaque, index: usize, _: usize, symbol: []const u8, status: zfin.DataService.SymbolStatus) void {
const self: *CliProgress = @ptrCast(@alignCast(ctx));
const display_idx = self.index_offset + index + 1;
switch (status) {
.fetching => {
// Show rate-limit wait before the fetch
if (self.svc.estimateWaitSeconds()) |w| {
if (w > 0) cli.stderrRateLimitWait(w, self.color) catch {};
}
cli.stderrProgress(symbol, " (fetching)", display_idx, self.grand_total, self.color) catch {};
},
.cached => {
cli.stderrProgress(symbol, " (cached)", display_idx, self.grand_total, self.color) catch {};
},
.fetched => {
// Already showed "(fetching)" no extra line needed
},
.failed_used_stale => {
cli.stderrProgress(symbol, " FAILED (using cached)", display_idx, self.grand_total, self.color) catch {};
},
.failed => {
cli.stderrProgress(symbol, " FAILED", display_idx, self.grand_total, self.color) catch {};
},
}
}
fn callback(self: *CliProgress) zfin.DataService.ProgressCallback {
return .{
.context = @ptrCast(self),
.on_progress = onProgress,
};
}
};
pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, file_path: []const u8, watchlist_path: ?[]const u8, force_refresh: bool, color: bool, out: *std.Io.Writer) !void { pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, file_path: []const u8, watchlist_path: ?[]const u8, force_refresh: bool, color: bool, out: *std.Io.Writer) !void {
// Load portfolio from SRF file // Load portfolio from SRF file
const data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch |err| { const data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch |err| {
@ -104,7 +61,7 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer
} }
// Progress callback for per-symbol output // Progress callback for per-symbol output
var progress_ctx = CliProgress{ var progress_ctx = cli.LoadProgress{
.svc = svc, .svc = svc,
.color = color, .color = color,
.index_offset = 0, .index_offset = 0,

View file

@ -2,6 +2,7 @@ const std = @import("std");
const vaxis = @import("vaxis"); const vaxis = @import("vaxis");
const zfin = @import("root.zig"); const zfin = @import("root.zig");
const fmt = zfin.format; const fmt = zfin.format;
const cli = @import("commands/common.zig");
const keybinds = @import("tui/keybinds.zig"); const keybinds = @import("tui/keybinds.zig");
const theme_mod = @import("tui/theme.zig"); const theme_mod = @import("tui/theme.zig");
const chart_mod = @import("tui/chart.zig"); const chart_mod = @import("tui/chart.zig");
@ -234,6 +235,7 @@ const App = struct {
portfolio_sort_field: PortfolioSortField = .symbol, // current sort column portfolio_sort_field: PortfolioSortField = .symbol, // current sort column
portfolio_sort_dir: SortDirection = .asc, // current sort direction portfolio_sort_dir: SortDirection = .asc, // current sort direction
watchlist_prices: ?std.StringHashMap(f64) = null, // cached watchlist prices (no disk I/O during render) watchlist_prices: ?std.StringHashMap(f64) = null, // cached watchlist prices (no disk I/O during render)
prefetched_prices: ?std.StringHashMap(f64) = null, // prices loaded before TUI starts (with stderr progress)
// Options navigation (inline expand/collapse like portfolio) // Options navigation (inline expand/collapse like portfolio)
options_cursor: usize = 0, // selected row in flattened options view options_cursor: usize = 0, // selected row in flattened options view
@ -1017,34 +1019,6 @@ const App = struct {
self.portfolio_loaded = true; self.portfolio_loaded = true;
self.freePortfolioSummary(); self.freePortfolioSummary();
// Fetch data for watchlist symbols so they have prices to display
// (from both the separate watchlist file and watch lots in the portfolio)
if (self.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
self.watchlist_prices = std.StringHashMap(f64).init(self.allocator);
}
var wp = &(self.watchlist_prices.?);
if (self.watchlist) |wl| {
for (wl) |sym| {
const result = self.svc.getCandles(sym) catch continue;
defer self.allocator.free(result.data);
if (result.data.len > 0) {
wp.put(sym, result.data[result.data.len - 1].close) catch {};
}
}
}
if (self.portfolio) |pf| {
for (pf.lots) |lot| {
if (lot.lot_type == .watch) {
const sym = lot.priceSymbol();
const result = self.svc.getCandles(sym) catch continue;
defer self.allocator.free(result.data);
if (result.data.len > 0) {
wp.put(sym, result.data[result.data.len - 1].close) catch {};
}
}
}
}
const pf = self.portfolio orelse return; const pf = self.portfolio orelse return;
const positions = pf.positions(self.allocator) catch { const positions = pf.positions(self.allocator) catch {
@ -1068,7 +1042,57 @@ const App = struct {
var fetch_count: usize = 0; var fetch_count: usize = 0;
var stale_count: usize = 0; var stale_count: usize = 0;
var failed_syms: [8][]const u8 = undefined; var failed_syms: [8][]const u8 = undefined;
{
if (self.prefetched_prices) |*pp| {
// Use pre-fetched prices from before TUI started (first load only)
// Move stock prices into the working map
for (syms) |sym| {
if (pp.get(sym)) |price| {
prices.put(sym, price) catch {};
}
}
// Extract watchlist prices
if (self.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
self.watchlist_prices = std.StringHashMap(f64).init(self.allocator);
}
var wp = &(self.watchlist_prices.?);
var pp_iter = pp.iterator();
while (pp_iter.next()) |entry| {
if (!prices.contains(entry.key_ptr.*)) {
wp.put(entry.key_ptr.*, entry.value_ptr.*) catch {};
}
}
pp.deinit();
self.prefetched_prices = null;
} else {
// Live fetch (refresh path) fetch watchlist first, then stock prices
if (self.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
self.watchlist_prices = std.StringHashMap(f64).init(self.allocator);
}
var wp = &(self.watchlist_prices.?);
if (self.watchlist) |wl| {
for (wl) |sym| {
const result = self.svc.getCandles(sym) catch continue;
defer self.allocator.free(result.data);
if (result.data.len > 0) {
wp.put(sym, result.data[result.data.len - 1].close) catch {};
}
}
}
for (pf.lots) |lot| {
if (lot.lot_type == .watch) {
const sym = lot.priceSymbol();
const result = self.svc.getCandles(sym) catch continue;
defer self.allocator.free(result.data);
if (result.data.len > 0) {
wp.put(sym, result.data[result.data.len - 1].close) catch {};
}
}
}
// Fetch stock prices with TUI status-bar progress
const TuiProgress = struct { const TuiProgress = struct {
app: *App, app: *App,
failed: *[8][]const u8, failed: *[8][]const u8,
@ -3705,6 +3729,68 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, args: []const []co
app_inst.active_tab = .quote; app_inst.active_tab = .quote;
} }
// Pre-fetch portfolio prices before TUI starts, with stderr progress.
// This runs while the terminal is still in normal mode so output is visible.
if (app_inst.portfolio) |pf| {
const syms = pf.stockSymbols(allocator) catch null;
defer if (syms) |s| allocator.free(s);
// Collect watchlist symbols
var watch_syms: std.ArrayList([]const u8) = .empty;
defer watch_syms.deinit(allocator);
{
var seen = std.StringHashMap(void).init(allocator);
defer seen.deinit();
if (syms) |ss| for (ss) |s| seen.put(s, {}) catch {};
if (app_inst.watchlist) |wl| {
for (wl) |sym_w| {
if (!seen.contains(sym_w)) {
seen.put(sym_w, {}) catch {};
watch_syms.append(allocator, sym_w) catch {};
}
}
}
for (pf.lots) |lot| {
if (lot.lot_type == .watch and !seen.contains(lot.priceSymbol())) {
seen.put(lot.priceSymbol(), {}) catch {};
watch_syms.append(allocator, lot.priceSymbol()) catch {};
}
}
}
const stock_count = if (syms) |ss| ss.len else 0;
const total_count = stock_count + watch_syms.items.len;
if (total_count > 0) {
var prices = std.StringHashMap(f64).init(allocator);
var progress = cli.LoadProgress{
.svc = svc,
.color = true,
.index_offset = 0,
.grand_total = total_count,
};
if (syms) |ss| {
const result = svc.loadPrices(ss, &prices, false, progress.callback());
progress.index_offset = stock_count;
if (result.fetched_count > 0 or result.fail_count > 0) {
var msg_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Loaded {d} symbols ({d} cached, {d} fetched, {d} failed)\n", .{ ss.len, result.cached_count, result.fetched_count, result.fail_count }) catch "Done loading\n";
cli.stderrPrint(msg) catch {};
}
}
// Load watchlist prices
if (watch_syms.items.len > 0) {
_ = svc.loadPrices(watch_syms.items, &prices, false, progress.callback());
}
app_inst.prefetched_prices = prices;
}
}
defer if (app_inst.portfolio) |*pf| pf.deinit(); defer if (app_inst.portfolio) |*pf| pf.deinit();
defer freeWatchlist(allocator, app_inst.watchlist); defer freeWatchlist(allocator, app_inst.watchlist);
defer app_inst.deinitData(); defer app_inst.deinitData();