dsh-code 0.6.1 → 0.8.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.
Files changed (56) hide show
  1. package/README.en.md +20 -6
  2. package/README.md +20 -6
  3. package/lib/index.mjs +3952 -1315
  4. package/lib/startup.mjs +21 -9
  5. package/lib/theme-BEi4i_aN.mjs +624 -0
  6. package/lib/types/app.d.ts +108 -4
  7. package/lib/types/history.d.ts +15 -4
  8. package/lib/types/index.d.ts +49 -0
  9. package/lib/types/kernel-panels.d.ts +28 -0
  10. package/lib/types/mentions.d.ts +29 -12
  11. package/lib/types/models.d.ts +66 -0
  12. package/lib/types/permissions.d.ts +37 -0
  13. package/lib/types/presets.d.ts +2 -0
  14. package/lib/types/provider-settings.d.ts +144 -0
  15. package/lib/types/questions.d.ts +2 -0
  16. package/lib/types/render/animations.d.ts +177 -2
  17. package/lib/types/render/lines.d.ts +6 -0
  18. package/lib/types/render/markdown.d.ts +3 -3
  19. package/lib/types/render/projection.d.ts +123 -3
  20. package/lib/types/render/status.d.ts +35 -24
  21. package/lib/types/render/text.d.ts +14 -7
  22. package/lib/types/render/tool-detail.d.ts +3 -1
  23. package/lib/types/render/tool-preview.d.ts +4 -1
  24. package/lib/types/session-directory.d.ts +15 -0
  25. package/lib/types/startup.d.ts +12 -4
  26. package/lib/types/store.d.ts +13 -2
  27. package/lib/types/theme-panel.d.ts +24 -0
  28. package/lib/types/theme.d.ts +158 -2
  29. package/lib/types/version.d.ts +5 -0
  30. package/package.json +1 -1
  31. package/src/app.ts +1283 -206
  32. package/src/approval.ts +11 -2
  33. package/src/history.ts +20 -5
  34. package/src/index.ts +1207 -905
  35. package/src/kernel-panels.ts +518 -419
  36. package/src/mentions.ts +57 -27
  37. package/src/models.ts +200 -66
  38. package/src/permissions.ts +85 -0
  39. package/src/presets.ts +12 -0
  40. package/src/provider-settings.ts +520 -0
  41. package/src/questions.ts +15 -5
  42. package/src/render/animations.ts +373 -2
  43. package/src/render/lines.ts +21 -6
  44. package/src/render/markdown.ts +302 -4
  45. package/src/render/projection.ts +1419 -659
  46. package/src/render/status.ts +650 -603
  47. package/src/render/text.ts +28 -9
  48. package/src/render/tool-detail.ts +81 -40
  49. package/src/render/tool-preview.ts +18 -2
  50. package/src/session-directory.ts +44 -5
  51. package/src/skills.ts +8 -4
  52. package/src/startup.ts +119 -109
  53. package/src/store.ts +26 -8
  54. package/src/theme-panel.ts +72 -0
  55. package/src/theme.ts +206 -70
  56. package/src/version.ts +16 -0
