dsh-tacit 0.2.2 → 0.3.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.
package/client/client.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Copyright (c) 2026 hackernotfound — https://github.com/hackernotfound/dsh-tacit
3
+ // GENERATED by scripts/build-client.mjs from client/src/ — edit the sources, not this file.
3
4
  /**
4
5
  * dsh-tacit — Client half (installed package bundle entry, zero-build).
5
6
  *
@@ -57,9 +58,18 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
57
58
  'trigger.correction': '纠正',
58
59
  'trigger.manual': '手动',
59
60
  'trigger.bootstrap': '引导',
61
+ 'trigger.good': '做对了',
62
+ 'report.strengths': '这次做对了什么',
63
+ 'report.lesson': '经验',
64
+ 'settings.learnGood': '也从混乱之后的顺利提示词中学习',
60
65
  'bootstrap.btn': '从我最近 20 轮中学习',
61
66
  'bootstrap.running': '学习中… {done}/{total}',
62
- 'bootstrap.hint': ' $0.02–0.05,一次性。已顺利完成的提示词会被跳过。',
67
+ 'bootstrap.runningUsd': '学习中… {done}/{total} · {usd}',
68
+ 'bootstrap.hint': '分析你最近完成的轮次:提示词至少 8 个字符、不是单纯的「继续」类消息(「继续」「好的」…)、且还没有报告——最多 20 条。',
69
+ 'bootstrap.estimateDoc': '使用 deepseek-v4-flash 约 $0.02–0.05,一次性。',
70
+ 'bootstrap.preview': '有 {eligible} 轮符合条件 · 按目录价约 {usd}',
71
+ 'bootstrap.previewMeasured': '(根据你最近 {samples} 次分析的实测数据)',
72
+ 'bootstrap.previewDoc': '(文档估算——还没有足够的实测分析)',
63
73
  'steer.enrich': '发送前补充学到的上下文(实验性,每次发送一次小调用)',
64
74
  'steer.title': '智能体被告知的关于你的信息',
65
75
  'steer.desc': '这些指令会注入每个新会话的系统提示词,让智能体替你补上你通常没说的内容。',
@@ -74,6 +84,9 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
74
84
  'steer.active': '已生效',
75
85
  'steer.retired': '已淘汰 · {reason}',
76
86
  'steer.user': '你添加的',
87
+ 'steer.workspace': '仅 {name}',
88
+ 'steer.scope': '适用范围',
89
+ 'steer.everywhere': '所有工作区',
77
90
  'settings.auto': '自动分析混乱轮次',
78
91
  'settings.budget': '每日自动分析上限',
79
92
  settings: '设置',
@@ -124,6 +137,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
124
137
  'preview.improved': '改进后',
125
138
  'preview.rationale': '改动说明',
126
139
  'preview.pending': '正在分析草稿…',
140
+ 'preview.unchanged': '草稿已经完整,无需修改。',
127
141
  'preview.apply': '应用改进',
128
142
  'preview.cancel': '取消',
129
143
  'feedback.title': '这次改进有帮助吗?',
@@ -134,6 +148,131 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
134
148
  'feedback.noted': '已记录,谢谢!',
135
149
  'panel.styleRules': '已学习的风格规则',
136
150
  'panel.styleRulesEmpty': '还没有学习到风格规则——应用改进后给出 👎 反馈(附一句话原因),累计 3 次即可提炼出规则。',
151
+ 'card.overview': '概览',
152
+ 'card.usage': '用量',
153
+ 'card.pricing': '价格',
154
+ 'card.learning': '学习',
155
+ 'card.guidance': '智能体指令',
156
+ 'card.improve': '改进与反馈',
157
+ 'card.history': '分析历史',
158
+ 'card.privacy': '数据与隐私',
159
+ 'card.expand': '展开这一节',
160
+ 'card.collapse': '收起这一节',
161
+ 'overview.model': '模型:{model}',
162
+ 'usage.empty': '还没有计费调用——用量从 {since} 开始记录。',
163
+ 'usage.today': '今日',
164
+ 'usage.month': '本月',
165
+ 'usage.last30': '最近 30 天',
166
+ 'usage.since': '自 {day} 起',
167
+ 'usage.calls': '计费调用(30 天)',
168
+ 'usage.avgAnalysis': '单次分析中位成本',
169
+ 'usage.cachedRate': '输入缓存命中率(30 天)',
170
+ 'usage.unpriced': '无价目调用(30 天)',
171
+ 'usage.unpricedShort': '{n} 次无价目',
172
+ 'usage.priceUnavailable': '无价目',
173
+ 'usage.chartLabel': '每日花费,最近 {n} 天',
174
+ 'usage.chartSummary': '最近 {n} 天:合计 {total};最高 {day} {max}',
175
+ 'usage.barTitle': '{day} · {usd} · {calls} 次调用',
176
+ 'usage.series7': '7 天',
177
+ 'usage.series30': '30 天',
178
+ 'usage.breakdown': '按操作分类(最近 30 天)',
179
+ 'usage.filters': '筛选',
180
+ 'usage.loading': '正在载入这次运行的调用明细…',
181
+ 'usage.page': '第 {page} / {pages} 页',
182
+ 'usage.prev': '上一页',
183
+ 'usage.next': '下一页',
184
+ 'usage.label': '真实用量 · 按目录价计算',
185
+ 'usage.col.time': '时间',
186
+ 'usage.col.op': '操作',
187
+ 'usage.col.scope': '范围',
188
+ 'usage.col.model': '模型',
189
+ 'usage.col.status': '状态',
190
+ 'usage.col.calls': '计费调用',
191
+ 'usage.col.tokens': 'Tokens',
192
+ 'usage.col.cost': '花费',
193
+ 'filter.range': '时间范围',
194
+ 'filter.type': '操作',
195
+ 'filter.status': '状态',
196
+ 'filter.model': '模型',
197
+ 'filter.workspace': '工作区',
198
+ 'filter.session': '会话 ID',
199
+ 'filter.all': '全部',
200
+ 'range.today': '今日',
201
+ 'range.7d': '最近 7 天',
202
+ 'range.30d': '最近 30 天',
203
+ 'range.month': '本月',
204
+ 'range.all': '全部保留期',
205
+ 'runtype.bootstrap': '引导批量',
206
+ 'runtype.analysis': '分析',
207
+ 'runtype.analysis-batch': '批量分析',
208
+ 'runtype.improve': '改进',
209
+ 'runtype.directive-distillation': '指令提炼',
210
+ 'runtype.style-distillation': '风格提炼',
211
+ 'runtype.prompt-enrichment': '提示词补充',
212
+ 'op.analysis': '分析',
213
+ 'op.analysis-repair': '分析修复',
214
+ 'op.directive-distillation': '指令提炼',
215
+ 'op.style-distillation': '风格提炼',
216
+ 'op.improve': '改进',
217
+ 'op.improve-repair': '改进修复',
218
+ 'op.enrichment': '上下文补充',
219
+ 'status.success': '成功',
220
+ 'status.partial': '部分成功',
221
+ 'status.failed': '失败',
222
+ 'status.ok': '成功',
223
+ 'status.unmetered': '未计量',
224
+ 'status.running': '进行中',
225
+ 'attempt.effort': '推理强度 {effort}',
226
+ 'attempt.finish': '结束 {finish} {code}',
227
+ 'attempt.source': '{source} · {tier}',
228
+ 'attempt.duration': '{ms} 毫秒',
229
+ 'warn.daily': '今日 {spent} / 每日上限 {limit}({pct}%)',
230
+ 'warn.monthly': '本月 {spent} / 每月上限 {limit}({pct}%)',
231
+ 'improve.explain': '「改进」会在你发送前重写草稿。给某次重写点 👎 并写一句原因,Tacit 会从中提炼出持久的风格规则。',
232
+ 'privacy.stored': '分析报告、指令和风格规则只保存在本机的 Tacit 数据目录里。一条用量记录只包含:时间、操作、会话 ID、轮次编号、工作区名称、模型、服务商、token 计数与目录价——绝不包含你的提示词或模型回复的内容。清除操作只会删除 Tacit 自己的文件。',
233
+ 'privacy.retention': '详细用量记录保留天数',
234
+ 'privacy.warnDaily': '每日花费提醒阈值(美元)',
235
+ 'privacy.warnMonthly': '每月花费提醒阈值(美元)',
236
+ 'privacy.warnHint': '填 0 表示关闭提醒。达到阈值的 80% 时开始提醒,超过阈值后标记为已超出。',
237
+ 'privacy.clearUsage': '清除用量记录',
238
+ 'privacy.apply': '应用',
239
+ 'confirm.reportsTitle': '清除所有分析报告?',
240
+ 'confirm.reportsBody': '这会删除 Tacit 为所有会话写下的全部分析报告。指令、风格规则和用量记录会保留。此操作无法撤销。',
241
+ 'confirm.usageTitle': '清除用量记录?',
242
+ 'confirm.usageBody': '这会删除 Tacit 的用量账本——每一次记录的调用、它的 token 计数和目录价——并重新开始计量窗口。分析报告和指令会保留。此操作无法撤销。',
243
+ 'confirm.cancel': '取消',
244
+ 'confirm.clear': '清除',
245
+ 'pricing.summary': '{model} {tier} {rates} / 100 万 · {source}({asOf})',
246
+ 'pricing.rateTable': '每 100 万 tokens 的目录价',
247
+ 'pricing.model': '模型',
248
+ 'pricing.tier': '时段',
249
+ 'pricing.cacheHit': '缓存命中输入',
250
+ 'pricing.cacheMiss': '输入',
251
+ 'pricing.output': '输出',
252
+ 'pricing.reasoning': '推理',
253
+ 'pricing.reasoningSameAsOutput': '按输出计费',
254
+ 'pricing.peak': '高峰',
255
+ 'pricing.offPeak': '非高峰',
256
+ 'pricing.tierNow': '当前时段:{tier}',
257
+ 'pricing.schedule': '高峰时段为 UTC 01:00–04:00 与 06:00–10:00,其余时间均为非高峰;时段按调用开始的时刻判定。',
258
+ 'pricing.weekendRule': '自 2026-08-22 16:00 UTC 起,北京日历的周末(UTC+8 的周六与周日)全天按非高峰计费。',
259
+ 'pricing.source': '价目来源',
260
+ 'pricing.sourceBundled': '内置价目表',
261
+ 'pricing.sourceCostMeter': 'cost-meter 服务',
262
+ 'pricing.refreshedAt': '刷新于 {time}',
263
+ 'pricing.never': '从未刷新',
264
+ 'pricing.formula': '费用 =(输入 × 输入价 + 缓存命中输入 × 缓存价 + 输出 × 输出价)÷ 1,000,000',
265
+ 'pricing.accuracy': '目录价;以服务商账单为准',
266
+ 'pricing.refresh': '刷新价目',
267
+ 'pricing.refreshing': '刷新中…',
268
+ 'pricing.error': '无法读取价目来源:{error}',
269
+ 'notice.bootstrap': '引导完成 · 已分析 {analyzed} 条 · 跳过 {skipped} 条 · {calls} 次计费调用 · {tokens} tokens · 目录价 {usd}',
270
+ 'notice.analyze': '已分析 #{turn} · {calls} 次调用 · {tokens} tokens · {usd}',
271
+ 'notice.improve': '已改进草稿 · {calls} 次调用 · {tokens} tokens · {usd}',
272
+ 'notice.batch': '已分析 {analyzed}/{requested} 条 · {calls} 次调用 · {tokens} tokens · {usd}',
273
+ 'notice.usageCleared': '用量记录已清除 · 删除 {n} 天的记录 · 计量窗口重新开始',
274
+ 'notice.pricingRefreshed': '价目已刷新 · {source}({asOf})',
275
+ 'notice.unpriced': ' · {n} 次无价目',
137
276
  'err.bad-request': '请求无效。',
138
277
  'err.bad-json': '请求体无效。',
139
278
  'err.no-session': '当前会话不可用(可能尚未在本进程恢复)。',
@@ -164,9 +303,18 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
164
303
  'trigger.correction': 'correction',
165
304
  'trigger.manual': 'manual',
166
305
  'trigger.bootstrap': 'bootstrap',
306
+ 'trigger.good': 'what worked',
307
+ 'report.strengths': 'What this prompt got right',
308
+ 'report.lesson': 'Lesson',
309
+ 'settings.learnGood': 'Also learn from a clean prompt right after a messy turn',
167
310
  'bootstrap.btn': 'Learn from my last 20 turns',
168
311
  'bootstrap.running': 'Learning… {done}/{total}',
169
- 'bootstrap.hint': ' $0.02–0.05, one time. Skips prompts that already went fine on their own.',
312
+ 'bootstrap.runningUsd': 'Learning… {done}/{total} · {usd}',
313
+ 'bootstrap.hint': 'Analyzes your newest finished turns whose prompt is at least 8 characters, is not a bare continuation (“continue”, “ok”…), and has no report yet — up to 20.',
314
+ 'bootstrap.estimateDoc': '≈ $0.02–0.05 with deepseek-v4-flash, one time',
315
+ 'bootstrap.preview': '{eligible} turn(s) eligible · ≈{usd} at list price',
316
+ 'bootstrap.previewMeasured': '(measured from your last {samples} analyses)',
317
+ 'bootstrap.previewDoc': '(documented estimate — not enough measured analyses yet)',
170
318
  'steer.enrich': 'Add learned context before each send (experimental — one small call per send)',
171
319
  'steer.title': 'What the agent is told about you',
172
320
  'steer.desc': 'These directives ride every new session\'s system prompt so the agent fills in what you usually leave unsaid.',
@@ -181,6 +329,9 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
181
329
  'steer.active': 'active',
182
330
  'steer.retired': 'retired · {reason}',
183
331
  'steer.user': 'yours',
332
+ 'steer.workspace': 'only {name}',
333
+ 'steer.scope': 'Applies to',
334
+ 'steer.everywhere': 'Everywhere',
184
335
  'settings.auto': 'Auto-analyze messy turns',
185
336
  'settings.budget': 'Daily cap on automatic analyses',
186
337
  settings: 'Settings',
@@ -231,6 +382,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
231
382
  'preview.improved': 'Improved',
232
383
  'preview.rationale': 'What changed',
233
384
  'preview.pending': 'Analyzing your draft…',
385
+ 'preview.unchanged': 'Your draft is already complete — nothing to change.',
234
386
  'preview.apply': 'Apply improvement',
235
387
  'preview.cancel': 'Cancel',
236
388
  'feedback.title': 'Was this better?',
@@ -241,6 +393,131 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
241
393
  'feedback.noted': 'Noted — thanks!',
242
394
  'panel.styleRules': 'Learned style rules',
243
395
  'panel.styleRulesEmpty': 'No style rules yet — apply an improvement and rate it 👎 with a one-line reason; after 3, Tacit distills durable rules from them.',
396
+ 'card.overview': 'Overview',
397
+ 'card.usage': 'Usage',
398
+ 'card.pricing': 'Pricing',
399
+ 'card.learning': 'Learning',
400
+ 'card.guidance': 'Agent guidance',
401
+ 'card.improve': 'Improve & feedback',
402
+ 'card.history': 'Analysis history',
403
+ 'card.privacy': 'Data & privacy',
404
+ 'card.expand': 'Expand this section',
405
+ 'card.collapse': 'Collapse this section',
406
+ 'overview.model': 'Model: {model}',
407
+ 'usage.empty': 'No billed calls yet — usage has been tracked since {since}.',
408
+ 'usage.today': 'Today',
409
+ 'usage.month': 'This month',
410
+ 'usage.last30': 'Last 30 days',
411
+ 'usage.since': 'Since {day}',
412
+ 'usage.calls': 'Billed calls (30 d)',
413
+ 'usage.avgAnalysis': 'Median cost per analysis',
414
+ 'usage.cachedRate': 'Cached input rate (30 d)',
415
+ 'usage.unpriced': 'Unpriced calls (30 d)',
416
+ 'usage.unpricedShort': '{n} unpriced',
417
+ 'usage.priceUnavailable': 'No price',
418
+ 'usage.chartLabel': 'Daily spend, last {n} days',
419
+ 'usage.chartSummary': 'Last {n} days: total {total}; highest {day} {max}',
420
+ 'usage.barTitle': '{day} · {usd} · {calls} calls',
421
+ 'usage.series7': '7 d',
422
+ 'usage.series30': '30 d',
423
+ 'usage.breakdown': 'By operation (last 30 days)',
424
+ 'usage.filters': 'Filters',
425
+ 'usage.loading': 'Loading this run\u2019s calls…',
426
+ 'usage.page': 'Page {page} of {pages}',
427
+ 'usage.prev': 'Previous',
428
+ 'usage.next': 'Next',
429
+ 'usage.label': 'Measured usage · list-price cost',
430
+ 'usage.col.time': 'Time',
431
+ 'usage.col.op': 'Operation',
432
+ 'usage.col.scope': 'Scope',
433
+ 'usage.col.model': 'Model',
434
+ 'usage.col.status': 'Status',
435
+ 'usage.col.calls': 'Billed calls',
436
+ 'usage.col.tokens': 'Tokens',
437
+ 'usage.col.cost': 'Cost',
438
+ 'filter.range': 'Range',
439
+ 'filter.type': 'Operation',
440
+ 'filter.status': 'Status',
441
+ 'filter.model': 'Model',
442
+ 'filter.workspace': 'Workspace',
443
+ 'filter.session': 'Session id',
444
+ 'filter.all': 'All',
445
+ 'range.today': 'Today',
446
+ 'range.7d': 'Last 7 days',
447
+ 'range.30d': 'Last 30 days',
448
+ 'range.month': 'This month',
449
+ 'range.all': 'Everything retained',
450
+ 'runtype.bootstrap': 'Bootstrap batch',
451
+ 'runtype.analysis': 'Analysis',
452
+ 'runtype.analysis-batch': 'Analysis batch',
453
+ 'runtype.improve': 'Improve',
454
+ 'runtype.directive-distillation': 'Directive distillation',
455
+ 'runtype.style-distillation': 'Style distillation',
456
+ 'runtype.prompt-enrichment': 'Prompt enrichment',
457
+ 'op.analysis': 'analysis',
458
+ 'op.analysis-repair': 'analysis repair',
459
+ 'op.directive-distillation': 'directive distillation',
460
+ 'op.style-distillation': 'style distillation',
461
+ 'op.improve': 'improve',
462
+ 'op.improve-repair': 'improve repair',
463
+ 'op.enrichment': 'enrichment',
464
+ 'status.success': 'success',
465
+ 'status.partial': 'partial',
466
+ 'status.failed': 'failed',
467
+ 'status.ok': 'ok',
468
+ 'status.unmetered': 'unmetered',
469
+ 'status.running': 'running',
470
+ 'attempt.effort': 'effort {effort}',
471
+ 'attempt.finish': 'finish {finish} {code}',
472
+ 'attempt.source': '{source} · {tier}',
473
+ 'attempt.duration': '{ms} ms',
474
+ 'warn.daily': 'Today {spent} of the {limit} daily cap ({pct}%)',
475
+ 'warn.monthly': 'This month {spent} of the {limit} monthly cap ({pct}%)',
476
+ 'improve.explain': 'Improve rewrites your draft before you send it. Rate a rewrite 👎 with a one-line reason and Tacit distills a durable style rule from it.',
477
+ 'privacy.stored': 'Analysis reports, directives and style rules are stored on this machine only, in Tacit’s own data directory. A usage record holds the time, the operation, the session id, the turn number, the workspace name, the model, the provider, token counts and the list price — never your prompt or the response text. Clearing only ever removes Tacit’s own files.',
478
+ 'privacy.retention': 'Keep detailed usage history (days)',
479
+ 'privacy.warnDaily': 'Warn above this daily spend (USD)',
480
+ 'privacy.warnMonthly': 'Warn above this monthly spend (USD)',
481
+ 'privacy.warnHint': '0 turns the warning off. Tacit warns at 80 % of the amount and marks it exceeded above it.',
482
+ 'privacy.clearUsage': 'Clear usage history',
483
+ 'privacy.apply': 'Apply',
484
+ 'confirm.reportsTitle': 'Clear all analysis reports?',
485
+ 'confirm.reportsBody': 'This deletes every analysis report Tacit has written, for every session. Your directives, style rules and usage history are kept. This cannot be undone.',
486
+ 'confirm.usageTitle': 'Clear usage history?',
487
+ 'confirm.usageBody': 'This deletes Tacit’s usage ledger — every recorded call, its token counts and its list price — and restarts the tracking window. Your analysis reports and directives are kept. This cannot be undone.',
488
+ 'confirm.cancel': 'Cancel',
489
+ 'confirm.clear': 'Clear',
490
+ 'pricing.summary': '{model} {tier} {rates} per 1M · {source} ({asOf})',
491
+ 'pricing.rateTable': 'List price per 1M tokens',
492
+ 'pricing.model': 'Model',
493
+ 'pricing.tier': 'Tier',
494
+ 'pricing.cacheHit': 'Cached input',
495
+ 'pricing.cacheMiss': 'Input',
496
+ 'pricing.output': 'Output',
497
+ 'pricing.reasoning': 'Reasoning',
498
+ 'pricing.reasoningSameAsOutput': 'billed as output',
499
+ 'pricing.peak': 'peak',
500
+ 'pricing.offPeak': 'off-peak',
501
+ 'pricing.tierNow': 'Right now: {tier}',
502
+ 'pricing.schedule': 'Peak is 01:00–04:00 and 06:00–10:00 UTC; every other hour is off-peak. The tier is decided when a call starts.',
503
+ 'pricing.weekendRule': 'Beijing-calendar weekends (Saturday and Sunday in UTC+8) are off-peak all day, from 2026-08-22 16:00 UTC.',
504
+ 'pricing.source': 'Price source',
505
+ 'pricing.sourceBundled': 'bundled table',
506
+ 'pricing.sourceCostMeter': 'cost-meter service',
507
+ 'pricing.refreshedAt': 'refreshed {time}',
508
+ 'pricing.never': 'never refreshed',
509
+ 'pricing.formula': 'Cost = (input × input rate + cached input × cached rate + output × output rate) ÷ 1,000,000',
510
+ 'pricing.accuracy': 'List price; your provider invoice is the billing authority',
511
+ 'pricing.refresh': 'Refresh prices',
512
+ 'pricing.refreshing': 'Refreshing…',
513
+ 'pricing.error': 'Could not read the price source: {error}',
514
+ 'notice.bootstrap': 'Bootstrap complete · {analyzed} analyzed · {skipped} skipped · {calls} billed calls · {tokens} tokens · {usd} list price',
515
+ 'notice.analyze': 'Analyzed #{turn} · {calls} call(s) · {tokens} tokens · {usd}',
516
+ 'notice.improve': 'Improved your draft · {calls} call(s) · {tokens} tokens · {usd}',
517
+ 'notice.batch': 'Analyzed {analyzed} of {requested} · {calls} calls · {tokens} tokens · {usd}',
518
+ 'notice.usageCleared': 'Usage history cleared · {n} day file(s) removed · tracking restarts now',
519
+ 'notice.pricingRefreshed': 'Prices refreshed · {source} ({asOf})',
520
+ 'notice.unpriced': ' · {n} unpriced',
244
521
  'err.bad-request': 'Invalid request.',
245
522
  'err.bad-json': 'Invalid request body.',
246
523
  'err.no-session': 'The session is not available (it may not be restored in this process).',
@@ -346,16 +623,41 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
346
623
  return version
347
624
  }
348
625
 
349
- // ── Root store (sidebar action + settings section + frame overlay) ─────
626
+ // ── Root store (settings section + bootstrap + directives editor) ──────
350
627
 
351
628
  const rootStore = {
352
629
  config: null,
353
630
  profile: null,
354
631
  auto: null,
355
632
  steering: null, // {enabled, text}
633
+ workspaces: [], // [{cwd, label}] from /state
356
634
  trend: null, // measured early-vs-recent trend
357
635
  bootstrap: null, // {running, done, total}
358
636
  coached: [], // cross-session coached-prompt entries
637
+ // Which Settings cards are expanded. Open state lives here (not in React
638
+ // state) so it survives re-renders and is seedable from the SSR suite.
639
+ sections: {
640
+ overview: true,
641
+ usage: true,
642
+ pricing: false,
643
+ learning: false,
644
+ guidance: false,
645
+ improve: false,
646
+ history: false,
647
+ privacy: false,
648
+ },
649
+ notice: null, // {text} — a short-lived result line, cleared after 5s
650
+ // A destructive action waiting on its confirmation dialog:
651
+ // null | {kind:'reports'|'usage'}.
652
+ confirm: null,
653
+ preview: null, // the last /bootstrap-preview envelope
654
+ usage: null, // the last /usage envelope
655
+ usageFilters: { range: '30d', type: '', status: '', model: '', workspace: '', sessionId: '', page: 1, pageSize: 20 },
656
+ usageRuns: {}, // runId → the full run (attempts included), fetched on first expand
657
+ usageExpanded: new Set(), // runIds whose attempt rows are open
658
+ usageLoading: false,
659
+ usageSeries: '30', // '7' | '30' — which sparkline the strip shows
660
+ pricingRefreshing: false, // a /pricing-refresh call is in flight
359
661
  initStarted: false,
360
662
  initDone: false,
361
663
  error: null,
@@ -366,6 +668,217 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
366
668
  for (const listener of rootStore.listeners) listener()
367
669
  }
368
670
 
671
+ /** Expand/collapse one Settings card; unknown ids are ignored. */
672
+ function toggleSection(id) {
673
+ const key = String(id)
674
+ if (rootStore.sections === null || typeof rootStore.sections !== 'object'
675
+ || typeof rootStore.sections[key] !== 'boolean') return
676
+ rootStore.sections[key] = !rootStore.sections[key]
677
+ notifyRoot()
678
+ }
679
+
680
+ let noticeTimer = null
681
+
682
+ /**
683
+ * Show one result line (e.g. "Bootstrap complete · 7 analyzed"). It clears
684
+ * itself after 5s; the timer is skipped where there is no scheduler (SSR).
685
+ */
686
+ function setRootNotice(text) {
687
+ rootStore.notice = typeof text === 'string' && text.length > 0 ? { text } : null
688
+ notifyRoot()
689
+ if (typeof setTimeout !== 'function' || rootStore.notice === null) return
690
+ if (noticeTimer !== null && typeof clearTimeout === 'function') clearTimeout(noticeTimer)
691
+ noticeTimer = setTimeout(() => {
692
+ noticeTimer = null
693
+ rootStore.notice = null
694
+ notifyRoot()
695
+ }, 5000)
696
+ unrefTimer(noticeTimer)
697
+ }
698
+
699
+ /**
700
+ * Node hands back a Timeout object that keeps the process alive; the
701
+ * browser hands back a number. Unref'ing where it exists means a test that
702
+ * merely touches one of these paths can never hold the runner open.
703
+ */
704
+ function unrefTimer(handle) {
705
+ if (handle !== null && typeof handle === 'object' && typeof handle.unref === 'function') handle.unref()
706
+ }
707
+
708
+ /**
709
+ * The bound translator, captured once when the plugin applies.
710
+ *
711
+ * A notice states what a call actually cost, so it is written where those
712
+ * figures land — inside the store actions. Those run far from any `kit`
713
+ * (a click in the conversation tab, a poll), and there is exactly one
714
+ * translator per plugin instance, so it is held here rather than threaded
715
+ * through every call site.
716
+ */
717
+ let translate = null
718
+
719
+ function setTranslator(fn) {
720
+ translate = typeof fn === 'function' ? fn : null
721
+ }
722
+
723
+ /**
724
+ * A result line with the run's measured figures: billed calls, total
725
+ * tokens and list-price cost. A run that priced nothing says so instead of
726
+ * claiming `$0.0000`, and the count of unpriced calls rides as a suffix —
727
+ * an unmetered call has no price, not a price of zero.
728
+ */
729
+ function runNotice(t, key, vars, run) {
730
+ const source = run !== null && typeof run === 'object' ? run : {}
731
+ const count = (value) => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0)
732
+ const totals = {
733
+ // `attempts` rides along so a run whose every call went unmetered
734
+ // reads as an unknown cost rather than a free one.
735
+ attempts: count(source.attempts),
736
+ billedCalls: count(source.billedCalls),
737
+ unpricedCalls: count(source.unpricedCalls),
738
+ usdKnown: count(source.usdKnown),
739
+ }
740
+ const text = t(key, {
741
+ ...(vars === null || typeof vars !== 'object' ? {} : vars),
742
+ calls: fmtTokens(totals.billedCalls),
743
+ tokens: fmtTokens(tokensTotal(source.tokens)),
744
+ usd: usageSpend({ t }, totals),
745
+ })
746
+ return totals.unpricedCalls > 0 ? text + t('notice.unpriced', { n: String(totals.unpricedCalls) }) : text
747
+ }
748
+
749
+ /**
750
+ * Record a run's result line on a session store, where the conversation
751
+ * tab's live region reads it. It stays on that store: a tab action has no
752
+ * business announcing itself on the Settings page.
753
+ */
754
+ function setStoreNotice(store, key, vars, run) {
755
+ if (translate === null || store === null || typeof store !== 'object') return
756
+ store.notice = { code: key, text: runNotice(translate, key, vars, run) }
757
+ }
758
+
759
+ // ── Usage ledger (the Settings → Usage card) ───────────────────────────
760
+
761
+ /** The filter payload for `/usage`: defaults always, empty strings never. */
762
+ function usageQuery() {
763
+ const filters = rootStore.usageFilters !== null && typeof rootStore.usageFilters === 'object' ? rootStore.usageFilters : {}
764
+ const query = {
765
+ range: typeof filters.range === 'string' && filters.range.length > 0 ? filters.range : '30d',
766
+ page: typeof filters.page === 'number' && filters.page > 0 ? Math.floor(filters.page) : 1,
767
+ pageSize: typeof filters.pageSize === 'number' && filters.pageSize > 0 ? Math.floor(filters.pageSize) : 20,
768
+ }
769
+ for (const key of ['type', 'status', 'model', 'workspace', 'sessionId']) {
770
+ const value = filters[key]
771
+ if (typeof value === 'string' && value.length > 0) query[key] = value
772
+ }
773
+ return query
774
+ }
775
+
776
+ /** Read the whole cost panel in one call; a failure keeps the last envelope. */
777
+ async function fetchUsage() {
778
+ rootStore.usageLoading = true
779
+ try {
780
+ const result = await api('/usage', usageQuery())
781
+ if (result !== null && typeof result === 'object' && result.ok === true) rootStore.usage = result
782
+ } catch {
783
+ // A stale panel beats a blank one; the next poll tries again.
784
+ }
785
+ rootStore.usageLoading = false
786
+ notifyRoot()
787
+ }
788
+
789
+ /** Narrow the runs list. Any change but an explicit page jump goes back to page 1. */
790
+ function setUsageFilter(patch) {
791
+ if (patch === null || typeof patch !== 'object') return
792
+ const next = { ...rootStore.usageFilters, ...patch }
793
+ if (patch.page === undefined) next.page = 1
794
+ rootStore.usageFilters = next
795
+ notifyRoot()
796
+ fetchUsage()
797
+ }
798
+
799
+ /** Open/close one run's attempt rows, fetching its detail the first time. */
800
+ async function toggleUsageRun(runId) {
801
+ const key = String(runId)
802
+ if (key.length === 0) return
803
+ if (rootStore.usageExpanded.has(key)) {
804
+ rootStore.usageExpanded.delete(key)
805
+ notifyRoot()
806
+ return
807
+ }
808
+ rootStore.usageExpanded.add(key)
809
+ notifyRoot()
810
+ if (rootStore.usageRuns[key] !== undefined) return
811
+ try {
812
+ const result = await api('/usage-run', { runId: key })
813
+ if (result !== null && typeof result === 'object' && result.ok === true
814
+ && result.run !== null && typeof result.run === 'object') {
815
+ rootStore.usageRuns[key] = result.run
816
+ }
817
+ } catch {
818
+ // The row keeps its loading line; closing and reopening retries.
819
+ }
820
+ notifyRoot()
821
+ }
822
+
823
+ /**
824
+ * Re-read the prices from the cost-meter service. The card renders the
825
+ * rates the `/usage` envelope carries, so the refreshed table only shows
826
+ * once that envelope has been re-fetched.
827
+ */
828
+ async function refreshPricing(t) {
829
+ if (rootStore.pricingRefreshing) return
830
+ rootStore.pricingRefreshing = true
831
+ rootStore.error = null
832
+ notifyRoot()
833
+ try {
834
+ const result = await api('/pricing-refresh', {})
835
+ if (result !== null && typeof result === 'object' && result.ok === true) {
836
+ const pricing = result.pricing !== null && typeof result.pricing === 'object' ? result.pricing : {}
837
+ if (typeof t === 'function') {
838
+ setRootNotice(t('notice.pricingRefreshed', {
839
+ source: t(pricing.source === 'costMeter' ? 'pricing.sourceCostMeter' : 'pricing.sourceBundled'),
840
+ asOf: typeof pricing.asOf === 'string' && pricing.asOf.length > 0 ? pricing.asOf : '—',
841
+ }))
842
+ }
843
+ } else {
844
+ rootStore.error = { code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'call-failed', detail: '' }
845
+ }
846
+ } catch (error) {
847
+ rootStore.error = errorOf(error)
848
+ } finally {
849
+ rootStore.pricingRefreshing = false
850
+ }
851
+ await fetchUsage()
852
+ }
853
+
854
+ /** Which sparkline the bar strip shows; anything unknown means 30 days. */
855
+ function setUsageSeries(value) {
856
+ rootStore.usageSeries = value === '7' ? '7' : '30'
857
+ notifyRoot()
858
+ }
859
+
860
+ /** One shared 10s poll, reference-counted so remounts never stack timers. */
861
+ let usageTimer = null
862
+ let usageMounts = 0
863
+
864
+ function startUsagePolling() {
865
+ usageMounts += 1
866
+ if (usageTimer !== null || typeof setInterval !== 'function') return
867
+ usageTimer = setInterval(() => {
868
+ // A hidden tab costs nothing: the next visible tick catches up.
869
+ if (typeof document !== 'undefined' && document !== null && document.hidden === true) return
870
+ fetchUsage()
871
+ }, 10000)
872
+ unrefTimer(usageTimer)
873
+ }
874
+
875
+ function stopUsagePolling() {
876
+ usageMounts = usageMounts > 0 ? usageMounts - 1 : 0
877
+ if (usageMounts > 0 || usageTimer === null) return
878
+ if (typeof clearInterval === 'function') clearInterval(usageTimer)
879
+ usageTimer = null
880
+ }
881
+
369
882
  function useRootVersion() {
370
883
  const [version, setVersion] = useState(0)
371
884
  useEffect(() => {
@@ -388,6 +901,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
388
901
  rootStore.profile = state.profile !== null && typeof state.profile === 'object' ? state.profile : null
389
902
  rootStore.auto = state.auto !== null && typeof state.auto === 'object' ? state.auto : null
390
903
  rootStore.steering = state.steering !== null && typeof state.steering === 'object' ? state.steering : null
904
+ rootStore.workspaces = Array.isArray(state.workspaces) ? state.workspaces : []
391
905
  rootStore.bootstrap = state.bootstrap !== null && typeof state.bootstrap === 'object' ? state.bootstrap : null
392
906
  }
393
907
  const coached = await api('/history', { limit: 50 })
@@ -403,6 +917,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
403
917
  rootStore.error = errorOf(error)
404
918
  rootStore.initDone = true
405
919
  }
920
+ fetchUsage()
406
921
  notifyRoot()
407
922
  }
