@toddzheng024/dscode-bundle 0.7.11 → 0.7.13

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 (154) hide show
  1. package/THIRD_PARTY_NOTICES.md +3 -3
  2. package/cordis.patch.yml +1 -1
  3. package/package.json +5 -4
  4. package/plugins/i18n/messages.mjs +6 -6
  5. package/plugins/session-metrics/view.mjs +16 -5
  6. package/plugins/tui-tools/index.mjs +14 -1
  7. package/vendor/tui/lib/app.mjs +6674 -0
  8. package/vendor/tui/lib/approval.mjs +122 -0
  9. package/vendor/tui/lib/attachments.mjs +218 -0
  10. package/vendor/tui/lib/authorization-panel.mjs +242 -0
  11. package/vendor/tui/lib/authorization.mjs +111 -0
  12. package/vendor/tui/lib/commands.mjs +123 -0
  13. package/vendor/tui/lib/dscode/chat.mjs +64 -0
  14. package/vendor/tui/lib/dscode/flags.mjs +32 -0
  15. package/vendor/tui/lib/dscode/model-search.mjs +68 -0
  16. package/vendor/tui/lib/dscode/paste.mjs +87 -0
  17. package/vendor/tui/lib/dscode/telemetry.mjs +82 -0
  18. package/vendor/tui/lib/dscode/welcome.mjs +100 -0
  19. package/vendor/tui/lib/editor-keys.mjs +346 -0
  20. package/vendor/tui/lib/editor.mjs +50 -0
  21. package/vendor/tui/lib/fork.mjs +24 -0
  22. package/vendor/tui/lib/git-workflow.mjs +278 -0
  23. package/vendor/tui/{types/history.d.ts → lib/history.mjs} +69 -26
  24. package/vendor/tui/{types/i18n.d.ts → lib/i18n.mjs} +23 -15
  25. package/vendor/tui/lib/index.mjs +2198 -0
  26. package/vendor/tui/lib/input-split.mjs +170 -0
  27. package/vendor/tui/lib/internals.mjs +90 -0
  28. package/vendor/tui/lib/invariant.mjs +22 -0
  29. package/vendor/tui/lib/kernel-panels.mjs +1265 -0
  30. package/vendor/tui/lib/keyboard.mjs +283 -0
  31. package/vendor/tui/lib/language-panel.mjs +41 -0
  32. package/vendor/tui/lib/locales/en.mjs +495 -0
  33. package/vendor/tui/lib/locales/zh.mjs +495 -0
  34. package/vendor/tui/lib/mentions.mjs +150 -0
  35. package/vendor/tui/lib/model-capabilities.mjs +245 -0
  36. package/vendor/tui/lib/models.mjs +215 -0
  37. package/vendor/tui/{types/panel-accent.d.ts → lib/panel-accent.mjs} +11 -7
  38. package/vendor/tui/lib/permissions.mjs +45 -0
  39. package/vendor/tui/lib/plugin-inventory.mjs +27 -0
  40. package/vendor/tui/lib/presets.mjs +46 -0
  41. package/vendor/tui/lib/provider-settings.mjs +600 -0
  42. package/vendor/tui/lib/questions.mjs +116 -0
  43. package/vendor/tui/lib/rainbow.mjs +181 -0
  44. package/vendor/tui/lib/render/animations.mjs +665 -0
  45. package/vendor/tui/lib/render/editor.mjs +437 -0
  46. package/vendor/tui/lib/render/export.mjs +119 -0
  47. package/vendor/tui/lib/render/fuzzy.mjs +79 -0
  48. package/vendor/tui/lib/render/ime-cursor.mjs +135 -0
  49. package/vendor/tui/lib/render/inspector.mjs +93 -0
  50. package/vendor/tui/lib/render/lines.mjs +538 -0
  51. package/vendor/tui/lib/render/markdown.mjs +592 -0
  52. package/vendor/tui/lib/render/projection.mjs +1862 -0
  53. package/vendor/tui/lib/render/status.mjs +591 -0
  54. package/vendor/tui/lib/render/text.mjs +157 -0
  55. package/vendor/tui/lib/render/tool-detail.mjs +187 -0
  56. package/vendor/tui/lib/render/tool-preview.mjs +79 -0
  57. package/vendor/tui/lib/render/usage.mjs +336 -0
  58. package/vendor/tui/lib/render/width.mjs +191 -0
  59. package/vendor/tui/lib/session-directory.mjs +329 -0
  60. package/vendor/tui/lib/session-query.mjs +184 -0
  61. package/vendor/tui/lib/session-switch.mjs +51 -0
  62. package/vendor/tui/lib/settings-file.mjs +75 -0
  63. package/vendor/tui/lib/skills.mjs +119 -0
  64. package/vendor/tui/lib/startup.mjs +111 -0
  65. package/vendor/tui/lib/store.mjs +129 -0
  66. package/vendor/tui/lib/subagents.mjs +200 -0
  67. package/vendor/tui/lib/terminal-title.mjs +186 -0
  68. package/vendor/tui/lib/theme-panel.mjs +60 -0
  69. package/vendor/tui/lib/theme.mjs +379 -0
  70. package/vendor/tui/lib/update-panel.mjs +239 -0
  71. package/vendor/tui/lib/update.mjs +101 -0
  72. package/vendor/tui/lib/version.mjs +105 -0
  73. package/vendor/tui/lib/whale-glyph.mjs +20 -0
  74. package/vendor/tui/LICENSE +0 -21
  75. package/vendor/tui/devtools-CdTl3MNy.mjs +0 -3643
  76. package/vendor/tui/dscode-email/cli.mjs +0 -32
  77. package/vendor/tui/dscode-email/contacts.mjs +0 -36
  78. package/vendor/tui/dscode-email/gmail-oauth.mjs +0 -69
  79. package/vendor/tui/dscode-email/gmail-store.mjs +0 -2
  80. package/vendor/tui/dscode-email/gmail.mjs +0 -194
  81. package/vendor/tui/dscode-email/imap.mjs +0 -136
  82. package/vendor/tui/dscode-email/inbox.d.mts +0 -25
  83. package/vendor/tui/dscode-email/inbox.mjs +0 -76
  84. package/vendor/tui/dscode-email/smtp.mjs +0 -69
  85. package/vendor/tui/dscode-email/store.mjs +0 -32
  86. package/vendor/tui/dscode-providers/catalog.mjs +0 -136
  87. package/vendor/tui/dscode-providers/effort.mjs +0 -35
  88. package/vendor/tui/dscode-providers/openrouter-account.mjs +0 -171
  89. package/vendor/tui/index.mjs +0 -44483
  90. package/vendor/tui/invariant.mjs +0 -21
  91. package/vendor/tui/rolldown-runtime-CMFfr-1z.mjs +0 -26
  92. package/vendor/tui/session-query.mjs +0 -150
  93. package/vendor/tui/startup.mjs +0 -109
  94. package/vendor/tui/theme-7u5Qo3dF.mjs +0 -1265
  95. package/vendor/tui/types/app.d.ts +0 -348
  96. package/vendor/tui/types/approval.d.ts +0 -59
  97. package/vendor/tui/types/attachments.d.ts +0 -52
  98. package/vendor/tui/types/authorization-panel.d.ts +0 -22
  99. package/vendor/tui/types/authorization.d.ts +0 -36
  100. package/vendor/tui/types/commands.d.ts +0 -52
  101. package/vendor/tui/types/editor-keys.d.ts +0 -105
  102. package/vendor/tui/types/editor.d.ts +0 -6
  103. package/vendor/tui/types/fork.d.ts +0 -8
  104. package/vendor/tui/types/git-workflow.d.ts +0 -121
  105. package/vendor/tui/types/index.d.ts +0 -223
  106. package/vendor/tui/types/input-split.d.ts +0 -54
  107. package/vendor/tui/types/internals.d.ts +0 -26
  108. package/vendor/tui/types/invariant.d.ts +0 -15
  109. package/vendor/tui/types/kernel-panels.d.ts +0 -245
  110. package/vendor/tui/types/keyboard.d.ts +0 -80
  111. package/vendor/tui/types/language-panel.d.ts +0 -12
  112. package/vendor/tui/types/locales/en.d.ts +0 -450
  113. package/vendor/tui/types/locales/zh.d.ts +0 -9
  114. package/vendor/tui/types/mentions.d.ts +0 -85
  115. package/vendor/tui/types/model-capabilities.d.ts +0 -82
  116. package/vendor/tui/types/models.d.ts +0 -133
  117. package/vendor/tui/types/permissions.d.ts +0 -27
  118. package/vendor/tui/types/plugin-inventory.d.ts +0 -11
  119. package/vendor/tui/types/presets.d.ts +0 -22
  120. package/vendor/tui/types/provider-settings.d.ts +0 -239
  121. package/vendor/tui/types/questions.d.ts +0 -54
  122. package/vendor/tui/types/rainbow.d.ts +0 -69
  123. package/vendor/tui/types/render/animations.d.ts +0 -307
  124. package/vendor/tui/types/render/editor.d.ts +0 -163
  125. package/vendor/tui/types/render/export.d.ts +0 -9
  126. package/vendor/tui/types/render/fuzzy.d.ts +0 -21
  127. package/vendor/tui/types/render/ime-cursor.d.ts +0 -60
  128. package/vendor/tui/types/render/inspector.d.ts +0 -62
  129. package/vendor/tui/types/render/lines.d.ts +0 -86
  130. package/vendor/tui/types/render/markdown.d.ts +0 -29
  131. package/vendor/tui/types/render/projection.d.ts +0 -587
  132. package/vendor/tui/types/render/status.d.ts +0 -196
  133. package/vendor/tui/types/render/text.d.ts +0 -64
  134. package/vendor/tui/types/render/tool-detail.d.ts +0 -94
  135. package/vendor/tui/types/render/tool-preview.d.ts +0 -28
  136. package/vendor/tui/types/render/usage.d.ts +0 -113
  137. package/vendor/tui/types/render/width.d.ts +0 -29
  138. package/vendor/tui/types/session-directory.d.ts +0 -190
  139. package/vendor/tui/types/session-query.d.ts +0 -92
  140. package/vendor/tui/types/session-switch.d.ts +0 -17
  141. package/vendor/tui/types/settings-file.d.ts +0 -41
  142. package/vendor/tui/types/skills.d.ts +0 -47
  143. package/vendor/tui/types/startup.d.ts +0 -65
  144. package/vendor/tui/types/store.d.ts +0 -58
  145. package/vendor/tui/types/subagents.d.ts +0 -70
  146. package/vendor/tui/types/terminal-title.d.ts +0 -66
  147. package/vendor/tui/types/theme-panel.d.ts +0 -24
  148. package/vendor/tui/types/theme.d.ts +0 -434
  149. package/vendor/tui/types/update-panel.d.ts +0 -49
  150. package/vendor/tui/types/update.d.ts +0 -75
  151. package/vendor/tui/types/version.d.ts +0 -19
  152. package/vendor/tui/types/whale-glyph.d.ts +0 -6
  153. /package/vendor/tui/{dscode-clipboard-image → lib/dscode/clipboard-image}/clipboard-image.swift +0 -0
  154. /package/vendor/tui/{dscode-clipboard-image → lib/dscode/clipboard-image}/index.mjs +0 -0