@@ -1,603 +1,650 @@
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 { 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 fill: one DeepSeek blue for the whole occupied run (the free
83
+ // track uses the dim 'label' tone). Only the context bar emits it.
84
+ | 'ctxFill'
85
+
86
+ /** One colored run inside the status bar. */
87
+ export interface StatusSpan {
88
+ text: string
89
+ tone: StatusTone
90
+ }
91
+
92
+ /**
93
+ * One pipe-separated cluster on the leading side of the bar. Spans are the
94
+ * full visual sequence: junction separators ride along as their own dim
95
+ * 'label'-tone spans, so joining is a flat concat with no implicit glue.
96
+ */
97
+ export interface StatusGroup {
98
+ spans: readonly StatusSpan[]
99
+ }
100
+
101
+ /** One physical row of the footer: leading clusters and trailing badges. */
102
+ export interface StatusRow {
103
+ /** Leading clusters, pipe-separated in display order; index 0 is identity. */
104
+ left: readonly StatusGroup[]
105
+ /** Trailing spans pinned to the right edge, dot-separated in display order. */
106
+ right: readonly StatusSpan[]
107
+ /** Whether the shift+tab cycle hint rides after the permission badge. */
108
+ hint: boolean
109
+ }
110
+
111
+ /**
112
+ * The footer layout: two stacked physical rows. Row 1 keeps the primary
113
+ * controls in model, cwd, mode, branch, context, permission order. Row 2
114
+ * carries every secondary session/run figure and degrades independently.
115
+ */
116
+ export interface StatusLayout {
117
+ row1: StatusRow
118
+ row2: StatusRow
119
+ }
120
+
121
+ /** Separator between leading clusters. */
122
+ export const STATUS_GROUP_SEPARATOR = ' | '
123
+ /** Separator between trailing state spans. */
124
+ export const STATUS_ITEM_SEPARATOR = ' · '
125
+ /** The Codex-style mode cycle hint appended to the permission badge. */
126
+ export const STATUS_CYCLE_HINT = ' (shift+tab to cycle)'
127
+
128
+ /**
129
+ * Interior columns of the segmented context bar (content-type segments plus
130
+ * the free tail whose right edge carries the usage readout). Fixed so the
131
+ * row-2 drop ladder can pre-measure the group; the bar shrinks its labels and
132
+ * readout inside this budget rather than asking the layout for more room.
133
+ */
134
+ export const CONTEXT_BAR_WIDTH = 24
135
+ /** Occupancy at which the usage readout flips from brand blue to amber. */
136
+ export const CONTEXT_WARN_PERCENT = 90
137
+ /** Free-tail floor in columns: wide enough for the bare percent readout, so
138
+ * the warning stays visible even at 100%+ occupancy. */
139
+ const CONTEXT_MIN_FREE = 5
140
+
141
+ /**
142
+ * Render context occupancy as ONE stepless bar: a solid DeepSeek-blue fill
143
+ * run, a dim dotted free track, and the usage readout riding the track's
144
+ * right edge (`12.3K/1.0M 25%`, shrinking to the bare percent as the track
145
+ * narrows). No per-content-type segmentation. Column split is deterministic:
146
+ * the free share is `Math.round(free/window*width)` clamped to at least
147
+ * CONTEXT_MIN_FREE columns and at most the full width; the fill takes every
148
+ * remaining column, so a given occupancy always renders the identical bar.
149
+ * The readout flips to amber once occupancy reaches the warning threshold.
150
+ * @param usedTokens - reported used tokens (drives the readout and percent).
151
+ * @param contextWindow - route capacity.
152
+ * @param width - total bar interior columns.
153
+ * @returns tone-split spans for the footer to paint.
154
+ */
155
+ export function contextBar(
156
+ usedTokens: number,
157
+ contextWindow: number,
158
+ width: number,
159
+ ): readonly StatusSpan[] {
160
+ if (width <= 0 || contextWindow <= 0) return []
161
+ const used = Math.max(0, usedTokens)
162
+ const percent = Math.round(used / contextWindow * 100)
163
+ const warning = percent >= CONTEXT_WARN_PERCENT
164
+ const readoutTone: StatusTone = warning ? 'warn' : 'value'
165
+
166
+ const freeShare = Math.round(Math.max(0, contextWindow - used) / contextWindow * width)
167
+ const freeColumns = Math.min(width, Math.max(freeShare, CONTEXT_MIN_FREE))
168
+ const usedColumns = Math.max(0, width - freeColumns)
169
+
170
+ const total = `${formatTokens(used)}/${formatTokens(contextWindow)}`
171
+ const percentText = `${percent}%`
172
+ const readout = freeColumns >= visibleColumns(`${total} ${percentText}`)
173
+ ? `${total} ${percentText}`
174
+ : freeColumns >= visibleColumns(percentText)
175
+ ? percentText
176
+ : ''
177
+
178
+ const spans: StatusSpan[] = []
179
+ if (usedColumns > 0) spans.push({ text: '█'.repeat(usedColumns), tone: 'ctxFill' })
180
+ const pad = freeColumns - visibleColumns(readout)
181
+ if (pad > 0) spans.push({ text: '░'.repeat(pad), tone: 'label' })
182
+ if (readout !== '') spans.push({ text: readout, tone: readoutTone })
183
+ return spans
184
+ }
185
+
186
+ /**
187
+ * One customizable status item (the Codex /statusline picker contract).
188
+ * 'left' items render as pipe-separated clusters after the identity dot;
189
+ * 'right' items pin to the right edge as dot-separated state badges.
190
+ */
191
+ export type StatusItemId =
192
+ | 'model'
193
+ | 'cwd'
194
+ | 'branch'
195
+ | 'plan'
196
+ | 'mode'
197
+ | 'turns'
198
+ | 'durations'
199
+ | 'cache'
200
+ | 'context'
201
+ | 'tokens'
202
+ | 'title'
203
+ | 'goal'
204
+ | 'sandbox'
205
+ | 'permission'
206
+
207
+ /** Picker-facing metadata for one customizable item. */
208
+ export interface StatusItemInfo {
209
+ id: StatusItemId
210
+ /** Short picker label. */
211
+ label: string
212
+ /** One-line picker description of what the item shows. */
213
+ description: string
214
+ /** Which side of the split row the item renders on. */
215
+ side: 'left' | 'right'
216
+ }
217
+
218
+ /** The full item catalog in canonical order (the /statusline default). */
219
+ export const STATUS_ITEMS: readonly StatusItemInfo[] = [
220
+ { id: 'model', label: 'model', description: 'provider/model serving this session', side: 'left' },
221
+ { id: 'cwd', label: 'cwd', description: 'working-directory basename', side: 'left' },
222
+ { id: 'mode', label: 'mode', description: 'agent preset composing the session', side: 'left' },
223
+ { id: 'branch', label: 'branch', description: 'git branch inside a repository', side: 'left' },
224
+ { id: 'context', label: 'context', description: 'context-window occupancy meter', side: 'left' },
225
+ { id: 'permission', label: 'permission', description: 'permission preset badge with cycle hint', side: 'right' },
226
+ { id: 'plan', label: 'plan', description: 'plan-mode state mark', side: 'left' },
227
+ { id: 'turns', label: 'turns', description: 'turn and step counters', side: 'left' },
228
+ { id: 'durations', label: 'durations', description: 'llm/ttft/decode/tool wall time', side: 'left' },
229
+ { id: 'cache', label: 'cache', description: 'cache-hit share of billed input', side: 'left' },
230
+ { id: 'tokens', label: 'tokens', description: 'cumulative input/output tokens', side: 'left' },
231
+ { id: 'title', label: 'title', description: 'session title or short id', side: 'left' },
232
+ { id: 'goal', label: 'goal', description: 'live goal phase and round progress', side: 'left' },
233
+ { id: 'sandbox', label: 'sandbox', description: 'divergent sandbox-mode override', side: 'left' },
234
+ ]
235
+
236
+ /**
237
+ * Default order: the whole catalog (matches the pre-customization bar).
238
+ * The busy dot is not an item — it always leads the identity cluster.
239
+ */
240
+ export const DEFAULT_STATUSLINE_ITEMS: readonly StatusItemId[] = STATUS_ITEMS.map(item => item.id)
241
+
242
+ /**
243
+ * Parse a persisted statusline item list. The stored value is the ordered
244
+ * set of ENABLED items (the Codex /statusline contract): unknown ids and
245
+ * duplicates drop out, and a non-array value (missing or corrupt file)
246
+ * falls back to the full default set. An explicitly empty array is valid —
247
+ * the bar degrades to its busy dot alone.
248
+ * @param value - the raw parsed JSON value (expected string[]).
249
+ * @returns the normalized ordered item list.
250
+ */
251
+ export function parseStatuslineItems(value: unknown): readonly StatusItemId[] {
252
+ if (!Array.isArray(value)) return [...DEFAULT_STATUSLINE_ITEMS]
253
+ const known = new Set(STATUS_ITEMS.map(item => item.id))
254
+ const kept: StatusItemId[] = []
255
+ for (const entry of value) {
256
+ if (typeof entry === 'string' && known.has(entry as StatusItemId) && !kept.includes(entry as StatusItemId)) {
257
+ kept.push(entry as StatusItemId)
258
+ }
259
+ }
260
+ return kept
261
+ }
262
+
263
+ /** Minimum blank gap kept between the leading and trailing sides. */
264
+ const LEFT_RIGHT_GAP = 2
265
+ /** Column held back so Ink/yoga measurement drift can never force a wrap. */
266
+ const WIDTH_SAFETY = 1
267
+ /**
268
+ * Extra left padding on the secondary row so its content aligns with the
269
+ * model name's left edge on the primary row (padding 2 + busy dot 2). The
270
+ * layout subtracts it from row 2's budget so the indent can never wrap it.
271
+ */
272
+ export const STATUS_ROW2_INDENT = 2
273
+ /** Column budget for the session title before it ellipsizes. */
274
+ const TITLE_BUDGET = 48
275
+
276
+ /**
277
+ * Primary-row drop ranks: context drops before permission; the identity
278
+ * cluster never drops and ellipsizes only after the right badge is gone.
279
+ * Secondary-row groups reuse the remaining ranks independently.
280
+ */
281
+ const RANK_TITLE = 10
282
+ const RANK_TOKENS = 50
283
+ const RANK_COUNTS = 90
284
+ const RANK_CONTEXT = 90
285
+ const RANK_SANDBOX = 92
286
+ const RANK_GOAL = 95
287
+ const RANK_BADGE = 100
288
+ const RANK_IDENTITY = Number.POSITIVE_INFINITY
289
+
290
+ /** Row 2 drop ranks: title and durations go first; state and counts survive longest. */
291
+ const RANK2_DURATIONS = 40
292
+ const RANK2_CACHE = 50
293
+ const RANK2_PLAN = 70
294
+
295
+ /** Identity facts the runner resolves once at mount; empty strings drop out. */
296
+ export interface StatusFacts {
297
+ /** 'provider/model' selection serving this session. */
298
+ model: string
299
+ /** Agent preset composing this session. */
300
+ mode?: string
301
+ /** Working-directory basename the session serves. */
302
+ cwd: string
303
+ /** Git branch name, empty outside a repository or on a detached HEAD file. */
304
+ branch: string
305
+ /** Short session identifier (last dash-separated segment or tail). */
306
+ sessionId: string
307
+ /** Latest session title (folded from 'session/title'); shown in place of the id. */
308
+ title: string
309
+ /** Sandbox-mode override (folded from 'sandbox/mode'), empty when never switched. */
310
+ sandbox: string
311
+ /** Live goal summary (folded from 'goal/change'), undefined when none. */
312
+ goal: { phase: string; rounds: number; max: number } | undefined
313
+ /** Whether plan mode is active (folded from 'plan/mode'). */
314
+ plan: boolean
315
+ /** Active or pending permission preset; empty only when the service is unavailable. */
316
+ permission: string
317
+ }
318
+
319
+ /**
320
+ * Traffic-light tone for a permission preset: read-only stays success green,
321
+ * full access reads error red, and every workspace-scoped middle ground
322
+ * (including unknown presets) reads warning amber.
323
+ * @param permission - active permission preset label.
324
+ * @returns tone for the badge span.
325
+ */
326
+ export function permissionTone(permission: string): StatusTone {
327
+ const label = permission.toLowerCase()
328
+ if (label.includes('read')) return 'success'
329
+ if (label.includes('danger') || label.includes('full')) return 'error'
330
+ return 'warn'
331
+ }
332
+
333
+ /** Display-safe external text: one row, controls escaped. */
334
+ function safe(text: string): string {
335
+ return singleLineText(text)
336
+ }
337
+
338
+ /** Dim junction separator span inside a cluster. */
339
+ function sep(): StatusSpan {
340
+ return { text: ' · ', tone: 'label' }
341
+ }
342
+
343
+ /** Total visible columns of a span list (separators ride inside the spans). */
344
+ function spansWidth(spans: readonly StatusSpan[]): number {
345
+ let width = 0
346
+ for (const span of spans) width += visibleColumns(span.text)
347
+ return width
348
+ }
349
+
350
+ /** Join widths of parts with one fixed separator between neighbors. */
351
+ function joinWidth(parts: readonly number[], separator: number): number {
352
+ if (parts.length === 0) return 0
353
+ let width = 0
354
+ for (const part of parts) width += part
355
+ return width + separator * (parts.length - 1)
356
+ }
357
+
358
+ /** Build every candidate group/span with its drop rank and item id. */
359
+ function buildCandidates(
360
+ facts: StatusFacts,
361
+ stats: TranscriptStats,
362
+ busy: boolean,
363
+ enabled: ReadonlySet<string>,
364
+ ): {
365
+ left: { group: StatusGroup; rank: number; id: string }[]
366
+ right: { span: StatusSpan; rank: number; id: string }[]
367
+ badge: number
368
+ row2: { group: StatusGroup; rank: number; id: string }[]
369
+ } {
370
+ const identity: StatusSpan[] = [
371
+ { text: busy ? '● ' : '○ ', tone: busy ? 'live' : 'meta' },
372
+ ]
373
+ // The dot glues straight to the first fact; further facts join through
374
+ // explicit dim separators, so an absent model never strands a leading ' · '.
375
+ const push = (span: StatusSpan): void => {
376
+ if (identity.length > 1) identity.push(sep())
377
+ identity.push(span)
378
+ }
379
+ const model = safe(facts.model)
380
+ if (model !== '' && enabled.has('model')) {
381
+ // The effective reasoning effort rides the model identity as
382
+ // `provider/model@effort` (Codex's model-with-reasoning status item): the
383
+ // projection folds the latest request header's effort, so a resumed
384
+ // session and every request after a /effort pick show what the session
385
+ // actually uses. An empty effort keeps the bare pair.
386
+ const effort = safe(stats.reasoningEffort)
387
+ push({ text: effort === '' ? model : `${model}@${effort}`, tone: 'model' })
388
+ }
389
+ const cwd = safe(facts.cwd)
390
+ if (cwd !== '' && enabled.has('cwd')) push({ text: cwd, tone: 'path' })
391
+ const mode = safe(facts.mode ?? '')
392
+ if (mode !== '' && enabled.has('mode')) {
393
+ push({ text: '/mode ', tone: 'label' })
394
+ identity.push({ text: mode, tone: 'accent' })
395
+ }
396
+ const branch = safe(facts.branch)
397
+ if (branch !== '' && enabled.has('branch')) push({ text: '' + branch, tone: 'branch' })
398
+
399
+ const left: { group: StatusGroup; rank: number; id: string }[] = [
400
+ { group: { spans: identity }, rank: RANK_IDENTITY, id: 'identity' },
401
+ ]
402
+ const right: { span: StatusSpan; rank: number; id: string }[] = []
403
+ const row2: { group: StatusGroup; rank: number; id: string }[] = []
404
+
405
+ if (facts.plan && enabled.has('plan')) {
406
+ row2.push({ group: { spans: [{ text: '⧉ plan', tone: 'accent' }] }, rank: RANK2_PLAN, id: 'plan' })
407
+ }
408
+
409
+ if (stats.turns > 0 || stats.steps > 0) {
410
+ if (enabled.has('turns')) {
411
+ // Label/value pairs join through explicit dim separators.
412
+ const counts: StatusSpan[] = []
413
+ const pair = (label: string, value: string): void => {
414
+ if (counts.length > 0) counts.push(sep())
415
+ counts.push({ text: label + ' ', tone: 'label' }, { text: value, tone: 'value' })
416
+ }
417
+ pair('turns', String(stats.turns))
418
+ pair('steps', String(stats.steps))
419
+ row2.push({ group: { spans: counts }, rank: RANK_COUNTS, id: 'turns' })
420
+ }
421
+ if (enabled.has('durations')) {
422
+ // Model round-trip, first-token latency, decode rate, and tool wall
423
+ // time; the label keeps its one trailing space so each reads as one
424
+ // figure ('model 45.2s'). Named in full no single-letter codes.
425
+ const durations: StatusSpan[] = []
426
+ const pair = (label: string, value: string): void => {
427
+ if (durations.length > 0) durations.push(sep())
428
+ durations.push({ text: label + ' ', tone: 'label' }, { text: value, tone: 'value' })
429
+ }
430
+ if (stats.llmMs > 0) pair('model', formatDuration(stats.llmMs))
431
+ if (stats.ttftSteps > 0) pair('latency', formatDuration(stats.ttftMs / stats.ttftSteps))
432
+ if (stats.decodeMs > 0 && stats.decodeTokens > 0) {
433
+ if (durations.length > 0) durations.push(sep())
434
+ durations.push(
435
+ { text: formatRate(stats.decodeTokens / (stats.decodeMs / 1_000)), tone: 'value' },
436
+ { text: ' tokens/s', tone: 'label' },
437
+ )
438
+ }
439
+ if (stats.toolMs > 0) pair('tool', formatDuration(stats.toolMs))
440
+ if (durations.length > 0) {
441
+ row2.push({ group: { spans: durations }, rank: RANK2_DURATIONS, id: 'durations' })
442
+ }
443
+ }
444
+ }
445
+
446
+ const cacheHit = cacheHitPercent(stats.usage)
447
+ if (cacheHit !== null && enabled.has('cache')) {
448
+ row2.push({
449
+ group: { spans: [{ text: 'cache ', tone: 'label' }, { text: cacheHit + '%', tone: 'value' }] },
450
+ rank: RANK2_CACHE,
451
+ id: 'cache',
452
+ })
453
+ }
454
+ // Context occupancy as a segmented bar: per-content-type runs colored by
455
+ // their own blue shade with a right-aligned usage readout. The used total
456
+ // is the most recent reported prompt size against the advertised route
457
+ // capacity (the same figures the old bracket bar showed).
458
+ if (stats.contextWindow > 0 && stats.lastPromptTokens > 0 && enabled.has('context')) {
459
+ left.push({
460
+ group: {
461
+ spans: [
462
+ { text: 'context ', tone: 'label' },
463
+ ...contextBar(stats.lastPromptTokens, stats.contextWindow, CONTEXT_BAR_WIDTH),
464
+ ],
465
+ },
466
+ rank: RANK_CONTEXT,
467
+ id: 'context',
468
+ })
469
+ }
470
+ if ((stats.usage.inputTokens > 0 || stats.usage.outputTokens > 0) && enabled.has('tokens')) {
471
+ const tokens: StatusSpan[] = []
472
+ const pair = (label: string, value: string): void => {
473
+ if (tokens.length > 0) tokens.push(sep())
474
+ tokens.push({ text: label + ' ', tone: 'label' }, { text: value, tone: 'value' })
475
+ }
476
+ pair('in', formatTokens(stats.usage.inputTokens))
477
+ pair('out', formatTokens(stats.usage.outputTokens))
478
+ row2.push({ group: { spans: tokens }, rank: RANK_TOKENS, id: 'tokens' })
479
+ }
480
+
481
+ // The session title replaces the bare short id whenever one has landed
482
+ // (user rename or provider generation); the bound is column-based so a
483
+ // CJK title cannot outgrow its budget.
484
+ const rawLabel = facts.title !== undefined && facts.title !== '' ? facts.title : facts.sessionId
485
+ const label = truncateColumns(safe(rawLabel), TITLE_BUDGET)
486
+ if (label !== '' && enabled.has('title')) {
487
+ row2.push({ group: { spans: [{ text: label, tone: 'meta' }] }, rank: RANK_TITLE, id: 'title' })
488
+ }
489
+
490
+ // Secondary state rides row 2; permission alone remains right-pinned on row 1.
491
+ if (facts.goal !== undefined && enabled.has('goal')) {
492
+ row2.push({
493
+ group: {
494
+ spans: [{
495
+ text: facts.goal.phase === 'active'
496
+ ? '◎ round ' + facts.goal.rounds + '/' + facts.goal.max
497
+ : '◎ ' + safe(facts.goal.phase),
498
+ tone: 'accent',
499
+ }],
500
+ },
501
+ rank: RANK_GOAL,
502
+ id: 'goal',
503
+ })
504
+ }
505
+ // The sandbox override stays implicit when it merely echoes the preset.
506
+ const sandbox = safe(facts.sandbox ?? '')
507
+ if (sandbox !== '' && sandbox.toLowerCase() !== facts.permission.toLowerCase() && enabled.has('sandbox')) {
508
+ row2.push({ group: { spans: [{ text: 'sandbox ' + sandbox, tone: 'warn' }] }, rank: RANK_SANDBOX, id: 'sandbox' })
509
+ }
510
+ const permission = safe(facts.permission)
511
+ let badge = -1
512
+ if (permission !== '' && enabled.has('permission')) {
513
+ right.push({ span: { text: permission, tone: permissionTone(permission) }, rank: RANK_BADGE, id: 'permission' })
514
+ badge = right.length - 1
515
+ }
516
+ return { left, right, badge, row2 }
517
+ }
518
+
519
+ /**
520
+ * Compose the two-row footer layout under a column budget. Row 1 keeps model,
521
+ * cwd, mode, branch, context, then the right-pinned permission badge and cycle
522
+ * hint. It drops hint, context, and permission before ellipsizing identity.
523
+ * Row 2 fits all secondary figures and state within its own budget.
524
+ * @param facts - identity facts resolved by the runner.
525
+ * @param stats - session figures folded from the durable log.
526
+ * @param columns - usable columns for each row (before their left padding).
527
+ * @param options - 'busy' hides the cycle hint while a turn runs (Codex
528
+ * keeps mode hints idle-only); 'items' is the ordered enabled-item config
529
+ * from /statusline (defaults to the full catalog). Display order follows the
530
+ * config per side while the drop ladder keeps its fixed ranks.
531
+ * @returns the two rows to render; row1.left is never empty.
532
+ */
533
+ export function layoutStatusBar(
534
+ facts: StatusFacts,
535
+ stats: TranscriptStats,
536
+ columns: number,
537
+ options: { busy?: boolean; items?: readonly string[] } = {},
538
+ ): StatusLayout {
539
+ const busy = options.busy === true
540
+ const items = options.items ?? DEFAULT_STATUSLINE_ITEMS
541
+ const enabled = new Set(items)
542
+ const budget = Math.max(1, Math.floor(columns) - WIDTH_SAFETY)
543
+ const { left, right, badge, row2 } = buildCandidates(facts, stats, busy, enabled)
544
+ const groupSeparator = visibleColumns(STATUS_GROUP_SEPARATOR)
545
+ const itemSeparator = visibleColumns(STATUS_ITEM_SEPARATOR)
546
+
547
+ // Display order follows the config per side (Codex /statusline reorder).
548
+ // The identity cluster stays anchored first it owns the busy dot.
549
+ const position = new Map(items.map((id, index) => [id, index]))
550
+ const byPosition = (a: { id: string }, b: { id: string }): number =>
551
+ (position.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (position.get(b.id) ?? Number.MAX_SAFE_INTEGER)
552
+ const orderedLeft = [left[0], ...left.slice(1).sort(byPosition)]
553
+ const orderedRight = right.slice().sort(byPosition)
554
+ const orderedRow2 = row2.slice().sort(byPosition)
555
+
556
+ let hint = badge >= 0 && !busy
557
+ const leftKept = [...orderedLeft]
558
+ const rightKept = [...orderedRight]
559
+
560
+ const width = (): number => {
561
+ const leftWidth = joinWidth(
562
+ leftKept.map(entry => spansWidth(entry.group.spans)),
563
+ groupSeparator,
564
+ )
565
+ const rightWidth = joinWidth(rightKept.map(entry => visibleColumns(entry.span.text)), itemSeparator)
566
+ + (hint ? visibleColumns(STATUS_CYCLE_HINT) : 0)
567
+ return rightWidth > 0 ? leftWidth + LEFT_RIGHT_GAP + rightWidth : leftWidth
568
+ }
569
+
570
+ while (width() > budget) {
571
+ if (hint) {
572
+ hint = false
573
+ continue
574
+ }
575
+ let dropLeft = -1
576
+ let dropRight = -1
577
+ let dropRank = Number.POSITIVE_INFINITY
578
+ for (let index = 0; index < leftKept.length; index += 1) {
579
+ const rank = leftKept[index].rank
580
+ if (rank < dropRank) {
581
+ dropRank = rank
582
+ dropLeft = index
583
+ dropRight = -1
584
+ }
585
+ }
586
+ for (let index = 0; index < rightKept.length; index += 1) {
587
+ const rank = rightKept[index].rank
588
+ if (rank < dropRank) {
589
+ dropRank = rank
590
+ dropRight = index
591
+ dropLeft = -1
592
+ }
593
+ }
594
+ if (dropLeft < 0 && dropRight < 0) break
595
+ if (dropLeft >= 0) {
596
+ leftKept.splice(dropLeft, 1)
597
+ } else {
598
+ rightKept.splice(dropRight, 1)
599
+ if (dropRight === rightKept.length) hint = false
600
+ }
601
+ }
602
+
603
+ // Only the identity cluster can remain overflowing: collapse to it and
604
+ // ellipsize inside the budget as the last resort. Flat spans make the
605
+ // joined text identical to what the row would have displayed.
606
+ if (width() > budget) {
607
+ rightKept.length = 0
608
+ hint = false
609
+ while (leftKept.length > 1) leftKept.pop()
610
+ const identity = leftKept[0].group
611
+ const joined = identity.spans.map(span => span.text).join('')
612
+ leftKept[0] = {
613
+ group: { spans: [{ text: truncateColumns(joined, budget), tone: 'model' }] },
614
+ rank: RANK_IDENTITY,
615
+ id: 'identity',
616
+ }
617
+ }
618
+
619
+ // Row 2 fits its own budget minus the model-name indent; the lowest-rank
620
+ // group drops first until the row fits or nothing is left. An empty row2 is
621
+ // a valid state — the footer degrades back to a single status row.
622
+ const row2Kept = [...orderedRow2]
623
+ const row2Budget = Math.max(1, budget - STATUS_ROW2_INDENT)
624
+ const row2Width = (): number =>
625
+ joinWidth(row2Kept.map(entry => spansWidth(entry.group.spans)), groupSeparator)
626
+ while (row2Width() > row2Budget && row2Kept.length > 0) {
627
+ let dropIndex = 0
628
+ let dropRank = Number.POSITIVE_INFINITY
629
+ for (let index = 0; index < row2Kept.length; index += 1) {
630
+ if (row2Kept[index].rank < dropRank) {
631
+ dropRank = row2Kept[index].rank
632
+ dropIndex = index
633
+ }
634
+ }
635
+ row2Kept.splice(dropIndex, 1)
636
+ }
637
+
638
+ return {
639
+ row1: {
640
+ left: leftKept.map(entry => entry.group),
641
+ right: rightKept.map(entry => entry.span),
642
+ hint,
643
+ },
644
+ row2: {
645
+ left: row2Kept.map(entry => entry.group),
646
+ right: [],
647
+ hint: false,
648
+ },
649
+ }
650
+ }