408
923
 
@@ -415,11 +930,13 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
415
930
  rootStore.profile = state.profile !== null && typeof state.profile === 'object' ? state.profile : null
416
931
  rootStore.auto = state.auto !== null && typeof state.auto === 'object' ? state.auto : null
417
932
  rootStore.steering = state.steering !== null && typeof state.steering === 'object' ? state.steering : null
933
+ rootStore.workspaces = Array.isArray(state.workspaces) ? state.workspaces : []
418
934
  rootStore.bootstrap = state.bootstrap !== null && typeof state.bootstrap === 'object' ? state.bootstrap : null
419
935
  }
420
936
  } catch {
421
937
  // Stale view beats a broken panel.
422
938
  }
939
+ fetchUsage()
423
940
  notifyRoot()
424
941
  }
425
942
 
@@ -438,6 +955,8 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
438
955
  } catch (error) {
439
956
  rootStore.error = errorOf(error)
440
957
  }
958
+ // The warning thresholds live in the config, so the bars may have moved.
959
+ fetchUsage()
441
960
  notifyRoot()
442
961
  }
443
962
 
@@ -453,6 +972,78 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
453
972
  } catch (error) {
454
973
  rootStore.error = errorOf(error)
455
974
  }
975
+ fetchUsage()
976
+ // Turns without a report are eligible again, so a preview that last read
977
+ // `eligible: 0` would otherwise keep the Bootstrap button disabled.
978
+ await fetchBootstrapPreview()
979
+ notifyRoot()
980
+ }
981
+
982
+ /**
983
+ * Delete the usage ledger and restart the tracking window. The runs the
984
+ * panel had expanded are dropped with it — their ids no longer exist.
985
+ */
986
+ async function clearUsageHistory() {
987
+ rootStore.error = null
988
+ try {
989
+ const result = await api('/usage-clear', {})
990
+ if (result !== null && typeof result === 'object' && result.ok) {
991
+ rootStore.usageRuns = {}
992
+ rootStore.usageExpanded = new Set()
993
+ rootStore.usageFilters = { ...rootStore.usageFilters, page: 1 }
994
+ if (translate !== null) setRootNotice(translate('notice.usageCleared', { n: fmtTokens(usageNum(result.removed)) }))
995
+ } else {
996
+ rootStore.error = { code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'bad-request', detail: '' }
997
+ }
998
+ } catch (error) {
999
+ rootStore.error = errorOf(error)
1000
+ }
1001
+ await fetchUsage()
1002
+ // The measured basis of the bootstrap estimate came from the ledger this
1003
+ // just deleted; re-price it rather than quoting figures that are gone.
1004
+ await fetchBootstrapPreview()
1005
+ notifyRoot()
1006
+ }
1007
+
1008
+ /**
1009
+ * Ask before deleting. The two clears are the only irreversible actions in
1010
+ * the panel, so neither fires straight off a click: the kind is parked here
1011
+ * and the dialog does the asking.
1012
+ */
1013
+ function openConfirm(kind) {
1014
+ if (kind !== 'reports' && kind !== 'usage') return
1015
+ // Captured before the dialog steals focus, so closing it can hand focus
1016
+ // back to the button that opened it.
1017
+ captureConfirmOpener()
1018
+ rootStore.confirm = { kind }
1019
+ notifyRoot()
1020
+ }
1021
+
1022
+ function closeConfirm() {
1023
+ rootStore.confirm = null
1024
+ notifyRoot()
1025
+ }
1026
+
1027
+ /** Run the parked destructive action, whichever it is, and close the dialog. */
1028
+ async function confirmAction() {
1029
+ const kind = rootStore.confirm !== null && typeof rootStore.confirm === 'object' ? rootStore.confirm.kind : null
1030
+ rootStore.confirm = null
1031
+ notifyRoot()
1032
+ if (kind === 'reports') await clearAllRoot()
1033
+ else if (kind === 'usage') await clearUsageHistory()
1034
+ }
1035
+
1036
+ /**
1037
+ * "What would Bootstrap do, and what would it cost?" — read-only, no model
1038
+ * call. A failure leaves `preview` alone: the documented estimate stands in.
1039
+ */
1040
+ async function fetchBootstrapPreview() {
1041
+ try {
1042
+ const result = await api('/bootstrap-preview', { limit: 20 })
1043
+ if (result !== null && typeof result === 'object' && result.ok === true) rootStore.preview = result
1044
+ } catch {
1045
+ // One missing hint line; the next mount tries again.
1046
+ }
456
1047
  notifyRoot()
457
1048
  }
