@krtclcdy/tersio 2.16.0 → 2.18.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.
@@ -41,6 +41,18 @@
41
41
  --danger: #f87171; --danger-border: rgba(248, 113, 113, .3); --danger-soft: rgba(248, 113, 113, .07);
42
42
  --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.55);
43
43
  }
44
+ html[data-theme="dark"] .modelsSection {
45
+ --panel: #ffffff;
46
+ --ink: #18181b;
47
+ --dim: #52525b;
48
+ --line: rgba(24, 24, 27, .12);
49
+ --track: rgba(24, 24, 27, .08);
50
+ --cell-0: rgba(24, 24, 27, .07);
51
+ --accent-soft: rgba(4, 120, 87, .12);
52
+ --accent: #047857;
53
+ color-scheme: light;
54
+ }
55
+ html[data-theme="dark"] .modelsSection .brandmark.placeholder { background: #fff !important; color: #000 !important; }
44
56
  .ticker { animation: tick 36s linear infinite; will-change: transform; }
45
57
  .ticker:hover { animation-play-state: paused; }
46
58
  @keyframes tick { from { transform: translateX(0); } to { transform: translateX(-50%); } }
@@ -58,7 +70,21 @@
58
70
  .pill { display: inline-flex; align-items: center; gap: 4px; border-radius: 999px; padding: 3px 10px; font-size: 11px; white-space: nowrap; }
59
71
  .pill.good { background: var(--accent-soft); color: var(--accent); }
60
72
  .pill.bad { background: rgba(248, 113, 113, .12); color: #f87171; }
73
+ /* .pill.flat was referenced by the est. / served chips but never defined, so
74
+ they rendered as bare text. Muted fill matches the badge family. */
61
75
  .pill.flat { background: var(--track); color: var(--dim); }
76
+ /* shadcn/ui badge variants, used for per-request run status. Neutral by
77
+ default so the destructive (error) badge is the only thing that shouts. */
78
+ .badge { display: inline-flex; align-items: center; border-radius: 999px; padding: 2px 9px; font-size: 11px; line-height: 1.55; white-space: nowrap; border: 1px solid transparent; }
79
+ .badge.secondary { background: var(--track); color: var(--dim); }
80
+ .badge.outline { border-color: var(--line); color: var(--ink); background: transparent; }
81
+ .badge.destructive { background: var(--danger-soft); color: var(--danger); border-color: var(--danger-border); }
82
+ #tip .tnote { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--line); font-size: 11px; line-height: 1.5; color: var(--dim); white-space: normal; word-break: break-word; }
83
+ .zface { display: inline-block; vertical-align: -2px; width: 14px; height: 14px; }
84
+ .zface.good { color: var(--accent); }
85
+ .zface.warn { color: #fbbf24; }
86
+ .zface.bad { color: #f87171; }
87
+ #tip .tvr .zface { vertical-align: -3px; }
62
88
  .fxbtn { background: var(--track); color: var(--ink); border: 0; cursor: pointer; gap: 6px; }
63
89
  .fxbtn:focus-visible { outline: 1px solid var(--accent); }
64
90
  .fxbtn svg { transition: transform .15s; }
@@ -97,7 +123,7 @@
97
123
  .seg button.on { background: var(--accent-soft); color: var(--ink); }
98
124
  .seg button:not(.on):hover { color: var(--ink); }
99
125
  .mrow { transition: background .25s; }
100
- .mrow:hover { box-shadow: var(--shadow-xs); }
126
+ .mrow:hover { background: var(--track); box-shadow: var(--shadow-xs); }
101
127
  .stripcell { transition: background .25s; }
102
128
  .stripcell:hover { box-shadow: var(--shadow-xs); }
103
129
  button, .tilt, .cell, .stripcell { cursor: pointer; }
@@ -117,25 +143,33 @@
117
143
  .cell.l1 { background: var(--cell-1); } .cell.l2 { background: var(--cell-2); }
118
144
  .cell.l3 { background: var(--cell-3); } .cell.l4 { background: var(--cell-4); }
119
145
  .brandmark { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; flex-shrink: 0; color: #fff; }
146
+ .brandmark.placeholder { background: transparent !important; border: 1px solid var(--line); color: var(--dim); }
147
+ html[data-theme="dark"] .brandmark.placeholder { background: #fff !important; color: #000 !important; }
120
148
  .brandmark img { width: 18px; height: 18px; }
149
+ .brandmark svg { width: 18px; height: 18px; display: block; }
121
150
  .brandmark.sm { width: 28px; height: 28px; border-radius: 8px; }
122
151
  .brandmark.sm img { width: 14px; height: 14px; }
152
+ .brandmark.sm svg { width: 14px; height: 14px; }
123
153
  .brandmark .fb { display: none; width: 18px; height: 18px; }
124
154
  .brandmark.sm .fb { width: 14px; height: 14px; }
125
155
  .ghostimg { position: absolute; right: -6px; bottom: -10px; width: 88px; height: 88px;
126
156
  opacity: .08; pointer-events: none; user-select: none; }
157
+ span.ghostimg { display: grid; place-items: center; }
158
+ span.ghostimg svg { width: 72px; height: 72px; display: block; }
159
+ .ghosticon { position: absolute; right: 4px; bottom: -6px; width: 72px; height: 72px;
160
+ opacity: .06; pointer-events: none; user-select: none; color: var(--dim); }
127
161
  @media (prefers-color-scheme: light) {
128
162
  :root:not([data-theme="dark"]) .ghostimg { opacity: .06; filter: brightness(0); }
129
163
  }
130
164
  html[data-theme="light"] .ghostimg { opacity: .06; filter: brightness(0); }
131
- #tip { position: fixed; z-index: 100; pointer-events: none; min-width: 230px; max-width: 300px;
165
+ #tip { position: fixed; z-index: 100; pointer-events: none; min-width: 250px; max-width: 370px;
132
166
  background: var(--panel);
133
167
  border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px;
134
168
  box-shadow: var(--shadow-xs); opacity: 0; transition: opacity .15s; }
135
169
  #tip.show { opacity: 1; }
136
170
  #tip .tt { font-size: 11px; text-transform: uppercase; letter-spacing: .14em; font-weight: 700; }
137
171
  #tip .tv { font-size: 13px; margin: 2px 0 8px; }
138
- #tip .tr { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 2.5px 0; }
172
+ #tip .tr { display: flex; align-items: center; gap: 10px; font-size: 12px; padding: 3px 0; }
139
173
  #tip .sw { width: 9px; height: 9px; border-radius: 2.5px; flex-shrink: 0; }
140
174
  #tip .tn { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
141
175
  .scrollarea { overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--line) transparent; }
@@ -143,9 +177,9 @@
143
177
  .scrollarea::-webkit-scrollbar-thumb { background: var(--line); border-radius: 99px; }
144
178
  .scrollarea::-webkit-scrollbar-track { background: transparent; }
145
179
  .rrow { transition: background .2s; cursor: pointer; }
146
- .rrow:hover { box-shadow: var(--shadow-xs); }
180
+ .rrow:hover { background: var(--track); box-shadow: var(--shadow-xs); }
147
181
  #recentTable thead th { position: sticky; top: 0; background: var(--panel); z-index: 1; }
148
- #tip .tvr { flex-shrink: 0; }
182
+ #tip .tvr { flex-shrink: 0; white-space: nowrap; font-variant-numeric: tabular-nums; }
149
183
  .modelcard { transition: background .25s; }
150
184
  .modelcard:hover { box-shadow: var(--shadow-xs); }
151
185
 
@@ -46,7 +46,7 @@
46
46
  <div class="dlg-head">
47
47
  <div class="min-w-0">
48
48
  <p class="dlg-title" id="settingsTitle">Settings</p>
49
- <p class="dlg-desc">Appearance, data, and where statistics live.</p>
49
+ <p class="dlg-desc">Appearance, data, and where statistics live. <span id="verChip" class="mono">tersio</span></p>
50
50
  </div>
51
51
  <button id="settingsClose" class="btn-push flex shrink-0 items-center p-2 rounded-xl" style="border: 1px solid var(--line); color: var(--ink)" aria-label="Close settings">
52
52
  <i data-lucide="x" class="size-4"></i>
@@ -193,48 +193,64 @@
193
193
  </section>
194
194
 
195
195
  <!-- Top models: 3 columns -->
196
- <section class="mt-8" aria-label="Top models">
196
+ <section class="mt-8 modelsSection" aria-label="Top models">
197
197
  <div class="flex items-center gap-2 mb-1">
198
198
  <i data-lucide="cpu" class="size-4" style="color: var(--accent)"></i>
199
199
  <h2 class="display font-bold tracking-tight text-xl truncate min-w-0">Top models</h2>
200
200
  <span class="mono text-xs ml-auto" style="color: var(--dim)">usage across sessions</span>
201
201
  </div>
202
- <p class="mono text-xs mb-4" style="color: var(--dim)">week-over-week vs prior 7 days</p>
203
202
  <div class="grid grid-cols-1 md:grid-cols-3 gap-3" id="modelCards"></div>
204
203
  </section>
205
204
 
206
205
 
207
- <!-- Models + recent: twin cards -->
208
- <div class="rise grid grid-cols-12 grid-flow-dense gap-3 mt-8">
209
- <section class="col-span-12 lg:col-span-7 rounded-xl p-5 overflow-hidden" style="border: 1px solid var(--line); background: var(--panel)" aria-label="All models">
206
+ <!-- Models: own full-width section (split out of the old 50/50 twin card) -->
207
+ <section class="rise mt-8 rounded-xl p-5 overflow-hidden flex flex-col modelsSection" style="border: 1px solid var(--line); background: var(--panel)" aria-label="All models">
210
208
  <div class="flex items-center gap-2 mb-1">
211
209
  <i data-lucide="layers" class="size-4" style="color: var(--accent)"></i>
212
210
  <h2 class="display font-bold tracking-tight text-xl truncate min-w-0">Models</h2>
213
211
  <span class="mono text-xs ml-auto truncate shrink-0" style="color: var(--dim)" id="modelsCount"></span>
214
212
  </div>
215
- <p class="mono text-xs mb-4" style="color: var(--dim)">ranked by tokens / top 8</p>
216
- <div class="scrollarea" style="max-height:410px"><ol id="models" class="overflow-hidden"></ol></div>
213
+ <p class="mono text-xs mb-4" style="color: var(--dim)">ranked by tokens / top 10</p>
214
+ <div class="scrollarea"><ol id="models" class="overflow-hidden"></ol></div>
215
+ <div class="mono text-xs mt-4 pt-4 flex flex-wrap items-center gap-x-4 gap-y-3 mt-auto" style="color: var(--dim); border-top: 1px solid var(--line)">
216
+ <span id="modelRange">Showing 0 of 0</span>
217
+ <span class="seg flex items-center gap-1 p-1 rounded-[10px]" style="border: 1px solid var(--line)" role="group" aria-label="Models per page">
218
+ <button data-pp="10" class="on px-2.5 py-1" role="button">10</button>
219
+ <button data-pp="15" class="px-2.5 py-1" role="button">15</button>
220
+ <button data-pp="25" class="px-2.5 py-1" role="button">25</button>
221
+ <button data-pp="50" class="px-2.5 py-1" role="button">50</button>
222
+ </span>
223
+ <span class="ml-auto flex items-center gap-1" id="modelPages" role="navigation" aria-label="Model pages"></span>
224
+ </div>
217
225
  <div id="emptyModels" class="empty hidden">
218
226
  <span class="empty-icon"><i data-lucide="boxes" class="size-5"></i></span>
219
227
  <p class="empty-title">No models yet</p>
220
228
  <p class="empty-desc">Model token totals will appear here once sessions report tokens.</p>
221
229
  </div>
222
230
  </section>
223
- <section class="col-span-12 lg:col-span-5 rounded-xl p-5 overflow-hidden" style="border: 1px solid var(--line); background: var(--panel)" aria-label="Recent requests">
231
+
232
+ <!-- Recent requests: full-width, sitting with the other per-request views
233
+ right after Models. When is absolutely stamped, so the column reads as
234
+ a real timeline and sorts honestly against Time. -->
235
+ <section class="rise mt-8 rounded-xl p-5 overflow-hidden flex flex-col" style="border: 1px solid var(--line); background: var(--panel); scroll-margin-top: 90px" aria-label="Recent requests">
224
236
  <div class="flex items-center gap-2 mb-1">
225
237
  <i data-lucide="history" class="size-4" style="color: var(--accent)"></i>
226
238
  <h2 class="display font-bold tracking-tight text-xl truncate min-w-0">Recent requests</h2>
227
239
  <span class="mono text-xs ml-auto truncate shrink-0" style="color: var(--dim)" id="recentCount"></span>
228
240
  </div>
229
- <p class="mono text-xs mb-4 truncate" style="color: var(--dim)">latest assistant messages</p>
230
- <div class="scrollarea overflow-x-auto" style="max-height:410px">
241
+ <p class="mono text-xs mb-4 truncate" style="color: var(--dim)" title="Time = elapsed time with the model; Speed = output tokens per second">latest assistant messages · local time · sort any column · cost is measured when the host recorded it</p>
242
+ <div class="scrollarea overflow-x-auto" style="max-height: 560px">
231
243
  <table class="w-full mono text-[13px]" id="recentTable">
232
- <colgroup><col><col style="width:150px"><col style="width:90px"></colgroup>
244
+ <colgroup><col><col style="width:96px"><col style="width:96px"><col style="width:120px"><col style="width:116px"><col style="width:120px"><col style="width:158px"></colgroup>
233
245
  <thead>
234
246
  <tr class="text-left text-[11px] uppercase tracking-[0.14em]" style="color: var(--dim)">
235
- <th class="font-normal py-2 pr-3">Model</th>
236
- <th class="font-normal text-right py-2 pr-3">In / Out</th>
237
- <th class="font-normal text-right py-2">When</th>
247
+ <th class="font-normal py-2 pr-3" aria-sort="none"><button class="thsort" data-sort="model">Model <span class="arr">⇅</span></button></th>
248
+ <th class="font-normal text-right py-2 pr-3" aria-sort="none"><button class="thsort num" data-sort="input">Input <span class="arr">⇅</span></button></th>
249
+ <th class="font-normal text-right py-2 pr-3" aria-sort="none"><button class="thsort num" data-sort="output">Output <span class="arr">⇅</span></button></th>
250
+ <th class="font-normal text-right py-2 pr-3" aria-sort="none"><button class="thsort num" data-sort="time" title="Elapsed time with the model / output token speed">Time <span class="arr">⇅</span></button></th>
251
+ <th class="font-normal py-2 pr-3" aria-sort="none"><button class="thsort" data-sort="status">Status <span class="arr">⇅</span></button></th>
252
+ <th class="font-normal text-right py-2 pr-3" aria-sort="none"><button class="thsort num" data-sort="cost">Cost <span class="arr">⇅</span></button></th>
253
+ <th class="font-normal text-right py-2" aria-sort="none"><button class="thsort num" data-sort="when" title="Local timestamp: month day, hour:minute:second">When <span class="arr">⇅</span></button></th>
238
254
  </tr>
239
255
  </thead>
240
256
  <tbody id="recent"></tbody>
@@ -245,24 +261,37 @@
245
261
  <p class="empty-title">No requests yet</p>
246
262
  <p class="empty-desc">Recent assistant messages will show here once sessions report tokens.</p>
247
263
  </div>
264
+ <div class="mono text-xs mt-4 pt-4 flex flex-wrap items-center gap-x-4 gap-y-3 mt-auto" style="color: var(--dim); border-top: 1px solid var(--line)">
265
+ <span id="recentRange">Showing 0 of 0</span>
266
+ <span class="seg flex items-center gap-1 p-1 rounded-[10px]" style="border: 1px solid var(--line)" role="group" aria-label="Requests per page">
267
+ <button data-pp="10" class="px-2.5 py-1" role="button">10</button>
268
+ <button data-pp="15" class="px-2.5 py-1" role="button">15</button>
269
+ <button data-pp="25" class="on px-2.5 py-1" role="button">25</button>
270
+ <button data-pp="50" class="px-2.5 py-1" role="button">50</button>
271
+ </span>
272
+ <span class="ml-auto flex items-center gap-1" id="recentPages" role="navigation" aria-label="Recent pages"></span>
273
+ </div>
248
274
  </section>
249
- </div>
250
275
 
251
276
  <!-- Command tools: own card, header outside like Models -->
252
277
  <section id="tools" class="rise mt-8 rounded-xl p-5 overflow-hidden" style="border: 1px solid var(--line); background: var(--panel); scroll-margin-top: 90px" aria-label="Command tools">
253
278
  <div class="flex items-center gap-2 mb-1">
254
279
  <i data-lucide="terminal" class="size-4" style="color: var(--accent)"></i>
255
280
  <h2 class="display font-bold tracking-tight text-xl truncate min-w-0">Command tools</h2>
256
- <span class="mono text-xs ml-auto truncate min-w-0" style="color: var(--dim)" id="toolsScope">tool calls in sessions</span>
281
+ <div class="flex items-center gap-2 ml-auto min-w-0">
282
+ <select id="cmdProject" class="mono text-xs px-2 py-1.5 rounded-[10px]" style="border: 1px solid var(--line); background: var(--panel); color: var(--ink); max-width: 190px" aria-label="Filter command tools by project"></select>
283
+ <span class="mono text-xs truncate min-w-0" style="color: var(--dim)" id="toolsScope">tool calls in sessions</span>
284
+ </div>
257
285
  </div>
258
286
  <p class="mono text-xs mb-4 truncate" style="color: var(--dim)">count · tokens saved · avg rate · avg time · share of executions</p>
259
287
  <div class="overflow-x-auto">
260
288
  <table class="w-full mono text-[13px]" id="cmdTable">
261
- <colgroup><col style="width:40px"><col><col style="width:90px"><col style="width:90px"><col style="width:90px"><col style="width:90px"><col style="width:140px"></colgroup>
289
+ <colgroup><col style="width:40px"><col><col style="width:120px"><col style="width:90px"><col style="width:90px"><col style="width:90px"><col style="width:90px"><col style="width:140px"></colgroup>
262
290
  <thead>
263
291
  <tr class="text-left text-[11px] uppercase tracking-[0.14em]" style="color: var(--dim)">
264
292
  <th class="font-normal text-right pr-3 py-2 w-10">#</th>
265
293
  <th class="font-normal py-2 pr-3" aria-sort="none"><button class="thsort" data-sort="name">Tool / Command <span class="arr">⇅</span></button></th>
294
+ <th class="font-normal py-2 pr-3" aria-sort="none"><button class="thsort" data-sort="project">Project <span class="arr">⇅</span></button></th>
266
295
  <th class="font-normal text-right py-2 pr-3" aria-sort="descending"><button class="thsort num" data-sort="count">Count <span class="arr">↓</span></button></th>
267
296
  <th class="font-normal text-right py-2 pr-3" aria-sort="none"><button class="thsort num" data-sort="saved">Saved <span class="arr">⇅</span></button></th>
268
297
  <th class="font-normal text-right py-2 pr-3" aria-sort="none"><button class="thsort num" data-sort="avgPct">Avg% <span class="arr">⇅</span></button></th>
@@ -294,7 +323,6 @@
294
323
  <footer class="mono text-xs mt-12 pt-4" style="color: var(--dim); border-top: 1px solid var(--line)">
295
324
  <div class="flex flex-wrap items-center gap-x-4 gap-y-3">
296
325
  <span>© 2026 Tersio. KurutoDenzeru. All rights reserved.</span>
297
- <span class="pill flat" id="verChip">tersio</span>
298
326
  <span class="ml-auto flex items-center gap-1" style="color: var(--ink)">
299
327
  <a href="https://github.com/KurutoDenzeru/tersio" target="_blank" rel="noopener" aria-label="GitHub" class="btn-push social grid size-8 place-items-center rounded-lg"><img height="16" width="16" src="https://cdn.jsdelivr.net/npm/simple-icons@v16/icons/github.svg" alt="GitHub"></a>
300
328
  <a href="https://linkedin.com/in/kurtcalacday/" target="_blank" rel="noopener" aria-label="LinkedIn" class="btn-push social grid size-8 place-items-center rounded-lg"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg></a>
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
- import { activeModesSummary, asPromptArray, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, lastCustomValue, normalizeInputCommand, normalizeMode, paintStatusBar, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode } from '../shared/session-state.js';
3
+ import { activeModesSummary, asPromptArray, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, lastCustomValue, normalizeInputCommand, normalizeMode, paintStatusBar, paintableCtx, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode } from '../shared/session-state.js';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { readCavemanDefault } from '../shared/plugin-settings.js';
6
6
  const CAVERN_DIR = dirname(fileURLToPath(import.meta.url));
@@ -46,9 +46,8 @@ export default function cavemanSessionExtension(pi) {
46
46
  let isActive = false;
47
47
  let lastCtx = undefined;
48
48
  function syncStatus(ctx) {
49
- if (ctx)
50
- lastCtx = ctx;
51
- const c = ctx || lastCtx;
49
+ lastCtx = paintableCtx(lastCtx, ctx);
50
+ const c = lastCtx;
52
51
  if (!c?.ui?.setStatus)
53
52
  return;
54
53
  // Combo owns the bar when any preset is active; keep ours empty to avoid duplication.
@@ -3,7 +3,7 @@
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { createRequire } from 'node:module';
6
- import { activeModesSummary, asPromptArray, COMBO_LEVELS, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, normalizeComboLevel, reconcileSharedComboEntries, sessionEntries, setSharedComboLevel, setSharedComboListener, setSharedComboMode, systemPromptIncludes, } from '../shared/session-state.js';
6
+ import { activeModesSummary, asPromptArray, COMBO_LEVELS, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, normalizeComboLevel, paintableCtx, reconcileSharedComboEntries, sessionEntries, setSharedComboLevel, setSharedComboListener, setSharedComboMode, systemPromptIncludes, } from '../shared/session-state.js';
7
7
  import { readComboDefault, readPonytailDefault } from '../shared/plugin-settings.js';
8
8
  const require = createRequire(import.meta.url);
9
9
  const PONYTAIL_FALLBACK_INTENSITY = {
@@ -31,9 +31,8 @@ export default function comboToggleExtension(pi) {
31
31
  pi.setLabel?.('Combo session toggle (all 3 add-ons)');
32
32
  let lastCtx = undefined;
33
33
  function syncStatus(ctx) {
34
- if (ctx)
35
- lastCtx = ctx;
36
- const c = ctx || lastCtx;
34
+ lastCtx = paintableCtx(lastCtx, ctx);
35
+ const c = lastCtx;
37
36
  if (!c?.ui?.setStatus)
38
37
  return;
39
38
  // Single unified bar replaces the three per-extension bars while a preset is
@@ -1,6 +1,6 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
- import { activeModesSummary, asPromptArray, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, lastCustomValue, normalizeInputCommand, paintStatusBar, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode } from '../shared/session-state.js';
3
+ import { activeModesSummary, asPromptArray, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, lastCustomValue, normalizeInputCommand, paintStatusBar, paintableCtx, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode } from '../shared/session-state.js';
4
4
  import { readRtkDefault } from '../shared/plugin-settings.js';
5
5
  const DEFAULT_ENABLED = false;
6
6
  function asBoolean(value) {
@@ -27,9 +27,8 @@ export default function rtkSessionExtension(pi) {
27
27
  let isActive = false;
28
28
  let lastCtx = undefined;
29
29
  function syncStatus(ctx) {
30
- if (ctx)
31
- lastCtx = ctx;
32
- const c = ctx || lastCtx;
30
+ lastCtx = paintableCtx(lastCtx, ctx);
31
+ const c = lastCtx;
33
32
  if (!c?.ui?.setStatus)
34
33
  return;
35
34
  // Combo owns the bar when any preset is active; keep ours empty to avoid duplication.
@@ -7,11 +7,25 @@
7
7
  // Caveman and Ponytail have no per-command counters (instruction-following
8
8
  // isn't metered); their gains are bench-measured in BENCHMARK.md and cited
9
9
  // as static figures wherever RTK rows appear.
10
+ //
11
+ // Rows are grouped by (command, project) rather than command alone: RTK's
12
+ // history is machine-wide, so fusing projects made one busy repo's `rtk grep`
13
+ // count as everyone's and let high-saving commands from other projects crowd
14
+ // every other project out of the table entirely.
10
15
  import { execFileSync } from 'node:child_process';
11
16
  import fs from 'node:fs';
12
17
  import os from 'node:os';
13
18
  import path from 'node:path';
14
19
  const EMPTY = { commands: 0, saved: 0, input: 0, avgPct: 0, totalMs: 0, byCommand: [] };
20
+ // RTK stores the whole command line, and `rtk ls -d` with a long path list
21
+ // runs to tens of KB. The table shows one clipped cell, so bound the string
22
+ // before it crosses the sqlite3 CLI and lands in every /data.json poll.
23
+ const MAX_CMD_CHARS = 200;
24
+ // The query() reader is line- and tab-delimited, and RTK stores heredocs and
25
+ // `node -e` scripts verbatim (~67 rows here), so one logical row can arrive as
26
+ // several lines and derail every row after it. Fold whitespace into spaces
27
+ // first: the stored command keeps its newlines, only the read view flattens.
28
+ const CMD_ONE_LINE = `replace(replace(replace(rtk_cmd, char(10), ' '), char(13), ' '), char(9), ' ')`;
15
29
  export function rtkDbPath() {
16
30
  const override = process.env.TERSIO_RTK_DB;
17
31
  if (override)
@@ -44,8 +58,9 @@ export function readRtkGain(limit = 10, cutoffMs) {
44
58
  ? `WHERE CAST(strftime('%s', timestamp) AS INTEGER) >= ${Math.floor(cutoffMs / 1000)}`
45
59
  : '';
46
60
  const [[commands = '0', saved = '0', input = '0', avgPct = '0', totalMs = '0'] = []] = query(rtkDbPath(), `SELECT COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(SUM(input_tokens),0), COALESCE(AVG(savings_pct),0), COALESCE(SUM(exec_time_ms),0) FROM commands ${where};`);
47
- const byCommand = query(rtkDbPath(), `SELECT rtk_cmd, COUNT(*), SUM(saved_tokens), AVG(savings_pct), AVG(exec_time_ms) FROM commands ${where} GROUP BY rtk_cmd ORDER BY SUM(saved_tokens) DESC LIMIT ${Math.max(1, Math.floor(limit))};`).map(([command, count, savedRow, pct, ms]) => ({
61
+ const byCommand = query(rtkDbPath(), `SELECT substr(${CMD_ONE_LINE}, 1, ${MAX_CMD_CHARS}), COALESCE(project_path, ''), COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(AVG(savings_pct),0), COALESCE(AVG(exec_time_ms),0) FROM commands ${where} GROUP BY rtk_cmd, project_path ORDER BY SUM(saved_tokens) DESC LIMIT ${Math.max(1, Math.floor(limit))};`).map(([command, project, count, savedRow, pct, ms]) => ({
48
62
  command,
63
+ project,
49
64
  count: Number(count) || 0,
50
65
  saved: Number(savedRow) || 0,
51
66
  avgPct: Number(pct) || 0,
@@ -79,6 +79,21 @@ export function paintStatusBar(ui, key, emoji, label, isActive) {
79
79
  const indicator = isActive && theme?.fg ? theme.fg('accent', emoji) : emoji;
80
80
  ui?.setStatus?.(key, theme?.fg ? `${indicator} ${theme.fg('muted', label)}` : `${indicator} ${label}`);
81
81
  }
82
+ // Which context a status bar should remember to paint through.
83
+ //
84
+ // A UI-less event — a headless child session_start, or a top-level
85
+ // session_start that fires before the TUI attaches — must never become the
86
+ // remembered context. Two things went wrong when it did: the old
87
+ // `ctx || lastCtx` fallback only triggered for a *falsy* ctx, so a truthy
88
+ // ctx without `ui` returned early anyway, and remembering it destroyed the one
89
+ // paintable context we had. Every later bare syncStatus() then went mute —
90
+ // and the bare call is the bridge-listener path, which is how a mode change
91
+ // made by a sibling extension reaches this bar. The symptom is a bar frozen on
92
+ // a preset the session no longer has: it shows "combo BALANCED" while caveman
93
+ // and rtk are already off.
94
+ export function paintableCtx(remembered, next) {
95
+ return next?.ui?.setStatus ? next : remembered;
96
+ }
82
97
  // Last-wins scan for a custom session entry; skips entries whose value fails
83
98
  // to parse so a corrupt write never shadows an older valid one.
84
99
  export function lastCustomValue(entries, customType, pick) {
@@ -50,6 +50,42 @@ export function appendUsage(kind, detail) {
50
50
  function zeroBreakdown() {
51
51
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
52
52
  }
53
+ // Message-level generation time in ms. OMP writes `duration` (ms, float);
54
+ // codex token_count rows carry none. Non-positive/non-finite → undefined
55
+ // so renderers print – instead of a fake 0.0s.
56
+ function durOf(v) {
57
+ return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
58
+ }
59
+ // Measured cost for one message. OMP writes usage.cost as
60
+ // { input, output, cacheRead, cacheWrite, total }; a bare number shows up in
61
+ // older fixtures. Anything else means "not recorded" — deliberately not 0, so
62
+ // the dashboard can tell a genuinely free run from an unrecorded one.
63
+ function costOf(usage) {
64
+ const c = usage.cost;
65
+ if (typeof c === 'number')
66
+ return Number.isFinite(c) ? c : undefined;
67
+ if (c && typeof c === 'object') {
68
+ const total = c.total;
69
+ if (typeof total === 'number' && Number.isFinite(total))
70
+ return total;
71
+ }
72
+ return undefined;
73
+ }
74
+ // First line only: real messages carry multi-line provider errors, and this
75
+ // ends up in a tooltip.
76
+ function statusOf(msg) {
77
+ const stop = typeof msg.stopReason === 'string' ? msg.stopReason : '';
78
+ const note = typeof msg.errorMessage === 'string' && msg.errorMessage.trim()
79
+ ? msg.errorMessage.split('\n')[0].trim().slice(0, 180)
80
+ : undefined;
81
+ if (stop === 'error' || msg.isError === true) {
82
+ const code = typeof msg.errorStatus === 'number' && Number.isFinite(msg.errorStatus) ? msg.errorStatus : undefined;
83
+ return { st: 'error', code, note };
84
+ }
85
+ if (stop === 'aborted')
86
+ return { st: 'aborted', note };
87
+ return { st: 'completed', note };
88
+ }
53
89
  function addInto(into, u) {
54
90
  for (const k of ['input', 'output', 'cacheRead', 'cacheWrite']) {
55
91
  const v = u[k];
@@ -98,6 +134,10 @@ function walkJsonl(dir, out, cap) {
98
134
  out.push(full);
99
135
  }
100
136
  }
137
+ // Recent requests are their own full-width table in the dashboard, so this is
138
+ // a payload bound rather than a "few highlights" bound. Rows are small
139
+ // (model, tokens, timing, status), so a few hundred cost little to ship.
140
+ const RECENT_LIMIT = 200;
101
141
  export function importSessionTokens() {
102
142
  const watermark = readResetWatermark();
103
143
  const byModel = {};
@@ -118,8 +158,7 @@ export function importSessionTokens() {
118
158
  let codexProvider = null;
119
159
  const recent = [];
120
160
  // Rows from before the reset watermark (and rows with no usable timestamp,
121
- // which may predate it) stay out of every derived statistic.
122
- function ingest(model, usage, ts) {
161
+ function ingest(model, usage, ts, durMs, run) {
123
162
  const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
124
163
  if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark))
125
164
  return false;
@@ -128,8 +167,22 @@ export function importSessionTokens() {
128
167
  addInto(byModel[model], usage);
129
168
  byModelMessages[model] = (byModelMessages[model] ?? 0) + 1;
130
169
  const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
131
- if (Number.isFinite(ms))
132
- recent.push({ m: model, i: num(usage.input), o: num(usage.output), t: ms });
170
+ if (Number.isFinite(ms)) {
171
+ const outcome = run ?? { st: 'completed' };
172
+ recent.push({
173
+ m: model,
174
+ i: num(usage.input),
175
+ o: num(usage.output),
176
+ t: ms,
177
+ d: durOf(durMs),
178
+ cr: num(usage.cacheRead),
179
+ cw: num(usage.cacheWrite),
180
+ usd: costOf(usage),
181
+ st: outcome.st,
182
+ code: outcome.code,
183
+ note: outcome.note,
184
+ });
185
+ }
133
186
  const day = ts !== undefined ? dayKey(ts) : null;
134
187
  if (day) {
135
188
  byDay[day] ??= zeroBreakdown();
@@ -179,9 +232,12 @@ export function importSessionTokens() {
179
232
  if (!msg || msg.role !== 'assistant' || !msg.usage)
180
233
  continue;
181
234
  const model = typeof msg.model === 'string' && msg.model ? msg.model : 'unknown';
182
- const counted = ingest(model, msg.usage, row.timestamp);
183
- if (counted && typeof msg.usage.cost === 'number' && Number.isFinite(msg.usage.cost))
184
- costMeasured += msg.usage.cost;
235
+ const explicitDur = msg.duration;
236
+ const computedDur = (typeof msg.completedAt === 'number' && typeof msg.timestamp === 'number') ? msg.completedAt - msg.timestamp : undefined;
237
+ const counted = ingest(model, msg.usage, row.timestamp, typeof explicitDur === 'number' ? explicitDur : computedDur, statusOf(msg));
238
+ const measured = costOf(msg.usage);
239
+ if (counted && measured !== undefined)
240
+ costMeasured += measured;
185
241
  if (!counted)
186
242
  continue;
187
243
  for (const part of msg.content ?? []) {
@@ -195,7 +251,7 @@ export function importSessionTokens() {
195
251
  }
196
252
  }
197
253
  recent.sort((a, b) => b.t - a.t);
198
- return { messages, totals, byModel, byDay, byDayModel, byTool, byModelMessages, costMeasured, recent: recent.slice(0, 25) };
254
+ return { messages, totals, byModel, byDay, byDayModel, byTool, byModelMessages, costMeasured, recent: recent.slice(0, RECENT_LIMIT) };
199
255
  }
200
256
  // Lead binary of a shell string: first segment head past `cd` chains and
201
257
  // VAR=x assignments (`cd /x && git status` → `git`). Falls back to `bash`.
@@ -222,9 +278,16 @@ export { co2GramsFor, energyWhFor };
222
278
  export function usdCost(t, model) {
223
279
  const { price, known } = model ? priceFor(model) : { price: DEFAULT_PRICE, known: false };
224
280
  const m = 1 / 1_000_000;
281
+ const buckets = {
282
+ input: t.input * price.input * m,
283
+ output: t.output * price.output * m,
284
+ cacheRead: t.cacheRead * price.cacheRead * m,
285
+ cacheWrite: t.cacheWrite * price.cacheWrite * m,
286
+ };
225
287
  return {
226
- usd: (t.input * price.input + t.output * price.output + t.cacheRead * price.cacheRead + t.cacheWrite * price.cacheWrite) * m,
288
+ usd: buckets.input + buckets.output + buckets.cacheRead + buckets.cacheWrite,
227
289
  priced: model ? known : false,
290
+ buckets,
228
291
  };
229
292
  }
230
293
  // CO2 now model-differentiated via ./carbon.ts (EcoLogits 0.8.2 port).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krtclcdy/tersio",
3
- "version": "2.16.0",
3
+ "version": "2.18.0",
4
4
  "description": "✂️ Token-saving OMP add-ons: terse Caveman replies, compact RTK shell output, lean Ponytail code calls — one-command combo presets.",
5
5
  "keywords": [
6
6
  "omp",