@wrongstack/tui 0.309.1 → 0.310.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.
@@ -14,10 +14,19 @@ export interface ContextPanelData {
14
14
  * Surfaced in the panel so `/context` answers "how much of my spend
15
15
  * is hitting the cache" without asking the user to chase the CLI
16
16
  * `/context cache` subcommand.
17
+ *
18
+ * `savedUsd` is the gross read-discount USD figure computed by the
19
+ * counter (cache-read tokens × (input price − cache-read price)).
20
+ * `cacheWrite5m` / `cacheWrite1h` are present when the upstream exposed
21
+ * an Anthropic-style TTL split (Anthropic family, including MiniMax
22
+ * routed through the Anthropic Messages surface); OpenAI-family
23
+ * gateways emit only the aggregate and leave them undefined.
17
24
  */
18
25
  cacheStats?: {
19
26
  readTokens: number;
20
27
  writeTokens: number;
28
+ cacheWrite5m?: number | undefined;
29
+ cacheWrite1h?: number | undefined;
21
30
  hitRatio: number;
22
31
  savedUsd: number;
23
32
  } | undefined;
@@ -27,6 +36,16 @@ export interface ContextPanelData {
27
36
  * exact extent of the cache, not just a percentage.
28
37
  */
29
38
  cacheCoverageTokens?: number | undefined;
39
+ /** Session cache telemetry split by provider, including fallback/model switches. */
40
+ providerCacheStats?: Array<{
41
+ provider: string;
42
+ input: number;
43
+ cacheRead: number;
44
+ cacheWrite: number;
45
+ cacheWrite5m?: number | undefined;
46
+ cacheWrite1h?: number | undefined;
47
+ hitRatio: number;
48
+ }> | undefined;
30
49
  /**
31
50
  * Real, measured per-category token accounting for the live request. When
32
51
  * present the Composition tab shows honest numbers; when absent (no request
package/dist/index.js CHANGED
@@ -5439,6 +5439,7 @@ function MetricsSection({ data }) {
5439
5439
  function CacheSection({ data }) {
5440
5440
  const cs = data.cacheStats ?? { readTokens: 0, writeTokens: 0, hitRatio: 0, savedUsd: 0 };
5441
5441
  const hasCache = cs.readTokens > 0 || cs.writeTokens > 0;
5442
+ const hasTtlSplit = cs.cacheWrite5m !== void 0 && cs.cacheWrite1h !== void 0;
5442
5443
  const coverage = Math.max(0, data.cacheCoverageTokens ?? 0);
5443
5444
  const max = data.ctxMaxTokens ?? 0;
5444
5445
  const covBarLen = 30;
@@ -5455,8 +5456,58 @@ function CacheSection({ data }) {
5455
5456
  /* @__PURE__ */ jsx10(Text, { color: theme.textMuted, children: " \xB7 read " }),
5456
5457
  /* @__PURE__ */ jsx10(Text, { color: theme.textPrimary, children: cs.readTokens.toLocaleString("en-US") }),
5457
5458
  /* @__PURE__ */ jsx10(Text, { color: theme.textMuted, children: " \xB7 write " }),
5458
- /* @__PURE__ */ jsx10(Text, { color: theme.textSecondary, children: cs.writeTokens.toLocaleString("en-US") })
5459
+ /* @__PURE__ */ jsx10(Text, { color: theme.textSecondary, children: cs.writeTokens.toLocaleString("en-US") }),
5460
+ cs.savedUsd > 0 ? /* @__PURE__ */ jsxs9(Fragment4, { children: [
5461
+ /* @__PURE__ */ jsx10(Text, { color: theme.textMuted, children: " \xB7 saved " }),
5462
+ /* @__PURE__ */ jsxs9(Text, { color: theme.success, children: [
5463
+ "~$",
5464
+ cs.savedUsd.toFixed(2)
5465
+ ] })
5466
+ ] }) : null
5459
5467
  ] }),
