diff --git a/include/cst_version.h b/include/cst_version.h index 0c6e982..55b281a 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -3,7 +3,7 @@ #define CST_VERSION_MAJOR 0 #define CST_VERSION_MINOR 9 -#define CST_VERSION_PATCH 26 +#define CST_VERSION_PATCH 27 #define CST_VERSION_STR_(x) #x #define CST_VERSION_STR(x) CST_VERSION_STR_(x) diff --git a/src/tui.c b/src/tui.c index b0b82a0..259d04c 100644 --- a/src/tui.c +++ b/src/tui.c @@ -339,7 +339,10 @@ typedef struct { char *md_live; size_t md_len; size_t md_cap; - size_t md_rendered; /* md_len at the last live_render */ + size_t md_shown; /* bytes of md_live revealed on screen (drip buffer) */ + size_t md_rendered; /* md_shown at the last live_render */ + double drip_carry; /* fractional codepoints awaiting reveal */ + struct timespec drip_at; char *md_prefix_ansi; /* rendered md_live[0, md_prefix) for long replies */ size_t md_prefix; struct timespec md_render_at; @@ -855,7 +858,9 @@ static void live_end_cleanup(tui_t *t) { t->md_live = NULL; t->md_len = 0; t->md_cap = 0; + t->md_shown = 0; t->md_rendered = 0; + t->drip_carry = 0; free(t->md_prefix_ansi); t->md_prefix_ansi = NULL; t->md_prefix = 0; @@ -915,12 +920,13 @@ static char *md_render_part(const char *md, size_t n, int close_fence) { } /* Markdown → ANSI while streaming: unfinished fences are closed for display, - * and long replies keep the finished prefix rendered once. Caller locks. */ + * and long replies keep the finished prefix rendered once. Renders only the + * dripped prefix [0, md_shown). Caller locks. */ static void live_render(tui_t *t) { - if (!t->as_live || !t->md_live || t->md_rendered == t->md_len) + if (!t->as_live || !t->md_live || t->md_rendered == t->md_shown) return; const char *md = t->md_live; - size_t n = t->md_len, stable = 0; + size_t n = t->md_shown, stable = 0; if (n >= LIVE_PREFIX_MIN) { md_scan(md, t->md_prefix, n, &stable); if (stable > t->md_prefix + 4096) { @@ -1057,11 +1063,130 @@ static char *glitch_stream_frontier(const char *src, int spin) { return out; } +static size_t utf8_codepoints(const char *s, size_t n) { + size_t i = 0, c = 0; + while (i < n) { + wchar_t wc; + size_t eaten; + utf8_decode(s + i, n - i, &wc, &eaten); + if (eaten == 0) + break; + i += eaten; + c++; + } + return c; +} + +static size_t utf8_advance_n(const char *s, size_t n, size_t max_chars) { + size_t i = 0, c = 0; + while (i < n && c < max_chars) { + wchar_t wc; + size_t eaten; + utf8_decode(s + i, n - i, &wc, &eaten); + if (eaten == 0) + break; + i += eaten; + c++; + } + return i; +} + +static int isqrt_sz(size_t n) { + if (n <= 1) + return (int)n; + size_t x = n, y = (x + 1) / 2; + while (y < x) { + x = y; + y = (x + n / x) / 2; + } + return (int)x; +} + +/* Non-linear drip: slow and readable near the tip; accelerates with backlog + * so a fast model never leaves us many seconds behind. */ +static double drip_cps(size_t backlog_chars) { + if (backlog_chars == 0) + return 0; + if (backlog_chars <= 6) + return 14.0; /* ~one glyph / tick — glitch is visible */ + if (backlog_chars <= 40) + return 18.0 + (double)backlog_chars * 0.9; + /* ~ 22 + 14*sqrt(backlog) */ + double cps = 22.0 + 14.0 * (double)isqrt_sz(backlog_chars); + if (backlog_chars > 600) + cps = (double)backlog_chars * 8.0; /* drain hard */ + if (cps > 900.0) + cps = 900.0; + return cps; +} + +static int live_apply_shown(tui_t *t) { + free(t->live.body); + t->live.body = t->md_shown ? xstrndup(t->md_live, t->md_shown) : xstrdup(""); + t->md_rendered = 0; /* force live_render */ + live_render(t); + return 1; +} + +/* Advance md_shown toward md_len. Returns 1 if the display changed. */ +static int live_drip(tui_t *t) { + if (!t->as_live || !t->md_live || t->md_shown >= t->md_len) + return 0; + long dt = ms_since(&t->drip_at); + if (dt < 12) + return 0; + if (dt > 200) + dt = 200; + clock_gettime(CLOCK_MONOTONIC, &t->drip_at); + + size_t backlog = + utf8_codepoints(t->md_live + t->md_shown, t->md_len - t->md_shown); + if (backlog == 0) { + t->md_shown = t->md_len; + return live_apply_shown(t); + } + t->drip_carry += drip_cps(backlog) * ((double)dt / 1000.0); + size_t want = (size_t)t->drip_carry; + if (want < 1) { + if (dt >= 70 && backlog > 0) { + want = 1; /* guarantee tip motion */ + t->drip_carry = 0; + } else { + return 0; + } + } else { + t->drip_carry -= (double)want; + } + if (want > backlog) + want = backlog; + if (want > 64) + want = 64; /* hard per-frame cap for readability */ + size_t adv = + utf8_advance_n(t->md_live + t->md_shown, t->md_len - t->md_shown, want); + if (adv == 0) + return 0; + t->md_shown += adv; + return live_apply_shown(t); +} + +static void live_flush(tui_t *t) { + if (!t->as_live || !t->md_live) + return; + if (t->md_shown >= t->md_len) + return; + t->md_shown = t->md_len; + t->drip_carry = 0; + (void)live_apply_shown(t); +} + static void live_begin(tui_t *t) { live_end_cleanup(t); t->md_cap = MD_INIT; t->md_live = calloc(1, t->md_cap); t->md_len = 0; + t->md_shown = 0; + t->drip_carry = 0; + clock_gettime(CLOCK_MONOTONIC, &t->drip_at); t->live.kind = BLK_ASSISTANT; block_set_title(&t->live, "Assistant"); t->live.expanded = 1; @@ -1084,17 +1209,13 @@ static void live_feed(tui_t *t, const char *text, size_t len) { memcpy(t->md_live + t->md_len, text, len); t->md_len += len; t->md_live[t->md_len] = 0; - /* The last render stays on screen until the next throttled live_render; - * before the first one the raw text shows. */ - free(t->live.body); - t->live.body = xstrndup(t->md_live, t->md_len); - if (!t->live.render || ms_since(&t->md_render_at) >= LIVE_RENDER_MS) - live_render(t); + /* Network buffer only — screen catch-up is live_drip on the UI tick. */ } static void live_seal(tui_t *t) { if (!t->as_live) return; + live_flush(t); block_t *b = blocks_push(&t->hist); if (b) { b->kind = BLK_ASSISTANT; @@ -7533,10 +7654,14 @@ int cst_tui_run(cst_agent_t *agent) { } if (busy) { tui_lock(&t); - if (t.as_live && t.md_rendered != t.md_len && - ms_since(&t.md_render_at) >= LIVE_RENDER_MS) { - live_render(&t); - dirty = 1; + if (t.as_live) { + if (live_drip(&t)) + dirty = 1; + else if (t.md_rendered != t.md_shown && + ms_since(&t.md_render_at) >= LIVE_RENDER_MS) { + live_render(&t); + dirty = 1; + } } if (t.think_timing && t.think_idx >= 0 && t.think_idx < t.hist.n) { char title[64];