dsh-code 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,18 @@
1
1
  /**
2
- * Status-line composition for the TUI footer: pipe-separated groups blending
3
- * the Claude-Code-style identity facts (model, working directory, git branch,
4
- * session) with the web StatsLine's session figures (turns/steps, model and
5
- * tool wall time, cache hit, token totals). Pure functions only the footer
6
- * renders exactly what {@link buildStatusGroups} returns.
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.
7
9
  *
8
10
  * @module @deepseek-ai/dsh-tui/render/status
9
11
  */
10
12
 
13
+ import { visibleColumns } from './markdown.ts'
11
14
  import type { TranscriptStats } from './projection.ts'
15
+ import { singleLineText, truncateColumns } from './text.ts'
12
16
 
13
17
  /**
14
18
  * Compact token count: 517 / 12.2K / 517K / 1.2M (one decimal under three
@@ -20,8 +24,8 @@ export function formatTokens(n: number): string {
20
24
  const scaled = (v: number): string =>
21
25
  v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10)
22
26
  if (n < 1_000) return String(n)
23
- if (n < 1_000_000) return `${scaled(n / 1_000)}K`
24
- return `${scaled(n / 1_000_000)}M`
27
+ if (n < 1_000_000) return scaled(n / 1_000) + 'K'
28
+ return scaled(n / 1_000_000) + 'M'
25
29
  }
26
30
 
27
31
  /**
@@ -31,9 +35,9 @@ export function formatTokens(n: number): string {
31
35
  */
32
36
  export function formatDuration(ms: number): string {
33
37
  const s = ms / 1_000
34
- if (s < 60) return `${Math.round(s * 10) / 10}s`
38
+ if (s < 60) return String(Math.round(s * 10) / 10) + 's'
35
39
  const whole = Math.round(s)
36
- return `${Math.floor(whole / 60)}m${whole % 60}s`
40
+ return Math.floor(whole / 60) + 'm' + (whole % 60) + 's'
37
41
  }
38
42
 
39
43
  /**
@@ -45,7 +49,7 @@ export function formatDuration(ms: number): string {
45
49
  export function formatRate(n: number): string {
46
50
  if (n < 100) return String(Math.round(n * 10) / 10)
47
51
  if (n < 1_000) return String(Math.round(n))
48
- return `${Math.round(n / 100) / 10}K`
52
+ return String(Math.round(n / 100) / 10) + 'K'
49
53
  }
50
54
 
51
55
  /**
@@ -59,9 +63,202 @@ export function cacheHitPercent(usage: TranscriptStats['usage']): number | null
59
63
  : Math.round(usage.cacheReadTokens / usage.inputTokens * 100)
60
64
  }
61
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
+
62
259
  /** Identity facts the runner resolves once at mount; empty strings drop out. */
