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