dsh-code 0.6.0 → 0.7.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/README.en.md +4 -2
- package/README.md +4 -2
- package/bin/deepseek.mjs +38 -3
- package/lib/index.mjs +1485 -906
- package/lib/startup.mjs +21 -9
- package/lib/theme-BEi4i_aN.mjs +624 -0
- package/lib/types/app.d.ts +31 -3
- package/lib/types/index.d.ts +1 -0
- package/lib/types/kernel-panels.d.ts +21 -0
- package/lib/types/mentions.d.ts +29 -12
- package/lib/types/models.d.ts +66 -0
- package/lib/types/render/animations.d.ts +175 -2
- package/lib/types/render/projection.d.ts +38 -7
- package/lib/types/render/status.d.ts +34 -13
- package/lib/types/startup.d.ts +12 -4
- package/lib/types/theme-panel.d.ts +24 -0
- package/lib/types/theme.d.ts +158 -2
- package/package.json +1 -1
- package/src/app.ts +510 -130
- package/src/index.ts +964 -900
- package/src/kernel-panels.ts +481 -419
- package/src/mentions.ts +57 -27
- package/src/models.ts +200 -66
- package/src/render/animations.ts +359 -2
- package/src/render/projection.ts +764 -655
- package/src/render/status.ts +744 -603
- package/src/startup.ts +119 -109
- package/src/theme-panel.ts +72 -0
- package/src/theme.ts +206 -70
package/src/render/status.ts
CHANGED
|
@@ -1,603 +1,744 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Status-bar composition for the TUI footer. Codex/Claude-Code-style split
|
|
3
|
-
* line: identity facts and session figures flow from the left, while the
|
|
4
|
-
* permission badge (the Codex "autonomous selection" anchor, with its
|
|
5
|
-
* shift+tab cycle hint) pins to the right edge. Every segment carries a tone
|
|
6
|
-
* the footer maps to a theme color, and layoutStatusBar degrades the line
|
|
7
|
-
* item by item so it always fits one physical row — truncation with an
|
|
8
|
-
* ellipsis happens only after every lesser group has already dropped out.
|
|
9
|
-
*
|
|
10
|
-
* @module @deepseek-ai/dsh-tui/render/status
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { visibleColumns } from './markdown.ts'
|
|
14
|
-
import type { TranscriptStats } from './projection.ts'
|
|
15
|
-
import { singleLineText, truncateColumns } from './text.ts'
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Compact token count: 517 / 12.2K / 517K / 1.2M (one decimal under three
|
|
19
|
-
* digits), mirroring the web composer's StatsLine format.
|
|
20
|
-
* @param n - token count.
|
|
21
|
-
* @returns display string.
|
|
22
|
-
*/
|
|
23
|
-
export function formatTokens(n: number): string {
|
|
24
|
-
const scaled = (v: number): string =>
|
|
25
|
-
v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10)
|
|
26
|
-
if (n < 1_000) return String(n)
|
|
27
|
-
if (n < 1_000_000) return scaled(n / 1_000) + 'K'
|
|
28
|
-
return scaled(n / 1_000_000) + 'M'
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Compact duration: 45.2s under a minute, 2m42s from there on.
|
|
33
|
-
* @param ms - duration in milliseconds.
|
|
34
|
-
* @returns display string.
|
|
35
|
-
*/
|
|
36
|
-
export function formatDuration(ms: number): string {
|
|
37
|
-
const s = ms / 1_000
|
|
38
|
-
if (s < 60) return String(Math.round(s * 10) / 10) + 's'
|
|
39
|
-
const whole = Math.round(s)
|
|
40
|
-
return Math.floor(whole / 60) + 'm' + (whole % 60) + 's'
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Compact decode rate: one decimal under a hundred, whole below a thousand,
|
|
45
|
-
* then thousands (15.3 / 124 / 1.2K).
|
|
46
|
-
* @param n - tokens per second.
|
|
47
|
-
* @returns display string.
|
|
48
|
-
*/
|
|
49
|
-
export function formatRate(n: number): string {
|
|
50
|
-
if (n < 100) return String(Math.round(n * 10) / 10)
|
|
51
|
-
if (n < 1_000) return String(Math.round(n))
|
|
52
|
-
return String(Math.round(n / 100) / 10) + 'K'
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Cache-hit share of billed prompt-side input.
|
|
57
|
-
* @param usage - cumulative token totals.
|
|
58
|
-
* @returns rounded integer percent, or null when no input was billed.
|
|
59
|
-
*/
|
|
60
|
-
export function cacheHitPercent(usage: TranscriptStats['usage']): number | null {
|
|
61
|
-
return usage.inputTokens === 0
|
|
62
|
-
? null
|
|
63
|
-
: Math.round(usage.cacheReadTokens / usage.inputTokens * 100)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Presentation tones for status spans; the footer maps each to a theme color
|
|
68
|
-
* (Codex status-line accents: model/path/branch/state/usage categories).
|
|
69
|
-
*/
|
|
70
|
-
export type StatusTone =
|
|
71
|
-
| 'model'
|
|
72
|
-
| 'live'
|
|
73
|
-
| 'path'
|
|
74
|
-
| 'branch'
|
|
75
|
-
| 'value'
|
|
76
|
-
| 'label'
|
|
77
|
-
| 'meta'
|
|
78
|
-
| 'accent'
|
|
79
|
-
| 'success'
|
|
80
|
-
| 'warn'
|
|
81
|
-
| 'error'
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
export
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
const
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
const
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
if (
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
const
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
|
|
577
|
-
const
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
},
|
|
602
|
-
}
|
|
603
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Status-bar composition for the TUI footer. Codex/Claude-Code-style split
|
|
3
|
+
* line: identity facts and session figures flow from the left, while the
|
|
4
|
+
* permission badge (the Codex "autonomous selection" anchor, with its
|
|
5
|
+
* shift+tab cycle hint) pins to the right edge. Every segment carries a tone
|
|
6
|
+
* the footer maps to a theme color, and layoutStatusBar degrades the line
|
|
7
|
+
* item by item so it always fits one physical row — truncation with an
|
|
8
|
+
* ellipsis happens only after every lesser group has already dropped out.
|
|
9
|
+
*
|
|
10
|
+
* @module @deepseek-ai/dsh-tui/render/status
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { visibleColumns } from './markdown.ts'
|
|
14
|
+
import type { ContextSegments, TranscriptStats } from './projection.ts'
|
|
15
|
+
import { singleLineText, truncateColumns } from './text.ts'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Compact token count: 517 / 12.2K / 517K / 1.2M (one decimal under three
|
|
19
|
+
* digits), mirroring the web composer's StatsLine format.
|
|
20
|
+
* @param n - token count.
|
|
21
|
+
* @returns display string.
|
|
22
|
+
*/
|
|
23
|
+
export function formatTokens(n: number): string {
|
|
24
|
+
const scaled = (v: number): string =>
|
|
25
|
+
v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10)
|
|
26
|
+
if (n < 1_000) return String(n)
|
|
27
|
+
if (n < 1_000_000) return scaled(n / 1_000) + 'K'
|
|
28
|
+
return scaled(n / 1_000_000) + 'M'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Compact duration: 45.2s under a minute, 2m42s from there on.
|
|
33
|
+
* @param ms - duration in milliseconds.
|
|
34
|
+
* @returns display string.
|
|
35
|
+
*/
|
|
36
|
+
export function formatDuration(ms: number): string {
|
|
37
|
+
const s = ms / 1_000
|
|
38
|
+
if (s < 60) return String(Math.round(s * 10) / 10) + 's'
|
|
39
|
+
const whole = Math.round(s)
|
|
40
|
+
return Math.floor(whole / 60) + 'm' + (whole % 60) + 's'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Compact decode rate: one decimal under a hundred, whole below a thousand,
|
|
45
|
+
* then thousands (15.3 / 124 / 1.2K).
|
|
46
|
+
* @param n - tokens per second.
|
|
47
|
+
* @returns display string.
|
|
48
|
+
*/
|
|
49
|
+
export function formatRate(n: number): string {
|
|
50
|
+
if (n < 100) return String(Math.round(n * 10) / 10)
|
|
51
|
+
if (n < 1_000) return String(Math.round(n))
|
|
52
|
+
return String(Math.round(n / 100) / 10) + 'K'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Cache-hit share of billed prompt-side input.
|
|
57
|
+
* @param usage - cumulative token totals.
|
|
58
|
+
* @returns rounded integer percent, or null when no input was billed.
|
|
59
|
+
*/
|
|
60
|
+
export function cacheHitPercent(usage: TranscriptStats['usage']): number | null {
|
|
61
|
+
return usage.inputTokens === 0
|
|
62
|
+
? null
|
|
63
|
+
: Math.round(usage.cacheReadTokens / usage.inputTokens * 100)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Presentation tones for status spans; the footer maps each to a theme color
|
|
68
|
+
* (Codex status-line accents: model/path/branch/state/usage categories).
|
|
69
|
+
*/
|
|
70
|
+
export type StatusTone =
|
|
71
|
+
| 'model'
|
|
72
|
+
| 'live'
|
|
73
|
+
| 'path'
|
|
74
|
+
| 'branch'
|
|
75
|
+
| 'value'
|
|
76
|
+
| 'label'
|
|
77
|
+
| 'meta'
|
|
78
|
+
| 'accent'
|
|
79
|
+
| 'success'
|
|
80
|
+
| 'warn'
|
|
81
|
+
| 'error'
|
|
82
|
+
// Context-bar segment tones, one DeepSeek blue shade per content type
|
|
83
|
+
// (dark → light: system/prompt/assistant/thinking/tools). Only the
|
|
84
|
+
// context bar emits them; the footer maps each to an existing theme color.
|
|
85
|
+
| 'ctxSystem'
|
|
86
|
+
| 'ctxPrompt'
|
|
87
|
+
| 'ctxAssistant'
|
|
88
|
+
| 'ctxThinking'
|
|
89
|
+
| 'ctxTools'
|
|
90
|
+
|
|
91
|
+
/** One colored run inside the status bar. */
|
|
92
|
+
export interface StatusSpan {
|
|
93
|
+
text: string
|
|
94
|
+
tone: StatusTone
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* One pipe-separated cluster on the leading side of the bar. Spans are the
|
|
99
|
+
* full visual sequence: junction separators ride along as their own dim
|
|
100
|
+
* 'label'-tone spans, so joining is a flat concat with no implicit glue.
|
|
101
|
+
*/
|
|
102
|
+
export interface StatusGroup {
|
|
103
|
+
spans: readonly StatusSpan[]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One physical row of the footer: leading clusters and trailing badges. */
|
|
107
|
+
export interface StatusRow {
|
|
108
|
+
/** Leading clusters, pipe-separated in display order; index 0 is identity. */
|
|
109
|
+
left: readonly StatusGroup[]
|
|
110
|
+
/** Trailing spans pinned to the right edge, dot-separated in display order. */
|
|
111
|
+
right: readonly StatusSpan[]
|
|
112
|
+
/** Whether the shift+tab cycle hint rides after the permission badge. */
|
|
113
|
+
hint: boolean
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The footer layout: two stacked physical rows. Row 1 is the identity/state
|
|
118
|
+
* row (busy dot, model, cwd, branch, plan, turns, tokens, title; goal,
|
|
119
|
+
* sandbox, and permission badges). Row 2 is the run-meters row (mode, the
|
|
120
|
+
* context progress bar, cache, and duration figures) and degrades to empty
|
|
121
|
+
* before any row-1 content is touched.
|
|
122
|
+
*/
|
|
123
|
+
export interface StatusLayout {
|
|
124
|
+
row1: StatusRow
|
|
125
|
+
row2: StatusRow
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Separator between leading clusters. */
|
|
129
|
+
export const STATUS_GROUP_SEPARATOR = ' | '
|
|
130
|
+
/** Separator between trailing state spans. */
|
|
131
|
+
export const STATUS_ITEM_SEPARATOR = ' · '
|
|
132
|
+
/** The Codex-style mode cycle hint appended to the permission badge. */
|
|
133
|
+
export const STATUS_CYCLE_HINT = ' (shift+tab to cycle)'
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Interior columns of the segmented context bar (content-type segments plus
|
|
137
|
+
* the free tail whose right edge carries the usage readout). Fixed so the
|
|
138
|
+
* row-2 drop ladder can pre-measure the group; the bar shrinks its labels and
|
|
139
|
+
* readout inside this budget rather than asking the layout for more room.
|
|
140
|
+
*/
|
|
141
|
+
export const CONTEXT_BAR_WIDTH = 24
|
|
142
|
+
/** Occupancy at which the usage readout flips from brand blue to amber. */
|
|
143
|
+
export const CONTEXT_WARN_PERCENT = 90
|
|
144
|
+
/** Free-tail floor in columns: wide enough for the bare percent readout, so
|
|
145
|
+
* the warning stays visible even at 100%+ occupancy. */
|
|
146
|
+
const CONTEXT_MIN_FREE = 5
|
|
147
|
+
|
|
148
|
+
/** One content-type segment of the context bar (pure data; colors live in app.ts). */
|
|
149
|
+
export interface ContextSegmentSpec {
|
|
150
|
+
key: keyof ContextSegments
|
|
151
|
+
/** Tone the footer maps to a DeepSeek blue shade. */
|
|
152
|
+
tone: StatusTone
|
|
153
|
+
/** Labels longest → shortest; the first one fitting the segment width wins. */
|
|
154
|
+
labels: readonly string[]
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The five content types in conversation order, dark → light blue. */
|
|
158
|
+
export const CONTEXT_SEGMENTS: readonly ContextSegmentSpec[] = [
|
|
159
|
+
{ key: 'system', tone: 'ctxSystem', labels: ['system', 'sys', 's'] },
|
|
160
|
+
{ key: 'prompt', tone: 'ctxPrompt', labels: ['prompt', 'pr', 'p'] },
|
|
161
|
+
{ key: 'assistant', tone: 'ctxAssistant', labels: ['assistant', 'ast', 'a'] },
|
|
162
|
+
{ key: 'thinking', tone: 'ctxThinking', labels: ['think', 'th', 't'] },
|
|
163
|
+
{ key: 'tools', tone: 'ctxTools', labels: ['tools', 'tl', 'x'] },
|
|
164
|
+
] as const
|
|
165
|
+
|
|
166
|
+
/** First label whose visible width fits the segment; empty only when none do. */
|
|
167
|
+
function chooseContextLabel(labels: readonly string[], width: number): string {
|
|
168
|
+
for (const label of labels) {
|
|
169
|
+
if (visibleColumns(label) <= width) return label
|
|
170
|
+
}
|
|
171
|
+
return ''
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Center a label inside its segment width; the padding spaces carry the tone. */
|
|
175
|
+
function centerInSegment(text: string, width: number): string {
|
|
176
|
+
const textWidth = visibleColumns(text)
|
|
177
|
+
const left = Math.floor((width - textWidth) / 2)
|
|
178
|
+
return ' '.repeat(Math.max(0, left)) + text + ' '.repeat(Math.max(0, width - textWidth - left))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Largest-remainder allocation: distribute `columns` across `values`
|
|
183
|
+
* proportionally, handing each leftover column to the largest remainder.
|
|
184
|
+
*/
|
|
185
|
+
function allocateProportionally(values: readonly number[], columns: number): number[] {
|
|
186
|
+
if (columns <= 0) return values.map(() => 0)
|
|
187
|
+
const total = values.reduce((sum, value) => sum + value, 0)
|
|
188
|
+
if (total <= 0) return values.map(() => 0)
|
|
189
|
+
const raw = values.map(value => value / total * columns)
|
|
190
|
+
const allocated = raw.map(Math.floor)
|
|
191
|
+
let remaining = columns - allocated.reduce((sum, value) => sum + value, 0)
|
|
192
|
+
const remainders = raw
|
|
193
|
+
.map((value, index) => ({ index, remainder: value - Math.floor(value) }))
|
|
194
|
+
.sort((left, right) => right.remainder - left.remainder)
|
|
195
|
+
for (const slot of remainders) {
|
|
196
|
+
if (remaining <= 0) break
|
|
197
|
+
allocated[slot.index] = (allocated[slot.index] ?? 0) + 1
|
|
198
|
+
remaining -= 1
|
|
199
|
+
}
|
|
200
|
+
return allocated
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Bar column widths: every non-zero segment keeps at least one column before
|
|
204
|
+
* the remaining columns share by token proportion. */
|
|
205
|
+
function allocateBarColumns(values: readonly number[], width: number): number[] {
|
|
206
|
+
const visible = values
|
|
207
|
+
.map((value, index) => (value > 0 ? index : -1))
|
|
208
|
+
.filter(index => index >= 0)
|
|
209
|
+
if (visible.length === 0 || visible.length >= width) {
|
|
210
|
+
return allocateProportionally(values, width)
|
|
211
|
+
}
|
|
212
|
+
const minimum = values.map(() => 0)
|
|
213
|
+
for (const index of visible) minimum[index] = 1
|
|
214
|
+
const remaining = allocateProportionally(values, width - visible.length)
|
|
215
|
+
return minimum.map((min, index) => min + (remaining[index] ?? 0))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Render context occupancy as a segmented bar: one DeepSeek-blue run per
|
|
220
|
+
* content type (system/prompt/assistant/thinking/tools), column widths
|
|
221
|
+
* proportional to their estimated token share, each with a centered label
|
|
222
|
+
* that shortens to fit (system→sys→s). The remaining free tail is a dim
|
|
223
|
+
* track whose right edge carries the usage readout (`12.3K/1.0M 25%`,
|
|
224
|
+
* shrinking to the bare percent as the tail narrows). The readout flips to
|
|
225
|
+
* amber once occupancy reaches the warning threshold; the segment blues stay
|
|
226
|
+
* untouched so the composition remains readable at full context. The used
|
|
227
|
+
* total comes from the reported `lastPromptTokens`, never from the estimates.
|
|
228
|
+
* @param segments - estimated used tokens per content type.
|
|
229
|
+
* @param usedTokens - reported used tokens (drives the readout and percent).
|
|
230
|
+
* @param contextWindow - route capacity.
|
|
231
|
+
* @param width - total bar interior columns.
|
|
232
|
+
* @returns tone-split spans for the footer to paint.
|
|
233
|
+
*/
|
|
234
|
+
export function contextBar(
|
|
235
|
+
segments: ContextSegments,
|
|
236
|
+
usedTokens: number,
|
|
237
|
+
contextWindow: number,
|
|
238
|
+
width: number,
|
|
239
|
+
): readonly StatusSpan[] {
|
|
240
|
+
if (width <= 0 || contextWindow <= 0) return []
|
|
241
|
+
const used = Math.max(0, usedTokens)
|
|
242
|
+
const percent = Math.round(used / contextWindow * 100)
|
|
243
|
+
const warning = percent >= CONTEXT_WARN_PERCENT
|
|
244
|
+
const readoutTone: StatusTone = warning ? 'warn' : 'value'
|
|
245
|
+
|
|
246
|
+
const freeShare = Math.round(Math.max(0, contextWindow - used) / contextWindow * width)
|
|
247
|
+
const freeColumns = Math.min(width, Math.max(freeShare, CONTEXT_MIN_FREE))
|
|
248
|
+
const usedColumns = Math.max(0, width - freeColumns)
|
|
249
|
+
|
|
250
|
+
const total = `${formatTokens(used)}/${formatTokens(contextWindow)}`
|
|
251
|
+
const percentText = `${percent}%`
|
|
252
|
+
const readout = freeColumns >= visibleColumns(`${total} ${percentText}`)
|
|
253
|
+
? `${total} ${percentText}`
|
|
254
|
+
: freeColumns >= visibleColumns(percentText)
|
|
255
|
+
? percentText
|
|
256
|
+
: ''
|
|
257
|
+
|
|
258
|
+
const values = CONTEXT_SEGMENTS.map(segment => segments[segment.key])
|
|
259
|
+
const estimated = values.reduce((sum, value) => sum + value, 0)
|
|
260
|
+
// No estimate yet (a resumed session before any text-bearing event): treat
|
|
261
|
+
// the whole reported used context as one prompt segment instead of an
|
|
262
|
+
// empty bar.
|
|
263
|
+
const allocation = allocateBarColumns(
|
|
264
|
+
estimated > 0 ? values : [0, used, 0, 0, 0],
|
|
265
|
+
usedColumns,
|
|
266
|
+
)
|
|
267
|
+
const spans: StatusSpan[] = []
|
|
268
|
+
for (let index = 0; index < CONTEXT_SEGMENTS.length; index += 1) {
|
|
269
|
+
const columns = allocation[index] ?? 0
|
|
270
|
+
if (columns <= 0) continue
|
|
271
|
+
spans.push({
|
|
272
|
+
text: centerInSegment(chooseContextLabel(CONTEXT_SEGMENTS[index].labels, columns), columns),
|
|
273
|
+
tone: CONTEXT_SEGMENTS[index].tone,
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
const pad = freeColumns - visibleColumns(readout)
|
|
277
|
+
if (pad > 0) spans.push({ text: '▱'.repeat(pad), tone: 'label' })
|
|
278
|
+
if (readout !== '') spans.push({ text: readout, tone: readoutTone })
|
|
279
|
+
return spans
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* One customizable status item (the Codex /statusline picker contract).
|
|
284
|
+
* 'left' items render as pipe-separated clusters after the identity dot;
|
|
285
|
+
* 'right' items pin to the right edge as dot-separated state badges.
|
|
286
|
+
*/
|
|
287
|
+
export type StatusItemId =
|
|
288
|
+
| 'model'
|
|
289
|
+
| 'cwd'
|
|
290
|
+
| 'branch'
|
|
291
|
+
| 'plan'
|
|
292
|
+
| 'mode'
|
|
293
|
+
| 'turns'
|
|
294
|
+
| 'durations'
|
|
295
|
+
| 'cache'
|
|
296
|
+
| 'context'
|
|
297
|
+
| 'tokens'
|
|
298
|
+
| 'title'
|
|
299
|
+
| 'goal'
|
|
300
|
+
| 'sandbox'
|
|
301
|
+
| 'permission'
|
|
302
|
+
|
|
303
|
+
/** Picker-facing metadata for one customizable item. */
|
|
304
|
+
export interface StatusItemInfo {
|
|
305
|
+
id: StatusItemId
|
|
306
|
+
/** Short picker label. */
|
|
307
|
+
label: string
|
|
308
|
+
/** One-line picker description of what the item shows. */
|
|
309
|
+
description: string
|
|
310
|
+
/** Which side of the split row the item renders on. */
|
|
311
|
+
side: 'left' | 'right'
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** The full item catalog in canonical order (the /statusline default). */
|
|
315
|
+
export const STATUS_ITEMS: readonly StatusItemInfo[] = [
|
|
316
|
+
{ id: 'model', label: 'model', description: 'provider/model serving this session', side: 'left' },
|
|
317
|
+
{ id: 'cwd', label: 'cwd', description: 'working-directory basename', side: 'left' },
|
|
318
|
+
{ id: 'branch', label: 'branch', description: 'git branch inside a repository', side: 'left' },
|
|
319
|
+
{ id: 'plan', label: 'plan', description: 'plan-mode state mark', side: 'left' },
|
|
320
|
+
{ id: 'mode', label: 'mode', description: 'agent preset composing the session', side: 'left' },
|
|
321
|
+
{ id: 'turns', label: 'turns', description: 'turn and step counters', side: 'left' },
|
|
322
|
+
{ id: 'durations', label: 'durations', description: 'llm/ttft/decode/tool wall time', side: 'left' },
|
|
323
|
+
{ id: 'cache', label: 'cache', description: 'cache-hit share of billed input', side: 'left' },
|
|
324
|
+
{ id: 'context', label: 'context', description: 'context-window occupancy meter', side: 'left' },
|
|
325
|
+
{ id: 'tokens', label: 'tokens', description: 'cumulative input/output tokens', side: 'left' },
|
|
326
|
+
{ id: 'title', label: 'title', description: 'session title or short id', side: 'left' },
|
|
327
|
+
{ id: 'goal', label: 'goal', description: 'live goal phase and round progress', side: 'right' },
|
|
328
|
+
{ id: 'sandbox', label: 'sandbox', description: 'divergent sandbox-mode override', side: 'right' },
|
|
329
|
+
{ id: 'permission', label: 'permission', description: 'permission preset badge with cycle hint', side: 'right' },
|
|
330
|
+
]
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Default order: the whole catalog (matches the pre-customization bar).
|
|
334
|
+
* The busy dot is not an item — it always leads the identity cluster.
|
|
335
|
+
*/
|
|
336
|
+
export const DEFAULT_STATUSLINE_ITEMS: readonly StatusItemId[] = STATUS_ITEMS.map(item => item.id)
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Parse a persisted statusline item list. The stored value is the ordered
|
|
340
|
+
* set of ENABLED items (the Codex /statusline contract): unknown ids and
|
|
341
|
+
* duplicates drop out, and a non-array value (missing or corrupt file)
|
|
342
|
+
* falls back to the full default set. An explicitly empty array is valid —
|
|
343
|
+
* the bar degrades to its busy dot alone.
|
|
344
|
+
* @param value - the raw parsed JSON value (expected string[]).
|
|
345
|
+
* @returns the normalized ordered item list.
|
|
346
|
+
*/
|
|
347
|
+
export function parseStatuslineItems(value: unknown): readonly StatusItemId[] {
|
|
348
|
+
if (!Array.isArray(value)) return [...DEFAULT_STATUSLINE_ITEMS]
|
|
349
|
+
const known = new Set(STATUS_ITEMS.map(item => item.id))
|
|
350
|
+
const kept: StatusItemId[] = []
|
|
351
|
+
for (const entry of value) {
|
|
352
|
+
if (typeof entry === 'string' && known.has(entry as StatusItemId) && !kept.includes(entry as StatusItemId)) {
|
|
353
|
+
kept.push(entry as StatusItemId)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return kept
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Minimum blank gap kept between the leading and trailing sides. */
|
|
360
|
+
const LEFT_RIGHT_GAP = 2
|
|
361
|
+
/** Column held back so Ink/yoga measurement drift can never force a wrap. */
|
|
362
|
+
const WIDTH_SAFETY = 1
|
|
363
|
+
/** Column budget for the session title before it ellipsizes. */
|
|
364
|
+
const TITLE_BUDGET = 48
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Row 1 drop ranks (lowest drops first): the session title, then the token
|
|
368
|
+
* figures, then turn/step counts, then the goal and divergent-sandbox badges,
|
|
369
|
+
* with the permission badge last. The identity cluster never drops — it
|
|
370
|
+
* ellipsizes instead.
|
|
371
|
+
*/
|
|
372
|
+
const RANK_TITLE = 10
|
|
373
|
+
const RANK_TOKENS = 50
|
|
374
|
+
const RANK_COUNTS = 90
|
|
375
|
+
const RANK_SANDBOX = 92
|
|
376
|
+
const RANK_GOAL = 95
|
|
377
|
+
const RANK_BADGE = 100
|
|
378
|
+
const RANK_IDENTITY = Number.POSITIVE_INFINITY
|
|
379
|
+
|
|
380
|
+
/** Row 2 drop ranks: duration figures go first, then cache, then the context bar, and mode survives longest. */
|
|
381
|
+
const RANK2_DURATIONS = 40
|
|
382
|
+
const RANK2_CACHE = 50
|
|
383
|
+
const RANK2_CONTEXT = 60
|
|
384
|
+
const RANK2_MODE = 70
|
|
385
|
+
|
|
386
|
+
/** Identity facts the runner resolves once at mount; empty strings drop out. */
|
|
387
|
+
export interface StatusFacts {
|
|
388
|
+
/** 'provider/model' selection serving this session. */
|
|
389
|
+
model: string
|
|
390
|
+
/** Agent preset composing this session. */
|
|
391
|
+
mode?: string
|
|
392
|
+
/** Working-directory basename the session serves. */
|
|
393
|
+
cwd: string
|
|
394
|
+
/** Git branch name, empty outside a repository or on a detached HEAD file. */
|
|
395
|
+
branch: string
|
|
396
|
+
/** Short session identifier (last dash-separated segment or tail). */
|
|
397
|
+
sessionId: string
|
|
398
|
+
/** Latest session title (folded from 'session/title'); shown in place of the id. */
|
|
399
|
+
title: string
|
|
400
|
+
/** Sandbox-mode override (folded from 'sandbox/mode'), empty when never switched. */
|
|
401
|
+
sandbox: string
|
|
402
|
+
/** Live goal summary (folded from 'goal/change'), undefined when none. */
|
|
403
|
+
goal: { phase: string; rounds: number; max: number } | undefined
|
|
404
|
+
/** Whether plan mode is active (folded from 'plan/mode'). */
|
|
405
|
+
plan: boolean
|
|
406
|
+
/** Active permission preset (folded from 'permission/preset'), empty when unknown. */
|
|
407
|
+
permission: string
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Traffic-light tone for a permission preset: read-only stays success green,
|
|
412
|
+
* full access reads error red, and every workspace-scoped middle ground
|
|
413
|
+
* (including unknown presets) reads warning amber.
|
|
414
|
+
* @param permission - active permission preset label.
|
|
415
|
+
* @returns tone for the badge span.
|
|
416
|
+
*/
|
|
417
|
+
export function permissionTone(permission: string): StatusTone {
|
|
418
|
+
const label = permission.toLowerCase()
|
|
419
|
+
if (label.includes('read')) return 'success'
|
|
420
|
+
if (label.includes('danger') || label.includes('full')) return 'error'
|
|
421
|
+
return 'warn'
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Display-safe external text: one row, controls escaped. */
|
|
425
|
+
function safe(text: string): string {
|
|
426
|
+
return singleLineText(text)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Dim junction separator span inside a cluster. */
|
|
430
|
+
function sep(): StatusSpan {
|
|
431
|
+
return { text: ' · ', tone: 'label' }
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Total visible columns of a span list (separators ride inside the spans). */
|
|
435
|
+
function spansWidth(spans: readonly StatusSpan[]): number {
|
|
436
|
+
let width = 0
|
|
437
|
+
for (const span of spans) width += visibleColumns(span.text)
|
|
438
|
+
return width
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Join widths of parts with one fixed separator between neighbors. */
|
|
442
|
+
function joinWidth(parts: readonly number[], separator: number): number {
|
|
443
|
+
if (parts.length === 0) return 0
|
|
444
|
+
let width = 0
|
|
445
|
+
for (const part of parts) width += part
|
|
446
|
+
return width + separator * (parts.length - 1)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Build every candidate group/span with its drop rank and item id. */
|
|
450
|
+
function buildCandidates(
|
|
451
|
+
facts: StatusFacts,
|
|
452
|
+
stats: TranscriptStats,
|
|
453
|
+
busy: boolean,
|
|
454
|
+
enabled: ReadonlySet<string>,
|
|
455
|
+
): {
|
|
456
|
+
left: { group: StatusGroup; rank: number; id: string }[]
|
|
457
|
+
right: { span: StatusSpan; rank: number; id: string }[]
|
|
458
|
+
badge: number
|
|
459
|
+
row2: { group: StatusGroup; rank: number; id: string }[]
|
|
460
|
+
} {
|
|
461
|
+
const identity: StatusSpan[] = [
|
|
462
|
+
{ text: busy ? '● ' : '○ ', tone: busy ? 'live' : 'meta' },
|
|
463
|
+
]
|
|
464
|
+
// The dot glues straight to the first fact; further facts join through
|
|
465
|
+
// explicit dim separators, so an absent model never strands a leading ' · '.
|
|
466
|
+
const push = (span: StatusSpan): void => {
|
|
467
|
+
if (identity.length > 1) identity.push(sep())
|
|
468
|
+
identity.push(span)
|
|
469
|
+
}
|
|
470
|
+
const model = safe(facts.model)
|
|
471
|
+
if (model !== '' && enabled.has('model')) {
|
|
472
|
+
// The effective reasoning effort rides the model identity as
|
|
473
|
+
// `provider/model@effort` (Codex's model-with-reasoning status item): the
|
|
474
|
+
// projection folds the latest request header's effort, so a resumed
|
|
475
|
+
// session and every request after a /effort pick show what the session
|
|
476
|
+
// actually uses. An empty effort keeps the bare pair.
|
|
477
|
+
const effort = safe(stats.reasoningEffort)
|
|
478
|
+
push({ text: effort === '' ? model : `${model}@${effort}`, tone: 'model' })
|
|
479
|
+
}
|
|
480
|
+
const cwd = safe(facts.cwd)
|
|
481
|
+
if (cwd !== '' && enabled.has('cwd')) push({ text: cwd, tone: 'path' })
|
|
482
|
+
const branch = safe(facts.branch)
|
|
483
|
+
if (branch !== '' && enabled.has('branch')) push({ text: '⑂ ' + branch, tone: 'branch' })
|
|
484
|
+
if (facts.plan && enabled.has('plan')) push({ text: '⧉ plan', tone: 'accent' })
|
|
485
|
+
|
|
486
|
+
const left: { group: StatusGroup; rank: number; id: string }[] = [
|
|
487
|
+
{ group: { spans: identity }, rank: RANK_IDENTITY, id: 'identity' },
|
|
488
|
+
]
|
|
489
|
+
const right: { span: StatusSpan; rank: number; id: string }[] = []
|
|
490
|
+
const row2: { group: StatusGroup; rank: number; id: string }[] = []
|
|
491
|
+
|
|
492
|
+
// Row 2 anchor: the agent preset composing the session.
|
|
493
|
+
const mode = safe(facts.mode ?? '')
|
|
494
|
+
if (mode !== '' && enabled.has('mode')) {
|
|
495
|
+
row2.push({
|
|
496
|
+
group: { spans: [{ text: 'mode ', tone: 'label' }, { text: mode, tone: 'accent' }] },
|
|
497
|
+
rank: RANK2_MODE,
|
|
498
|
+
id: 'mode',
|
|
499
|
+
})
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (stats.turns > 0 || stats.steps > 0) {
|
|
503
|
+
if (enabled.has('turns')) {
|
|
504
|
+
// Label/value pairs join through explicit dim separators.
|
|
505
|
+
const counts: StatusSpan[] = []
|
|
506
|
+
const pair = (label: string, value: string): void => {
|
|
507
|
+
if (counts.length > 0) counts.push(sep())
|
|
508
|
+
counts.push({ text: label + ' ', tone: 'label' }, { text: value, tone: 'value' })
|
|
509
|
+
}
|
|
510
|
+
pair('turns', String(stats.turns))
|
|
511
|
+
pair('steps', String(stats.steps))
|
|
512
|
+
left.push({ group: { spans: counts }, rank: RANK_COUNTS, id: 'turns' })
|
|
513
|
+
}
|
|
514
|
+
if (enabled.has('durations')) {
|
|
515
|
+
// Model round-trip, first-token latency, decode rate, and tool wall
|
|
516
|
+
// time; the label keeps its one trailing space so each reads as one
|
|
517
|
+
// figure ('model 45.2s'). Named in full — no single-letter codes.
|
|
518
|
+
const durations: StatusSpan[] = []
|
|
519
|
+
const pair = (label: string, value: string): void => {
|
|
520
|
+
if (durations.length > 0) durations.push(sep())
|
|
521
|
+
durations.push({ text: label + ' ', tone: 'label' }, { text: value, tone: 'value' })
|
|
522
|
+
}
|
|
523
|
+
if (stats.llmMs > 0) pair('model', formatDuration(stats.llmMs))
|
|
524
|
+
if (stats.ttftSteps > 0) pair('latency', formatDuration(stats.ttftMs / stats.ttftSteps))
|
|
525
|
+
if (stats.decodeMs > 0 && stats.decodeTokens > 0) {
|
|
526
|
+
if (durations.length > 0) durations.push(sep())
|
|
527
|
+
durations.push(
|
|
528
|
+
{ text: formatRate(stats.decodeTokens / (stats.decodeMs / 1_000)), tone: 'value' },
|
|
529
|
+
{ text: ' tokens/s', tone: 'label' },
|
|
530
|
+
)
|
|
531
|
+
}
|
|
532
|
+
if (stats.toolMs > 0) pair('tool', formatDuration(stats.toolMs))
|
|
533
|
+
if (durations.length > 0) {
|
|
534
|
+
row2.push({ group: { spans: durations }, rank: RANK2_DURATIONS, id: 'durations' })
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const cacheHit = cacheHitPercent(stats.usage)
|
|
540
|
+
if (cacheHit !== null && enabled.has('cache')) {
|
|
541
|
+
row2.push({
|
|
542
|
+
group: { spans: [{ text: 'cache ', tone: 'label' }, { text: cacheHit + '%', tone: 'value' }] },
|
|
543
|
+
rank: RANK2_CACHE,
|
|
544
|
+
id: 'cache',
|
|
545
|
+
})
|
|
546
|
+
}
|
|
547
|
+
// Context occupancy as a segmented bar: per-content-type runs colored by
|
|
548
|
+
// their own blue shade with a right-aligned usage readout. The used total
|
|
549
|
+
// is the most recent reported prompt size against the advertised route
|
|
550
|
+
// capacity (the same figures the old bracket bar showed).
|
|
551
|
+
if (stats.contextWindow > 0 && stats.lastPromptTokens > 0 && enabled.has('context')) {
|
|
552
|
+
row2.push({
|
|
553
|
+
group: {
|
|
554
|
+
spans: [
|
|
555
|
+
{ text: 'context ', tone: 'label' },
|
|
556
|
+
...contextBar(stats.contextSegments, stats.lastPromptTokens, stats.contextWindow, CONTEXT_BAR_WIDTH),
|
|
557
|
+
],
|
|
558
|
+
},
|
|
559
|
+
rank: RANK2_CONTEXT,
|
|
560
|
+
id: 'context',
|
|
561
|
+
})
|
|
562
|
+
}
|
|
563
|
+
if ((stats.usage.inputTokens > 0 || stats.usage.outputTokens > 0) && enabled.has('tokens')) {
|
|
564
|
+
const tokens: StatusSpan[] = []
|
|
565
|
+
const pair = (label: string, value: string): void => {
|
|
566
|
+
if (tokens.length > 0) tokens.push(sep())
|
|
567
|
+
tokens.push({ text: label + ' ', tone: 'label' }, { text: value, tone: 'value' })
|
|
568
|
+
}
|
|
569
|
+
pair('in', formatTokens(stats.usage.inputTokens))
|
|
570
|
+
pair('out', formatTokens(stats.usage.outputTokens))
|
|
571
|
+
left.push({ group: { spans: tokens }, rank: RANK_TOKENS, id: 'tokens' })
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// The session title replaces the bare short id whenever one has landed
|
|
575
|
+
// (user rename or provider generation); the bound is column-based so a
|
|
576
|
+
// CJK title cannot outgrow its budget.
|
|
577
|
+
const rawLabel = facts.title !== undefined && facts.title !== '' ? facts.title : facts.sessionId
|
|
578
|
+
const label = truncateColumns(safe(rawLabel), TITLE_BUDGET)
|
|
579
|
+
if (label !== '' && enabled.has('title')) {
|
|
580
|
+
left.push({ group: { spans: [{ text: label, tone: 'meta' }] }, rank: RANK_TITLE, id: 'title' })
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Goal badge on the right (Codex goal indicator): round progress while
|
|
584
|
+
// active, the phase otherwise.
|
|
585
|
+
if (facts.goal !== undefined && enabled.has('goal')) {
|
|
586
|
+
right.push({
|
|
587
|
+
span: {
|
|
588
|
+
text: facts.goal.phase === 'active'
|
|
589
|
+
? '◎ round ' + facts.goal.rounds + '/' + facts.goal.max
|
|
590
|
+
: '◎ ' + safe(facts.goal.phase),
|
|
591
|
+
tone: 'accent',
|
|
592
|
+
},
|
|
593
|
+
rank: RANK_GOAL,
|
|
594
|
+
id: 'goal',
|
|
595
|
+
})
|
|
596
|
+
}
|
|
597
|
+
// The sandbox override stays implicit when it merely echoes the preset —
|
|
598
|
+
// the badge exists to surface a divergence, not to duplicate the label.
|
|
599
|
+
const sandbox = safe(facts.sandbox ?? '')
|
|
600
|
+
if (sandbox !== '' && sandbox.toLowerCase() !== facts.permission.toLowerCase() && enabled.has('sandbox')) {
|
|
601
|
+
right.push({ span: { text: 'sandbox ' + sandbox, tone: 'warn' }, rank: RANK_SANDBOX, id: 'sandbox' })
|
|
602
|
+
}
|
|
603
|
+
const permission = safe(facts.permission)
|
|
604
|
+
let badge = -1
|
|
605
|
+
if (permission !== '' && enabled.has('permission')) {
|
|
606
|
+
right.push({ span: { text: permission, tone: permissionTone(permission) }, rank: RANK_BADGE, id: 'permission' })
|
|
607
|
+
badge = right.length - 1
|
|
608
|
+
}
|
|
609
|
+
return { left, right, badge, row2 }
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Compose the two-row footer layout under a column budget. Row 1 (identity
|
|
614
|
+
* and state badges) degrades in a fixed order — cycle hint, then title, token
|
|
615
|
+
* figures, turn/step counts, goal, divergent sandbox, permission badge — and
|
|
616
|
+
* only then ellipsizes the identity cluster, so the row never wraps. Row 2
|
|
617
|
+
* (mode, context bar, cache, duration figures) fits its own budget and
|
|
618
|
+
* degrades to empty before any row-1 content is touched.
|
|
619
|
+
* @param facts - identity facts resolved by the runner.
|
|
620
|
+
* @param stats - session figures folded from the durable log.
|
|
621
|
+
* @param columns - usable columns for each row (before their left padding).
|
|
622
|
+
* @param options - 'busy' hides the cycle hint while a turn runs (Codex
|
|
623
|
+
* keeps mode hints idle-only); 'items' is the ordered enabled-item config
|
|
624
|
+
* from /statusline (defaults to the full catalog). Display order follows the
|
|
625
|
+
* config per side while the drop ladder keeps its fixed ranks.
|
|
626
|
+
* @returns the two rows to render; row1.left is never empty.
|
|
627
|
+
*/
|
|
628
|
+
export function layoutStatusBar(
|
|
629
|
+
facts: StatusFacts,
|
|
630
|
+
stats: TranscriptStats,
|
|
631
|
+
columns: number,
|
|
632
|
+
options: { busy?: boolean; items?: readonly string[] } = {},
|
|
633
|
+
): StatusLayout {
|
|
634
|
+
const busy = options.busy === true
|
|
635
|
+
const items = options.items ?? DEFAULT_STATUSLINE_ITEMS
|
|
636
|
+
const enabled = new Set(items)
|
|
637
|
+
const budget = Math.max(1, Math.floor(columns) - WIDTH_SAFETY)
|
|
638
|
+
const { left, right, badge, row2 } = buildCandidates(facts, stats, busy, enabled)
|
|
639
|
+
const groupSeparator = visibleColumns(STATUS_GROUP_SEPARATOR)
|
|
640
|
+
const itemSeparator = visibleColumns(STATUS_ITEM_SEPARATOR)
|
|
641
|
+
|
|
642
|
+
// Display order follows the config per side (Codex /statusline reorder).
|
|
643
|
+
// The identity cluster stays anchored first — it owns the busy dot.
|
|
644
|
+
const position = new Map(items.map((id, index) => [id, index]))
|
|
645
|
+
const byPosition = (a: { id: string }, b: { id: string }): number =>
|
|
646
|
+
(position.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (position.get(b.id) ?? Number.MAX_SAFE_INTEGER)
|
|
647
|
+
const orderedLeft = [left[0], ...left.slice(1).sort(byPosition)]
|
|
648
|
+
const orderedRight = right.slice().sort(byPosition)
|
|
649
|
+
const orderedRow2 = row2.slice().sort(byPosition)
|
|
650
|
+
|
|
651
|
+
let hint = badge >= 0 && !busy
|
|
652
|
+
const leftKept = [...orderedLeft]
|
|
653
|
+
const rightKept = [...orderedRight]
|
|
654
|
+
|
|
655
|
+
const width = (): number => {
|
|
656
|
+
const leftWidth = joinWidth(
|
|
657
|
+
leftKept.map(entry => spansWidth(entry.group.spans)),
|
|
658
|
+
groupSeparator,
|
|
659
|
+
)
|
|
660
|
+
const rightWidth = joinWidth(rightKept.map(entry => visibleColumns(entry.span.text)), itemSeparator)
|
|
661
|
+
+ (hint ? visibleColumns(STATUS_CYCLE_HINT) : 0)
|
|
662
|
+
return rightWidth > 0 ? leftWidth + LEFT_RIGHT_GAP + rightWidth : leftWidth
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
while (width() > budget) {
|
|
666
|
+
if (hint) {
|
|
667
|
+
hint = false
|
|
668
|
+
continue
|
|
669
|
+
}
|
|
670
|
+
let dropLeft = -1
|
|
671
|
+
let dropRight = -1
|
|
672
|
+
let dropRank = Number.POSITIVE_INFINITY
|
|
673
|
+
for (let index = 0; index < leftKept.length; index += 1) {
|
|
674
|
+
const rank = leftKept[index].rank
|
|
675
|
+
if (rank < dropRank) {
|
|
676
|
+
dropRank = rank
|
|
677
|
+
dropLeft = index
|
|
678
|
+
dropRight = -1
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
for (let index = 0; index < rightKept.length; index += 1) {
|
|
682
|
+
const rank = rightKept[index].rank
|
|
683
|
+
if (rank < dropRank) {
|
|
684
|
+
dropRank = rank
|
|
685
|
+
dropRight = index
|
|
686
|
+
dropLeft = -1
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
if (dropLeft < 0 && dropRight < 0) break
|
|
690
|
+
if (dropLeft >= 0) {
|
|
691
|
+
leftKept.splice(dropLeft, 1)
|
|
692
|
+
} else {
|
|
693
|
+
rightKept.splice(dropRight, 1)
|
|
694
|
+
if (dropRight === rightKept.length) hint = false
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Only the identity cluster can remain overflowing: collapse to it and
|
|
699
|
+
// ellipsize inside the budget as the last resort. Flat spans make the
|
|
700
|
+
// joined text identical to what the row would have displayed.
|
|
701
|
+
if (width() > budget) {
|
|
702
|
+
rightKept.length = 0
|
|
703
|
+
hint = false
|
|
704
|
+
while (leftKept.length > 1) leftKept.pop()
|
|
705
|
+
const identity = leftKept[0].group
|
|
706
|
+
const joined = identity.spans.map(span => span.text).join('')
|
|
707
|
+
leftKept[0] = {
|
|
708
|
+
group: { spans: [{ text: truncateColumns(joined, budget), tone: 'model' }] },
|
|
709
|
+
rank: RANK_IDENTITY,
|
|
710
|
+
id: 'identity',
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Row 2 fits its own budget; the lowest-rank group drops first until the
|
|
715
|
+
// row fits or nothing is left. An empty row2 is a valid state — the footer
|
|
716
|
+
// degrades back to a single status row.
|
|
717
|
+
const row2Kept = [...orderedRow2]
|
|
718
|
+
const row2Width = (): number =>
|
|
719
|
+
joinWidth(row2Kept.map(entry => spansWidth(entry.group.spans)), groupSeparator)
|
|
720
|
+
while (row2Width() > budget && row2Kept.length > 0) {
|
|
721
|
+
let dropIndex = 0
|
|
722
|
+
let dropRank = Number.POSITIVE_INFINITY
|
|
723
|
+
for (let index = 0; index < row2Kept.length; index += 1) {
|
|
724
|
+
if (row2Kept[index].rank < dropRank) {
|
|
725
|
+
dropRank = row2Kept[index].rank
|
|
726
|
+
dropIndex = index
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
row2Kept.splice(dropIndex, 1)
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
return {
|
|
733
|
+
row1: {
|
|
734
|
+
left: leftKept.map(entry => entry.group),
|
|
735
|
+
right: rightKept.map(entry => entry.span),
|
|
736
|
+
hint,
|
|
737
|
+
},
|
|
738
|
+
row2: {
|
|
739
|
+
left: row2Kept.map(entry => entry.group),
|
|
740
|
+
right: [],
|
|
741
|
+
hint: false,
|
|
742
|
+
},
|
|
743
|
+
}
|
|
744
|
+
}
|