458
1049
 
@@ -499,10 +1090,12 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
499
1090
  notify(store)
500
1091
  }
501
1092
 
502
- async function bootstrapAll() {
1093
+ /** `t` is the caller's bound translator, used only for the result notice. */
1094
+ async function bootstrapAll(t) {
503
1095
  if (rootStore.bootstrap !== null && typeof rootStore.bootstrap === 'object' && rootStore.bootstrap.running) return
504
1096
  rootStore.bootstrap = { running: true, done: 0, total: 0 }
505
1097
  rootStore.error = null
1098
+ rootStore.notice = null
506
1099
  notifyRoot()
507
1100
  pollBootstrap((state) => {
508
1101
  if (state !== null && typeof state === 'object' && state.bootstrap !== null && typeof state.bootstrap === 'object') rootStore.bootstrap = state.bootstrap
@@ -510,7 +1103,14 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
510
1103
  })
511
1104
  try {
512
1105
  const result = await api('/bootstrap', { limit: 20 })
513
- if (!(result !== null && typeof result === 'object' && result.ok)) {
1106
+ if (result !== null && typeof result === 'object' && result.ok) {
1107
+ if (typeof t === 'function') {
1108
+ setRootNotice(runNotice(t, 'notice.bootstrap', {
1109
+ analyzed: String(typeof result.analyzed === 'number' ? result.analyzed : 0),
1110
+ skipped: String(typeof result.skipped === 'number' ? result.skipped : 0),
1111
+ }, result.run))
1112
+ }
1113
+ } else {
514
1114
  rootStore.error = { code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'call-failed', detail: '' }
515
1115
  }
516
1116
  } catch (error) {
@@ -518,7 +1118,13 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
518
1118
  }
519
1119
  rootStore.initStarted = false
520
1120
  rootStore.initDone = false
1121
+ // `initRootStore` refetches the ledger itself, so there is no `fetchUsage`
1122
+ // here: a second call would only duplicate the one the batch already
1123
+ // makes worth issuing.
521
1124
  await initRootStore()
1125
+ // Fewer turns are eligible now, and the ledger has fresh measurements to
1126
+ // price the next batch from.
1127
+ await fetchBootstrapPreview()
522
1128
  }
523
1129
 
524
1130
  async function editDirectives(payload) {
@@ -534,6 +1140,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
534
1140
  } catch (error) {
535
1141
  rootStore.error = errorOf(error)
536
1142
  }
1143
+ fetchUsage()
537
1144
  notifyRoot()
538
1145
  }
539
1146
 
@@ -565,12 +1172,15 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
565
1172
  store.inFlight[String(turn)] = true
566
1173
  store.expanded.add(turn)
567
1174
  store.error = null
1175
+ store.notice = null
568
1176
  notify(store)
569
1177
  api('/analyze', { sessionId: store.sessionId, turn })
570
1178
  .then((result) => {
571
1179
  if (result !== null && typeof result === 'object' && result.ok && result.report !== null && typeof result.report === 'object') {
572
1180
  store.reports[String(turn)] = result.report
573
1181
  store.error = null
1182
+ // What that one analysis cost, from the envelope's own run record.
1183
+ setStoreNotice(store, 'notice.analyze', { turn: String(turn) }, result.run)
574
1184
  } else {
575
1185
  store.error = {
576
1186
  code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'call-failed',
@@ -590,54 +1200,68 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
590
1200
  })
591
1201
  }
592
1202
 
593
- /** Run one /analyze call and settle when it finishes (batch building block). */
594
- function analyzeTurnAsync(store, turn) {
595
- return new Promise((resolve) => {
596
- store.inFlight[String(turn)] = true
597
- notify(store)
598
- api('/analyze', { sessionId: store.sessionId, turn })
599
- .then((result) => {
600
- if (result !== null && typeof result === 'object' && result.ok && result.report !== null && typeof result.report === 'object') {
601
- store.reports[String(turn)] = result.report
602
- store.selection.delete(turn)
603
- store.expanded.add(turn)
604
- store.error = null
605
- } else {
606
- store.error = {
607
- code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'call-failed',
608
- detail: result !== null && typeof result === 'object' && typeof result.detail === 'string' ? result.detail : '',
609
- }
610
- }
611
- if (result !== null && typeof result === 'object' && result.profile !== null && typeof result.profile === 'object') {
612
- store.profile = result.profile
613
- }
614
- })
615
- .catch((error) => {
616
- store.error = errorOf(error)
617
- })
618
- .finally(() => {
619
- delete store.inFlight[String(turn)]
620
- notify(store)
621
- resolve()
622
- })
623
- })
624
- }
625
-
626
- /** Coach every ticked prompt sequentially (the user-chosen 20). */
1203
+ /**
1204
+ * Coach every ticked prompt in ONE `/analyze-batch` call.
1205
+ *
1206
+ * The host runs the whole selection under a single ledger run, so the tab
1207
+ * reports one price for the batch instead of N separate analyses — and the
1208
+ * turns settle together rather than one row at a time.
1209
+ */
627
1210
  async function coachSelected(store) {
628
1211
  if (store.batchRunning) return
629
1212
  const turns = [...store.selection].sort((a, b) => a - b)
630
1213
  if (turns.length === 0) return
631
1214
  store.batchRunning = true
632
1215
  store.error = null
1216
+ store.notice = null
1217
+ // Every ticked row shows its spinner for the whole call: the batch has no
1218
+ // per-turn progress to report.
1219
+ for (const turn of turns) store.inFlight[String(turn)] = true
633
1220
  notify(store)
634
- for (const turn of turns) {
635
- if (store.inFlight[String(turn)]) continue
636
- await analyzeTurnAsync(store, turn)
1221
+ try {
1222
+ const result = await api('/analyze-batch', { sessionId: store.sessionId, turns })
1223
+ if (result !== null && typeof result === 'object' && result.ok) {
1224
+ const results = Array.isArray(result.results) ? result.results : []
1225
+ let analyzed = 0
1226
+ let failure = null
1227
+ for (const entry of results) {
1228
+ if (entry === null || typeof entry !== 'object') continue
1229
+ const turn = typeof entry.turn === 'number' ? entry.turn : null
1230
+ if (entry.ok === true && entry.report !== null && typeof entry.report === 'object') {
1231
+ if (turn !== null) {
1232
+ store.reports[String(turn)] = entry.report
1233
+ store.expanded.add(turn)
1234
+ }
1235
+ analyzed += 1
1236
+ continue
1237
+ }
1238
+ // `busy` means an analysis of that turn was already running — a
1239
+ // race with the auto-analyzer, not a failure of this batch.
1240
+ const code = typeof entry.code === 'string' && entry.code.length > 0 ? entry.code : 'call-failed'
1241
+ if (failure === null && code !== 'busy') failure = { code, detail: '' }
1242
+ }
1243
+ if (failure !== null) store.error = failure
1244
+ if (result.profile !== null && typeof result.profile === 'object') store.profile = result.profile
1245
+ // What the whole batch cost, from the run the host closed for it.
1246
+ setStoreNotice(store, 'notice.batch', { analyzed: String(analyzed), requested: String(turns.length) }, result.run)
1247
+ } else {
1248
+ store.error = {
1249
+ code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'call-failed',
1250
+ detail: result !== null && typeof result === 'object' && typeof result.detail === 'string' ? result.detail : '',
1251
+ }
1252
+ }
1253
+ } catch (error) {
1254
+ store.error = errorOf(error)
637
1255
  }
1256
+ for (const turn of turns) delete store.inFlight[String(turn)]
1257
+ store.selection.clear()
638
1258
  store.batchRunning = false
639
1259
  store.selecting = false
640
1260
  notify(store)
1261
+ // The batch just spent money, and analyzed turns stop being eligible for
1262
+ // a bootstrap; the Settings page should say both.
1263
+ fetchUsage()
1264
+ fetchBootstrapPreview()
641
1265
  }
642
1266
 
643
1267
  function toggleSelecting(store) {
@@ -655,11 +1279,13 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
655
1279
  function improveDraft(store, draft) {
656
1280
  if (store.preview.open || typeof draft !== 'string' || draft.trim().length === 0) return
657
1281
  store.preview = { open: true, pending: true, original: draft, data: null, error: null }
1282
+ store.notice = null
658
1283
  notify(store)
659
1284
  api('/improve', { sessionId: store.sessionId, draft })
660
1285
  .then((result) => {
661
1286
  if (result !== null && typeof result === 'object' && result.ok && typeof result.improved === 'string' && result.improved.trim().length > 0) {
662
1287
  store.preview = { ...store.preview, pending: false, data: result }
1288
+ setStoreNotice(store, 'notice.improve', {}, result.run)
663
1289
  } else {
664
1290
  store.preview = {
665
1291
  ...store.preview,
@@ -869,6 +1495,193 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
869
1495
  }
870
1496
  }
871
1497
 
1498
+ // ── Usage formatting & narrowing (money is formatted ONLY here) ─────────
1499
+
1500
+ /**
1501
+ * List-price money, always four decimals — a coach analysis costs a few
1502
+ * hundredths of a cent, and two decimals would round every honest figure
1503
+ * to `$0.00`. A positive amount too small to show at four decimals reads
1504
+ * `< $0.0001` rather than a zero it is not.
1505
+ */
1506
+ function fmtUsd(n) {
1507
+ const value = Number(n)
1508
+ if (!Number.isFinite(value)) return '—'
1509
+ if (value > 0 && value < 0.0001) return '< $0.0001'
1510
+ if (value < 0) return '-$' + Math.abs(value).toFixed(4)
1511
+ return '$' + value.toFixed(4)
1512
+ }
1513
+
1514
+ /**
1515
+ * A published rate, per 1M tokens. Rates are quoted, not measured, so they
1516
+ * read as they are quoted: three decimals at most and no trailing zeros —
1517
+ * `$0.007`, `$0.22`, `$1.32`. `fmtUsd`'s four fixed decimals belong to
1518
+ * spend, where every hundredth of a cent is a real figure.
1519
+ */
1520
+ function fmtRate(n) {
1521
+ // Strict about the type: `Number(null)` is 0, and a rate nobody quoted
1522
+ // must not print as a free one.
1523
+ if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return '—'
1524
+ const trimmed = n.toFixed(3).replace(/\.?0+$/, '')
1525
+ if (n > 0 && Number(trimmed) === 0) return '< $0.001'
1526
+ return '$' + trimmed
1527
+ }
1528
+
1529
+ /**
1530
+ * Spend for one bucket of calls. A bucket that billed calls but priced
1531
+ * none of them says so — `$0.0000` would claim the calls were free, and an
1532
+ * unmetered call is never `$0.00`. Same for a bucket that made calls and
1533
+ * billed none of them: every call came back without usage, so its cost is
1534
+ * unknown, not zero. A genuinely idle bucket (no attempts) stays `$0.0000`.
1535
+ */
1536
+ function usageSpend(kit, totals) {
1537
+ const attempts = usageNum(totals.attempts)
1538
+ const billed = usageNum(totals.billedCalls)
1539
+ if (billed > 0 && usageNum(totals.unpricedCalls) >= billed) return kit.t('usage.priceUnavailable')
1540
+ if (attempts > 0 && billed === 0) return kit.t('usage.priceUnavailable')
1541
+ return fmtUsd(totals.usdKnown)
1542
+ }
1543
+
1544
+ /** Thousands-separated token counts, grouped here so no locale data is needed. */
1545
+ function fmtTokens(n) {
1546
+ const value = Number(n)
1547
+ if (!Number.isFinite(value)) return '—'
1548
+ const grouped = String(Math.round(Math.abs(value))).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
1549
+ return (value < 0 ? '-' : '') + grouped
1550
+ }
1551
+
1552
+ /** A `YYYY-MM-DD` day, from either a day key (passed through) or an epoch ms. */
1553
+ function fmtDay(day) {
1554
+ if (typeof day === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(day)) return day
1555
+ const value = Number(day)
1556
+ if (!Number.isFinite(value) || value <= 0) return '—'
1557
+ const date = new Date(value)
1558
+ if (Number.isNaN(date.getTime())) return '—'
1559
+ const pad = (part) => String(part).padStart(2, '0')
1560
+ return String(date.getFullYear()) + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate())
1561
+ }
1562
+
1563
+ /** A 0..1 ratio as a whole percentage; `null`/non-finite stays an em dash. */
1564
+ function fmtPct(x) {
1565
+ const value = Number(x)
1566
+ if (!Number.isFinite(value)) return '—'
1567
+ return String(Math.round(value * 100)) + '%'
1568
+ }
1569
+
1570
+ /**
1571
+ * Billed tokens in one bucket set: uncached input + output + cache reads +
1572
+ * cache writes. `reasoningTokens` is a subset of `outputTokens`, so adding
1573
+ * it would count every reasoning token twice.
1574
+ */
1575
+ function tokensTotal(buckets) {
1576
+ if (buckets === null || typeof buckets !== 'object') return 0
1577
+ const num = (value) => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0)
1578
+ return num(buckets.inputTokens) + num(buckets.outputTokens) + num(buckets.cacheReadTokens) + num(buckets.cacheWriteTokens)
1579
+ }
1580
+
1581
+ function usageNum(value) {
1582
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0
1583
+ }
1584
+
1585
+ function usageTokens(value) {
1586
+ const source = value !== null && typeof value === 'object' ? value : {}
1587
+ return {
1588
+ inputTokens: usageNum(source.inputTokens),
1589
+ outputTokens: usageNum(source.outputTokens),
1590
+ cacheReadTokens: usageNum(source.cacheReadTokens),
1591
+ cacheWriteTokens: usageNum(source.cacheWriteTokens),
1592
+ reasoningTokens: usageNum(source.reasoningTokens),
1593
+ }
1594
+ }
1595
+
1596
+ /** One period card, zero-filled; the two derived figures stay nullable. */
1597
+ function usagePeriod(value) {
1598
+ const source = value !== null && typeof value === 'object' ? value : {}
1599
+ return {
1600
+ attempts: usageNum(source.attempts),
1601
+ billedCalls: usageNum(source.billedCalls),
1602
+ unmeteredCalls: usageNum(source.unmeteredCalls),
1603
+ unpricedCalls: usageNum(source.unpricedCalls),
1604
+ tokens: usageTokens(source.tokens),
1605
+ usdKnown: usageNum(source.usdKnown),
1606
+ avgAnalysisUsd: typeof source.avgAnalysisUsd === 'number' && Number.isFinite(source.avgAnalysisUsd) ? source.avgAnalysisUsd : null,
1607
+ cachedInputRate: typeof source.cachedInputRate === 'number' && Number.isFinite(source.cachedInputRate) ? source.cachedInputRate : null,
1608
+ }
1609
+ }
1610
+
1611
+ function usageBuckets(value) {
1612
+ const out = {}
1613
+ if (value === null || typeof value !== 'object') return out
1614
+ for (const [key, totals] of Object.entries(value)) out[key] = usagePeriod(totals)
1615
+ return out
1616
+ }
1617
+
1618
+ function usageSeries(value) {
1619
+ if (!Array.isArray(value)) return []
1620
+ return value
1621
+ .filter((point) => point !== null && typeof point === 'object' && typeof point.day === 'string')
1622
+ .map((point) => ({ day: point.day, usdKnown: usageNum(point.usdKnown), billedCalls: usageNum(point.billedCalls) }))
1623
+ }
1624
+
1625
+ function usageWarning(value) {
1626
+ const source = value !== null && typeof value === 'object' ? value : {}
1627
+ const level = source.level === 'warn' || source.level === 'exceeded' ? source.level : 'none'
1628
+ return { limit: usageNum(source.limit), spent: usageNum(source.spent), level }
1629
+ }
1630
+
1631
+ function usageRunItem(value) {
1632
+ const source = value !== null && typeof value === 'object' ? value : {}
1633
+ const text = (key) => (typeof source[key] === 'string' ? source[key] : '')
1634
+ return {
1635
+ runId: text('runId'),
1636
+ type: text('type'),
1637
+ status: text('status'),
1638
+ attempts: usageNum(source.attempts),
1639
+ billedCalls: usageNum(source.billedCalls),
1640
+ unmeteredCalls: usageNum(source.unmeteredCalls),
1641
+ unpricedCalls: usageNum(source.unpricedCalls),
1642
+ tokens: usageTokens(source.tokens),
1643
+ usdKnown: usageNum(source.usdKnown),
1644
+ trigger: text('trigger'),
1645
+ startedAt: usageNum(source.startedAt),
1646
+ endedAt: usageNum(source.endedAt),
1647
+ sessionId: text('sessionId'),
1648
+ turn: typeof source.turn === 'number' ? source.turn : null,
1649
+ workspace: text('workspace'),
1650
+ model: text('model'),
1651
+ provider: text('provider'),
1652
+ }
1653
+ }
1654
+
1655
+ /**
1656
+ * The `/usage` envelope, narrowed. `null` for anything that is not a
1657
+ * successful report, so the card can render its empty state instead of
1658
+ * reaching into `undefined`.
1659
+ */
1660
+ function usageOf(value) {
1661
+ if (value === null || typeof value !== 'object' || value.ok !== true) return null
1662
+ const runs = value.runs !== null && typeof value.runs === 'object' ? value.runs : {}
1663
+ const warnings = value.warnings !== null && typeof value.warnings === 'object' ? value.warnings : {}
1664
+ return {
1665
+ trackingSince: usageNum(value.trackingSince),
1666
+ pricing: value.pricing !== null && typeof value.pricing === 'object' ? value.pricing : {},
1667
+ today: usagePeriod(value.today),
1668
+ month: usagePeriod(value.month),
1669
+ last7: usagePeriod(value.last7),
1670
+ last30: usagePeriod(value.last30),
1671
+ lifetime: usagePeriod(value.lifetime),
1672
+ byType: usageBuckets(value.byType),
1673
+ byModel: usageBuckets(value.byModel),
1674
+ series7: usageSeries(value.series7),
1675
+ series30: usageSeries(value.series30),
1676
+ warnings: { daily: usageWarning(warnings.daily), monthly: usageWarning(warnings.monthly) },
1677
+ runs: {
1678
+ items: (Array.isArray(runs.items) ? runs.items : []).map(usageRunItem),
1679
+ page: usageNum(runs.page) > 0 ? Math.floor(usageNum(runs.page)) : 1,
1680
+ pageSize: usageNum(runs.pageSize) > 0 ? Math.floor(usageNum(runs.pageSize)) : 20,
1681
+ total: usageNum(runs.total),
1682
+ },
1683
+ }
1684
+ }
872
1685
  // ── Components ─────────────────────────────────────────────────────────
873
1686
 
874
1687
  function makeKit(t) {
@@ -876,20 +1689,46 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
876
1689
  }
877
1690
 
878
1691
  /** "Learned from N prompts · Auto-learning on · x/y today" status card. */
879
- /** "Learn from my last 20 turns" — spinner label while a bootstrap runs. */
880
- function BootstrapButton(kit, { bootstrap, onClick }) {
1692
+ /**
1693
+ * "Learn from my last 20 turns" — while a bootstrap runs the label counts
1694
+ * the turns and what the batch has cost so far.
1695
+ *
1696
+ * The service ships `usdKnown: 0` from the first tick, so the money is
1697
+ * gated on a billed call, not on the field being present: before that the
1698
+ * label carries no figure at all, and a batch whose billed calls all came
1699
+ * back unpriced says so through `usageSpend` rather than claiming `$0.0000`.
1700
+ */
1701
+ function BootstrapButton(kit, { bootstrap, onClick, disabled }) {
881
1702
  const { t } = kit
882
1703
  const running = bootstrap !== null && bootstrap !== undefined && typeof bootstrap === 'object' && bootstrap.running === true
1704
+ const progress = running
1705
+ ? {
1706
+ done: String(typeof bootstrap.done === 'number' ? bootstrap.done : 0),
1707
+ total: String(typeof bootstrap.total === 'number' ? bootstrap.total : 0),
1708
+ }
1709
+ : null
1710
+ const billed = running ? usageNum(bootstrap.billedCalls) : 0
1711
+ const label = () => {
1712
+ if (!running) return t('bootstrap.btn')
1713
+ if (billed === 0) return t('bootstrap.running', progress)
1714
+ return t('bootstrap.runningUsd', {
1715
+ ...progress,
1716
+ usd: usageSpend(kit, {
1717
+ billedCalls: billed,
1718
+ unpricedCalls: usageNum(bootstrap.unpricedCalls),
1719
+ usdKnown: usageNum(bootstrap.usdKnown),
1720
+ }),
1721
+ })
1722
+ }
883
1723
  return h('span', { className: 'tacit-bootstrap' },
884
1724
  h('button', {
885
1725
  type: 'button',
886
1726
  className: 'tacit-btn tacit-btn-sm',
887
- disabled: running,
1727
+ // Also disabled when the caller knows there is nothing eligible left.
1728
+ disabled: running || disabled === true,
888
1729
  title: t('bootstrap.hint'),
889
1730
  onClick,
890
- }, running
891
- ? t('bootstrap.running', { done: String(typeof bootstrap.done === 'number' ? bootstrap.done : 0), total: String(typeof bootstrap.total === 'number' ? bootstrap.total : 0) })
892
- : t('bootstrap.btn')))
1731
+ }, label()))
893
1732
  }
894
1733
 
895
1734
  function pct(value) {
@@ -944,14 +1783,21 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
944
1783
  }
945
1784
  return h('div', { className: 'tacit-report' },
946
1785
  h('div', { className: 'tacit-report-title' },
947
- t('report.problems'),
1786
+ report.trigger === 'good' ? t('report.strengths') : t('report.problems'),
948
1787
  ' ',
949
- h('span', { className: 'tacit-chip tacit-chip-trigger' }, t('trigger.' + report.trigger))),
950
- report.problems.length === 0
951
- ? h('div', { className: 'tacit-report-note' }, '—')
952
- : report.problems.map((problem, index) => ProblemRow(kit, problem, index)),
953
- h('div', { className: 'tacit-report-title' }, t('report.improved')),
954
- improved.length > 0
1788
+ h('span', { className: 'tacit-chip tacit-chip-trigger' + (report.trigger === 'good' ? ' tacit-chip-ok' : '') }, t('trigger.' + report.trigger))),
1789
+ report.trigger === 'good'
1790
+ ? (Array.isArray(report.strengths) && report.strengths.length > 0
1791
+ ? report.strengths.map((strength, index) => h('div', { key: index, className: 'tacit-problem' },
1792
+ h('span', { className: 'tacit-chip tacit-chip-ok' }, String(strength.kind)),
1793
+ ' ',
1794
+ h('span', { className: 'tacit-problem-what' }, String(strength.what))))
1795
+ : h('div', { className: 'tacit-report-note' }, '—'))
1796
+ : report.problems.length === 0
1797
+ ? h('div', { className: 'tacit-report-note' }, '—')
1798
+ : report.problems.map((problem, index) => ProblemRow(kit, problem, index)),
1799
+ report.trigger === 'good' ? null : h('div', { className: 'tacit-report-title' }, t('report.improved')),
1800
+ report.trigger === 'good' ? null : improved.length > 0
955
1801
  ? h('div', { className: 'tacit-report-improved' },
956
1802
  h('div', { className: 'tacit-report-actions' },
957
1803
  h('button', {
@@ -966,7 +1812,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
966
1812
  : h('div', { className: 'tacit-report-note' }, t('report.emptyImproved')),
967
1813
  typeof report.explanation === 'string' && report.explanation.length > 0
968
1814
  ? h('div', null,
969
- h('div', { className: 'tacit-report-title' }, t('report.explanation')),
1815
+ h('div', { className: 'tacit-report-title' }, report.trigger === 'good' ? t('report.lesson') : t('report.explanation')),
970
1816
  h('div', { className: 'tacit-report-body' },
971
1817
  MarkdownText !== null
972
1818
  ? h(MarkdownText, { text: report.explanation })
@@ -1063,6 +1909,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1063
1909
  const [model, setModel] = useState(config !== null && typeof config.model === 'string' ? config.model : 'deepseek-v4-flash')
1064
1910
  const [budgetText, setBudgetText] = useState(config !== null && typeof config.autoDailyBudget === 'number' ? String(config.autoDailyBudget) : '30')
1065
1911
  const [auto, setAuto] = useState(config !== null && config.autoAnalyze !== false)
1912
+ const [learnGood, setLearnGood] = useState(config !== null && config.learnFromGood !== false)
1066
1913
  const [live, setLive] = useState(config !== null && config.liveSuggestions !== false)
1067
1914
  const [notice, setNotice] = useState(null)
1068
1915
 
@@ -1071,6 +1918,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1071
1918
  if (typeof config.model === 'string') setModel(config.model)
1072
1919
  if (typeof config.autoDailyBudget === 'number') setBudgetText(String(config.autoDailyBudget))
1073
1920
  if (typeof config.autoAnalyze === 'boolean') setAuto(config.autoAnalyze)
1921
+ if (typeof config.learnFromGood === 'boolean') setLearnGood(config.learnFromGood)
1074
1922
  if (typeof config.liveSuggestions === 'boolean') setLive(config.liveSuggestions)
1075
1923
  }
1076
1924
  }, [config])
@@ -1084,6 +1932,11 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1084
1932
  setBudgetText(String(number))
1085
1933
  updateConfig(store, { autoDailyBudget: number })
1086
1934
  }
1935
+ const toggleLearnGood = () => {
1936
+ const next = !learnGood
1937
+ setLearnGood(next)
1938
+ updateConfig(store, { learnFromGood: next })
1939
+ }
1087
1940
  const toggleAuto = () => {
1088
1941
  const next = !auto
1089
1942
  setAuto(next)
@@ -1116,6 +1969,9 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1116
1969
  h('div', { className: 'tacit-settings-row' },
1117
1970
  h('label', { className: 'tacit-settings-label' }, t('settings.auto')),
1118
1971
  h('input', { type: 'checkbox', checked: auto, onChange: toggleAuto })),
1972
+ h('div', { className: 'tacit-settings-row' },
1973
+ h('label', { className: 'tacit-settings-label' }, t('settings.learnGood')),
1974
+ h('input', { type: 'checkbox', checked: learnGood, onChange: toggleLearnGood })),
1119
1975
  h('div', { className: 'tacit-settings-row' },
1120
1976
  h('label', { className: 'tacit-settings-label' }, t('settings.budget')),
1121
1977
  h('input', {
@@ -1169,6 +2025,14 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1169
2025
  error !== null && typeof error === 'object'
1170
2026
  ? h('div', { className: 'tacit-error' }, t('err.' + String(error.code), { detail: String(error.detail || '') }))
1171
2027
  : null,
2028
+ // Analyze and Improve report what they cost here. Always mounted and
2029
+ // empty when idle: a live region that appears together with its text
2030
+ // is missed by screen readers. The settings clear notice carries a
2031
+ // code and a count instead of text, and stays this panel's business.
2032
+ h('div', { className: 'tacit-settings-notice', role: 'status' },
2033
+ store.notice !== null && typeof store.notice === 'object' && typeof store.notice.text === 'string'
2034
+ ? store.notice.text
2035
+ : ''),
1172
2036
  turns.length === 0
1173
2037
  ? h('div', { className: 'tacit-empty' }, t('empty'))
1174
2038
  : h('div', null,
@@ -1229,6 +2093,13 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1229
2093
  const preview = store.preview
1230
2094
  const onApply = () => applyImproved(store, props.inputActions)
1231
2095
  const onCancel = () => closePreview(store)
2096
+ const improvedText = preview.data !== null && typeof preview.data === 'object' && typeof preview.data.improved === 'string'
2097
+ ? preview.data.improved
2098
+ : ''
2099
+ // The model returns a finished draft verbatim (its fixed point): show
2100
+ // that instead of two identical columns, and offer no Apply so a no-op
2101
+ // rewrite never counts as applied.
2102
+ const unchanged = !preview.pending && preview.error === null && improvedText.trim() === preview.original.trim()
1232
2103
 
1233
2104
  return h('div', { className: 'tacit-modal-backdrop' },
1234
2105
  h('div', { className: 'tacit-modal-card' },
@@ -1241,25 +2112,29 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1241
2112
  ? h('div', { className: 'tacit-error' },
1242
2113
  t('err.' + String(preview.error.code), { detail: String(preview.error.detail || '') }))
1243
2114
  : h('div', null,
1244
- h('div', { className: 'tacit-modal-cols' },
1245
- h('div', { className: 'tacit-modal-col' },
1246
- h('div', { className: 'tacit-modal-col-title' }, t('preview.original')),
1247
- h('pre', { className: 'tacit-pre' }, preview.original)),
1248
- h('div', { className: 'tacit-modal-col' },
1249
- h('div', { className: 'tacit-modal-col-title' }, t('preview.improved')),
1250
- h('pre', { className: 'tacit-pre' }, preview.data !== null && typeof preview.data.improved === 'string' ? preview.data.improved : ''))),
2115
+ unchanged
2116
+ ? h('div', { className: 'tacit-modal-unchanged' }, t('preview.unchanged'))
2117
+ : h('div', { className: 'tacit-modal-cols' },
2118
+ h('div', { className: 'tacit-modal-col' },
2119
+ h('div', { className: 'tacit-modal-col-title' }, t('preview.original')),
2120
+ h('pre', { className: 'tacit-pre' }, preview.original)),
2121
+ h('div', { className: 'tacit-modal-col' },
2122
+ h('div', { className: 'tacit-modal-col-title' }, t('preview.improved')),
2123
+ h('pre', { className: 'tacit-pre' }, improvedText))),
1251
2124
  preview.data !== null && typeof preview.data === 'object' && typeof preview.data.rationale === 'string' && preview.data.rationale.length > 0
1252
2125
  ? h('div', { className: 'tacit-modal-rationale' },
1253
2126
  h('div', { className: 'tacit-modal-col-title' }, t('preview.rationale')),
1254
2127
  h('div', { className: 'tacit-modal-rationale-text' }, preview.data.rationale))
1255
2128
  : null,
1256
2129
  h('div', { className: 'tacit-modal-actions' },
1257
- h('button', { type: 'button', className: 'tacit-btn tacit-btn-primary', onClick: onApply }, t('preview.apply')),
2130
+ unchanged
2131
+ ? null
2132
+ : h('button', { type: 'button', className: 'tacit-btn tacit-btn-primary', onClick: onApply }, t('preview.apply')),
1258
2133
  h('button', { type: 'button', className: 'tacit-btn', onClick: onCancel }, t('preview.cancel'))))))
1259
2134
  }
1260
2135
  }
1261
2136
 
1262
- // ── Feedback strip (under the composer, Improve enabled only) ───────────
2137
+ // ── Feedback strip (above the composer, Improve enabled only) ───────────
1263
2138
 
1264
2139
  function FeedbackStrip(kit) {
1265
2140
  const { t } = kit
@@ -1329,13 +2204,117 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1329
2204
  }
1330
2205
  }
1331
2206
 
1332
- // ── Global panel (settings section, sidebar action, frame overlay) ─────
2207
+ // ── Collapsible section card (Settings Tacit) ────────────────────────
2208
+
2209
+ /**
2210
+ * One accessible collapsible card.
2211
+ *
2212
+ * A `<details>` would be shorter, but its open state lives in the DOM: the
2213
+ * store could not drive it and the SSR suite could not seed it. So this is
2214
+ * the button/`aria-expanded`/`aria-controls` pattern instead, with the body
2215
+ * always in the DOM and only the `hidden` attribute toggled — the content
2216
+ * stays findable (browser find-in-page aside) and nothing remounts.
2217
+ */
2218
+ function SectionCard(kit, { id, title, summary, count, open, onToggle, children }) {
2219
+ const { t } = kit
2220
+ const key = String(id)
2221
+ const isOpen = open === true
2222
+ const bodyId = 'tacit-card-' + key + '-body'
2223
+ const label = isOpen ? t('card.collapse') : t('card.expand')
2224
+ const kids = Array.isArray(children) ? children : (children === undefined || children === null ? [] : [children])
2225
+ return h('div', { className: 'tacit-card' },
2226
+ h('button', {
2227
+ type: 'button',
2228
+ className: 'tacit-card-head',
2229
+ id: 'tacit-card-' + key + '-head',
2230
+ 'aria-expanded': isOpen,
2231
+ 'aria-controls': bodyId,
2232
+ onClick: onToggle,
2233
+ },
2234
+ h('span', { className: 'tacit-card-title' }, title),
2235
+ typeof summary === 'string' && summary.length > 0
2236
+ ? h('span', { className: 'tacit-card-summary' }, summary)
2237
+ : null,
2238
+ count !== undefined && count !== null
2239
+ ? h('span', { className: 'tacit-card-count' }, String(count))
2240
+ : null,
2241
+ h('span', { className: 'tacit-card-chevron', 'aria-label': label, title: label }, isOpen ? '▾' : '▸')),
2242
+ h('div', { className: 'tacit-card-body', id: bodyId, hidden: !isOpen }, ...kids))
2243
+ }
2244
+
2245
+ // ── Confirm dialog (destructive actions) ───────────────────────────────
2246
+
2247
+ /**
2248
+ * What had focus when a destructive action was requested.
2249
+ *
2250
+ * It is captured by the store action that opens the dialog rather than by
2251
+ * the dialog itself: React focuses the `autoFocus`ed Cancel button while it
2252
+ * commits, so by the time an effect runs the opener is already gone.
2253
+ */
2254
+ let confirmOpener = null
2255
+
2256
+ function captureConfirmOpener() {
2257
+ confirmOpener = typeof document !== 'undefined' && document !== null ? document.activeElement : null
2258
+ }
2259
+
2260
+ /**
2261
+ * One small modal confirmation, rendered unconditionally by its owner (and
2262
+ * `null` when closed) so React's hook count never changes between renders.
2263
+ * Hook-free but for the focus restore, which is the one thing it cannot do
2264
+ * with markup alone.
2265
+ *
2266
+ * Cancel comes first and takes focus: the safe answer is the one a keyboard
2267
+ * or screen-reader user reaches without looking.
2268
+ */
2269
+ function ConfirmDialog(kit, { open, title, body, confirmLabel, onConfirm, onCancel }) {
2270
+ const { t } = kit
2271
+ const isOpen = open === true
2272
+ useEffect(() => {
2273
+ if (!isOpen || typeof document === 'undefined') return undefined
2274
+ const opener = confirmOpener
2275
+ return () => {
2276
+ if (opener !== null && typeof opener.focus === 'function') opener.focus()
2277
+ }
2278
+ }, [isOpen])
2279
+ if (!isOpen) return null
2280
+ const cancel = typeof onCancel === 'function' ? onCancel : () => {}
2281
+ return h('div', {
2282
+ className: 'tacit-modal-backdrop',
2283
+ onClick: cancel,
2284
+ // Escape cancels from anywhere inside: keydown from either button
2285
+ // bubbles here.
2286
+ onKeyDown: (event) => {
2287
+ if (event.key === 'Escape') cancel()
2288
+ },
2289
+ },
2290
+ h('div', {
2291
+ className: 'tacit-modal-card',
2292
+ role: 'dialog',
2293
+ 'aria-modal': 'true',
2294
+ 'aria-labelledby': 'tacit-confirm-title',
2295
+ // A click on the card is not a click on the backdrop.
2296
+ onClick: (event) => event.stopPropagation(),
2297
+ },
2298
+ h('h3', { className: 'tacit-modal-title', id: 'tacit-confirm-title' }, title),
2299
+ h('p', { className: 'tacit-confirm-body' }, body),
2300
+ h('div', { className: 'tacit-confirm-actions' },
2301
+ h('button', { type: 'button', className: 'tacit-btn', autoFocus: true, onClick: cancel }, t('confirm.cancel')),
2302
+ h('button', { type: 'button', className: 'tacit-btn tacit-btn-danger', onClick: onConfirm }, confirmLabel))))
2303
+ }
2304
+
2305
+ // ── Global panel (Settings → Tacit section) ────────────────────────────
1333
2306
 
1334
2307
  function DirectivesEditor(kit) {
1335
2308
  const { t } = kit
1336
2309
  return function DirectivesEditorView(props) {
1337
2310
  const { config, directives, steering } = props
2311
+ const workspaces = Array.isArray(props.workspaces) ? props.workspaces : []
1338
2312
  const [draft, setDraft] = useState('')
2313
+ const [scope, setScope] = useState('')
2314
+ const labelOf = (cwd) => {
2315
+ const parts = String(cwd).split(/[\\/]+/).filter((part) => part.length > 0)
2316
+ return parts.length > 0 ? parts[parts.length - 1] : String(cwd)
2317
+ }
1339
2318
  const [steerOn, setSteerOn] = useState(config !== null && config.steerAgent !== false)
1340
2319
  const [enrichOn, setEnrichOn] = useState(config !== null && config.enrichPrompts === true)
1341
2320
  useEffect(() => {
@@ -1356,7 +2335,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1356
2335
  const text = draft.trim()
1357
2336
  if (text.length === 0) return
1358
2337
  setDraft('')
1359
- editDirectives({ action: 'add', text: text.slice(0, 300) })
2338
+ editDirectives({ action: 'add', text: text.slice(0, 300), ...(scope.length > 0 ? { workspace: scope } : {}) })
1360
2339
  }
1361
2340
  return h('div', { className: 'tacit-panel-section', 'data-testid': 'tacit-steering' },
1362
2341
  h('div', { className: 'tacit-report-title' }, t('steer.title')),
@@ -1380,6 +2359,9 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1380
2359
  }),
1381
2360
  h('span', { className: 'tacit-directive-text' }, String(entry.text)),
1382
2361
  h('span', { className: 'tacit-chip' }, entry.source === 'user' ? t('steer.user') : t('steer.distilled')),
2362
+ typeof entry.workspace === 'string' && entry.workspace.length > 0
2363
+ ? h('span', { className: 'tacit-chip tacit-chip-scope', title: entry.workspace }, t('steer.workspace', { name: labelOf(entry.workspace) }))
2364
+ : null,
1383
2365
  entry.status === 'candidate'
1384
2366
  ? h('span', { className: 'tacit-chip tacit-chip-trial' }, t('steer.trial', {
1385
2367
  n: String(entry.trial !== null && typeof entry.trial === 'object' && typeof entry.trial.turns === 'number' ? entry.trial.turns : 0),
@@ -1402,6 +2384,16 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1402
2384
  if (event.key === 'Enter') onAdd()
1403
2385
  },
1404
2386
  }),
2387
+ workspaces.length > 0
2388
+ ? h('select', {
2389
+ className: 'tacit-input tacit-select',
2390
+ 'aria-label': t('steer.scope'),
2391
+ value: scope,
2392
+ onChange: (event) => setScope(event.target.value),
2393
+ },
2394
+ h('option', { value: '' }, t('steer.everywhere')),
2395
+ ...workspaces.map((entry) => h('option', { key: entry.cwd, value: entry.cwd }, entry.label)))
2396
+ : null,
1405
2397
  h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', disabled: draft.trim().length === 0, onClick: onAdd }, t('steer.add'))),
1406
2398
  steering !== null && typeof steering === 'object' && typeof steering.text === 'string' && steering.text.length > 0
1407
2399
  ? h('details', { className: 'tacit-preview' },
@@ -1411,6 +2403,26 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1411
2403
  }
1412
2404
  }
1413
2405
 
2406
+ /** Days offered by the retention select; the host clamps to 7–365 anyway. */
2407
+ const RETENTION_DAYS = [7, 14, 30, 90, 180, 365]
2408
+
2409
+ /**
2410
+ * The `/bootstrap-preview` envelope, narrowed. `null` for anything that is
2411
+ * not a successful preview, so the Overview card falls back to the
2412
+ * documented estimate rather than pricing an envelope it cannot read.
2413
+ */
2414
+ function previewOf(value) {
2415
+ if (value === null || typeof value !== 'object' || value.ok !== true) return null
2416
+ if (typeof value.eligible !== 'number' || !Number.isFinite(value.eligible)) return null
2417
+ const estimate = value.estimate !== null && typeof value.estimate === 'object' ? value.estimate : {}
2418
+ return {
2419
+ eligible: Math.max(0, Math.floor(value.eligible)),
2420
+ usd: typeof estimate.usd === 'number' && Number.isFinite(estimate.usd) ? estimate.usd : null,
2421
+ basis: estimate.basis === 'measured' ? 'measured' : 'doc',
2422
+ samples: typeof estimate.samples === 'number' && Number.isFinite(estimate.samples) ? Math.max(0, Math.floor(estimate.samples)) : 0,
2423
+ }
2424
+ }
2425
+
1414
2426
  function CoachPanel(kit) {
1415
2427
  const { t, fmtTime } = kit
1416
2428
  const DirectivesEditorView = DirectivesEditor(kit)
@@ -1425,14 +2437,24 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1425
2437
  const [model, setModel] = useState(config !== null && typeof config.model === 'string' ? config.model : 'deepseek-v4-flash')
1426
2438
  const [budgetText, setBudgetText] = useState(config !== null && typeof config.autoDailyBudget === 'number' ? String(config.autoDailyBudget) : '30')
1427
2439
  const [auto, setAuto] = useState(config !== null && config.autoAnalyze !== false)
2440
+ const [learnGood, setLearnGood] = useState(config !== null && config.learnFromGood !== false)
1428
2441
  const [live, setLive] = useState(config !== null && config.liveSuggestions !== false)
2442
+ // The two USD thresholds follow the budget field's text-state + Apply
2443
+ // pattern: money is typed a character at a time, and clamping mid-typing
2444
+ // fights the user. `0` is a real value here (the warning off), so the
2445
+ // fallback is `??`, never `||`.
2446
+ const [dailyText, setDailyText] = useState(config !== null && typeof config.costWarnDailyUsd === 'number' ? String(config.costWarnDailyUsd) : '0')
2447
+ const [monthlyText, setMonthlyText] = useState(config !== null && typeof config.costWarnMonthlyUsd === 'number' ? String(config.costWarnMonthlyUsd) : '0')
1429
2448
 
1430
2449
  useEffect(() => {
1431
2450
  if (config !== null) {
1432
2451
  if (typeof config.model === 'string') setModel(config.model)
1433
2452
  if (typeof config.autoDailyBudget === 'number') setBudgetText(String(config.autoDailyBudget))
1434
2453
  if (typeof config.autoAnalyze === 'boolean') setAuto(config.autoAnalyze)
2454
+ if (typeof config.learnFromGood === 'boolean') setLearnGood(config.learnFromGood)
1435
2455
  if (typeof config.liveSuggestions === 'boolean') setLive(config.liveSuggestions)
2456
+ if (typeof config.costWarnDailyUsd === 'number') setDailyText(String(config.costWarnDailyUsd))
2457
+ if (typeof config.costWarnMonthlyUsd === 'number') setMonthlyText(String(config.costWarnMonthlyUsd))
1436
2458
  }
1437
2459
  }, [config])
1438
2460
 
@@ -1445,6 +2467,11 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1445
2467
  setBudgetText(String(number))
1446
2468
  updateRootConfig({ autoDailyBudget: number })
1447
2469
  }
2470
+ const toggleLearnGood = () => {
2471
+ const next = !learnGood
2472
+ setLearnGood(next)
2473
+ updateRootConfig({ learnFromGood: next })
2474
+ }
1448
2475
  const toggleAuto = () => {
1449
2476
  const next = !auto
1450
2477
  setAuto(next)
@@ -1455,6 +2482,17 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1455
2482
  setLive(next)
1456
2483
  updateRootConfig({ liveSuggestions: next })
1457
2484
  }
2485
+ /** One USD threshold: never negative, `0` = off, no rounding to whole dollars. */
2486
+ const applyWarn = (key, text, setText) => {
2487
+ const typed = String(text).trim()
2488
+ // An empty field means "off". Anything that is not a number at all is
2489
+ // a typo: silently writing 0 would turn a warning off behind the
2490
+ // user's back, so the entry is left alone instead.
2491
+ if (typed.length > 0 && !Number.isFinite(Number(typed))) return
2492
+ const value = Math.max(0, typed.length === 0 ? 0 : Number(typed))
2493
+ setText(String(value))
2494
+ updateRootConfig({ [key]: value })
2495
+ }
1458
2496
 
1459
2497
  const coached = Array.isArray(rootStore.coached) ? rootStore.coached : []
1460
2498
  const error = rootStore.error
@@ -1465,13 +2503,93 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1465
2503
  ? rootStore.profile.directives.filter((entry) => entry !== null && typeof entry === 'object' && typeof entry.id === 'string' && typeof entry.text === 'string')
1466
2504
  : []
1467
2505
 
2506
+ const sections = rootStore.sections !== null && typeof rootStore.sections === 'object' ? rootStore.sections : {}
2507
+ const notice = rootStore.notice !== null && typeof rootStore.notice === 'object' && typeof rootStore.notice.text === 'string'
2508
+ ? rootStore.notice
2509
+ : null
2510
+ // Rates ride the /usage envelope, so the Pricing card reads them there
2511
+ // rather than calling a route of its own on every render.
2512
+ const usageReport = usageOf(rootStore.usage)
2513
+ const usagePricing = usageReport === null ? null : usageReport.pricing
2514
+ const preview = previewOf(rootStore.preview)
2515
+ const confirm = rootStore.confirm !== null && typeof rootStore.confirm === 'object' && rootStore.confirm.kind === 'usage'
2516
+ ? 'usage'
2517
+ : (rootStore.confirm !== null && typeof rootStore.confirm === 'object' && rootStore.confirm.kind === 'reports' ? 'reports' : null)
2518
+ const retention = config !== null && typeof config.costHistoryDays === 'number' && RETENTION_DAYS.includes(config.costHistoryDays)
2519
+ ? config.costHistoryDays
2520
+ : 30
2521
+ /** Every card is titled by `card.<id>` and driven by `rootStore.sections`. */
2522
+ const card = (id, children, extra) => SectionCard(kit, {
2523
+ id,
2524
+ title: t('card.' + id),
2525
+ open: sections[id] === true,
2526
+ onToggle: () => toggleSection(id),
2527
+ children,
2528
+ ...(extra !== undefined ? extra : {}),
2529
+ })
2530
+
1468
2531
  return h('div', { className: 'tacit-panel' },
1469
- StatusCard(kit, { config, profile, auto: rootStore.auto, trend: rootStore.trend }),
1470
- h('div', { className: 'tacit-settings-row' },
1471
- BootstrapButton(kit, { bootstrap: rootStore.bootstrap, onClick: () => bootstrapAll() }),
1472
- h('span', { className: 'tacit-panel-hint' }, t('bootstrap.hint'))),
1473
- h('div', { className: 'tacit-settings' },
1474
- h('div', { className: 'tacit-settings-title' }, t('settings.title')),
2532
+ error !== null && typeof error === 'object'
2533
+ ? h('div', { className: 'tacit-error' }, t('err.' + String(error.code), { detail: String(error.detail || '') }))
2534
+ : null,
2535
+ // Above the cards, and always mounted: a live region that appears
2536
+ // together with its text is missed by screen readers, and one nested
2537
+ // in the Overview body would be silent while that card is collapsed.
2538
+ h('div', { className: 'tacit-settings-notice', role: 'status' }, notice === null ? '' : notice.text),
2539
+ card('overview', [
2540
+ StatusCard(kit, { config, profile, auto: rootStore.auto, trend: rootStore.trend }),
2541
+ config !== null && typeof config.model === 'string' && config.model.length > 0
2542
+ ? h('div', { className: 'tacit-settings-row' },
2543
+ h('span', { className: 'tacit-chip' }, t('overview.model', { model: config.model })))
2544
+ : null,
2545
+ h('div', { className: 'tacit-settings-row' },
2546
+ BootstrapButton(kit, {
2547
+ bootstrap: rootStore.bootstrap,
2548
+ // Only a loaded preview may disable the button: before one
2549
+ // lands, "0 eligible" is an absence of data, not a fact.
2550
+ disabled: preview !== null && preview.eligible === 0,
2551
+ onClick: () => bootstrapAll(t),
2552
+ }),
2553
+ h('span', { className: 'tacit-panel-hint' }, t('bootstrap.hint'))),
2554
+ // What the run would actually cost, priced from the ledger once it
2555
+ // holds enough analyses; until the preview lands, the documented
2556
+ // figure stands in.
2557
+ preview === null
2558
+ ? h('p', { className: 'tacit-panel-hint' }, t('bootstrap.estimateDoc'))
2559
+ : h('p', { className: 'tacit-panel-hint' },
2560
+ // An estimate the host could not price says so; `fmtUsd(null)`
2561
+ // would read `$0.0000` and claim the run is free.
2562
+ t('bootstrap.preview', {
2563
+ eligible: String(preview.eligible),
2564
+ usd: preview.usd === null ? t('usage.priceUnavailable') : fmtUsd(preview.usd),
2565
+ }),
2566
+ ' ',
2567
+ preview.basis === 'measured'
2568
+ ? t('bootstrap.previewMeasured', { samples: String(preview.samples) })
2569
+ : t('bootstrap.previewDoc')),
2570
+ ]),
2571
+ card('usage', [
2572
+ UsageCard(kit, {
2573
+ usage: rootStore.usage,
2574
+ config,
2575
+ filters: rootStore.usageFilters,
2576
+ series: rootStore.usageSeries,
2577
+ expanded: rootStore.usageExpanded,
2578
+ runs: rootStore.usageRuns,
2579
+ onFilter: (patch) => setUsageFilter(patch),
2580
+ onToggleRun: (runId) => toggleUsageRun(runId),
2581
+ onSeries: (value) => setUsageSeries(value),
2582
+ }),
2583
+ ]),
2584
+ card('pricing', [
2585
+ PricingCard(kit, {
2586
+ pricing: usagePricing,
2587
+ open: sections.pricing === true,
2588
+ refreshing: rootStore.pricingRefreshing === true,
2589
+ onRefresh: () => refreshPricing(t),
2590
+ }),
2591
+ ], { summary: pricingSummary(kit, usagePricing) }),
2592
+ card('learning', [
1475
2593
  h('div', { className: 'tacit-settings-row' },
1476
2594
  h('label', { className: 'tacit-settings-label' }, t('settings.model')),
1477
2595
  h('select', {
@@ -1484,6 +2602,9 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1484
2602
  h('div', { className: 'tacit-settings-row' },
1485
2603
  h('label', { className: 'tacit-settings-label' }, t('settings.auto')),
1486
2604
  h('input', { type: 'checkbox', checked: auto, onChange: toggleAuto })),
2605
+ h('div', { className: 'tacit-settings-row' },
2606
+ h('label', { className: 'tacit-settings-label' }, t('settings.learnGood')),
2607
+ h('input', { type: 'checkbox', checked: learnGood, onChange: toggleLearnGood })),
1487
2608
  h('div', { className: 'tacit-settings-row' },
1488
2609
  h('label', { className: 'tacit-settings-label' }, t('settings.budget')),
1489
2610
  h('input', {
@@ -1494,38 +2615,107 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1494
2615
  onChange: (event) => setBudgetText(event.target.value),
1495
2616
  }),
1496
2617
  h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: applyBudget }, t('settings.apply'))),
2618
+ ]),
2619
+ card('guidance', [
2620
+ h(DirectivesEditorView, {
2621
+ workspaces: rootStore.workspaces, config, directives, steering: rootStore.steering }),
2622
+ ]),
2623
+ card('improve', [
1497
2624
  h('div', { className: 'tacit-settings-row' },
1498
2625
  h('label', { className: 'tacit-settings-label' }, t('settings.live')),
1499
2626
  h('input', { type: 'checkbox', checked: live, onChange: toggleLive })),
2627
+ h('div', { className: 'tacit-panel-section' },
2628
+ h('div', { className: 'tacit-report-title' }, t('panel.styleRules')),
2629
+ styleRules.length === 0
2630
+ ? h('div', { className: 'tacit-empty' }, t('panel.styleRulesEmpty'))
2631
+ : h('div', { className: 'tacit-rules-list' },
2632
+ styleRules.map((entry, index) => h('div', { key: 'rule-' + index, className: 'tacit-rule' }, String(entry.rule))))),
2633
+ h('p', { className: 'tacit-panel-hint' }, t('improve.explain')),
2634
+ ]),
2635
+ card('history', [
2636
+ h('div', { className: 'tacit-panel-section' },
2637
+ h('div', { className: 'tacit-report-title' }, t('panel.coached')),
2638
+ coached.length === 0
2639
+ ? h('div', { className: 'tacit-empty' }, t('panel.coachedEmpty'))
2640
+ : h('div', { className: 'tacit-coached-list' },
2641
+ coached.map((entry, index) => h('div', { key: 'c' + index, className: 'tacit-coached-row' },
2642
+ h('div', { className: 'tacit-coached-meta' },
2643
+ h('span', { className: 'tacit-coached-turn' }, (typeof entry.sessionLabel === 'string' && entry.sessionLabel.length > 0 ? entry.sessionLabel + ' · ' : '') + '# ' + String(entry.turn)),
2644
+ h('span', { className: 'tacit-coached-time' }, fmtTime(entry.time)),
2645
+ h('span', { className: 'tacit-chip tacit-coached-trigger' }, t('trigger.' + (entry.trigger === 'auto' || entry.trigger === 'correction' || entry.trigger === 'bootstrap' ? entry.trigger : 'manual')))),
2646
+ typeof entry.promptExcerpt === 'string' && entry.promptExcerpt.length > 0
2647
+ ? h('div', { className: 'tacit-coached-excerpt' }, entry.promptExcerpt)
2648
+ : null,
2649
+ typeof entry.improvedPrompt === 'string' && entry.improvedPrompt.length > 0
2650
+ ? h('div', { className: 'tacit-coached-improved' }, entry.improvedPrompt.slice(0, 240) + (entry.improvedPrompt.length > 240 ? '…' : ''))
2651
+ : null)))),
2652
+ h('p', { className: 'tacit-panel-hint' }, t('panel.hint')),
2653
+ ], { count: coached.length }),
2654
+ card('privacy', [
2655
+ h('p', { className: 'tacit-panel-hint' }, t('privacy.stored')),
1500
2656
  h('div', { className: 'tacit-settings-row' },
1501
- h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: () => clearAllRoot() }, t('settings.clear')))),
1502
- error !== null && typeof error === 'object'
1503
- ? h('div', { className: 'tacit-error' }, t('err.' + String(error.code), { detail: String(error.detail || '') }))
1504
- : null,
1505
- h(DirectivesEditorView, { config, directives, steering: rootStore.steering }),
1506
- h('div', { className: 'tacit-panel-section' },
1507
- h('div', { className: 'tacit-report-title' }, t('panel.styleRules')),
1508
- styleRules.length === 0
1509
- ? h('div', { className: 'tacit-empty' }, t('panel.styleRulesEmpty'))
1510
- : h('div', { className: 'tacit-rules-list' },
1511
- styleRules.map((entry, index) => h('div', { key: 'rule-' + index, className: 'tacit-rule' }, String(entry.rule))))),
1512
- h('div', { className: 'tacit-panel-section' },
1513
- h('div', { className: 'tacit-report-title' }, t('panel.coached')),
1514
- coached.length === 0
1515
- ? h('div', { className: 'tacit-empty' }, t('panel.coachedEmpty'))
1516
- : h('div', { className: 'tacit-coached-list' },
1517
- coached.map((entry, index) => h('div', { key: 'c' + index, className: 'tacit-coached-row' },
1518
- h('div', { className: 'tacit-coached-meta' },
1519
- h('span', { className: 'tacit-coached-turn' }, (typeof entry.sessionLabel === 'string' && entry.sessionLabel.length > 0 ? entry.sessionLabel + ' · ' : '') + '# ' + String(entry.turn)),
1520
- h('span', { className: 'tacit-coached-time' }, fmtTime(entry.time)),
1521
- h('span', { className: 'tacit-chip tacit-coached-trigger' }, t('trigger.' + (entry.trigger === 'auto' || entry.trigger === 'correction' || entry.trigger === 'bootstrap' ? entry.trigger : 'manual')))),
1522
- typeof entry.promptExcerpt === 'string' && entry.promptExcerpt.length > 0
1523
- ? h('div', { className: 'tacit-coached-excerpt' }, entry.promptExcerpt)
1524
- : null,
1525
- typeof entry.improvedPrompt === 'string' && entry.improvedPrompt.length > 0
1526
- ? h('div', { className: 'tacit-coached-improved' }, entry.improvedPrompt.slice(0, 240) + (entry.improvedPrompt.length > 240 ? '' : ''))
1527
- : null)))),
1528
- h('div', { className: 'tacit-panel-hint' }, t('panel.hint')))
2657
+ h('label', { className: 'tacit-settings-label', htmlFor: 'tacit-retention' }, t('privacy.retention')),
2658
+ h('select', {
2659
+ id: 'tacit-retention',
2660
+ className: 'tacit-select',
2661
+ value: String(retention),
2662
+ onChange: (event) => updateRootConfig({ costHistoryDays: Number(event.target.value) }),
2663
+ },
2664
+ ...RETENTION_DAYS.map((days) => h('option', { key: days, value: String(days) }, String(days))))),
2665
+ h('div', { className: 'tacit-settings-row' },
2666
+ h('label', { className: 'tacit-settings-label', htmlFor: 'tacit-warn-daily' }, t('privacy.warnDaily')),
2667
+ h('input', {
2668
+ id: 'tacit-warn-daily',
2669
+ className: 'tacit-input',
2670
+ type: 'number',
2671
+ min: 0,
2672
+ step: '0.01',
2673
+ value: dailyText,
2674
+ onChange: (event) => setDailyText(event.target.value),
2675
+ }),
2676
+ h('button', {
2677
+ type: 'button',
2678
+ className: 'tacit-btn tacit-btn-sm',
2679
+ // Two identically labelled buttons in one card: the accessible
2680
+ // name has to say which threshold each one applies.
2681
+ 'aria-label': t('privacy.apply') + ': ' + t('privacy.warnDaily'),
2682
+ onClick: () => applyWarn('costWarnDailyUsd', dailyText, setDailyText),
2683
+ }, t('privacy.apply'))),
2684
+ h('div', { className: 'tacit-settings-row' },
2685
+ h('label', { className: 'tacit-settings-label', htmlFor: 'tacit-warn-monthly' }, t('privacy.warnMonthly')),
2686
+ h('input', {
2687
+ id: 'tacit-warn-monthly',
2688
+ className: 'tacit-input',
2689
+ type: 'number',
2690
+ min: 0,
2691
+ step: '0.01',
2692
+ value: monthlyText,
2693
+ onChange: (event) => setMonthlyText(event.target.value),
2694
+ }),
2695
+ h('button', {
2696
+ type: 'button',
2697
+ className: 'tacit-btn tacit-btn-sm',
2698
+ // Two identically labelled buttons in one card: the accessible
2699
+ // name has to say which threshold each one applies.
2700
+ 'aria-label': t('privacy.apply') + ': ' + t('privacy.warnMonthly'),
2701
+ onClick: () => applyWarn('costWarnMonthlyUsd', monthlyText, setMonthlyText),
2702
+ }, t('privacy.apply'))),
2703
+ h('p', { className: 'tacit-panel-hint' }, t('privacy.warnHint')),
2704
+ // Both clears are irreversible, so both go through the dialog.
2705
+ h('div', { className: 'tacit-settings-row' },
2706
+ h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: () => openConfirm('reports') }, t('settings.clear')),
2707
+ h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: () => openConfirm('usage') }, t('privacy.clearUsage'))),
2708
+ ]),
2709
+ // Always rendered (null while closed) so its one effect keeps this
2710
+ // component's hook count stable.
2711
+ ConfirmDialog(kit, {
2712
+ open: confirm !== null,
2713
+ title: t(confirm === 'usage' ? 'confirm.usageTitle' : 'confirm.reportsTitle'),
2714
+ body: t(confirm === 'usage' ? 'confirm.usageBody' : 'confirm.reportsBody'),
2715
+ confirmLabel: t('confirm.clear'),
2716
+ onConfirm: () => confirmAction(),
2717
+ onCancel: () => closeConfirm(),
2718
+ }))
1529
2719
  }