5468
+ hasTtlSplit ? /* @__PURE__ */ jsxs9(Box, { children: [
5469
+ /* @__PURE__ */ jsx10(Text, { color: theme.textMuted, children: "Write TTL " }),
5470
+ /* @__PURE__ */ jsxs9(Text, { color: theme.textPrimary, children: [
5471
+ "5m ",
5472
+ (cs.cacheWrite5m ?? 0).toLocaleString("en-US")
5473
+ ] }),
5474
+ /* @__PURE__ */ jsx10(Text, { color: theme.textMuted, children: " \xB7 " }),
5475
+ /* @__PURE__ */ jsxs9(Text, { color: theme.textSecondary, children: [
5476
+ "1h ",
5477
+ (cs.cacheWrite1h ?? 0).toLocaleString("en-US")
5478
+ ] })
5479
+ ] }) : null,
5480
+ (data.providerCacheStats?.length ?? 0) > 0 ? /* @__PURE__ */ jsxs9(Box, { flexDirection: "column", children: [
5481
+ /* @__PURE__ */ jsx10(Text, { color: theme.textMuted, children: "Providers" }),
5482
+ data.providerCacheStats?.map((provider) => /* @__PURE__ */ jsxs9(Text, { children: [
5483
+ /* @__PURE__ */ jsxs9(Text, { color: theme.textPrimary, children: [
5484
+ " ",
5485
+ provider.provider
5486
+ ] }),
5487
+ /* @__PURE__ */ jsx10(Text, { color: theme.textMuted, children: " \xB7 " }),
5488
+ /* @__PURE__ */ jsxs9(Text, { color: provider.cacheRead > 0 ? theme.success : theme.textMuted, children: [
5489
+ (provider.hitRatio * 100).toFixed(1),
5490
+ "%"
5491
+ ] }),
5492
+ /* @__PURE__ */ jsx10(Text, { color: theme.textMuted, children: " \xB7 read " }),
5493
+ /* @__PURE__ */ jsx10(Text, { color: theme.textPrimary, children: provider.cacheRead.toLocaleString("en-US") }),
5494
+ /* @__PURE__ */ jsx10(Text, { color: theme.textMuted, children: " \xB7 write " }),
5495
+ /* @__PURE__ */ jsx10(Text, { color: theme.textSecondary, children: provider.cacheWrite.toLocaleString("en-US") }),
5496
+ provider.cacheWrite5m !== void 0 && provider.cacheWrite1h !== void 0 ? /* @__PURE__ */ jsxs9(Fragment4, { children: [
5497
+ /* @__PURE__ */ jsx10(Text, { color: theme.textMuted, children: " (" }),
5498
+ /* @__PURE__ */ jsxs9(Text, { color: theme.textPrimary, children: [
5499
+ "5m ",
5500
+ provider.cacheWrite5m.toLocaleString("en-US")
5501
+ ] }),
5502
+ /* @__PURE__ */ jsx10(Text, { color: theme.textMuted, children: ", " }),
5503
+ /* @__PURE__ */ jsxs9(Text, { color: theme.textSecondary, children: [
5504
+ "1h ",
5505
+ provider.cacheWrite1h.toLocaleString("en-US")
5506
+ ] }),
5507
+ /* @__PURE__ */ jsx10(Text, { color: theme.textMuted, children: ")" })
5508
+ ] }) : null
5509
+ ] }, provider.provider))
5510
+ ] }) : null,
5460
5511
  coverage > 0 && max > 0 ? /* @__PURE__ */ jsxs9(Fragment4, { children: [
5461
5512
  /* @__PURE__ */ jsxs9(Box, { marginTop: 1, children: [
5462
5513
  /* @__PURE__ */ jsx10(Text, { color: theme.textMuted, children: "Coverage " }),
@@ -10316,6 +10367,7 @@ function AppStatusRegion({
10316
10367
  })(),
10317
10368
  cacheStats: cacheStats ?? { readTokens: 0, writeTokens: 0, hitRatio: 0, savedUsd: 0 },
10318
10369
  cacheCoverageTokens: cacheCoverageTokens ?? 0,
10370
+ providerCacheStats: cacheStats?.providers ?? [],
10319
10371
  fleetEntries: Object.values(state.fleet).map((e) => ({
10320
10372
  name: e.name,
10321
10373
  status: e.status,
@@ -20901,6 +20953,7 @@ function SidebarContent({
20901
20953
  const cs = cacheStats ?? { readTokens: 0, writeTokens: 0, hitRatio: 0, savedUsd: 0 };
20902
20954
  const hasCacheActivity = cs.readTokens > 0 || cs.writeTokens > 0;
20903
20955
  const cacheHitPct = Math.round(cs.hitRatio * 100);
20956
+ const activeProviderCache = cs.providers?.find((entry) => entry.provider === provider);
20904
20957
  const cacheCoveragePct = contextWindow && contextWindow.max > 0 ? Math.min(100, Math.round(cacheCoverageTokens / contextWindow.max * 100)) : 0;
20905
20958
  const MAX_SIDEBAR_AGENTS = 12;
20906
20959
  const allEntries = Object.values(entries);
@@ -21065,6 +21118,16 @@ function SidebarContent({
21065
21118
  )
21066
21119
  ) })
21067
21120
  ] }),
21121
+ /* @__PURE__ */ jsx78(
21122
+ SidebarStatRow,
21123
+ {
21124
+ label: `${activeProviderCache?.provider ?? provider ?? "provider"} hit`,
21125
+ value: activeProviderCache ? `${(activeProviderCache.hitRatio * 100).toFixed(1)}%` : `${cacheHitPct}%`,
21126
+ color: theme.success,
21127
+ accent: theme.success,
21128
+ innerWidth: bodyWidth
21129
+ }
21130
+ ),
21068
21131
  /* @__PURE__ */ jsx78(
21069
21132
  SidebarStatRow,
21070
21133
  {
@@ -46254,6 +46317,50 @@ function createRunTuiClientRegistration(opts) {
46254
46317
  return { register, unregister };
46255
46318
  }
46256
46319
 
46320
+ // src/run-tui-teardown.ts
46321
+ var DEFAULT_CLOSE_BUDGET_MS = 1500;
46322
+ function createDurableTeardown(opts) {
46323
+ const closeBudgetMs = opts.closeBudgetMs ?? DEFAULT_CLOSE_BUDGET_MS;
46324
+ const salvageSync = () => {
46325
+ try {
46326
+ opts.getSession()?.flushSync?.();
46327
+ } catch {
46328
+ }
46329
+ };
46330
+ const awaitCloseBounded = async () => {
46331
+ await Promise.race([
46332
+ (async () => {
46333
+ try {
46334
+ await opts.getSession()?.close();
46335
+ } catch {
46336
+ }
46337
+ })(),
46338
+ new Promise((resolve) => {
46339
+ const t = setTimeout(resolve, closeBudgetMs);
46340
+ t.unref();
46341
+ })
46342
+ ]);
46343
+ };
46344
+ const awaitDurableClose = async () => {
46345
+ salvageSync();
46346
+ try {
46347
+ opts.cleanup();
46348
+ } catch {
46349
+ }
46350
+ await awaitCloseBounded();
46351
+ salvageSync();
46352
+ };
46353
+ const shutdownViaSignal = async (exitCode) => {
46354
+ try {
46355
+ opts.killChildren?.();
46356
+ } catch {
46357
+ }
46358
+ await awaitDurableClose();
46359
+ opts.exit(exitCode);
46360
+ };
46361
+ return { shutdownViaSignal, awaitDurableClose, salvageSync };
46362
+ }
46363
+
46257
46364
  // src/run-tui-title-controller.ts
46258
46365
  import * as path11 from "node:path";
46259
46366
 
@@ -46508,6 +46615,24 @@ async function runTui(opts) {
46508
46615
  unsilenceTerminal();
46509
46616
  }
46510
46617
  };
46618
+ let resolveRun;
46619
+ let runExitCode = 0;
46620
+ const finishRun = (code) => {
46621
+ detachListeners();
46622
+ if (!resolveRun) return;
46623
+ const resolve = resolveRun;
46624
+ resolveRun = void 0;
46625
+ resolve(code);
46626
+ };
46627
+ const durableTeardown = createDurableTeardown({
46628
+ getSession: () => opts.agent.ctx.session,
46629
+ killChildren: () => getProcessRegistry10().killAll({ force: true, preserveBackground: true }),
46630
+ cleanup,
46631
+ // Resolve the host instead of process.exit so CLI teardown (plugin
46632
+ // stop, Telegram lock.release, vector-memory close) still runs.
46633
+ // Rapid Ctrl+C and the hung-unmount timer still hard-exit.
46634
+ exit: (code) => finishRun(code)
46635
+ });
46511
46636
  const RAPID_EXIT_WINDOW_MS = 2e3;
46512
46637
  const RAPID_EXIT_THRESHOLD = 2;
46513
46638
  let ctrlCPressTimestamps = [];
@@ -46518,21 +46643,20 @@ async function runTui(opts) {
46518
46643
  getProcessRegistry10().killAll({ force: true, preserveBackground: true });
46519
46644
  } catch {
46520
46645
  }
46521
- try {
46522
- opts.agent.ctx.session.flushSync?.();
46523
- } catch {
46524
- }
46646
+ durableTeardown.salvageSync();
46525
46647
  cleanup();
46526
46648
  process.exit(130);
46527
46649
  };
46528
46650
  const signals = ["SIGTERM", "SIGHUP", "SIGINT"];
46529
46651
  const signalHandler = () => {
46652
+ runExitCode = 143;
46530
46653
  inkInstance?.unmount();
46654
+ void durableTeardown.shutdownViaSignal(143);
46655
+ };
46656
+ const exitHandler = () => {
46657
+ durableTeardown.salvageSync();
46531
46658
  cleanup();
46532
- const sig = setTimeout(() => process.exit(143), 5e3);
46533
- sig.unref();
46534
46659
  };
46535
- const exitHandler = () => cleanup();
46536
46660
  const sigintHandler = () => {
46537
46661
  const now = Date.now();
46538
46662
  ctrlCPressTimestamps = ctrlCPressTimestamps.filter((t) => now - t < RAPID_EXIT_WINDOW_MS);
@@ -46542,10 +46666,9 @@ async function runTui(opts) {
46542
46666
  forceExitViaRapidCtrlC();
46543
46667
  return;
46544
46668
  }
46669
+ runExitCode = 130;
46545
46670
  inkInstance?.unmount();
46546
- cleanup();
46547
- const sig = setTimeout(() => process.exit(130), 5e3);
46548
- sig.unref();
46671
+ void durableTeardown.shutdownViaSignal(130);
46549
46672
  };
46550
46673
  process.on("SIGINT", sigintHandler);
46551
46674
  for (const s2 of ["SIGTERM", "SIGHUP", "SIGBREAK"]) {
@@ -46584,24 +46707,32 @@ async function runTui(opts) {
46584
46707
  };
46585
46708
  void tuiClientRegistration.register();
46586
46709
  return new Promise((resolve) => {
46587
- let exitCode = 0;
46710
+ resolveRun = resolve;
46588
46711
  let hardExitTimer = null;
46589
46712
  const onExit = (code) => {
46590
- exitCode = code;
46713
+ runExitCode = code;
46591
46714
  };
46592
46715
  const settle = (code) => {
46593
46716
  if (hardExitTimer) {
46594
46717
  clearTimeout(hardExitTimer);
46595
46718
  hardExitTimer = null;
46596
46719
  }
46597
- cleanup();
46598
- detachListeners();
46599
- resolve(code);
46720
+ void durableTeardown.awaitDurableClose().then(
46721
+ () => finishRun(code),
46722
+ () => finishRun(code)
46723
+ );
46600
46724
  };
46601
46725
  const requestExit = (code) => {
46602
46726
  onExit(code);
46603
46727
  inkInstance?.unmount();
46604
- hardExitTimer = setTimeout(() => process.exit(code), 5e3);
46728
+ hardExitTimer = setTimeout(() => {
46729
+ durableTeardown.salvageSync();
46730
+ try {
46731
+ getProcessRegistry10().killAll({ force: true, preserveBackground: true });
46732
+ } catch {
46733
+ }
46734
+ process.exit(code);
46735
+ }, 5e3);
46605
46736
  hardExitTimer.unref();
46606
46737
  };
46607
46738
  opts.requestExit = requestExit;
@@ -46772,7 +46903,7 @@ async function runTui(opts) {
46772
46903
  detachResize = () => stdout.off("resize", onResize2);
46773
46904
  instance.waitUntilExit().then(() => {
46774
46905
  detachResize?.();
46775
- settle(exitCode);
46906
+ settle(runExitCode);
46776
46907
  }).catch(() => {
46777
46908
  detachResize?.();
46778
46909
  settle(1);
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Durable teardown primitives for the TUI host.
3
+ *
4
+ * Extracted from run-tui.ts so the kill-safety semantics are unit-testable
5
+ * without an Ink render loop.
6
+ *
7
+ * Why both a sync and an async step exist:
8
+ * - `flushSync()` drains the writer's in-memory buffer with blocking
9
+ * writes. That data survives ANY concurrent hard exit (the host's
10
+ * 500ms exit-grace timer, a second Ctrl+C, a supervisor SIGKILL racing
11
+ * us) because by the time it returns, the bytes are already handed to
12
+ * the OS.
13
+ * - `close()` additionally drains the async write chain, `datasync()`s
14
+ * the handle, and writes the `.summary.json` sidecar + `_index.jsonl`
15
+ * row. That work is async, cannot be forced synchronous, and therefore
16
+ * gets a bounded budget instead of being trusted to outlive the loop.
17
+ *
18
+ * Ordering contract of `shutdownViaSignal`:
19
+ * 1. killChildren — stop tool subprocesses from producing new events.
20
+ * 2. salvageSync — buffered events are durably on disk from here on.
21
+ * 3. cleanup — terminal restore; also kicks off close() (idempotent).
22
+ * 4. await close — bounded by closeBudgetMs; never hangs shutdown.
23
+ * 5. salvageSync — catch anything close() re-buffered while failing.
24
+ * 6. exit — caller-supplied exit function.
25
+ */
26
+ /** Structural subset of SessionWriter the teardown needs. */
27
+ export interface TeardownSession {
28
+ /** Durable drain: buffer → disk → datasync → sidecar/index. Idempotent. */
29
+ close(): Promise<void>;
30
+ /** Last-gasp synchronous drain of whatever is still in memory. */
31
+ flushSync?(): void;
32
+ }
33
+ export interface DurableTeardownOptions {
34
+ /** Reads the CURRENT writer so swaps (e.g. /resume) stay covered. */
35
+ getSession: () => TeardownSession | undefined;
36
+ /** Foreground child cleanup (process registry killAll). Best-effort. */
37
+ killChildren?: (() => void) | undefined;
38
+ /** Terminal restore + client unregister. Idempotent. */
39
+ cleanup: () => void;
40
+ /** Hard ceiling for awaiting close(). Default 1500ms. */
41
+ closeBudgetMs?: number;
42
+ /** Exit function — injectable so tests don't need process.exit. */
43
+ exit: (code: number) => void;
44
+ }
45
+ export interface DurableTeardown {
46
+ /**
47
+ * Signal-driven shutdown (SIGTERM/SIGHUP/Ctrl+C): full ordered sequence,
48
+ * ends by calling exit(). Safe to fire concurrently from several handlers
49
+ * — every step is idempotent and exit wins once.
50
+ */
51
+ shutdownViaSignal(exitCode: number): Promise<void>;
52
+ /**
53
+ * Awaited close WITHOUT exiting — used by the natural exit path (settle)
54
+ * so the host continues only after durability, and project-switch respawns
55
+ * keep working.
56
+ */
57
+ awaitDurableClose(): Promise<void>;
58
+ /** Sync-only last-chance drain for 'exit' listeners / process.exit contexts. */
59
+ salvageSync(): void;
60
+ }
61
+ export declare function createDurableTeardown(opts: DurableTeardownOptions): DurableTeardown;
62
+ //# sourceMappingURL=run-tui-teardown.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrongstack/tui",
3
- "version": "0.309.1",
3
+ "version": "0.310.0",
4
4
  "license": "MIT",
5
5
  "description": "WrongStack terminal UI: Ink-based interactive renderer with history, pickers, and slash menus.",
6
6
  "repository": {
@@ -29,12 +29,12 @@
29
29
  "ink": "7.1.1",
30
30
  "react": "^19.2.8",
31
31
  "string-width": "8.2.2",
32
- "@wrongstack/core": "0.309.1",
33
- "@wrongstack/sage": "0.309.1",
34
- "@wrongstack/runtime": "0.309.1",
35
- "@wrongstack/kanban": "0.309.1",
36
- "@wrongstack/sdd": "0.309.1",
37
- "@wrongstack/tools": "0.309.1"
32
+ "@wrongstack/core": "0.310.0",
33
+ "@wrongstack/kanban": "0.310.0",
34
+ "@wrongstack/runtime": "0.310.0",
35
+ "@wrongstack/sage": "0.310.0",
36
+ "@wrongstack/sdd": "0.310.0",
37
+ "@wrongstack/tools": "0.310.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@testing-library/dom": "^10.4.1",