@@ -0,0 +1,591 @@
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
+ import { visibleColumns } from './markdown.mjs';
13
+ import { billedInputTokens } from './usage.mjs';
14
+ import { singleLineText, truncateColumns, formatTokens } from './text.mjs';
15
+ // Re-exported for the callers that have always read the formatter here.
16
+ export { formatTokens };
17
+ import { t } from '../i18n.mjs';
18
+ /**
19
+ * Compact duration: 45.2s under a minute, 2m42s from there on.
20
+ * @param ms - duration in milliseconds.
21
+ * @returns display string.
22
+ */
23
+ export function formatDuration(ms) {
24
+ const s = ms / 1_000;
25
+ if (s < 60)
26
+ return String(Math.round(s * 10) / 10) + 's';
27
+ const whole = Math.round(s);
28
+ return Math.floor(whole / 60) + 'm' + (whole % 60) + 's';
29
+ }
30
+ /**
31
+ * Compact decode rate: one decimal under a hundred, whole below a thousand,
32
+ * then thousands (15.3 / 124 / 1.2K).
33
+ * @param n - tokens per second.
34
+ * @returns display string.
35
+ */
36
+ function formatRate(n) {
37
+ if (n < 100)
38
+ return String(Math.round(n * 10) / 10);
39
+ if (n < 1_000)
40
+ return String(Math.round(n));
41
+ return String(Math.round(n / 100) / 10) + 'K';
42
+ }
43
+ /**
44
+ * Cache-hit share of billed prompt-side input. The denominator is the same
45
+ * billed total the /usage panel shows (uncached input plus both cache
46
+ * buckets), so the two readouts can never disagree.
47
+ * @param usage - cumulative token totals.
48
+ * @returns percent rounded to one decimal place, or null when no input was billed.
49
+ */
50
+ export function cacheHitPercent(usage) {
51
+ const billed = billedInputTokens(usage);
52
+ return billed === 0
53
+ ? null
54
+ : Math.round(usage.cacheReadTokens / billed * 1_000) / 10;
55
+ }
56
+ /** Separator between leading clusters. */
57
+ export const STATUS_GROUP_SEPARATOR = ' | ';
58
+ /** Separator between trailing state spans. */
59
+ export const STATUS_ITEM_SEPARATOR = ' | ';
60
+ /** The Codex-style mode cycle hint appended to the permission badge. */
61
+ /** English compatibility value for callers that only measure the default layout. */
62
+ export const STATUS_CYCLE_HINT = ' (shift+tab to cycle)';
63
+ /** Localized mode-cycle hint used by the live layout. */
64
+ export function statusCycleHint() {
65
+ return ` ${t('status.cycleHint')}`;
66
+ }
67
+ /**
68
+ * Interior columns of the context bar. The layout starts every bar at this
69
+ * width so the drop ladder can pre-measure the group, then degrades the
70
+ * readout and shrinks the bar inside a tighter budget before dropping the
71
+ * group (see CONTEXT_MIN_WIDTH) rather than asking the layout for more room.
72
+ */
73
+ export const CONTEXT_BAR_WIDTH = 24;
74
+ /** Occupancy at which the usage readout flips from brand blue to amber. */
75
+ const CONTEXT_WARN_PERCENT = 90;
76
+ /**
77
+ * Narrowest bar width the drop ladder keeps before dropping the whole
78
+ * context group: the bar shrinks to this floor first (the absolute readout
79
+ * survives), and only past it does the readout degrade and the group go.
80
+ */
81
+ const CONTEXT_MIN_WIDTH = 5;
82
+ /**
83
+ * Render context occupancy as ONE stepless proportional bar: a solid
84
+ * DeepSeek-blue fill run tracking the occupancy and a dim dotted free
85
+ * track for the rest. Nothing else lives inside the bar — the usage
86
+ * readout rides outside it (see contextGroupSpans) — so the geometry
87
+ * always reads as the true remaining share. A given occupancy always
88
+ * renders the identical bar.
89
+ * @param usedTokens - reported used tokens.
90
+ * @param contextWindow - route capacity.
91
+ * @param width - total bar columns.
92
+ * @returns tone-split spans for the footer to paint.
93
+ */
94
+ export function contextBar(usedTokens, contextWindow, width) {
95
+ if (width <= 0 || contextWindow <= 0)
96
+ return [];
97
+ const used = Math.max(0, usedTokens);
98
+ const fill = Math.min(width, Math.max(0, Math.round(used / contextWindow * width)));
99
+ const spans = [];
100
+ if (fill > 0)
101
+ spans.push({ text: '█'.repeat(fill), tone: 'ctxFill' });
102
+ const free = width - fill;
103
+ if (free > 0)
104
+ spans.push({ text: '░'.repeat(free), tone: 'label' });
105
+ return spans;
106
+ }
107
+ /**
108
+ * Compose the context group: the proportional bar plus the usage readout
109
+ * OUTSIDE the bar, so the dotted track keeps its proportional meaning no
110
+ * matter how wide the readout is. `full` reads `12.3K/1.0M 25%`; `percent`
111
+ * drops the absolute pair; `none` is the bare bar. The readout turns amber
112
+ * once occupancy reaches the warning threshold.
113
+ */
114
+ export function contextGroupSpans(usedTokens, contextWindow, barWidth, readout) {
115
+ const spans = [{ text: t('status.label.context') + ' ', tone: 'label' }];
116
+ spans.push(...contextBar(usedTokens, contextWindow, barWidth));
117
+ if (readout === 'none' || barWidth <= 0 || contextWindow <= 0)
118
+ return spans;
119
+ const used = Math.max(0, usedTokens);
120
+ const percent = Math.round(used / contextWindow * 100);
121
+ const text = readout === 'full'
122
+ ? `${formatTokens(used)}/${formatTokens(contextWindow)} ${percent}%`
123
+ : `${percent}%`;
124
+ spans.push({ text: ' ', tone: 'label' }, { text, tone: percent >= CONTEXT_WARN_PERCENT ? 'warn' : 'value' });
125
+ return spans;
126
+ }
127
+ /** The full item catalog in canonical order (the /statusline default). */
128
+ export const STATUS_ITEMS = [
129
+ { id: 'model', label: 'model', description: 'provider/model serving this session', side: 'left' },
130
+ { id: 'cwd', label: 'cwd', description: 'working-directory basename', side: 'left' },
131
+ { id: 'mode', label: 'mode', description: 'agent preset composing the session', side: 'left' },
132
+ { id: 'branch', label: 'branch', description: 'git branch inside a repository', side: 'left' },
133
+ { id: 'context', label: 'context', description: 'context-window occupancy meter', side: 'left' },
134
+ { id: 'permission', label: 'permission', description: 'permission preset badge with cycle hint', side: 'right' },
135
+ { id: 'plan', label: 'plan', description: 'plan-mode state mark', side: 'left' },
136
+ { id: 'turns', label: 'turns', description: 'turn and step counters', side: 'left' },
137
+ { id: 'durations', label: 'durations', description: 'llm/ttft/decode/tool wall time', side: 'left' },
138
+ { id: 'cache', label: 'cache', description: 'cache-hit share of billed input', side: 'left' },
139
+ { id: 'tokens', label: 'tokens', description: 'cumulative input/output tokens', side: 'left' },
140
+ { id: 'title', label: 'title', description: 'session title or short id', side: 'left' },
141
+ { id: 'goal', label: 'goal', description: 'live goal phase and round progress', side: 'left' },
142
+ { id: 'sandbox', label: 'sandbox', description: 'divergent sandbox-mode override', side: 'left' },
143
+ ];
144
+ /**
145
+ * Default order: the whole catalog (matches the pre-customization bar).
146
+ * The busy dot is not an item — it always leads the identity cluster.
147
+ */
148
+ export const DEFAULT_STATUSLINE_ITEMS = ['model', 'permission', 'title', 'plan', 'goal', 'sandbox'];
149
+ /**
150
+ * Parse a persisted statusline item list. The stored value is the ordered
151
+ * set of ENABLED items (the Codex /statusline contract): unknown ids and
152
+ * duplicates drop out, and a non-array value (missing or corrupt file)
153
+ * falls back to the full default set. An explicitly empty array is valid —
154
+ * the bar degrades to its busy dot alone.
155
+ * @param value - the raw parsed JSON value (expected string[]).
156
+ * @returns the normalized ordered item list.
157
+ */
158
+ export function parseStatuslineItems(value) {
159
+ if (!Array.isArray(value))
160
+ return [...DEFAULT_STATUSLINE_ITEMS];
161
+ const known = new Set(STATUS_ITEMS.map(item => item.id));
162
+ const kept = [];
163
+ for (const entry of value) {
164
+ if (typeof entry === 'string' && known.has(entry) && !kept.includes(entry)) {
165
+ kept.push(entry);
166
+ }
167
+ }
168
+ return kept;
169
+ }
170
+ /** Minimum blank gap kept between the leading and trailing sides. */
171
+ const LEFT_RIGHT_GAP = 2;
172
+ /** Column held back so Ink/yoga measurement drift can never force a wrap. */
173
+ const WIDTH_SAFETY = 1;
174
+ /**
175
+ * Extra left padding on the secondary row so its content aligns with the
176
+ * model name's left edge on the primary row (padding 2 + busy dot 2). The
177
+ * layout subtracts it from row 2's budget so the indent can never wrap it.
178
+ */
179
+ export const STATUS_ROW2_INDENT = 2;
180
+ /** Column budget for the session title before it ellipsizes. */
181
+ const TITLE_BUDGET = 48;
182
+ /**
183
+ * Primary-row drop ranks: context drops before permission; the identity
184
+ * cluster never drops and ellipsizes only after the right badge is gone.
185
+ * Secondary-row groups reuse the remaining ranks independently.
186
+ */
187
+ const RANK_TITLE = 10;
188
+ const RANK_TOKENS = 50;
189
+ const RANK_COUNTS = 90;
190
+ const RANK_CONTEXT = 90;
191
+ const RANK_SANDBOX = 92;
192
+ const RANK_GOAL = 95;
193
+ const RANK_BADGE = 100;
194
+ const RANK_IDENTITY = Number.POSITIVE_INFINITY;
195
+ /** Row 2 drop ranks: title and durations go first; state and counts survive longest. */
196
+ const RANK2_DURATIONS = 40;
197
+ const RANK2_CACHE = 50;
198
+ const RANK2_PLAN = 70;
199
+ /**
200
+ * Traffic-light tone for a permission preset: read-only stays success green,
201
+ * full access reads error red, and every workspace-scoped middle ground
202
+ * (including unknown presets) reads warning amber.
203
+ * @param permission - active permission preset label.
204
+ * @returns tone for the badge span.
205
+ */
206
+ export function permissionTone(permission) {
207
+ const label = permission.toLowerCase();
208
+ if (label.includes('read'))
209
+ return 'success';
210
+ if (label.includes('danger') || label.includes('full'))
211
+ return 'error';
212
+ return 'warn';
213
+ }
214
+ /** Display-safe external text: one row, controls escaped. */
215
+ function safe(text) {
216
+ return singleLineText(text);
217
+ }
218
+ /** Dim junction separator span inside a cluster. */
219
+ function sep() {
220
+ return { text: ' | ', tone: 'label' };
221
+ }
222
+ /** Total visible columns of a span list (separators ride inside the spans). */
223
+ function spansWidth(spans) {
224
+ let width = 0;
225
+ for (const span of spans)
226
+ width += visibleColumns(span.text);
227
+ return width;
228
+ }
229
+ /** Join widths of parts with one fixed separator between neighbors. */
230
+ function joinWidth(parts, separator) {
231
+ if (parts.length === 0)
232
+ return 0;
233
+ let width = 0;
234
+ for (const part of parts)
235
+ width += part;
236
+ return width + separator * (parts.length - 1);
237
+ }
238
+ /** Build every candidate group/span with its drop rank and item id. */
239
+ /** dscode: the footer telemetry header — `provider: model @ effort`. */
240
+ export function dscodeFooterHeader(facts, stats) {
241
+ const raw = typeof facts.model === 'string' ? facts.model : '';
242
+ if (raw === '')
243
+ return '';
244
+ const cut = raw.indexOf('/');
245
+ const provider = cut > 0 ? raw.slice(0, cut) : '';
246
+ const model = cut > 0 ? raw.slice(cut + 1) : raw;
247
+ const effort = typeof facts.effort === 'string' && facts.effort !== '' ? facts.effort : (typeof stats.reasoningEffort === 'string' ? stats.reasoningEffort : '');
248
+ return (provider === '' ? model : provider + ': ' + model) + (effort === '' ? '' : ' @ ' + effort);
249
+ }
250
+ /** dscode: row 1's identity lead — the session title, else the model and its effort. */
251
+ function dscodeStatusLead(facts, model, effort) {
252
+ const source = facts.title !== undefined && facts.title !== '' ? facts.title : facts.sessionId;
253
+ const title = source === undefined || source === '' ? '' : truncateColumns(safe(source), TITLE_BUDGET);
254
+ if (title !== '')
255
+ return title;
256
+ return effort === '' ? model : model + ' | ' + effort;
257
+ }
258
+ function buildCandidates(facts, stats, busy, enabled, contextWidth) {
259
+ const identity = [
260
+ { text: busy ? '● ' : '○ ', tone: busy ? 'live' : 'meta' },
261
+ ];
262
+ // The dot glues straight to the first fact; further facts join through
263
+ // explicit dim separators, so an absent model never strands a leading ' · '.
264
+ const push = (span) => {
265
+ if (identity.length > 1)
266
+ identity.push(sep());
267
+ identity.push(span);
268
+ };
269
+ const model = safe(facts.model).split('/').at(-1) ?? '';
270
+ // dscode: row 1 leads with the session title (falling back to the model), and the
271
+ // full `provider: model @ effort` header rides the telemetry segment instead.
272
+ if ((model !== '' && enabled.has('model')) || enabled.has('title')) {
273
+ const effort = safe(facts.effort ?? stats.reasoningEffort);
274
+ push({ text: dscodeStatusLead(facts, model, effort), tone: 'model' });
275
+ }
276
+ const cwd = safe(facts.cwd);
277
+ if (cwd !== '' && enabled.has('cwd'))
278
+ push({ text: cwd, tone: 'path' });
279
+ const mode = safe(facts.mode ?? '');
280
+ if (mode !== '' && enabled.has('mode')) {
281
+ push({ text: t('status.label.mode') + ' ', tone: 'label' });
282
+ identity.push({ text: mode, tone: 'accent' });
283
+ }
284
+ const branch = safe(facts.branch);
285
+ if (branch !== '' && enabled.has('branch'))
286
+ push({ text: '⑂ ' + branch, tone: 'branch' });
287
+ const left = [
288
+ { group: { spans: identity }, rank: RANK_IDENTITY, id: 'identity' },
289
+ ];
290
+ const right = [];
291
+ const row2 = [];
292
+ if (stats.turns > 0 || stats.steps > 0) {
293
+ if (enabled.has('turns')) {
294
+ // Label/value pairs join through explicit dim separators.
295
+ const counts = [];
296
+ const pair = (label, value) => {
297
+ if (counts.length > 0)
298
+ counts.push(sep());
299
+ counts.push({ text: label + ' ', tone: 'label' }, { text: value, tone: 'value' });
300
+ };
301
+ pair(t('status.label.turns'), String(stats.turns));
302
+ pair(t('status.label.steps'), String(stats.steps));
303
+ row2.push({ group: { spans: counts }, rank: RANK_COUNTS, id: 'turns' });
304
+ }
305
+ if (enabled.has('durations')) {
306
+ // Model round-trip, first-token latency, decode rate, and tool wall
307
+ // time; the label keeps its one trailing space so each reads as one
308
+ // figure ('model 45.2s'). Named in full — no single-letter codes.
309
+ const durations = [];
310
+ const pair = (label, value) => {
311
+ if (durations.length > 0)
312
+ durations.push(sep());
313
+ durations.push({ text: label + ' ', tone: 'label' }, { text: value, tone: 'value' });
314
+ };
315
+ if (stats.llmMs > 0)
316
+ pair(t('status.label.modelTime'), formatDuration(stats.llmMs));
317
+ if (stats.ttftSteps > 0)
318
+ pair(t('status.label.latency'), formatDuration(stats.ttftMs / stats.ttftSteps));
319
+ if (stats.decodeMs > 0 && stats.decodeTokens > 0) {
320
+ if (durations.length > 0)
321
+ durations.push(sep());
322
+ durations.push({ text: formatRate(stats.decodeTokens / (stats.decodeMs / 1_000)), tone: 'value' }, { text: t('status.label.tokensPerSec'), tone: 'label' });
323
+ }
324
+ if (stats.toolMs > 0)
325
+ pair(t('status.label.tool'), formatDuration(stats.toolMs));
326
+ if (durations.length > 0) {
327
+ row2.push({ group: { spans: durations }, rank: RANK2_DURATIONS, id: 'durations' });
328
+ }
329
+ }
330
+ }
331
+ // The cache group carries both facts about cached prompt tokens: how many
332
+ // were read and what share of the billed prompt that was. The read count
333
+ // lives here rather than in the tokens group so the tokens group keeps
334
+ // meaning "what the provider billed outside the cache".
335
+ const cacheHit = cacheHitPercent(stats.usage);
336
+ if (cacheHit !== null && enabled.has('cache')) {
337
+ const spans = [{ text: t('status.label.cache') + ' ', tone: 'label' }];
338
+ if (stats.usage.cacheReadTokens > 0) {
339
+ spans.push({ text: formatTokens(stats.usage.cacheReadTokens), tone: 'value' }, sep());
340
+ }
341
+ spans.push({ text: cacheHit + '%', tone: 'value' });
342
+ row2.push({ group: { spans }, rank: RANK2_CACHE, id: 'cache' });
343
+ }
344
+ // Context occupancy as a purely proportional bar with the usage readout
345
+ // riding outside it: the used total is the most recent reported prompt
346
+ // size against the advertised route capacity.
347
+ if (stats.contextWindow > 0 && stats.lastPromptTokens > 0 && enabled.has('context')) {
348
+ left.push({
349
+ group: {
350
+ spans: contextGroupSpans(stats.lastPromptTokens, stats.contextWindow, contextWidth, 'full'),
351
+ },
352
+ rank: RANK_CONTEXT,
353
+ id: 'context',
354
+ });
355
+ }
356
+ if ((stats.usage.uncachedInputTokens > 0 || stats.usage.outputTokens > 0) && enabled.has('tokens')) {
357
+ const tokens = [];
358
+ const pair = (label, value) => {
359
+ if (tokens.length > 0)
360
+ tokens.push(sep());
361
+ tokens.push({ text: label + ' ', tone: 'label' }, { text: value, tone: 'value' });
362
+ };
363
+ pair(t('status.label.in'), formatTokens(stats.usage.uncachedInputTokens));
364
+ pair(t('status.label.out'), formatTokens(stats.usage.outputTokens));
365
+ row2.push({ group: { spans: tokens }, rank: RANK_TOKENS, id: 'tokens' });
366
+ }
367
+ // dscode: the session title moved to row 1's identity lead (dscodeStatusLead),
368
+ // so row 2 no longer carries it.
369
+ // Secondary state rides row 2; permission alone remains right-pinned on row 1.
370
+ if (facts.goal !== undefined && enabled.has('goal')) {
371
+ row2.push({
372
+ group: {
373
+ spans: [{
374
+ text: facts.goal.phase === 'active'
375
+ ? '◎ round ' + facts.goal.rounds + '/' + facts.goal.max
376
+ : '◎ ' + safe(facts.goal.phase),
377
+ tone: 'accent',
378
+ }],
379
+ },
380
+ rank: RANK_GOAL,
381
+ id: 'goal',
382
+ });
383
+ }
384
+ // The sandbox override stays implicit when it merely echoes the preset.
385
+ const sandbox = safe(facts.sandbox ?? '');
386
+ if (sandbox !== '' && sandbox.toLowerCase() !== facts.permission.toLowerCase() && enabled.has('sandbox')) {
387
+ row2.push({ group: { spans: [{ text: 'sandbox ' + sandbox, tone: 'warn' }] }, rank: RANK_SANDBOX, id: 'sandbox' });
388
+ }
389
+ const permission = safe(facts.permission);
390
+ let badge = -1;
391
+ // The plan STATION names itself in the permission badge: with the most
392
+ // restrictive preset active, plan mode reads as the green fourth cycle
393
+ // station 'plan' (that preset IS the station's permission layer). Plan on
394
+ // any other preset (a typed /plan mid-session) stays orthogonal: the badge
395
+ // keeps naming the preset and row 2 carries the green plan marker.
396
+ const planStation = facts.plan && permissionTone(permission) === 'success';
397
+ // dscode: the permission badge rides row 1 beside the title rather than
398
+ // right-pinning itself on the identity row.
399
+ if (permission !== '' && enabled.has('permission')) {
400
+ left.push({
401
+ group: {
402
+ spans: planStation
403
+ ? [{ text: 'plan on', tone: 'plan' }]
404
+ : [{ text: permission, tone: permissionTone(permission) }],
405
+ },
406
+ rank: RANK_BADGE,
407
+ id: 'permission',
408
+ });
409
+ badge = left.length - 1;
410
+ }
411
+ if (facts.plan && enabled.has('plan')) {
412
+ row2.push({ group: { spans: [{ text: '⧉ plan', tone: 'accent' }] }, rank: RANK2_PLAN, id: 'plan' });
413
+ }
414
+ return { left, right, badge, row2 };
415
+ }
416
+ /**
417
+ * Compose the two-row footer layout under a column budget. Row 1 keeps model,
418
+ * cwd, mode, branch, context, then the right-pinned permission badge and cycle
419
+ * hint. It drops hint, context, and permission before ellipsizing identity.
420
+ * Row 2 fits all secondary figures and state within its own budget.
421
+ * @param facts - identity facts resolved by the runner.
422
+ * @param stats - session figures folded from the durable log.
423
+ * @param columns - usable columns for each row (before their left padding).
424
+ * @param options - 'busy' hides the cycle hint while a turn runs (Codex
425
+ * keeps mode hints idle-only); 'items' is the ordered enabled-item config
426
+ * from /statusline (defaults to the full catalog). Display order follows the
427
+ * config per side while the drop ladder keeps its fixed ranks.
428
+ * @returns the two rows to render; row1.left is never empty.
429
+ */
430
+ export function layoutStatusBar(facts, stats, columns, options = {}) {
431
+ const busy = options.busy === true;
432
+ const items = options.items ?? DEFAULT_STATUSLINE_ITEMS;
433
+ const enabled = new Set(items);
434
+ const budget = Math.max(1, Math.floor(columns) - WIDTH_SAFETY);
435
+ // The context meter shares the same measured width as the composer. Its
436
+ // initial width is a ceiling only; the drop ladder below shrinks it around
437
+ // the other status groups before dropping any group.
438
+ const maxContextWidth = Math.max(CONTEXT_MIN_WIDTH, Math.min(budget, Math.floor(options.contextWidth ?? CONTEXT_BAR_WIDTH)));
439
+ const { left, right, badge, row2 } = buildCandidates(facts, stats, busy, enabled, maxContextWidth);
440
+ const groupSeparator = visibleColumns(STATUS_GROUP_SEPARATOR);
441
+ const itemSeparator = visibleColumns(STATUS_ITEM_SEPARATOR);
442
+ // Display order follows the config per side (Codex /statusline reorder).
443
+ // The identity cluster stays anchored first — it owns the busy dot.
444
+ const position = new Map(items.map((id, index) => [id, index]));
445
+ const byPosition = (a, b) => (position.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (position.get(b.id) ?? Number.MAX_SAFE_INTEGER);
446
+ const orderedLeft = [left[0], ...left.slice(1).sort(byPosition)];
447
+ const orderedRight = right.slice().sort(byPosition);
448
+ const orderedRow2 = row2.slice().sort(byPosition);
449
+ let hint = badge >= 0 && !busy;
450
+ const leftKept = [...orderedLeft];
451
+ const rightKept = [...orderedRight];
452
+ // Context degradation state: the readout drops its absolute pair first,
453
+ // then the bar shrinks inside its own budget, and only then is the whole
454
+ // group removed — the proportional meter outlives the auxiliary numbers.
455
+ // Rebuilding replaces the group's spans in place so width() re-measures it.
456
+ let contextReadout = 'full';
457
+ let contextWidth = maxContextWidth;
458
+ const rebuildContext = () => {
459
+ const index = leftKept.findIndex(entry => entry.id === 'context');
460
+ if (index < 0)
461
+ return;
462
+ leftKept[index] = {
463
+ group: {
464
+ spans: contextGroupSpans(stats.lastPromptTokens, stats.contextWindow, contextWidth, contextReadout),
465
+ },
466
+ rank: RANK_CONTEXT,
467
+ id: 'context',
468
+ };
469
+ };
470
+ const width = () => {
471
+ const leftWidth = joinWidth(leftKept.map(entry => spansWidth(entry.group.spans)), groupSeparator);
472
+ const rightWidth = joinWidth(rightKept.map(entry => visibleColumns(entry.span.text)), itemSeparator)
473
+ + (hint ? visibleColumns(statusCycleHint()) : 0);
474
+ return rightWidth > 0 ? leftWidth + LEFT_RIGHT_GAP + rightWidth : leftWidth;
475
+ };
476
+ while (width() > budget) {
477
+ // Context is the lowest-priority visual group: the bar shrinks inside
478
+ // its own budget first (the absolute readout survives), then the
479
+ // readout degrades to the bare percent, and only then does the whole
480
+ // group go — before the permission badge or its Shift+Tab affordance
481
+ // is touched.
482
+ if (leftKept.some(entry => entry.id === 'context')) {
483
+ if (contextWidth > CONTEXT_MIN_WIDTH) {
484
+ const overflow = width() - budget;
485
+ contextWidth = Math.max(CONTEXT_MIN_WIDTH, contextWidth - overflow);
486
+ rebuildContext();
487
+ continue;
488
+ }
489
+ if (contextReadout === 'full') {
490
+ contextReadout = 'percent';
491
+ rebuildContext();
492
+ continue;
493
+ }
494
+ leftKept.splice(leftKept.findIndex(entry => entry.id === 'context'), 1);
495
+ continue;
496
+ }
497
+ if (hint && rightKept.length > 0 && leftKept.length > 0) {
498
+ const identity = leftKept[0];
499
+ const identityText = identity.group.spans.map(span => span.text).join('');
500
+ const rightWidth = joinWidth(rightKept.map(entry => visibleColumns(entry.span.text)), itemSeparator);
501
+ const identityBudget = budget - rightWidth - LEFT_RIGHT_GAP - visibleColumns(statusCycleHint());
502
+ if (identityBudget > 0 && visibleColumns(identityText) > identityBudget) {
503
+ leftKept[0] = {
504
+ ...identity,
505
+ group: { spans: [{ text: truncateColumns(identityText, identityBudget), tone: 'model' }] },
506
+ };
507
+ continue;
508
+ }
509
+ }
510
+ if (hint) {
511
+ hint = false;
512
+ continue;
513
+ }
514
+ let dropLeft = -1;
515
+ let dropRight = -1;
516
+ let dropRank = Number.POSITIVE_INFINITY;
517
+ for (let index = 0; index < leftKept.length; index += 1) {
518
+ const rank = leftKept[index].rank;
519
+ if (rank < dropRank) {
520
+ dropRank = rank;
521
+ dropLeft = index;
522
+ dropRight = -1;
523
+ }
524
+ }
525
+ for (let index = 0; index < rightKept.length; index += 1) {
526
+ const rank = rightKept[index].rank;
527
+ if (rank < dropRank) {
528
+ dropRank = rank;
529
+ dropRight = index;
530
+ dropLeft = -1;
531
+ }
532
+ }
533
+ if (dropLeft < 0 && dropRight < 0)
534
+ break;
535
+ if (dropLeft >= 0) {
536
+ leftKept.splice(dropLeft, 1);
537
+ }
538
+ else {
539
+ rightKept.splice(dropRight, 1);
540
+ if (dropRight === rightKept.length)
541
+ hint = false;
542
+ }
543
+ }
544
+ // Only the identity cluster can remain overflowing: collapse to it and
545
+ // ellipsize inside the budget as the last resort. Flat spans make the
546
+ // joined text identical to what the row would have displayed.
547
+ if (width() > budget) {
548
+ rightKept.length = 0;
549
+ hint = false;
550
+ while (leftKept.length > 1)
551
+ leftKept.pop();
552
+ const identity = leftKept[0].group;
553
+ const joined = identity.spans.map(span => span.text).join('');
554
+ leftKept[0] = {
555
+ group: { spans: [{ text: truncateColumns(joined, budget), tone: 'model' }] },
556
+ rank: RANK_IDENTITY,
557
+ id: 'identity',
558
+ };
559
+ }
560
+ // Row 2 fits its own budget minus the model-name indent; the lowest-rank
561
+ // group drops first until the row fits or nothing is left. An empty row2 is
562
+ // a valid state — the footer degrades back to a single status row.
563
+ const row2Kept = [...orderedRow2];
564
+ // dscode: the telemetry segment owns row 2's right edge, so its width comes out
565
+ // of the left groups' budget first.
566
+ const row2Budget = Math.max(0, budget - 1 - (facts.telemetry ? visibleColumns(facts.telemetry) + 3 : 0));
567
+ const row2Width = () => joinWidth(row2Kept.map(entry => spansWidth(entry.group.spans)), groupSeparator);
568
+ while (row2Width() > row2Budget && row2Kept.length > 0) {
569
+ let dropIndex = 0;
570
+ let dropRank = Number.POSITIVE_INFINITY;
571
+ for (let index = 0; index < row2Kept.length; index += 1) {
572
+ if (row2Kept[index].rank < dropRank) {
573
+ dropRank = row2Kept[index].rank;
574
+ dropIndex = index;
575
+ }
576
+ }
577
+ row2Kept.splice(dropIndex, 1);
578
+ }
579
+ return {
580
+ row1: {
581
+ left: leftKept.map(entry => entry.group),
582
+ right: rightKept.map(entry => entry.span),
583
+ hint,
584
+ },
585
+ row2: {
586
+ left: row2Kept.map(entry => entry.group),
587
+ right: facts.telemetry ? [{ text: facts.telemetry, tone: 'meta' }] : [],
588
+ hint: false,
589
+ },
590
+ };
591
+ }