1530
2720
  }
1531
2721
 
@@ -1537,6 +2727,11 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1537
2727
  initRootStore()
1538
2728
  // Fresh read so freshly distilled style rules appear on open.
1539
2729
  refreshRootState()
2730
+ // What a bootstrap would cost right now, priced from the ledger.
2731
+ fetchBootstrapPreview()
2732
+ // One shared 10s /usage poll while any Tacit settings page is mounted.
2733
+ startUsagePolling()
2734
+ return stopUsagePolling
1540
2735
  }, [])
1541
2736
  return h('div', { className: 'tacit-root' },
1542
2737
  h('div', { className: 'tacit-head' }, h('div', { className: 'tacit-head-title' }, t('panel.title'))),
@@ -1544,6 +2739,459 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1544
2739
  }
1545
2740
  }
1546
2741
 
2742
+ // ── Usage dashboard (Settings → Usage card) ────────────────────────────
2743
+
2744
+ /** Every run type and op the ledger can tag, in the order the filters list them. */
2745
+ const USAGE_RUN_TYPES = ['bootstrap', 'analysis', 'analysis-batch', 'improve', 'directive-distillation', 'style-distillation', 'prompt-enrichment']
2746
+ const USAGE_RANGES = ['today', '7d', '30d', 'month', 'all']
2747
+ const USAGE_STATUSES = ['success', 'partial', 'failed']
2748
+ const USAGE_COLUMNS = ['time', 'op', 'scope', 'model', 'status', 'calls', 'tokens', 'cost']
2749
+
2750
+ /** One label/value tile; `note` is the small "n unpriced" line under the value. */
2751
+ function UsageTile(key, label, value, note) {
2752
+ return h('div', { key, className: 'tacit-tile' },
2753
+ h('span', { className: 'tacit-tile-label' }, label),
2754
+ h('span', { className: 'tacit-tile-value' }, value),
2755
+ typeof note === 'string' && note.length > 0 ? h('span', { className: 'tacit-tile-note' }, note) : null)
2756
+ }
2757
+
2758
+ /** Money tile: the period's spend plus its unpriced-call footnote. */
2759
+ function SpendTile(kit, id, label, totals) {
2760
+ const note = totals.unpricedCalls > 0 ? kit.t('usage.unpricedShort', { n: String(totals.unpricedCalls) }) : ''
2761
+ return UsageTile(id, label, usageSpend(kit, totals), note)
2762
+ }
2763
+
2764
+ /**
2765
+ * Daily spend as a bar per day. `role="img"` with one `<title>` per rect
2766
+ * carries the detail to a pointer; the visually hidden paragraph under it
2767
+ * carries the same shape to a screen reader, which cannot read the bars.
2768
+ */
2769
+ function BarStrip(kit, { series, which, onSeries }) {
2770
+ const { t } = kit
2771
+ const days = Array.isArray(series) ? series : []
2772
+ const count = days.length
2773
+ let max = 0
2774
+ let total = 0
2775
+ let peak = null
2776
+ for (const point of days) {
2777
+ if (point.usdKnown > max) max = point.usdKnown
2778
+ total += point.usdKnown
2779
+ if (peak === null || point.usdKnown > peak.usdKnown) peak = point
2780
+ }
2781
+ const bars = days.map((point, index) => {
2782
+ // A day with any spend at all keeps a visible 1px stub.
2783
+ const height = point.usdKnown > 0 && max > 0 ? Math.max(1, Math.round((point.usdKnown / max) * 46)) : 0
2784
+ return h('rect', {
2785
+ key: point.day,
2786
+ className: 'tacit-bar',
2787
+ x: String(index * 10 + 1),
2788
+ y: String(48 - height),
2789
+ width: '8',
2790
+ height: String(height),
2791
+ }, h('title', null, t('usage.barTitle', {
2792
+ day: fmtDay(point.day),
2793
+ usd: fmtUsd(point.usdKnown),
2794
+ calls: String(point.billedCalls),
2795
+ })))
2796
+ })
2797
+ const summary = t('usage.chartSummary', {
2798
+ n: String(count),
2799
+ total: fmtUsd(total),
2800
+ day: peak === null ? '—' : fmtDay(peak.day),
2801
+ max: peak === null ? '—' : fmtUsd(peak.usdKnown),
2802
+ })
2803
+ const seriesButton = (value, label) => h('button', {
2804
+ key: value,
2805
+ type: 'button',
2806
+ className: 'tacit-btn tacit-btn-sm',
2807
+ 'aria-pressed': which === value,
2808
+ onClick: () => onSeries(value),
2809
+ }, label)
2810
+ return h('div', { className: 'tacit-usage-chart' },
2811
+ h('svg', {
2812
+ className: 'tacit-bars',
2813
+ role: 'img',
2814
+ 'aria-label': t('usage.chartLabel', { n: String(count) }),
2815
+ viewBox: '0 0 ' + String(count * 10) + ' 48',
2816
+ preserveAspectRatio: 'none',
2817
+ }, bars),
2818
+ h('p', { className: 'tacit-visually-hidden' }, summary),
2819
+ h('div', { className: 'tacit-series-toggle' },
2820
+ seriesButton('7', t('usage.series7')),
2821
+ seriesButton('30', t('usage.series30'))))
2822
+ }
2823
+
2824
+ /** Spend per operation, costliest first, each row ending in a share bar. */
2825
+ function UsageBreakdown(kit, { byType }) {
2826
+ const { t } = kit
2827
+ const rows = Object.entries(byType).sort((a, b) => b[1].usdKnown - a[1].usdKnown)
2828
+ if (rows.length === 0) return null
2829
+ const top = rows[0][1].usdKnown
2830
+ return h('div', { className: 'tacit-usage-breakdown' },
2831
+ h('div', { className: 'tacit-report-title' }, t('usage.breakdown')),
2832
+ rows.map(([type, totals]) => {
2833
+ const share = top > 0 ? Math.round((totals.usdKnown / top) * 100) : 0
2834
+ return h('div', { key: type, className: 'tacit-breakdown-row' },
2835
+ h('span', { className: 'tacit-breakdown-name' }, t('runtype.' + type)),
2836
+ h('span', { className: 'tacit-breakdown-calls' }, fmtTokens(totals.billedCalls)),
2837
+ h('span', { className: 'tacit-breakdown-tokens' }, fmtTokens(tokensTotal(totals.tokens))),
2838
+ h('span', { className: 'tacit-breakdown-usd' }, usageSpend(kit, totals)),
2839
+ h('div', { className: 'tacit-share-track' },
2840
+ h('div', { className: 'tacit-share', style: { width: String(share) + '%' } })))
2841
+ }))
2842
+ }
2843
+
2844
+ /** One budget bar; nothing at all when that threshold is switched off (`limit` 0). */
2845
+ function WarnBar(kit, { id, warning }) {
2846
+ const { t } = kit
2847
+ if (!(warning.limit > 0)) return null
2848
+ const pct = Math.round((warning.spent / warning.limit) * 100)
2849
+ const label = t('warn.' + id, { spent: fmtUsd(warning.spent), limit: fmtUsd(warning.limit), pct: String(pct) })
2850
+ return h('div', {
2851
+ key: id,
2852
+ className: 'tacit-warn-bar tacit-warn-' + warning.level,
2853
+ role: 'progressbar',
2854
+ 'aria-valuemin': 0,
2855
+ 'aria-valuemax': warning.limit,
2856
+ // An overspend is real, but `aria-valuenow` past `aria-valuemax` is not a
2857
+ // valid range: the bar pins at the cap and the true figure rides valuetext.
2858
+ 'aria-valuenow': Math.min(warning.spent, warning.limit),
2859
+ 'aria-valuetext': fmtUsd(warning.spent),
2860
+ 'aria-label': label,
2861
+ },
2862
+ h('span', { className: 'tacit-warn-label' }, label),
2863
+ h('div', { className: 'tacit-warn-track' },
2864
+ h('div', { className: 'tacit-warn-fill', style: { width: String(Math.min(100, pct)) + '%' } })))
2865
+ }
2866
+
2867
+ /** Range/type/status/model selects plus the two free-text scopes. */
2868
+ function UsageFilters(kit, { filters, models, onFilter }) {
2869
+ const { t } = kit
2870
+ const select = (name, value, options) => h('label', { className: 'tacit-filter' },
2871
+ h('span', { className: 'tacit-filter-label' }, t('filter.' + name)),
2872
+ h('select', {
2873
+ className: 'tacit-select',
2874
+ value,
2875
+ onChange: (event) => onFilter({ [name]: event.target.value }),
2876
+ }, options.map((option) => h('option', { key: option.value, value: option.value }, option.label))))
2877
+ // A text scope applies on Enter or on leaving the field — not per keystroke,
2878
+ // which would fire a /usage call for every character typed. `name` is the
2879
+ // dictionary key, `field` the store/wire key: the session scope is labelled
2880
+ // `filter.session` but filtered on `sessionId`, which is what the server reads.
2881
+ const text = (name, field, value) => h('label', { className: 'tacit-filter' },
2882
+ h('span', { className: 'tacit-filter-label' }, t('filter.' + name)),
2883
+ h('input', {
2884
+ className: 'tacit-input',
2885
+ type: 'text',
2886
+ key: field + '-' + value,
2887
+ defaultValue: value,
2888
+ onBlur: (event) => {
2889
+ if (event.target.value !== value) onFilter({ [field]: event.target.value.trim() })
2890
+ },
2891
+ onKeyDown: (event) => {
2892
+ if (event.key === 'Enter') onFilter({ [field]: event.target.value.trim() })
2893
+ },
2894
+ }))
2895
+ const all = { value: '', label: t('filter.all') }
2896
+ return h('div', { className: 'tacit-usage-filters' },
2897
+ h('div', { className: 'tacit-report-title' }, t('usage.filters')),
2898
+ h('div', { className: 'tacit-filter-row' },
2899
+ select('range', filters.range, USAGE_RANGES.map((value) => ({ value, label: t('range.' + value) }))),
2900
+ select('type', filters.type, [all, ...USAGE_RUN_TYPES.map((value) => ({ value, label: t('runtype.' + value) }))]),
2901
+ select('status', filters.status, [all, ...USAGE_STATUSES.map((value) => ({ value, label: t('status.' + value) }))]),
2902
+ select('model', filters.model, [all, ...models.map((value) => ({ value, label: value }))]),
2903
+ text('workspace', 'workspace', filters.workspace),
2904
+ text('session', 'sessionId', filters.sessionId)))
2905
+ }
2906
+
2907
+ /** The five token buckets of one attempt, in the order the adapter reports them. */
2908
+ function attemptTokens(usage) {
2909
+ if (usage === null || typeof usage !== 'object') return '—'
2910
+ return 'in ' + fmtTokens(usage.inputTokens)
2911
+ + ' · out ' + fmtTokens(usage.outputTokens)
2912
+ + ' · cacheRead ' + fmtTokens(usage.cacheReadTokens)
2913
+ + ' · cacheWrite ' + fmtTokens(usage.cacheWriteTokens)
2914
+ + ' · reasoning ' + fmtTokens(usage.reasoningTokens)
2915
+ }
2916
+
2917
+ /** One `run()` of the coach model: what it was, how it ended, what it cost. */
2918
+ function AttemptRow(kit, attempt) {
2919
+ const { t } = kit
2920
+ const priced = attempt.priced !== null && typeof attempt.priced === 'object' ? attempt.priced : null
2921
+ const finish = String(attempt.finish || '')
2922
+ const code = String(attempt.code || '')
2923
+ return h('div', { key: String(attempt.id), className: 'tacit-attempt' },
2924
+ h('span', { className: 'tacit-chip' }, t('op.' + String(attempt.op))),
2925
+ typeof attempt.reasoningEffort === 'string' && attempt.reasoningEffort.length > 0
2926
+ ? h('span', { className: 'tacit-attempt-meta' }, t('attempt.effort', { effort: attempt.reasoningEffort }))
2927
+ : null,
2928
+ h('span', { className: 'tacit-chip tacit-status-' + String(attempt.status) }, t('status.' + String(attempt.status))),
2929
+ h('span', { className: 'tacit-attempt-meta' }, t('attempt.finish', { finish, code })),
2930
+ h('span', { className: 'tacit-attempt-meta' }, priced === null
2931
+ ? t('usage.priceUnavailable')
2932
+ : t('attempt.source', { source: String(priced.source), tier: String(priced.tier) })),
2933
+ h('span', { className: 'tacit-attempt-tokens' }, attemptTokens(attempt.usage)),
2934
+ h('span', { className: 'tacit-attempt-meta' }, t('attempt.duration', { ms: fmtTokens(attempt.durationMs) })),
2935
+ h('span', { className: 'tacit-attempt-usd' }, priced === null ? t('usage.priceUnavailable') : fmtUsd(priced.usd)))
2936
+ }
2937
+
2938
+ /** The expanded detail row of one run: its attempts, or the loading line. */
2939
+ function AttemptsRow(kit, { runId, run }) {
2940
+ const { t } = kit
2941
+ const attempts = run !== null && run !== undefined && typeof run === 'object' && Array.isArray(run.attempts)
2942
+ ? run.attempts.filter((attempt) => attempt !== null && typeof attempt === 'object')
2943
+ : null
2944
+ return h('div', { className: 'tacit-usage-attempts', id: 'tacit-run-' + runId, role: 'row' },
2945
+ h('div', { className: 'tacit-usage-cell tacit-usage-attempts-cell', role: 'cell' },
2946
+ attempts === null
2947
+ ? h('p', { className: 'tacit-panel-hint' }, t('usage.loading'))
2948
+ : attempts.map((attempt) => AttemptRow(kit, attempt))))
2949
+ }
2950
+
2951
+ /** One run row; the first cell is the disclosure button for its attempts. */
2952
+ function RunRow(kit, { item, open, onToggleRun }) {
2953
+ const { t, fmtTime } = kit
2954
+ const scope = item.workspace.length > 0 && item.turn !== null
2955
+ ? item.workspace + ' · #' + String(item.turn)
2956
+ : (item.workspace.length > 0 ? item.workspace : (item.turn !== null ? '#' + String(item.turn) : '—'))
2957
+ const cell = (key, child) => h('span', { key, className: 'tacit-usage-cell', role: 'cell' }, child)
2958
+ return h('div', { className: 'tacit-usage-row', role: 'row' },
2959
+ cell('time', h('button', {
2960
+ type: 'button',
2961
+ className: 'tacit-btn tacit-btn-sm tacit-run-toggle',
2962
+ 'aria-expanded': open,
2963
+ 'aria-controls': 'tacit-run-' + item.runId,
2964
+ onClick: () => onToggleRun(item.runId),
2965
+ }, (open ? '▾ ' : '▸ ') + fmtTime(item.startedAt))),
2966
+ cell('op', t('runtype.' + item.type)),
2967
+ cell('scope', scope),
2968
+ cell('model', item.model.length > 0 ? item.model : '—'),
2969
+ cell('status', h('span', { className: 'tacit-chip tacit-status-' + item.status }, t('status.' + item.status))),
2970
+ cell('calls', fmtTokens(item.billedCalls)),
2971
+ cell('tokens', fmtTokens(tokensTotal(item.tokens))),
2972
+ cell('cost', usageSpend(kit, item)))
2973
+ }
2974
+
2975
+ /** The runs table plus its pager. */
2976
+ function UsageRuns(kit, { runs, expanded, runDetails, onToggleRun, onFilter }) {
2977
+ const { t } = kit
2978
+ const pages = Math.max(1, Math.ceil(runs.total / Math.max(1, runs.pageSize)))
2979
+ const page = Math.min(Math.max(1, runs.page), pages)
2980
+ return h('div', { className: 'tacit-usage-runs' },
2981
+ h('div', { className: 'tacit-usage-table', role: 'table', 'aria-label': t('card.usage') },
2982
+ h('div', { className: 'tacit-usage-row tacit-usage-head', role: 'row' },
2983
+ USAGE_COLUMNS.map((column) => h('span', {
2984
+ key: column,
2985
+ className: 'tacit-usage-cell',
2986
+ role: 'columnheader',
2987
+ }, t('usage.col.' + column)))),
2988
+ runs.items.map((item) => {
2989
+ const open = expanded.has(item.runId)
2990
+ return h(React.Fragment, { key: item.runId },
2991
+ RunRow(kit, { item, open, onToggleRun }),
2992
+ open ? AttemptsRow(kit, { runId: item.runId, run: runDetails[item.runId] }) : null)
2993
+ })),
2994
+ h('div', { className: 'tacit-pager' },
2995
+ h('button', {
2996
+ type: 'button',
2997
+ className: 'tacit-btn tacit-btn-sm',
2998
+ disabled: page <= 1,
2999
+ onClick: () => onFilter({ page: page - 1 }),
3000
+ }, t('usage.prev')),
3001
+ h('span', { className: 'tacit-pager-label' }, t('usage.page', { page: String(page), pages: String(pages) })),
3002
+ h('button', {
3003
+ type: 'button',
3004
+ className: 'tacit-btn tacit-btn-sm',
3005
+ disabled: page >= pages,
3006
+ onClick: () => onFilter({ page: page + 1 }),
3007
+ }, t('usage.next'))))
3008
+ }
3009
+
3010
+ /**
3011
+ * The whole Usage card. A plain renderer, not a component: it is called
3012
+ * from `CoachPanelView`'s render, so it must never own hooks — every bit
3013
+ * of its state lives in `rootStore` and arrives as a prop.
3014
+ */
3015
+ function UsageCard(kit, props) {
3016
+ const { t } = kit
3017
+ const usage = usageOf(props.usage)
3018
+ const since = fmtDay(usage === null ? 0 : usage.trackingSince)
3019
+ if (usage === null || usage.lifetime.billedCalls === 0) {
3020
+ return h('div', { className: 'tacit-usage' },
3021
+ h('p', { className: 'tacit-panel-hint' }, t('usage.empty', { since })))
3022
+ }
3023
+ const filters = props.filters !== null && typeof props.filters === 'object' ? props.filters : {}
3024
+ const value = (key) => (typeof filters[key] === 'string' ? filters[key] : '')
3025
+ const expanded = props.expanded instanceof Set ? props.expanded : new Set()
3026
+ const runDetails = props.runs !== null && typeof props.runs === 'object' ? props.runs : {}
3027
+ const which = props.series === '7' ? '7' : '30'
3028
+ const last30 = usage.last30
3029
+ return h('div', { className: 'tacit-usage' },
3030
+ h('p', { className: 'tacit-panel-hint' }, t('usage.label')),
3031
+ h('div', { className: 'tacit-tiles' },
3032
+ SpendTile(kit, 'today', t('usage.today'), usage.today),
3033
+ SpendTile(kit, 'month', t('usage.month'), usage.month),
3034
+ SpendTile(kit, 'last30', t('usage.last30'), last30),
3035
+ SpendTile(kit, 'lifetime', t('usage.since', { day: since }), usage.lifetime)),
3036
+ h('div', { className: 'tacit-tiles' },
3037
+ UsageTile('calls', t('usage.calls'), fmtTokens(last30.billedCalls)),
3038
+ UsageTile('avg', t('usage.avgAnalysis'), last30.avgAnalysisUsd === null
3039
+ ? t('usage.priceUnavailable')
3040
+ : fmtUsd(last30.avgAnalysisUsd)),
3041
+ UsageTile('cached', t('usage.cachedRate'), fmtPct(last30.cachedInputRate)),
3042
+ UsageTile('unpriced', t('usage.unpriced'), fmtTokens(last30.unpricedCalls))),
3043
+ BarStrip(kit, { series: which === '7' ? usage.series7 : usage.series30, which, onSeries: props.onSeries }),
3044
+ UsageBreakdown(kit, { byType: usage.byType }),
3045
+ WarnBar(kit, { id: 'daily', warning: usage.warnings.daily }),
3046
+ WarnBar(kit, { id: 'monthly', warning: usage.warnings.monthly }),
3047
+ UsageFilters(kit, {
3048
+ filters: {
3049
+ range: value('range').length > 0 ? value('range') : '30d',
3050
+ type: value('type'),
3051
+ status: value('status'),
3052
+ model: value('model'),
3053
+ workspace: value('workspace'),
3054
+ sessionId: value('sessionId'),
3055
+ },
3056
+ models: Object.keys(usage.byModel).sort(),
3057
+ onFilter: props.onFilter,
3058
+ }),
3059
+ UsageRuns(kit, {
3060
+ runs: usage.runs,
3061
+ expanded,
3062
+ runDetails,
3063
+ onToggleRun: props.onToggleRun,
3064
+ onFilter: props.onFilter,
3065
+ }))
3066
+ }
3067
+
3068
+ // ── Pricing card (Settings → Pricing) ──────────────────────────────────
3069
+
3070
+ /** The two tiers and the three quoted rates, in the order the table lists them. */
3071
+ const PRICING_TIERS = ['offPeak', 'peak']
3072
+ const PRICING_COLUMNS = ['model', 'tier', 'cacheHit', 'cacheMiss', 'output', 'reasoning']
3073
+
3074
+ /**
3075
+ * The `usage.pricing` block, narrowed. Rates are copied per model and tier
3076
+ * so a missing or malformed triple renders an em dash instead of a number
3077
+ * the provider never quoted.
3078
+ */
3079
+ function pricingOf(value) {
3080
+ const source = value !== null && typeof value === 'object' ? value : {}
3081
+ const text = (key) => (typeof source[key] === 'string' ? source[key] : '')
3082
+ const rates = source.rates !== null && typeof source.rates === 'object' ? source.rates : {}
3083
+ const models = {}
3084
+ for (const [model, tiers] of Object.entries(rates)) {
3085
+ if (tiers === null || typeof tiers !== 'object') continue
3086
+ const entry = {}
3087
+ for (const tier of PRICING_TIERS) {
3088
+ const triple = tiers[tier] !== null && typeof tiers[tier] === 'object' ? tiers[tier] : {}
3089
+ const rate = (value) => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined)
3090
+ entry[tier] = { cacheHit: rate(triple.cacheHit), cacheMiss: rate(triple.cacheMiss), output: rate(triple.output) }
3091
+ }
3092
+ models[model] = entry
3093
+ }
3094
+ return {
3095
+ source: source.source === 'costMeter' ? 'costMeter' : 'bundled',
3096
+ asOf: text('asOf'),
3097
+ refreshedAt: typeof source.refreshedAt === 'number' && source.refreshedAt > 0 ? source.refreshedAt : 0,
3098
+ tierNow: source.tierNow === 'peak' ? 'peak' : 'offPeak',
3099
+ error: text('error'),
3100
+ models,
3101
+ }
3102
+ }
3103
+
3104
+ /** `deepseek-v4-flash` → `flash`, for the one-line header summary. */
3105
+ function shortModel(model) {
3106
+ const parts = String(model).split('-').filter((part) => part.length > 0)
3107
+ return parts.length > 0 ? parts[parts.length - 1] : String(model)
3108
+ }
3109
+
3110
+ /** Which price source this table came from, as a phrase. */
3111
+ function pricingSourceLabel(kit, priced) {
3112
+ return kit.t(priced.source === 'costMeter' ? 'pricing.sourceCostMeter' : 'pricing.sourceBundled')
3113
+ }
3114
+
3115
+ /**
3116
+ * The line the collapsed card shows in its header: the flash model — the
3117
+ * default coach model — at the tier in force right now, so the headline
3118
+ * figure is the one a call started this minute would actually be billed at.
3119
+ */
3120
+ function pricingSummary(kit, pricing) {
3121
+ const { t } = kit
3122
+ const priced = pricingOf(pricing)
3123
+ const models = Object.keys(priced.models).sort()
3124
+ const model = models.find((name) => name.endsWith('flash'))
3125
+ const pick = model !== undefined ? model : models[0]
3126
+ if (pick === undefined) return ''
3127
+ const triple = priced.models[pick][priced.tierNow]
3128
+ return t('pricing.summary', {
3129
+ model: shortModel(pick),
3130
+ tier: t('pricing.' + priced.tierNow),
3131
+ rates: fmtRate(triple.cacheHit) + ' / ' + fmtRate(triple.cacheMiss) + ' / ' + fmtRate(triple.output),
3132
+ source: pricingSourceLabel(kit, priced),
3133
+ asOf: priced.asOf.length > 0 ? priced.asOf : '—',
3134
+ })
3135
+ }
3136
+
3137
+ /** One model at one tier. Reasoning is prose: it is billed as output, never quoted apart. */
3138
+ function PricingRow(kit, model, tier, triple) {
3139
+ const { t } = kit
3140
+ const cell = (key, child) => h('span', { key, className: 'tacit-pricing-cell', role: 'cell' }, child)
3141
+ return h('div', { key: model + '-' + tier, className: 'tacit-pricing-row', role: 'row' },
3142
+ h('span', { className: 'tacit-pricing-cell tacit-pricing-model', role: 'rowheader' }, model),
3143
+ cell('tier', t('pricing.' + tier)),
3144
+ cell('cacheHit', fmtRate(triple.cacheHit)),
3145
+ cell('cacheMiss', fmtRate(triple.cacheMiss)),
3146
+ cell('output', fmtRate(triple.output)),
3147
+ cell('reasoning', t('pricing.reasoningSameAsOutput')))
3148
+ }
3149
+
3150
+ /**
3151
+ * The Pricing card body. Like `UsageCard` a plain renderer, not a
3152
+ * component: it owns no hooks and no state. It builds nothing at all while
3153
+ * collapsed — the header summary already carries the headline rate, and
3154
+ * this page re-renders on every 10s usage poll.
3155
+ */
3156
+ function PricingCard(kit, { pricing, open, onRefresh, refreshing }) {
3157
+ const { t, fmtTime } = kit
3158
+ if (open !== true) return null
3159
+ const priced = pricingOf(pricing)
3160
+ const models = Object.keys(priced.models).sort()
3161
+ const when = priced.refreshedAt > 0
3162
+ ? t('pricing.refreshedAt', { time: fmtDay(priced.refreshedAt) + ' ' + fmtTime(priced.refreshedAt) })
3163
+ : t('pricing.never')
3164
+ const asOf = priced.asOf.length > 0 ? priced.asOf : '—'
3165
+ return h('div', { className: 'tacit-pricing' },
3166
+ h('div', { className: 'tacit-report-title', id: 'tacit-pricing-title' }, t('pricing.rateTable')),
3167
+ models.length === 0
3168
+ ? h('p', { className: 'tacit-panel-hint' }, t('usage.priceUnavailable'))
3169
+ : h('div', { className: 'tacit-pricing-table', role: 'table', 'aria-labelledby': 'tacit-pricing-title' },
3170
+ h('div', { className: 'tacit-pricing-row tacit-pricing-head', role: 'row' },
3171
+ PRICING_COLUMNS.map((column) => h('span', {
3172
+ key: column,
3173
+ className: 'tacit-pricing-cell',
3174
+ role: 'columnheader',
3175
+ }, t('pricing.' + column)))),
3176
+ models.map((model) => PRICING_TIERS.map((tier) => PricingRow(kit, model, tier, priced.models[model][tier])))),
3177
+ h('p', { className: 'tacit-panel-hint' }, t('pricing.tierNow', { tier: t('pricing.' + priced.tierNow) })),
3178
+ h('p', { className: 'tacit-panel-hint' }, t('pricing.schedule')),
3179
+ h('p', { className: 'tacit-panel-hint' }, t('pricing.weekendRule')),
3180
+ h('p', { className: 'tacit-panel-hint' },
3181
+ t('pricing.source') + ': ' + pricingSourceLabel(kit, priced) + ' (' + asOf + ') · ' + when),
3182
+ priced.error.length > 0
3183
+ ? h('p', { className: 'tacit-pricing-error' }, t('pricing.error', { error: priced.error }))
3184
+ : null,
3185
+ h('p', { className: 'tacit-panel-hint' }, t('pricing.formula')),
3186
+ h('p', { className: 'tacit-panel-hint' }, t('pricing.accuracy')),
3187
+ h('div', { className: 'tacit-settings-row' },
3188
+ h('button', {
3189
+ type: 'button',
3190
+ className: 'tacit-btn tacit-btn-sm tacit-pricing-refresh',
3191
+ disabled: refreshing === true,
3192
+ onClick: onRefresh,
3193
+ }, refreshing === true ? t('pricing.refreshing') : t('pricing.refresh'))))
3194
+ }
1547
3195
  // ── CSS (injected once) ────────────────────────────────────────────────