63
260
  export interface StatusFacts {
64
- /** `provider/model` selection serving this session. */
261
+ /** 'provider/model' selection serving this session. */
65
262
  model: string
66
263
  /** Agent preset composing this session. */
67
264
  mode?: string
@@ -71,79 +268,336 @@ export interface StatusFacts {
71
268
  branch: string
72
269
  /** Short session identifier (last dash-separated segment or tail). */
73
270
  sessionId: string
74
- /** Latest session title (folded from `session/title`); shown in place of the id. */
271
+ /** Latest session title (folded from 'session/title'); shown in place of the id. */
75
272
  title: string
76
- /** Sandbox-mode override (folded from `sandbox/mode`), empty when never switched. */
273
+ /** Sandbox-mode override (folded from 'sandbox/mode'), empty when never switched. */
77
274
  sandbox: string
78
- /** Live goal summary (folded from `goal/change`), undefined when none. */
275
+ /** Live goal summary (folded from 'goal/change'), undefined when none. */
79
276
  goal: { phase: string; rounds: number; max: number } | undefined
80
- /** Whether plan mode is active (folded from `plan/mode`). */
277
+ /** Whether plan mode is active (folded from 'plan/mode'). */
81
278
  plan: boolean
82
- /** Active permission preset (folded from `permission/preset`), empty when unknown. */
279
+ /** Active permission preset (folded from 'permission/preset'), empty when unknown. */
83
280
  permission: string
84
281
  }
85
282
 
86
283
  /**
87
- * Build the footer's display groups; a group with no data drops out whole.
88
- * @param facts - identity facts resolved by the runner.
89
- * @param stats - session figures folded from the durable log.
90
- * @returns one string per pipe-separated group, in display order.
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.
91
289
  */
92
- export function buildStatusGroups(facts: StatusFacts, stats: TranscriptStats): string[] {
93
- const groups: string[] = []
94
- const identity = [
95
- facts.model,
96
- facts.cwd,
97
- facts.branch === '' ? undefined : `⑂ ${facts.branch}`,
98
- facts.plan ? '⧉ plan' : undefined,
99
- ].filter(part => part !== undefined && part !== '')
100
- if (identity.length > 0) groups.push(identity.join(' · '))
101
- if (facts.mode !== undefined && facts.mode !== '') groups.push(`mode ${facts.mode}`)
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
+
102
367
  if (stats.turns > 0 || stats.steps > 0) {
103
- groups.push(`T${stats.turns} · S${stats.steps}`)
104
- const durations: string[] = []
105
- if (stats.llmMs > 0) durations.push(`llm ${formatDuration(stats.llmMs)}`)
106
- // Decode latency figures (the web StatsLine's TTFT and throughput):
107
- // average first-token wait and tokens per second over timed steps.
108
- if (stats.ttftSteps > 0) durations.push(`ttft ${formatDuration(stats.ttftMs / stats.ttftSteps)}`)
109
- if (stats.decodeMs > 0 && stats.decodeTokens > 0) {
110
- durations.push(`${formatRate(stats.decodeTokens / (stats.decodeMs / 1_000))} tok/s`)
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
+ }
111
401
  }
112
- if (stats.toolMs > 0) durations.push(`tool ${formatDuration(stats.toolMs)}`)
113
- if (durations.length > 0) groups.push(durations.join(' · '))
114
402
  }
403
+
115
404
  const cacheHit = cacheHitPercent(stats.usage)
116
- if (stats.usage.inputTokens > 0 || stats.usage.outputTokens > 0) {
117
- if (cacheHit !== null) groups.push(`cache ${cacheHit}%`)
118
- // Context occupancy (the web StatsLine's meter): the most recent
119
- // reported prompt size against the advertised route capacity.
120
- if (stats.contextWindow > 0 && stats.lastPromptTokens > 0) {
121
- groups.push(`ctx ${Math.min(999, Math.round(stats.lastPromptTokens / stats.contextWindow * 100))}%`)
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' })
122
427
  }
123
- groups.push(`↑${formatTokens(stats.usage.inputTokens)} ↓${formatTokens(stats.usage.outputTokens)}`)
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' })
124
431
  }
432
+
125
433
  // The session title replaces the bare short id whenever one has landed
126
- // (user rename or provider generation), bounded so a long title cannot
127
- // crowd out the rest of the line.
128
- const label = facts.title !== undefined && facts.title !== ''
129
- ? (facts.title.length > 48 ? `${facts.title.slice(0, 47)}…` : facts.title)
130
- : facts.sessionId
131
- if (label !== '') groups.push(label)
132
- // The permission preset trails the line: switching it changes only the
133
- // tail, so the left-aligned bar never shifts its other groups. Plain text,
134
- // the Claude-Code permission-mode display (no glyphs).
135
- if (facts.permission !== undefined && facts.permission !== '') groups.push(facts.permission)
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
+ }
136
456
  // The sandbox override stays implicit when it merely echoes the preset —
137
457
  // the badge exists to surface a divergence, not to duplicate the label.
138
- const sandbox = facts.sandbox ?? ''
139
- if (sandbox !== '' && sandbox.toLowerCase() !== facts.permission.toLowerCase()) {
140
- groups.push(`sandbox ${sandbox}`)
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' })
141
461
  }
142
- // Goal badge: round progress while active, the phase otherwise.
143
- if (facts.goal !== undefined) {
144
- groups.push(facts.goal.phase === 'active'
145
- ? `◎ r${facts.goal.rounds}/${facts.goal.max}`
146
- : `◎ ${facts.goal.phase}`)
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
+ },
147
602
  }
148
- return groups
149
603
  }