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.
@@ -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
- /** One colored run inside the status bar. */
84
- export interface StatusSpan {
85
- text: string
86
- tone: StatusTone
87
- }
88
-
89
- /**
90
- * One pipe-separated cluster on the leading side of the bar. Spans are the
91
- * full visual sequence: junction separators ride along as their own dim
92
- * 'label'-tone spans, so joining is a flat concat with no implicit glue.
93
- */
94
- export interface StatusGroup {
95
- spans: readonly StatusSpan[]
96
- }
97
-
98
- /** One physical row of the footer: leading clusters and trailing badges. */
99
- export interface StatusRow {
100
- /** Leading clusters, pipe-separated in display order; index 0 is identity. */
101
- left: readonly StatusGroup[]
102
- /** Trailing spans pinned to the right edge, dot-separated in display order. */
103
- right: readonly StatusSpan[]
104
- /** Whether the shift+tab cycle hint rides after the permission badge. */
105
- hint: boolean
106
- }
107
-
108
- /**
109
- * The footer layout: two stacked physical rows. Row 1 is the identity/state
110
- * row (busy dot, model, cwd, branch, plan, turns, tokens, title; goal,
111
- * sandbox, and permission badges). Row 2 is the run-meters row (mode, the
112
- * context progress bar, cache, and duration figures) and degrades to empty
113
- * before any row-1 content is touched.
114
- */
115
- export interface StatusLayout {
116
- row1: StatusRow
117
- row2: StatusRow
118
- }
119
-
120
- /** Separator between leading clusters. */
121
- export const STATUS_GROUP_SEPARATOR = ' | '
122
- /** Separator between trailing state spans. */
123
- export const STATUS_ITEM_SEPARATOR = ' · '
124
- /** The Codex-style mode cycle hint appended to the permission badge. */
125
- export const STATUS_CYCLE_HINT = ' (shift+tab to cycle)'
126
-
127
- /** Cells in the context-occupancy progress bar (block glyphs count two columns in the budget). */
128
- export const CONTEXT_BAR_CELLS = 10
129
- /** Occupancy at which the bar switches from brand blue to a single amber warning. */
130
- export const CONTEXT_WARN_PERCENT = 90
131
-
132
- /**
133
- * Render a context-occupancy percent as a bracketed fixed-width progress bar
134
- * plus the percentage: `[▰▰▰▱▱▱▱▱▱▱] 25%`. Filled cells and the percent read
135
- * in brand blue (accent), empty cells and the brackets read dim (label), and
136
- * the whole meter flips to one amber warning once occupancy reaches the
137
- * warning threshold. The bar fill clamps to 100 while the printed percent
138
- * keeps the raw value so an over-budget session reads as such.
139
- * @param percent - occupancy percent (may exceed 100).
140
- * @returns tone-split spans for the footer to paint.
141
- */
142
- export function contextBar(percent: number): readonly StatusSpan[] {
143
- const clamped = Math.max(0, Math.min(100, Math.round(percent)))
144
- const filled = Math.round(clamped / 100 * CONTEXT_BAR_CELLS)
145
- const warning = clamped >= CONTEXT_WARN_PERCENT
146
- const tone: StatusTone = warning ? 'warn' : 'accent'
147
- const percentText = ' ' + Math.max(0, Math.min(999, Math.round(percent))) + '%'
148
- const spans: StatusSpan[] = [{ text: '[', tone: 'label' }]
149
- if (filled > 0) spans.push({ text: '▰'.repeat(filled), tone })
150
- if (filled < CONTEXT_BAR_CELLS) spans.push({ text: '▱'.repeat(CONTEXT_BAR_CELLS - filled), tone: 'label' })
151
- spans.push({ text: ']', tone: 'label' }, { text: percentText, tone })
152
- return spans
153
- }
154
-
155
- /**
156
- * One customizable status item (the Codex /statusline picker contract).
157
- * 'left' items render as pipe-separated clusters after the identity dot;
158
- * 'right' items pin to the right edge as dot-separated state badges.
159
- */
160
- export type StatusItemId =
161
- | 'model'
162
- | 'cwd'
163
- | 'branch'
164
- | 'plan'
165
- | 'mode'
166
- | 'turns'
167
- | 'durations'
168
- | 'cache'
169
- | 'context'
170
- | 'tokens'
171
- | 'title'
172
- | 'goal'
173
- | 'sandbox'
174
- | 'permission'
175
-
176
- /** Picker-facing metadata for one customizable item. */
177
- export interface StatusItemInfo {
178
- id: StatusItemId
179
- /** Short picker label. */
180
- label: string
181
- /** One-line picker description of what the item shows. */
182
- description: string
183
- /** Which side of the split row the item renders on. */
184
- side: 'left' | 'right'
185
- }
186
-
187
- /** The full item catalog in canonical order (the /statusline default). */
188
- export const STATUS_ITEMS: readonly StatusItemInfo[] = [
189
- { id: 'model', label: 'model', description: 'provider/model serving this session', side: 'left' },
190
- { id: 'cwd', label: 'cwd', description: 'working-directory basename', side: 'left' },
191
- { id: 'branch', label: 'branch', description: 'git branch inside a repository', side: 'left' },
192
- { id: 'plan', label: 'plan', description: 'plan-mode state mark', side: 'left' },
193
- { id: 'mode', label: 'mode', description: 'agent preset composing the session', side: 'left' },
194
- { id: 'turns', label: 'turns', description: 'turn and step counters', side: 'left' },
195
- { id: 'durations', label: 'durations', description: 'llm/ttft/decode/tool wall time', side: 'left' },
196
- { id: 'cache', label: 'cache', description: 'cache-hit share of billed input', side: 'left' },
197
- { id: 'context', label: 'context', description: 'context-window occupancy meter', side: 'left' },
198
- { id: 'tokens', label: 'tokens', description: 'cumulative input/output tokens', side: 'left' },
199
- { id: 'title', label: 'title', description: 'session title or short id', side: 'left' },
200
- { id: 'goal', label: 'goal', description: 'live goal phase and round progress', side: 'right' },
201
- { id: 'sandbox', label: 'sandbox', description: 'divergent sandbox-mode override', side: 'right' },
202
- { id: 'permission', label: 'permission', description: 'permission preset badge with cycle hint', side: 'right' },
203
- ]
204
-
205
- /**
206
- * Default order: the whole catalog (matches the pre-customization bar).
207
- * The busy dot is not an item — it always leads the identity cluster.
208
- */
209
- export const DEFAULT_STATUSLINE_ITEMS: readonly StatusItemId[] = STATUS_ITEMS.map(item => item.id)
210
-
211
- /**
212
- * Parse a persisted statusline item list. The stored value is the ordered
213
- * set of ENABLED items (the Codex /statusline contract): unknown ids and
214
- * duplicates drop out, and a non-array value (missing or corrupt file)
215
- * falls back to the full default set. An explicitly empty array is valid
216
- * the bar degrades to its busy dot alone.
217
- * @param value - the raw parsed JSON value (expected string[]).
218
- * @returns the normalized ordered item list.
219
- */
220
- export function parseStatuslineItems(value: unknown): readonly StatusItemId[] {
221
- if (!Array.isArray(value)) return [...DEFAULT_STATUSLINE_ITEMS]
222
- const known = new Set(STATUS_ITEMS.map(item => item.id))
223
- const kept: StatusItemId[] = []
224
- for (const entry of value) {
225
- if (typeof entry === 'string' && known.has(entry as StatusItemId) && !kept.includes(entry as StatusItemId)) {
226
- kept.push(entry as StatusItemId)
227
- }
228
- }
229
- return kept
230
- }
231
-
232
- /** Minimum blank gap kept between the leading and trailing sides. */
233
- const LEFT_RIGHT_GAP = 2
234
- /** Column held back so Ink/yoga measurement drift can never force a wrap. */
235
- const WIDTH_SAFETY = 1
236
- /** Column budget for the session title before it ellipsizes. */
237
- const TITLE_BUDGET = 48
238
-
239
- /**
240
- * Row 1 drop ranks (lowest drops first): the session title, then the token
241
- * figures, then turn/step counts, then the goal and divergent-sandbox badges,
242
- * with the permission badge last. The identity cluster never drops — it
243
- * ellipsizes instead.
244
- */
245
- const RANK_TITLE = 10
246
- const RANK_TOKENS = 50
247
- const RANK_COUNTS = 90
248
- const RANK_SANDBOX = 92
249
- const RANK_GOAL = 95
250
- const RANK_BADGE = 100
251
- const RANK_IDENTITY = Number.POSITIVE_INFINITY
252
-
253
- /** Row 2 drop ranks: duration figures go first, then cache, then the context bar, and mode survives longest. */
254
- const RANK2_DURATIONS = 40
255
- const RANK2_CACHE = 50
256
- const RANK2_CONTEXT = 60
257
- const RANK2_MODE = 70
258
-
259
- /** Identity facts the runner resolves once at mount; empty strings drop out. */
260
- export interface StatusFacts {
261
- /** 'provider/model' selection serving this session. */
262
- model: string
263
- /** Agent preset composing this session. */
264
- mode?: string
265
- /** Working-directory basename the session serves. */
266
- cwd: string
267
- /** Git branch name, empty outside a repository or on a detached HEAD file. */
268
- branch: string
269
- /** Short session identifier (last dash-separated segment or tail). */
270
- sessionId: string
271
- /** Latest session title (folded from 'session/title'); shown in place of the id. */
272
- title: string
273
- /** Sandbox-mode override (folded from 'sandbox/mode'), empty when never switched. */
274
- sandbox: string
275
- /** Live goal summary (folded from 'goal/change'), undefined when none. */
276
- goal: { phase: string; rounds: number; max: number } | undefined
277
- /** Whether plan mode is active (folded from 'plan/mode'). */
278
- plan: boolean
279
- /** Active permission preset (folded from 'permission/preset'), empty when unknown. */
280
- permission: string
281
- }
282
-
283
- /**
284
- * Traffic-light tone for a permission preset: read-only stays success green,
285
- * full access reads error red, and every workspace-scoped middle ground
286
- * (including unknown presets) reads warning amber.
287
- * @param permission - active permission preset label.
288
- * @returns tone for the badge span.
289
- */
290
- export function permissionTone(permission: string): StatusTone {
291
- const label = permission.toLowerCase()
292
- if (label.includes('read')) return 'success'
293
- if (label.includes('danger') || label.includes('full')) return 'error'
294
- return 'warn'
295
- }
296
-
297
- /** Display-safe external text: one row, controls escaped. */
298
- function safe(text: string): string {
299
- return singleLineText(text)
300
- }
301
-
302
- /** Dim junction separator span inside a cluster. */
303
- function sep(): StatusSpan {
304
- return { text: ' · ', tone: 'label' }
305
- }
306
-
307
- /** Total visible columns of a span list (separators ride inside the spans). */
308
- function spansWidth(spans: readonly StatusSpan[]): number {
309
- let width = 0
310
- for (const span of spans) width += visibleColumns(span.text)
311
- return width
312
- }
313
-
314
- /** Join widths of parts with one fixed separator between neighbors. */
315
- function joinWidth(parts: readonly number[], separator: number): number {
316
- if (parts.length === 0) return 0
317
- let width = 0
318
- for (const part of parts) width += part
319
- return width + separator * (parts.length - 1)
320
- }
321
-
322
- /** Build every candidate group/span with its drop rank and item id. */
323
- function buildCandidates(
324
- facts: StatusFacts,
325
- stats: TranscriptStats,
326
- busy: boolean,
327
- enabled: ReadonlySet<string>,
328
- ): {
329
- left: { group: StatusGroup; rank: number; id: string }[]
330
- right: { span: StatusSpan; rank: number; id: string }[]
331
- badge: number
332
- row2: { group: StatusGroup; rank: number; id: string }[]
333
- } {
334
- const identity: StatusSpan[] = [
335
- { text: busy ? '● ' : '○ ', tone: busy ? 'live' : 'meta' },
336
- ]
337
- // The dot glues straight to the first fact; further facts join through
338
- // explicit dim separators, so an absent model never strands a leading ' · '.
339
- const push = (span: StatusSpan): void => {
340
- if (identity.length > 1) identity.push(sep())
341
- identity.push(span)
342
- }
343
- const model = safe(facts.model)
344
- if (model !== '' && enabled.has('model')) push({ text: model, tone: 'model' })
345
- const cwd = safe(facts.cwd)
346
- if (cwd !== '' && enabled.has('cwd')) push({ text: cwd, tone: 'path' })
347
- const branch = safe(facts.branch)
348
- if (branch !== '' && enabled.has('branch')) push({ text: '⑂ ' + branch, tone: 'branch' })
349
- if (facts.plan && enabled.has('plan')) push({ text: '⧉ plan', tone: 'accent' })
350
-
351
- const left: { group: StatusGroup; rank: number; id: string }[] = [
352
- { group: { spans: identity }, rank: RANK_IDENTITY, id: 'identity' },
353
- ]
354
- const right: { span: StatusSpan; rank: number; id: string }[] = []
355
- const row2: { group: StatusGroup; rank: number; id: string }[] = []
356
-
357
- // Row 2 anchor: the agent preset composing the session.
358
- const mode = safe(facts.mode ?? '')
359
- if (mode !== '' && enabled.has('mode')) {
360
- row2.push({
361
- group: { spans: [{ text: 'mode ', tone: 'label' }, { text: mode, tone: 'accent' }] },
362
- rank: RANK2_MODE,
363
- id: 'mode',
364
- })
365
- }
366
-
367
- if (stats.turns > 0 || stats.steps > 0) {
368
- if (enabled.has('turns')) {
369
- // Label/value pairs join through explicit dim separators.
370
- const counts: StatusSpan[] = []
371
- const pair = (label: string, value: string): void => {
372
- if (counts.length > 0) counts.push(sep())
373
- counts.push({ text: label + ' ', tone: 'label' }, { text: value, tone: 'value' })
374
- }
375
- pair('turns', String(stats.turns))
376
- pair('steps', String(stats.steps))
377
- left.push({ group: { spans: counts }, rank: RANK_COUNTS, id: 'turns' })
378
- }
379
- if (enabled.has('durations')) {
380
- // Model round-trip, first-token latency, decode rate, and tool wall
381
- // time; the label keeps its one trailing space so each reads as one
382
- // figure ('model 45.2s'). Named in full — no single-letter codes.
383
- const durations: StatusSpan[] = []
384
- const pair = (label: string, value: string): void => {
385
- if (durations.length > 0) durations.push(sep())
386
- durations.push({ text: label + ' ', tone: 'label' }, { text: value, tone: 'value' })
387
- }
388
- if (stats.llmMs > 0) pair('model', formatDuration(stats.llmMs))
389
- if (stats.ttftSteps > 0) pair('latency', formatDuration(stats.ttftMs / stats.ttftSteps))
390
- if (stats.decodeMs > 0 && stats.decodeTokens > 0) {
391
- if (durations.length > 0) durations.push(sep())
392
- durations.push(
393
- { text: formatRate(stats.decodeTokens / (stats.decodeMs / 1_000)), tone: 'value' },
394
- { text: ' tokens/s', tone: 'label' },
395
- )
396
- }
397
- if (stats.toolMs > 0) pair('tool', formatDuration(stats.toolMs))
398
- if (durations.length > 0) {
399
- row2.push({ group: { spans: durations }, rank: RANK2_DURATIONS, id: 'durations' })
400
- }
401
- }
402
- }
403
-
404
- const cacheHit = cacheHitPercent(stats.usage)
405
- if (cacheHit !== null && enabled.has('cache')) {
406
- row2.push({
407
- group: { spans: [{ text: 'cache ', tone: 'label' }, { text: cacheHit + '%', tone: 'value' }] },
408
- rank: RANK2_CACHE,
409
- id: 'cache',
410
- })
411
- }
412
- // Context occupancy as a bracketed blue progress bar (the web StatsLine
413
- // meter): the most recent reported prompt size against the advertised
414
- // route capacity, rendered as fixed-width filled/empty cells plus percent.
415
- if (stats.contextWindow > 0 && stats.lastPromptTokens > 0 && enabled.has('context')) {
416
- row2.push({
417
- group: { spans: [{ text: 'context ', tone: 'label' }, ...contextBar(stats.lastPromptTokens / stats.contextWindow * 100)] },
418
- rank: RANK2_CONTEXT,
419
- id: 'context',
420
- })
421
- }
422
- if ((stats.usage.inputTokens > 0 || stats.usage.outputTokens > 0) && enabled.has('tokens')) {
423
- const tokens: StatusSpan[] = []
424
- const pair = (label: string, value: string): void => {
425
- if (tokens.length > 0) tokens.push(sep())
426
- tokens.push({ text: label + ' ', tone: 'label' }, { text: value, tone: 'value' })
427
- }
428
- pair('in', formatTokens(stats.usage.inputTokens))
429
- pair('out', formatTokens(stats.usage.outputTokens))
430
- left.push({ group: { spans: tokens }, rank: RANK_TOKENS, id: 'tokens' })
431
- }
432
-
433
- // The session title replaces the bare short id whenever one has landed
434
- // (user rename or provider generation); the bound is column-based so a
435
- // CJK title cannot outgrow its budget.
436
- const rawLabel = facts.title !== undefined && facts.title !== '' ? facts.title : facts.sessionId
437
- const label = truncateColumns(safe(rawLabel), TITLE_BUDGET)
438
- if (label !== '' && enabled.has('title')) {
439
- left.push({ group: { spans: [{ text: label, tone: 'meta' }] }, rank: RANK_TITLE, id: 'title' })
440
- }
441
-
442
- // Goal badge on the right (Codex goal indicator): round progress while
443
- // active, the phase otherwise.
444
- if (facts.goal !== undefined && enabled.has('goal')) {
445
- right.push({
446
- span: {
447
- text: facts.goal.phase === 'active'
448
- ? '◎ round ' + facts.goal.rounds + '/' + facts.goal.max
449
- : '◎ ' + safe(facts.goal.phase),
450
- tone: 'accent',
451
- },
452
- rank: RANK_GOAL,
453
- id: 'goal',
454
- })
455
- }
456
- // The sandbox override stays implicit when it merely echoes the preset —
457
- // the badge exists to surface a divergence, not to duplicate the label.
458
- const sandbox = safe(facts.sandbox ?? '')
459
- if (sandbox !== '' && sandbox.toLowerCase() !== facts.permission.toLowerCase() && enabled.has('sandbox')) {
460
- right.push({ span: { text: 'sandbox ' + sandbox, tone: 'warn' }, rank: RANK_SANDBOX, id: 'sandbox' })
461
- }
462
- const permission = safe(facts.permission)
463
- let badge = -1
464
- if (permission !== '' && enabled.has('permission')) {
465
- right.push({ span: { text: permission, tone: permissionTone(permission) }, rank: RANK_BADGE, id: 'permission' })
466
- badge = right.length - 1
467
- }
468
- return { left, right, badge, row2 }
469
- }
470
-
471
- /**
472
- * Compose the two-row footer layout under a column budget. Row 1 (identity
473
- * and state badges) degrades in a fixed order — cycle hint, then title, token
474
- * figures, turn/step counts, goal, divergent sandbox, permission badge — and
475
- * only then ellipsizes the identity cluster, so the row never wraps. Row 2
476
- * (mode, context bar, cache, duration figures) fits its own budget and
477
- * degrades to empty before any row-1 content is touched.
478
- * @param facts - identity facts resolved by the runner.
479
- * @param stats - session figures folded from the durable log.
480
- * @param columns - usable columns for each row (before their left padding).
481
- * @param options - 'busy' hides the cycle hint while a turn runs (Codex
482
- * keeps mode hints idle-only); 'items' is the ordered enabled-item config
483
- * from /statusline (defaults to the full catalog). Display order follows the
484
- * config per side while the drop ladder keeps its fixed ranks.
485
- * @returns the two rows to render; row1.left is never empty.
486
- */
487
- export function layoutStatusBar(
488
- facts: StatusFacts,
489
- stats: TranscriptStats,
490
- columns: number,
491
- options: { busy?: boolean; items?: readonly string[] } = {},
492
- ): StatusLayout {
493
- const busy = options.busy === true
494
- const items = options.items ?? DEFAULT_STATUSLINE_ITEMS
495
- const enabled = new Set(items)
496
- const budget = Math.max(1, Math.floor(columns) - WIDTH_SAFETY)
497
- const { left, right, badge, row2 } = buildCandidates(facts, stats, busy, enabled)
498
- const groupSeparator = visibleColumns(STATUS_GROUP_SEPARATOR)
499
- const itemSeparator = visibleColumns(STATUS_ITEM_SEPARATOR)
500
-
501
- // Display order follows the config per side (Codex /statusline reorder).
502
- // The identity cluster stays anchored first it owns the busy dot.
503
- const position = new Map(items.map((id, index) => [id, index]))
504
- const byPosition = (a: { id: string }, b: { id: string }): number =>
505
- (position.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (position.get(b.id) ?? Number.MAX_SAFE_INTEGER)
506
- const orderedLeft = [left[0], ...left.slice(1).sort(byPosition)]
507
- const orderedRight = right.slice().sort(byPosition)
508
- const orderedRow2 = row2.slice().sort(byPosition)
509
-
510
- let hint = badge >= 0 && !busy
511
- const leftKept = [...orderedLeft]
512
- const rightKept = [...orderedRight]
513
-
514
- const width = (): number => {
515
- const leftWidth = joinWidth(
516
- leftKept.map(entry => spansWidth(entry.group.spans)),
517
- groupSeparator,
518
- )
519
- const rightWidth = joinWidth(rightKept.map(entry => visibleColumns(entry.span.text)), itemSeparator)
520
- + (hint ? visibleColumns(STATUS_CYCLE_HINT) : 0)
521
- return rightWidth > 0 ? leftWidth + LEFT_RIGHT_GAP + rightWidth : leftWidth
522
- }
523
-
524
- while (width() > budget) {
525
- if (hint) {
526
- hint = false
527
- continue
528
- }
529
- let dropLeft = -1
530
- let dropRight = -1
531
- let dropRank = Number.POSITIVE_INFINITY
532
- for (let index = 0; index < leftKept.length; index += 1) {
533
- const rank = leftKept[index].rank
534
- if (rank < dropRank) {
535
- dropRank = rank
536
- dropLeft = index
537
- dropRight = -1
538
- }
539
- }
540
- for (let index = 0; index < rightKept.length; index += 1) {
541
- const rank = rightKept[index].rank
542
- if (rank < dropRank) {
543
- dropRank = rank
544
- dropRight = index
545
- dropLeft = -1
546
- }
547
- }
548
- if (dropLeft < 0 && dropRight < 0) break
549
- if (dropLeft >= 0) {
550
- leftKept.splice(dropLeft, 1)
551
- } else {
552
- rightKept.splice(dropRight, 1)
553
- if (dropRight === rightKept.length) hint = false
554
- }
555
- }
556
-
557
- // Only the identity cluster can remain overflowing: collapse to it and
558
- // ellipsize inside the budget as the last resort. Flat spans make the
559
- // joined text identical to what the row would have displayed.
560
- if (width() > budget) {
561
- rightKept.length = 0
562
- hint = false
563
- while (leftKept.length > 1) leftKept.pop()
564
- const identity = leftKept[0].group
565
- const joined = identity.spans.map(span => span.text).join('')
566
- leftKept[0] = {
567
- group: { spans: [{ text: truncateColumns(joined, budget), tone: 'model' }] },
568
- rank: RANK_IDENTITY,
569
- id: 'identity',
570
- }
571
- }
572
-
573
- // Row 2 fits its own budget; the lowest-rank group drops first until the
574
- // row fits or nothing is left. An empty row2 is a valid state — the footer
575
- // degrades back to a single status row.
576
- const row2Kept = [...orderedRow2]
577
- const row2Width = (): number =>
578
- joinWidth(row2Kept.map(entry => spansWidth(entry.group.spans)), groupSeparator)
579
- while (row2Width() > budget && row2Kept.length > 0) {
580
- let dropIndex = 0
581
- let dropRank = Number.POSITIVE_INFINITY
582
- for (let index = 0; index < row2Kept.length; index += 1) {
583
- if (row2Kept[index].rank < dropRank) {
584
- dropRank = row2Kept[index].rank
585
- dropIndex = index
586
- }
587
- }
588
- row2Kept.splice(dropIndex, 1)
589
- }
590
-
591
- return {
592
- row1: {
593
- left: leftKept.map(entry => entry.group),
594
- right: rightKept.map(entry => entry.span),
595
- hint,
596
- },
597
- row2: {
598
- left: row2Kept.map(entry => entry.group),
599
- right: [],
600
- hint: false,
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
+ }