1548
3196
 
1549
3197
  const css = '.tacit-root{box-sizing:border-box;height:100%;color:var(--dsw-alias-label-primary);padding:16px 20px 32px;font-size:13px;overflow-y:auto}'
@@ -1554,6 +3202,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1554
3202
  + '.tacit-btn{font:inherit;font-size:12px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:6px;cursor:pointer;padding:4px 10px}.tacit-btn:hover{border-color:var(--dsw-alias-label-primary)}.tacit-btn:disabled{opacity:.55;cursor:default}'
1555
3203
  + '.tacit-btn-primary{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground);border-color:transparent}.tacit-btn-primary:hover{border-color:transparent;filter:brightness(1.1)}'
1556
3204
  + '.tacit-btn-sm{padding:2px 8px;font-size:11px}'
3205
+ + '.tacit-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent)}.tacit-btn-danger:hover{border-color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent)}'
1557
3206
  + '.tacit-row{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:10px 12px;margin-bottom:10px}'
1558
3207
  + '.tacit-row-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 12px}.tacit-row-heading{display:flex;align-items:baseline;gap:8px;font-weight:600}.tacit-row-turn{font-size:13px}.tacit-row-time{color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:400}.tacit-row-live{color:var(--dsw-alias-state-success-primary);font-size:11px;font-weight:600}'
1559
3208
  + '.tacit-row-chips{display:flex;flex-wrap:wrap;gap:4px;margin-left:auto}.tacit-chip{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary);border-radius:4px;padding:1px 7px;font-size:11px}.tacit-chip-warn{color:var(--dsw-alias-state-warn-primary)}'
@@ -1588,21 +3237,64 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1588
3237
  + ''
1589
3238
  + '.tacit-modal-backdrop{z-index:200;background:#00000073;display:flex;justify-content:center;align-items:center;position:fixed;inset:0}'
1590
3239
  + '.tacit-modal-card{box-sizing:border-box;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);width:min(760px,100vw - 48px);max-height:min(84vh,800px);box-shadow:var(--dsw-shadow-lv3,0 12px 32px #0006);color:var(--dsw-alias-label-primary);border-radius:12px;padding:16px 18px 18px;font-size:13px;display:flex;flex-direction:column;gap:10px;overflow-y:auto}'
1591
- + '.tacit-modal-head{display:flex;align-items:baseline;gap:8px}.tacit-modal-title{font-size:14px;font-weight:600;margin-right:auto}.tacit-modal-close{color:var(--dsw-alias-label-secondary);cursor:pointer;background:none;border:0;border-radius:6px;padding:2px 8px;font-family:inherit;font-size:18px;line-height:1}.tacit-modal-close:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}'
3240
+ + '.tacit-modal-head{display:flex;align-items:baseline;gap:8px}.tacit-modal-title{font-size:14px;font-weight:600;margin:0 auto 0 0}.tacit-modal-close{color:var(--dsw-alias-label-secondary);cursor:pointer;background:none;border:0;border-radius:6px;padding:2px 8px;font-family:inherit;font-size:18px;line-height:1}.tacit-modal-close:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}'
1592
3241
  + '.tacit-modal-pending{color:var(--dsw-alias-label-secondary);padding:16px 0;text-align:center}'
3242
+ + '.tacit-modal-unchanged{color:var(--dsw-alias-label-primary);padding:16px 0;text-align:center}'
1593
3243
  + '.tacit-modal-cols{display:grid;grid-template-columns:1fr 1fr;gap:10px}.tacit-modal-col{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-2);padding:8px 10px;min-width:0;max-height:300px;overflow-y:auto}'
1594
3244
  + '.tacit-modal-col-title{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary);margin-bottom:6px}'
1595
3245
  + '.tacit-modal-rationale-text{font-size:12px;color:var(--dsw-alias-label-primary);line-height:1.6}.tacit-modal-savings{color:var(--dsw-alias-state-success-primary);font-size:12px;font-weight:600}'
1596
3246
  + '.tacit-modal-actions{display:flex;gap:8px;justify-content:flex-end}'
1597
- + '.tacit-feedback{display:flex;justify-content:center;align-items:center;margin:8px 2px 0;font-size:12px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:6px 10px;gap:8px;transition:opacity .35s ease}.tacit-feedback-fading{opacity:0}'
3247
+ + '.tacit-confirm-body{margin:0;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-primary)}'
3248
+ + '.tacit-confirm-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:4px}'
3249
+ + '.tacit-feedback{display:flex;justify-content:center;align-items:center;margin:0 2px 8px;font-size:12px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:6px 10px;gap:8px;transition:opacity .35s ease}.tacit-feedback-fading{opacity:0}'
1598
3250
  + '.tacit-feedback-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.tacit-feedback-title{color:var(--dsw-alias-label-secondary)}'
1599
3251
  + '.tacit-feedback-vote{font:inherit;font-size:14px;line-height:1;cursor:pointer;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:3px 8px}.tacit-feedback-vote:hover{border-color:var(--dsw-alias-brand-primary)}.tacit-feedback-vote-active{border-color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent)}'
1600
3252
  + '.tacit-feedback-reason{display:flex;align-items:center;gap:6px;flex-wrap:wrap}.tacit-feedback-input{width:260px;max-width:60vw}'
1601
3253
  + '.tacit-feedback-noted{color:var(--dsw-alias-state-success-primary);font-weight:600}'
1602
3254
  + '.tacit-trend{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px}.tacit-chip-trigger,.tacit-coached-trigger{text-transform:uppercase;letter-spacing:.04em;font-size:10px}.tacit-coached-trigger{margin-left:auto}.tacit-row-enrichment{margin-top:8px;border-left:3px solid var(--dsw-alias-brand-primary);padding:4px 10px;background:var(--dsw-alias-bg-layer-2);border-radius:6px}'
1603
- + '.tacit-chip-trial{color:var(--dsw-alias-brand-primary)}.tacit-chip-ok{color:var(--dsw-alias-state-success-primary)}'
3255
+ + '.tacit-chip-trial{color:var(--dsw-alias-brand-primary)}.tacit-chip-ok{color:var(--dsw-alias-state-success-primary)}.tacit-chip-scope{color:var(--dsw-alias-brand-primary)}.tacit-select{max-width:11rem}'
1604
3256
  + '.tacit-directive{display:flex;align-items:center;gap:8px}.tacit-directive-text{flex:1;min-width:0}.tacit-directive-off .tacit-directive-text{opacity:.5;text-decoration:line-through}.tacit-directive-input{width:min(420px,100%);flex:1}.tacit-preview summary{cursor:pointer;font-size:11px;color:var(--dsw-alias-label-secondary)}.tacit-preview pre{margin-top:6px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:6px;padding:8px 10px}'
1605
3257
  + '.tacit-rules-list{display:flex;flex-direction:column;gap:6px}.tacit-rule{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-left:3px solid var(--dsw-alias-brand-primary);border-radius:6px;padding:6px 10px;font-size:12px;line-height:1.55;color:var(--dsw-alias-label-primary);white-space:pre-wrap;word-break:break-word}'
3258
+ + '.tacit-card{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-1)}'
3259
+ + '.tacit-card-head{width:100%;display:flex;align-items:center;gap:8px;text-align:left;padding:10px 12px;background:none;border:0;cursor:pointer;font:inherit;color:inherit}'
3260
+ + '.tacit-card-head:focus-visible{outline:2px solid var(--dsw-alias-brand-primary)}'
3261
+ + '.tacit-card-title{font-size:13px;font-weight:600}.tacit-card-summary{font-size:11px;color:var(--dsw-alias-label-secondary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}'
3262
+ + '.tacit-card-count{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary);border-radius:4px;padding:1px 7px;font-size:11px;font-variant-numeric:tabular-nums}'
3263
+ + '.tacit-card-chevron{margin-left:auto;color:var(--dsw-alias-label-secondary);font-size:11px}'
3264
+ + '.tacit-card-body{padding:0 12px 12px}'
3265
+ + '.tacit-usage{display:flex;flex-direction:column;gap:10px}'
3266
+ + '.tacit-tiles{display:flex;flex-wrap:wrap;gap:8px}'
3267
+ + '.tacit-tile{flex:1 1 140px;display:flex;flex-direction:column;gap:2px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:8px 10px;min-width:0}'
3268
+ + '.tacit-tile-label{font-size:11px;color:var(--dsw-alias-label-secondary)}.tacit-tile-value{font-size:15px;font-weight:600;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.tacit-tile-note{font-size:10px;color:var(--dsw-alias-state-warn-primary)}'
3269
+ + '.tacit-usage-chart{display:flex;flex-direction:column;gap:6px}.tacit-bars{width:100%;height:48px}.tacit-bar{fill:var(--dsw-alias-brand-primary)}'
3270
+ + '.tacit-visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}'
3271
+ + '.tacit-series-toggle{display:flex;gap:6px}.tacit-series-toggle button[aria-pressed=true]{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}'
3272
+ + '.tacit-usage-breakdown{display:flex;flex-direction:column;gap:4px}'
3273
+ + '.tacit-breakdown-row{display:grid;grid-template-columns:minmax(0,1.6fr) 4rem 5rem 5rem minmax(60px,1fr);align-items:center;gap:6px;font-size:12px;font-variant-numeric:tabular-nums}'
3274
+ + '.tacit-breakdown-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tacit-breakdown-calls,.tacit-breakdown-tokens,.tacit-breakdown-usd{text-align:right;color:var(--dsw-alias-label-secondary)}'
3275
+ + '.tacit-share-track{background:var(--dsw-alias-bg-layer-2);border-radius:3px;height:6px;overflow:hidden}.tacit-share{background:var(--dsw-alias-brand-primary);border-radius:3px;height:6px}'
3276
+ + '.tacit-warn-bar{display:flex;flex-direction:column;gap:4px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:6px 10px;font-size:11px}'
3277
+ + '.tacit-warn-label{color:var(--dsw-alias-label-secondary)}.tacit-warn-track{background:var(--dsw-alias-bg-layer-2);border-radius:3px;height:6px;overflow:hidden}.tacit-warn-fill{background:var(--dsw-alias-label-secondary);border-radius:3px;height:6px}'
3278
+ + '.tacit-warn-warn{border-color:var(--dsw-alias-state-warn-primary)}.tacit-warn-warn .tacit-warn-label{color:var(--dsw-alias-state-warn-primary)}.tacit-warn-warn .tacit-warn-fill{background:var(--dsw-alias-state-warn-primary)}'
3279
+ + '.tacit-warn-exceeded{border-color:var(--dsw-alias-state-error-primary)}.tacit-warn-exceeded .tacit-warn-label{color:var(--dsw-alias-state-error-primary)}.tacit-warn-exceeded .tacit-warn-fill{background:var(--dsw-alias-state-error-primary)}'
3280
+ + '.tacit-usage-filters{display:flex;flex-direction:column;gap:6px}.tacit-filter-row{display:flex;flex-wrap:wrap;gap:8px}.tacit-filter{display:flex;flex-direction:column;gap:2px;min-width:0}.tacit-filter-label{font-size:10px;color:var(--dsw-alias-label-secondary)}'
3281
+ + '.tacit-usage-runs{display:flex;flex-direction:column;gap:6px}'
3282
+ + '.tacit-usage-table{display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;overflow:hidden}'
3283
+ + '.tacit-usage-row{display:grid;grid-template-columns:minmax(90px,1fr) minmax(90px,1.2fr) minmax(80px,1.2fr) minmax(90px,1.2fr) 5rem 4rem 5rem 5rem;gap:6px;align-items:center;padding:5px 8px;border-top:1px solid var(--dsw-alias-border-l1);font-size:11px;font-variant-numeric:tabular-nums}'
3284
+ + '.tacit-usage-table>.tacit-usage-row:first-child{border-top:0}.tacit-usage-head{color:var(--dsw-alias-label-secondary);font-weight:600;background:var(--dsw-alias-bg-layer-2)}'
3285
+ + '.tacit-usage-cell{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}'
3286
+ + '.tacit-run-toggle{width:100%;text-align:left}'
3287
+ + '.tacit-usage-attempts{border-top:1px dashed var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);padding:6px 8px}.tacit-usage-attempts-cell{display:flex;flex-direction:column;gap:4px}'
3288
+ + '.tacit-attempt{display:flex;flex-wrap:wrap;align-items:center;gap:6px;font-size:11px;color:var(--dsw-alias-label-primary)}.tacit-attempt-meta,.tacit-attempt-tokens{color:var(--dsw-alias-label-secondary)}.tacit-attempt-usd{margin-left:auto;font-variant-numeric:tabular-nums}'
3289
+ + '.tacit-status-success,.tacit-status-ok{color:var(--dsw-alias-state-success-primary)}.tacit-status-partial,.tacit-status-unmetered{color:var(--dsw-alias-state-warn-primary)}.tacit-status-failed{color:var(--dsw-alias-state-error-primary)}.tacit-status-running{color:var(--dsw-alias-label-secondary)}'
3290
+ + '.tacit-pager{display:flex;align-items:center;gap:8px}.tacit-pager-label{font-size:11px;color:var(--dsw-alias-label-secondary)}'
3291
+ + '.tacit-pricing{display:flex;flex-direction:column;gap:8px}'
3292
+ + '.tacit-pricing-table{display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;overflow:hidden}'
3293
+ + '.tacit-pricing-row{display:grid;grid-template-columns:minmax(120px,1.6fr) 6rem repeat(3,5rem) minmax(90px,1fr);gap:6px;align-items:center;padding:5px 8px;border-top:1px solid var(--dsw-alias-border-l1);font-size:11px;font-variant-numeric:tabular-nums}'
3294
+ + '.tacit-pricing-table>.tacit-pricing-row:first-child{border-top:0}.tacit-pricing-head{color:var(--dsw-alias-label-secondary);font-weight:600;background:var(--dsw-alias-bg-layer-2)}'
3295
+ + '.tacit-pricing-cell{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tacit-pricing-model{font-weight:600}'
3296
+ + '.tacit-pricing-error{font-size:11px;color:var(--dsw-alias-state-warn-primary);line-height:1.5}'
3297
+ + '@media (max-width:640px){.tacit-settings-label{min-width:0;flex-basis:100%}.tacit-modal-cols{grid-template-columns:1fr}.tacit-usage-row{display:flex;flex-direction:column}.tacit-usage-cell{white-space:normal}.tacit-pricing-row{display:flex;flex-direction:column}.tacit-pricing-cell{white-space:normal}.tacit-tile{flex-basis:45%}.tacit-breakdown-row{grid-template-columns:minmax(0,1fr) 4rem 5rem}}'
1606
3298
 
1607
3299
  function injectCss() {
1608
3300
  const tagId = 'dsh-tacit/styles.css'
@@ -1620,6 +3312,9 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1620
3312
  function apply(ctx) {
1621
3313
  ctx.effect(() => ctx.locale.register(NS, { zh: DICT_ZH, en: DICT_EN }), 'dsh-tacit: dictionaries')
1622
3314
  const t = ctx.locale.bind(NS)
3315
+ // Store actions write result notices themselves — they hold the measured
3316
+ // figures — so the one bound translator is handed to them here.
3317
+ setTranslator(t)
1623
3318
  const kit = makeKit(t)
1624
3319
  injectCss()
1625
3320
 
@@ -1650,11 +3345,12 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1650
3345
  inject: (sessionId) => ({ tacitStore: storeFor(sessionId) }),
1651
3346
  }, (props) => h(PreviewOverlayView, props)))
1652
3347
 
1653
- // The post-apply 👍/👎 strip lives in the band UNDER the composer card
1654
- // (composer.dock), only while the Improve button is enabled.
3348
+ // The post-apply 👍/👎 strip lives in the full-width row ABOVE the composer
3349
+ // card (input.dock). Not composer.dock: the harness hides that one until a
3350
+ // conversation has content, which is exactly when a first draft is improved.
1655
3351
  const FeedbackStripView = FeedbackStrip(kit)
1656
- ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
1657
- name: 'conversation.composer.dock',
3352
+ ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
3353
+ name: 'conversation.input.dock',
1658
3354
  id: 'tacit-feedback',
1659
3355
  order: 10,
1660
3356
  locale: NS,
@@ -1686,6 +3382,24 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1686
3382
  closeFeedback,
1687
3383
  fadeFeedback,
1688
3384
  rootStore,
3385
+ toggleSection,
3386
+ setRootNotice,
3387
+ fetchUsage,
3388
+ setUsageFilter,
3389
+ toggleUsageRun,
3390
+ startUsagePolling,
3391
+ stopUsagePolling,
3392
+ setUsageSeries,
3393
+ refreshPricing,
3394
+ openConfirm,
3395
+ closeConfirm,
3396
+ fetchBootstrapPreview,
3397
+ clearAllRoot,
3398
+ clearUsageHistory,
3399
+ runNotice,
3400
+ fmtRate,
3401
+ UsageCard,
3402
+ PricingCard,
1689
3403
  },
1690
3404
  }
1691
3405
  },