dsh-code 1.2.0 → 1.4.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 (102) hide show
  1. package/README.en.md +5 -5
  2. package/README.md +5 -5
  3. package/lib/index.mjs +6679 -5232
  4. package/lib/startup.mjs +1 -1
  5. package/lib/{theme-7u5Qo3dF.mjs → theme-B3orFUYz.mjs} +8 -0
  6. package/lib/types/app.d.ts +32 -45
  7. package/lib/types/attachments.d.ts +16 -7
  8. package/lib/types/completion.d.ts +29 -0
  9. package/lib/types/composer.d.ts +150 -0
  10. package/lib/types/git-workflow.d.ts +6 -0
  11. package/lib/types/index.d.ts +6 -188
  12. package/lib/types/locales/en.d.ts +65 -5
  13. package/lib/types/{authorization-panel.d.ts → panels/authorization-panel.d.ts} +6 -1
  14. package/lib/types/panels/completion-panel.d.ts +13 -0
  15. package/lib/types/panels/interaction-bars.d.ts +38 -0
  16. package/lib/types/{kernel-panels.d.ts → panels/kernel-panels.d.ts} +19 -38
  17. package/lib/types/{language-panel.d.ts → panels/language-panel.d.ts} +1 -1
  18. package/lib/types/panels/model-panels.d.ts +86 -0
  19. package/lib/types/{theme-panel.d.ts → panels/theme-panel.d.ts} +1 -1
  20. package/lib/types/{update-panel.d.ts → panels/update-panel.d.ts} +22 -1
  21. package/lib/types/provider-settings.d.ts +11 -0
  22. package/lib/types/render/inspector.d.ts +8 -0
  23. package/lib/types/render/status.d.ts +4 -1
  24. package/lib/types/render/text.d.ts +4 -0
  25. package/lib/types/runner/harness-gate.d.ts +83 -0
  26. package/lib/types/runner/input-history.d.ts +31 -0
  27. package/lib/types/runner/mode-cycle.d.ts +44 -0
  28. package/lib/types/runner/preferences.d.ts +40 -0
  29. package/lib/types/runner/quit.d.ts +27 -0
  30. package/lib/types/runner/search-rows.d.ts +38 -0
  31. package/lib/types/runner/session-io.d.ts +46 -0
  32. package/lib/types/runner/session-target.d.ts +42 -0
  33. package/lib/types/runner/startup-config.d.ts +33 -0
  34. package/lib/types/runner/submissions.d.ts +87 -0
  35. package/lib/types/session/attach.d.ts +39 -0
  36. package/lib/types/{history.d.ts → session/history.d.ts} +10 -0
  37. package/lib/types/{session-directory.d.ts → session/session-directory.d.ts} +25 -1
  38. package/lib/types/{session-switch.d.ts → session/session-switch.d.ts} +8 -0
  39. package/lib/types/{store.d.ts → session/store.d.ts} +1 -1
  40. package/lib/types/{subagents.d.ts → session/subagents.d.ts} +8 -1
  41. package/lib/types/settings-file.d.ts +10 -0
  42. package/lib/types/{panel-accent.d.ts → ui/panel-accent.d.ts} +1 -1
  43. package/lib/types/ui/panel-gap.d.ts +6 -0
  44. package/lib/types/ui/query-editor.d.ts +10 -0
  45. package/lib/types/ui/styled-rows.d.ts +8 -0
  46. package/lib/types/{terminal-title.d.ts → ui/terminal-title.d.ts} +1 -1
  47. package/lib/types/ui/ui-contract.d.ts +12 -0
  48. package/lib/types/ui/use-frames.d.ts +6 -0
  49. package/lib/types/ui/use-stable-input.d.ts +7 -0
  50. package/lib/types/version.d.ts +2 -0
  51. package/package.json +7 -5
  52. package/src/app.ts +707 -4108
  53. package/src/attachments.ts +65 -19
  54. package/src/completion.ts +117 -0
  55. package/src/composer.ts +1956 -0
  56. package/src/git-workflow.ts +18 -0
  57. package/src/index.ts +164 -645
  58. package/src/input-split.ts +24 -4
  59. package/src/internals.ts +1 -1
  60. package/src/locales/en.ts +66 -5
  61. package/src/locales/zh.ts +66 -5
  62. package/src/{authorization-panel.ts → panels/authorization-panel.ts} +30 -8
  63. package/src/panels/completion-panel.ts +79 -0
  64. package/src/panels/interaction-bars.ts +567 -0
  65. package/src/{kernel-panels.ts → panels/kernel-panels.ts} +142 -90
  66. package/src/{language-panel.ts → panels/language-panel.ts} +4 -4
  67. package/src/panels/model-panels.ts +1021 -0
  68. package/src/{theme-panel.ts → panels/theme-panel.ts} +5 -5
  69. package/src/{update-panel.ts → panels/update-panel.ts} +117 -10
  70. package/src/provider-settings.ts +38 -0
  71. package/src/rainbow.ts +13 -3
  72. package/src/render/inspector.ts +23 -0
  73. package/src/render/status.ts +80 -29
  74. package/src/render/text.ts +10 -1
  75. package/src/runner/harness-gate.ts +168 -0
  76. package/src/runner/input-history.ts +77 -0
  77. package/src/runner/mode-cycle.ts +49 -0
  78. package/src/runner/preferences.ts +67 -0
  79. package/src/runner/quit.ts +53 -0
  80. package/src/runner/search-rows.ts +55 -0
  81. package/src/runner/session-io.ts +206 -0
  82. package/src/runner/session-target.ts +81 -0
  83. package/src/runner/startup-config.ts +54 -0
  84. package/src/runner/submissions.ts +157 -0
  85. package/src/session/attach.ts +87 -0
  86. package/src/{fork.ts → session/fork.ts} +11 -7
  87. package/src/{history.ts → session/history.ts} +14 -0
  88. package/src/{session-directory.ts → session/session-directory.ts} +83 -4
  89. package/src/{session-switch.ts → session/session-switch.ts} +14 -0
  90. package/src/{store.ts → session/store.ts} +20 -2
  91. package/src/{subagents.ts → session/subagents.ts} +12 -1
  92. package/src/settings-file.ts +19 -1
  93. package/src/{panel-accent.ts → ui/panel-accent.ts} +1 -1
  94. package/src/ui/panel-gap.ts +9 -0
  95. package/src/ui/query-editor.ts +16 -0
  96. package/src/ui/styled-rows.ts +124 -0
  97. package/src/{terminal-title.ts → ui/terminal-title.ts} +1 -1
  98. package/src/ui/ui-contract.ts +10 -0
  99. package/src/ui/use-frames.ts +23 -0
  100. package/src/ui/use-stable-input.ts +17 -0
  101. package/src/version.ts +5 -0
  102. /package/lib/types/{fork.d.ts → session/fork.d.ts} +0 -0
@@ -9,11 +9,11 @@
9
9
 
10
10
  import { createElement, useState, type ReactElement } from 'react'
11
11
  import { Box, Text, useInput, useStdout } from 'ink'
12
- import { clampScroll, panelViewport } from './render/inspector.ts'
13
- import { truncateColumns } from './render/text.ts'
14
- import { panelAccent } from './panel-accent.ts'
15
- import { getPalette, inkColor, THEMES, type ThemeName } from './theme.ts'
16
- import { t } from './i18n.ts'
12
+ import { clampScroll, panelViewport } from '../render/inspector.ts'
13
+ import { truncateColumns } from '../render/text.ts'
14
+ import { panelAccent } from '../ui/panel-accent.ts'
15
+ import { getPalette, inkColor, THEMES, type ThemeName } from '../theme.ts'
16
+ import { t } from '../i18n.ts'
17
17
 
18
18
  /**
19
19
  * The /theme list: one row per theme, the current one marked with ●, the
@@ -11,12 +11,12 @@
11
11
 
12
12
  import { createElement, useEffect, useState, type ReactElement } from 'react'
13
13
  import { Box, Text, useInput, useStdout } from 'ink'
14
- import type { LauncherUpdateStatus } from './update.ts'
15
- import { clampScroll, panelViewport } from './render/inspector.ts'
16
- import { singleLineText, truncateColumns } from './render/text.ts'
17
- import { panelAccent } from './panel-accent.ts'
18
- import { getPalette, inkColor } from './theme.ts'
19
- import { t } from './i18n.ts'
14
+ import type { LauncherUpdatePlan, LauncherUpdateStatus } from '../update.ts'
15
+ import { clampScroll, panelViewport } from '../render/inspector.ts'
16
+ import { singleLineText, truncateColumns } from '../render/text.ts'
17
+ import { panelAccent } from '../ui/panel-accent.ts'
18
+ import { getPalette, inkColor } from '../theme.ts'
19
+ import { t } from '../i18n.ts'
20
20
 
21
21
  /** Retained apply-progress lines (ring tail; npm output is ephemeral). */
22
22
  export const UPDATE_OUTPUT_CAP = 800
@@ -26,6 +26,84 @@ export function clipUpdateLines(lines: readonly string[]): readonly string[] {
26
26
  return lines.length <= UPDATE_OUTPUT_CAP ? lines : lines.slice(lines.length - UPDATE_OUTPUT_CAP)
27
27
  }
28
28
 
29
+ /** One in-flight `update --apply`. Closing the panel must not start a second. */
30
+ interface UpdateApplyJob {
31
+ readonly promise: Promise<number>
32
+ readonly lines: string[]
33
+ readonly lineListeners: Set<(line: string) => void>
34
+ }
35
+
36
+ let updateApplyJob: UpdateApplyJob | undefined
37
+ const updateApplyRunningListeners = new Set<(running: boolean) => void>()
38
+
39
+ function emitUpdateApplyRunning(running: boolean): void {
40
+ for (const listener of updateApplyRunningListeners) listener(running)
41
+ }
42
+
43
+ /** True while an apply child is alive, even if the /update panel is closed. */
44
+ export function isUpdateApplyRunning(): boolean {
45
+ return updateApplyJob !== undefined
46
+ }
47
+
48
+ /** Subscribe to apply-running changes (frozen-hint "esc waits"). */
49
+ export function subscribeUpdateApplyRunning(listener: (running: boolean) => void): () => void {
50
+ updateApplyRunningListeners.add(listener)
51
+ listener(updateApplyJob !== undefined)
52
+ return () => { updateApplyRunningListeners.delete(listener) }
53
+ }
54
+
55
+ /** The live apply job, if any: lines already streamed plus the shared promise. */
56
+ export function currentUpdateApply(): UpdateApplyJob | undefined {
57
+ return updateApplyJob
58
+ }
59
+
60
+ /**
61
+ * Run `apply` once. A second call while the child is alive reuses the same
62
+ * promise and replays buffered lines — closing /update and opening it again
63
+ * must not spawn a second `npm install`.
64
+ */
65
+ export function runUpdateApply(
66
+ apply: (onLine: (line: string) => void, plan: LauncherUpdatePlan) => Promise<number>,
67
+ plan: LauncherUpdatePlan,
68
+ onLine?: (line: string) => void,
69
+ ): Promise<number> {
70
+ if (updateApplyJob !== undefined) {
71
+ if (onLine !== undefined) {
72
+ for (const line of updateApplyJob.lines) onLine(line)
73
+ updateApplyJob.lineListeners.add(onLine)
74
+ }
75
+ return updateApplyJob.promise
76
+ }
77
+ const lines: string[] = []
78
+ const lineListeners = new Set<(line: string) => void>()
79
+ if (onLine !== undefined) lineListeners.add(onLine)
80
+ const promise = apply(line => {
81
+ const text = singleLineText(line)
82
+ lines.push(text)
83
+ if (lines.length > UPDATE_OUTPUT_CAP) lines.splice(0, lines.length - UPDATE_OUTPUT_CAP)
84
+ for (const listener of lineListeners) listener(text)
85
+ }, plan)
86
+ updateApplyJob = { promise, lines, lineListeners }
87
+ emitUpdateApplyRunning(true)
88
+ const settle = (): void => {
89
+ if (updateApplyJob?.promise === promise) {
90
+ updateApplyJob = undefined
91
+ emitUpdateApplyRunning(false)
92
+ }
93
+ }
94
+ // Handle both outcomes directly. `finally()` mirrors a rejected source
95
+ // promise onto its returned promise; discarding that returned promise would
96
+ // create an unhandled rejection when the update child cannot start.
97
+ void promise.then(settle, settle)
98
+ return promise
99
+ }
100
+
101
+ /** Drop a leftover job between tests. */
102
+ export function resetUpdateApply(): void {
103
+ updateApplyJob = undefined
104
+ emitUpdateApplyRunning(false)
105
+ }
106
+
29
107
  /** One display row of the update surface. */
30
108
  export interface UpdateRow {
31
109
  readonly key: string
@@ -139,8 +217,35 @@ export function UpdatePanel({ probe, apply, close, notify }: {
139
217
  // visible row (up moves away from the tail, down onto it re-follows).
140
218
  const [anchor, setAnchor] = useState<'tail' | number>('tail')
141
219
  const [epoch, setEpoch] = useState(0)
220
+ const appendLine = (line: string): void => {
221
+ setLines(previous => clipUpdateLines([...previous, line]))
222
+ }
142
223
  useEffect(() => {
143
224
  let disposed = false
225
+ const existing = currentUpdateApply()
226
+ if (existing !== undefined) {
227
+ // The apply child outlives the panel: closing for an approval or
228
+ // Ctrl+C must reconnect to the same job, not probe-and-confirm again.
229
+ setPhase('apply')
230
+ setLines(existing.lines)
231
+ setExit(undefined)
232
+ setApplyError(undefined)
233
+ setAnchor('tail')
234
+ existing.lineListeners.add(appendLine)
235
+ void existing.promise.then(code => {
236
+ if (disposed) return
237
+ setExit(code)
238
+ setPhase('done')
239
+ }, reason => {
240
+ if (disposed) return
241
+ setApplyError(reason instanceof Error ? reason.message : String(reason))
242
+ setPhase('done')
243
+ })
244
+ return () => {
245
+ disposed = true
246
+ existing.lineListeners.delete(appendLine)
247
+ }
248
+ }
144
249
  setPhase('probe')
145
250
  setStatus(undefined)
146
251
  setProbeError(undefined)
@@ -154,21 +259,23 @@ export function UpdatePanel({ probe, apply, close, notify }: {
154
259
  setProbeError(reason instanceof Error ? reason.message : String(reason))
155
260
  setPhase('error')
156
261
  })
157
- return () => { disposed = true }
262
+ return () => {
263
+ disposed = true
264
+ currentUpdateApply()?.lineListeners.delete(appendLine)
265
+ }
158
266
  }, [epoch, probe])
159
267
  const stdout = useStdout().stdout
160
268
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
161
269
  const start = (): void => {
162
270
  if (phase !== 'plan' || status === undefined) return
163
271
  if (!updatePlanView(status).runnable) return
272
+ if (isUpdateApplyRunning()) return
164
273
  setPhase('apply')
165
274
  setLines([])
166
275
  setExit(undefined)
167
276
  setApplyError(undefined)
168
277
  setAnchor('tail')
169
- apply(line => {
170
- setLines(previous => clipUpdateLines([...previous, singleLineText(line)]))
171
- }, status.plan).then(code => {
278
+ runUpdateApply(apply, status.plan, appendLine).then(code => {
172
279
  setExit(code)
173
280
  setPhase('done')
174
281
  notify(code === 0 ? 'update installed — restart dsh to activate' : `update failed (exit ${code})`, code === 0 ? 'info' : 'error')
@@ -666,6 +666,44 @@ export async function saveProviderConfiguration(
666
666
  }
667
667
  }
668
668
 
669
+ /**
670
+ * Switch one provider route to its subscription (plan sign-in) channel: the
671
+ * profile keeps neither a key reference nor an explicit model list, so the
672
+ * official catalog endpoint serves requests and the stored OAuth record — not
673
+ * a settings-named key override — authenticates them. Enabling a route that
674
+ * has no profile yet creates the empty profile that registers it.
675
+ * @param ctx - context carrying the `settings` service.
676
+ * @param target - provider row whose settings namespace owns the profile.
677
+ * @throws when the route is unmanaged or settings are read-only.
678
+ */
679
+ export async function enableProviderSubscription(ctx: Context, target: ProviderTargetView): Promise<void> {
680
+ if (target.settingsNs.length === 0) {
681
+ throw new ProviderSettingsError(`provider "${target.provider}" has no managed settings namespace; configure it in settings.yaml`)
682
+ }
683
+ const settings = ctx.get('settings') as SettingsFace | undefined
684
+ if (settings === undefined || settings.writable !== true) {
685
+ throw new ProviderSettingsError('settings are read-only; provider configuration cannot be changed here')
686
+ }
687
+ const root = target.settingsPath
688
+ const ops: SettingsPathOpFace[] = target.configured
689
+ ? [
690
+ // The key reference would resolve as a request-level override BEFORE
691
+ // the stored sign-in record, so the subscription channel must drop it;
692
+ // the endpoint and the explicit model list belong to the key channel.
693
+ { op: 'unset', path: [...root, 'apiKeyEnv'] },
694
+ { op: 'unset', path: [...root, 'baseURL'] },
695
+ { op: 'unset', path: [...root, 'models'] },
696
+ ]
697
+ // A route with no profile stays dormant: an empty profile registers it
698
+ // with the catalog endpoint and every catalog model.
699
+ : [{ op: 'set', path: [...root], value: {} }]
700
+ try {
701
+ await settings.mutate(target.settingsNs, ops, target.settingsRevision)
702
+ } catch (error) {
703
+ throw new ProviderSettingsError(singleLine(messageOf(error)))
704
+ }
705
+ }
706
+
669
707
  /**
670
708
  * Interrogate a provider endpoint for the models it really serves, through
671
709
  * the model-discovery capability the provider's settings namespace
package/src/rainbow.ts CHANGED
@@ -115,6 +115,11 @@ function hueOf([r, g, b]: RgbTriple): number {
115
115
  return (60 * (r - g) / span + 240) % 360
116
116
  }
117
117
 
118
+ /** Strict RGB equality for the wrap-collision repair. */
119
+ function sameRgb(left: RgbTriple, right: RgbTriple): boolean {
120
+ return left[0] === right[0] && left[1] === right[1] && left[2] === right[2]
121
+ }
122
+
118
123
  /** A dark row tint for diff backgrounds: the hue at 22% strength over black. */
119
124
  function darkTint(hue: RgbTriple): RgbTriple {
120
125
  return [Math.round(hue[0] * 0.22), Math.round(hue[1] * 0.22), Math.round(hue[2] * 0.22)]
@@ -160,14 +165,19 @@ export function rollRainbow(seed: number): RainbowRoll {
160
165
  steered: code,
161
166
  }
162
167
  const ring = shuffle(RAINBOW_POOL, rng).slice(0, 4)
163
- // Adjacency by construction: consecutive tones take consecutive slots of a
164
- // 12-distinct shuffle, so neighbors can never match (13 tones wrap the
165
- // first color onto 'error', which is never adjacent to 'live' on screen).
168
+ // Consecutive tones take consecutive slots of a 12-distinct shuffle, so
169
+ // on-screen neighbors never match. 13 tones wrap: live (index 0) and error
170
+ // (index 12) would share a color. Reassign error to a pool hue that is
171
+ // neither live nor its neighbor warn — error must not look like the busy dot.
166
172
  const offset = Math.floor(rng() * RAINBOW_POOL.length)
167
173
  const toneColors = {} as Record<StatusTone, RgbTriple>
168
174
  TONE_ORDER.forEach((tone, index) => {
169
175
  toneColors[tone] = pool[(offset + index) % pool.length]!
170
176
  })
177
+ if (sameRgb(toneColors.live, toneColors.error)) {
178
+ const replacement = pool.find(hue => !sameRgb(hue, toneColors.live) && !sameRgb(hue, toneColors.warn))
179
+ if (replacement !== undefined) toneColors.error = replacement
180
+ }
171
181
  const flowAnchors = [...RAINBOW_POOL].sort((a, b) => hueOf(a) - hueOf(b))
172
182
  const flowPhaseMs = Math.floor(rng() * 2400)
173
183
  return { seed, palette, ring, toneColors, flowAnchors, flowPhaseMs }
@@ -99,6 +99,29 @@ export function panelViewport(columns: number, rows: number): InspectorViewport
99
99
  }
100
100
  }
101
101
 
102
+ /**
103
+ * Viewport for an expanded (near-fullscreen) document overlay: the document
104
+ * replaces the transcript area entirely, so the half-screen overlay cap and
105
+ * the inter-section gaps drop away while the bottom live region (composer,
106
+ * status) and the Static-rewrite margin stay reserved. The result stays
107
+ * below terminal height, which is what keeps Ink off its full-rewrite path.
108
+ */
109
+ export function expandedDocumentViewport(columns: number, rows: number): InspectorViewport {
110
+ const safeColumns = Math.max(1, Math.floor(columns))
111
+ const safeRows = Math.max(1, Math.floor(rows))
112
+ const maxHeight = Math.max(0, safeRows - 2 - INSPECTOR_CHROME_ROWS - layoutGutterRows(safeRows))
113
+ const compact = maxHeight < 5 || safeColumns < 8
114
+ const outerColumns = compact ? safeColumns : Math.max(1, safeColumns - 1)
115
+ return {
116
+ maxHeight,
117
+ bodyRows: compact ? 0 : maxHeight - 4,
118
+ gapRows: 0,
119
+ contentColumns: compact ? Math.max(1, safeColumns - 1) : Math.max(1, outerColumns - 4),
120
+ outerColumns,
121
+ compact,
122
+ }
123
+ }
124
+
102
125
  /** Backward-compatible name for the Ctrl+O-specific caller and tests. */
103
126
  export function inspectorViewport(columns: number, rows: number): InspectorViewport {
104
127
  return panelViewport(columns, rows)
@@ -17,7 +17,7 @@ import { singleLineText, truncateColumns, formatTokens } from './text.ts'
17
17
 
18
18
  // Re-exported for the callers that have always read the formatter here.
19
19
  export { formatTokens }
20
- import { t } from '../i18n.ts'
20
+ import { t, type MessageKey } from '../i18n.ts'
21
21
 
22
22
 
23
23
  /**
@@ -29,7 +29,11 @@ export function formatDuration(ms: number): string {
29
29
  const s = ms / 1_000
30
30
  if (s < 60) return String(Math.round(s * 10) / 10) + 's'
31
31
  const whole = Math.round(s)
32
- return Math.floor(whole / 60) + 'm' + (whole % 60) + 's'
32
+ const hours = Math.floor(whole / 3_600)
33
+ const minutes = Math.floor((whole % 3_600) / 60)
34
+ const seconds = whole % 60
35
+ if (hours > 0) return `${hours}h${minutes}m${seconds === 0 ? '' : `${seconds}s`}`
36
+ return `${minutes}m${seconds}s`
33
37
  }
34
38
 
35
39
  /**
@@ -251,6 +255,31 @@ export const STATUS_ITEMS: readonly StatusItemInfo[] = [
251
255
  { id: 'sandbox', label: 'sandbox', description: 'divergent sandbox-mode override', side: 'left' },
252
256
  ]
253
257
 
258
+ const STATUSLINE_ITEM_KEYS: Record<StatusItemId, { label: MessageKey; description: MessageKey }> = {
259
+ model: { label: 'statusline.item.model', description: 'statusline.item.model.desc' },
260
+ cwd: { label: 'statusline.item.cwd', description: 'statusline.item.cwd.desc' },
261
+ mode: { label: 'statusline.item.mode', description: 'statusline.item.mode.desc' },
262
+ branch: { label: 'statusline.item.branch', description: 'statusline.item.branch.desc' },
263
+ context: { label: 'statusline.item.context', description: 'statusline.item.context.desc' },
264
+ permission: { label: 'statusline.item.permission', description: 'statusline.item.permission.desc' },
265
+ plan: { label: 'statusline.item.plan', description: 'statusline.item.plan.desc' },
266
+ turns: { label: 'statusline.item.turns', description: 'statusline.item.turns.desc' },
267
+ durations: { label: 'statusline.item.durations', description: 'statusline.item.durations.desc' },
268
+ cache: { label: 'statusline.item.cache', description: 'statusline.item.cache.desc' },
269
+ tokens: { label: 'statusline.item.tokens', description: 'statusline.item.tokens.desc' },
270
+ title: { label: 'statusline.item.title', description: 'statusline.item.title.desc' },
271
+ goal: { label: 'statusline.item.goal', description: 'statusline.item.goal.desc' },
272
+ sandbox: { label: 'statusline.item.sandbox', description: 'statusline.item.sandbox.desc' },
273
+ }
274
+
275
+ /** Picker rows with labels/descriptions in the active interface language. */
276
+ export function localizedStatusItems(): readonly StatusItemInfo[] {
277
+ return STATUS_ITEMS.map(item => {
278
+ const keys = STATUSLINE_ITEM_KEYS[item.id]
279
+ return { ...item, label: t(keys.label), description: t(keys.description) }
280
+ })
281
+ }
282
+
254
283
  /**
255
284
  * Default order: the whole catalog (matches the pre-customization bar).
256
285
  * The busy dot is not an item — it always leads the identity cluster.
@@ -292,9 +321,10 @@ export const STATUS_ROW2_INDENT = 2
292
321
  const TITLE_BUDGET = 48
293
322
 
294
323
  /**
295
- * Primary-row drop ranks: context drops before permission; the identity
296
- * cluster never drops and ellipsizes only after the right badge is gone.
297
- * Secondary-row groups reuse the remaining ranks independently.
324
+ * Primary-row drop ranks: identity never drops; permission outranks context.
325
+ * The drop ladder peels hint and trailing identity facts before removing
326
+ * the context meter, so occupancy stays visible on a typical 120-column
327
+ * terminal. Secondary-row groups reuse the remaining ranks independently.
298
328
  */
299
329
  const RANK_TITLE = 10
300
330
  const RANK_TOKENS = 50
@@ -358,6 +388,30 @@ function sep(): StatusSpan {
358
388
  return { text: ' · ', tone: 'label' }
359
389
  }
360
390
 
391
+ /** True for the dim ` · ` that joins identity facts. */
392
+ function isIdentitySep(span: StatusSpan): boolean {
393
+ return span.text === ' · ' && span.tone === 'label'
394
+ }
395
+
396
+ /**
397
+ * Drop the trailing identity fact (branch, then mode, then cwd) so the
398
+ * context meter can keep a column. Returns undefined once only the busy
399
+ * dot and model remain.
400
+ */
401
+ function peelIdentityFact(spans: readonly StatusSpan[]): StatusSpan[] | undefined {
402
+ if (spans.length < 3) return undefined
403
+ const last = spans[spans.length - 1]
404
+ const before = spans[spans.length - 2]
405
+ const modeSep = spans[spans.length - 3]
406
+ if (last?.tone === 'accent' && modeSep !== undefined && isIdentitySep(modeSep) && before?.tone === 'label') {
407
+ return spans.slice(0, -3)
408
+ }
409
+ if ((last?.tone === 'branch' || last?.tone === 'path') && before !== undefined && isIdentitySep(before)) {
410
+ return spans.slice(0, -2)
411
+ }
412
+ return undefined
413
+ }
414
+
361
415
  /** Total visible columns of a span list (separators ride inside the spans). */
362
416
  function spansWidth(spans: readonly StatusSpan[]): number {
363
417
  let width = 0
@@ -511,8 +565,8 @@ function buildCandidates(
511
565
  group: {
512
566
  spans: [{
513
567
  text: facts.goal.phase === 'active'
514
- ? 'round ' + facts.goal.rounds + '/' + facts.goal.max
515
- : '' + safe(facts.goal.phase),
568
+ ? t('status.goal.round', { current: facts.goal.rounds, max: facts.goal.max })
569
+ : t('status.goal.phase', { phase: safe(facts.goal.phase) }),
516
570
  tone: 'accent',
517
571
  }],
518
572
  },
@@ -523,7 +577,7 @@ function buildCandidates(
523
577
  // The sandbox override stays implicit when it merely echoes the preset.
524
578
  const sandbox = safe(facts.sandbox ?? '')
525
579
  if (sandbox !== '' && sandbox.toLowerCase() !== facts.permission.toLowerCase() && enabled.has('sandbox')) {
526
- row2.push({ group: { spans: [{ text: 'sandbox ' + sandbox, tone: 'warn' }] }, rank: RANK_SANDBOX, id: 'sandbox' })
580
+ row2.push({ group: { spans: [{ text: t('status.label.sandbox') + ' ' + sandbox, tone: 'warn' }] }, rank: RANK_SANDBOX, id: 'sandbox' })
527
581
  }
528
582
  const permission = safe(facts.permission)
529
583
  let badge = -1
@@ -536,7 +590,7 @@ function buildCandidates(
536
590
  if (permission !== '' && enabled.has('permission')) {
537
591
  right.push({
538
592
  span: planStation
539
- ? { text: 'plan on', tone: 'plan' }
593
+ ? { text: t('status.plan.on'), tone: 'plan' }
540
594
  : { text: permission, tone: permissionTone(permission) },
541
595
  rank: RANK_BADGE,
542
596
  id: 'permission',
@@ -544,7 +598,7 @@ function buildCandidates(
544
598
  badge = right.length - 1
545
599
  }
546
600
  if (facts.plan && enabled.has('plan')) {
547
- row2.push({ group: { spans: [{ text: 'plan', tone: 'accent' }] }, rank: RANK2_PLAN, id: 'plan' })
601
+ row2.push({ group: { spans: [{ text: t('status.plan.mark'), tone: 'accent' }] }, rank: RANK2_PLAN, id: 'plan' })
548
602
  }
549
603
  return { left, right, badge, row2 }
550
604
  }
@@ -552,7 +606,8 @@ function buildCandidates(
552
606
  /**
553
607
  * Compose the two-row footer layout under a column budget. Row 1 keeps model,
554
608
  * cwd, mode, branch, context, then the right-pinned permission badge and cycle
555
- * hint. It drops hint, context, and permission before ellipsizing identity.
609
+ * hint. It shrinks the context bar, drops the hint, and peels trailing
610
+ * identity facts before removing the context group or the permission badge.
556
611
  * Row 2 fits all secondary figures and state within its own budget.
557
612
  * @param facts - identity facts resolved by the runner.
558
613
  * @param stats - session figures folded from the durable log.
@@ -623,11 +678,10 @@ export function layoutStatusBar(
623
678
  }
624
679
 
625
680
  while (width() > budget) {
626
- // Context is the lowest-priority visual group: the bar shrinks inside
627
- // its own budget first (the absolute readout survives), then the
628
- // readout degrades to the bare percent, and only then does the whole
629
- // group go before the permission badge or its Shift+Tab affordance
630
- // is touched.
681
+ // Occupancy outranks the cycle hint and trailing identity facts
682
+ // (cwd/mode/branch): shrink the bar, drop the hint, then peel those
683
+ // facts before removing the context group. Permission stays until
684
+ // context is already gone.
631
685
  if (leftKept.some(entry => entry.id === 'context')) {
632
686
  if (contextWidth > CONTEXT_MIN_WIDTH) {
633
687
  const overflow = width() - budget
@@ -640,24 +694,21 @@ export function layoutStatusBar(
640
694
  rebuildContext()
641
695
  continue
642
696
  }
643
- leftKept.splice(leftKept.findIndex(entry => entry.id === 'context'), 1)
697
+ }
698
+ if (hint) {
699
+ hint = false
644
700
  continue
645
701
  }
646
- if (hint && rightKept.length > 0 && leftKept.length > 0) {
647
- const identity = leftKept[0]
648
- const identityText = identity.group.spans.map(span => span.text).join('')
649
- const rightWidth = joinWidth(rightKept.map(entry => visibleColumns(entry.span.text)), itemSeparator)
650
- const identityBudget = budget - rightWidth - LEFT_RIGHT_GAP - visibleColumns(statusCycleHint())
651
- if (identityBudget > 0 && visibleColumns(identityText) > identityBudget) {
652
- leftKept[0] = {
653
- ...identity,
654
- group: { spans: [{ text: truncateColumns(identityText, identityBudget), tone: 'model' }] },
655
- }
702
+ const identityEntry = leftKept[0]
703
+ if (identityEntry?.id === 'identity') {
704
+ const peeled = peelIdentityFact(identityEntry.group.spans)
705
+ if (peeled !== undefined) {
706
+ leftKept[0] = { ...identityEntry, group: { spans: peeled } }
656
707
  continue
657
708
  }
658
709
  }
659
- if (hint) {
660
- hint = false
710
+ if (leftKept.some(entry => entry.id === 'context')) {
711
+ leftKept.splice(leftKept.findIndex(entry => entry.id === 'context'), 1)
661
712
  continue
662
713
  }
663
714
  let dropLeft = -1
@@ -14,6 +14,8 @@
14
14
 
15
15
  import { graphemeWidth, splitGraphemes, stringWidth } from './width.ts'
16
16
 
17
+ export { stringWidth }
18
+
17
19
  /**
18
20
  * Compact token count: exact below 1K, then one-decimal-ish K/M.
19
21
  * @param n - token count.
@@ -23,7 +25,8 @@ export function formatTokens(n: number): string {
23
25
  const scaled = (v: number): string =>
24
26
  v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10)
25
27
  if (n < 1_000) return String(n)
26
- if (n < 1_000_000) return scaled(n / 1_000) + 'K'
28
+ // 999,500+ rounds to 1000K; step up to M instead of printing four digits.
29
+ if (n < 999_500) return scaled(n / 1_000) + 'K'
27
30
  return scaled(n / 1_000_000) + 'M'
28
31
  }
29
32
 
@@ -83,6 +86,12 @@ export function truncateColumns(text: string, columns: number): string {
83
86
  return `${result}…`
84
87
  }
85
88
 
89
+ /** Sanitize, clip, and right-pad one cell to an exact terminal-column width. */
90
+ export function padColumns(text: string, columns: number): string {
91
+ const clipped = truncateColumns(singleLineText(text), columns)
92
+ return clipped + ' '.repeat(Math.max(0, columns - stringWidth(clipped)))
93
+ }
94
+
86
95
  /** A display-safe suffix bounded by terminal rows and columns. */
87
96
  export interface DisplayTail {
88
97
  /** Sanitized suffix suitable for direct terminal rendering. */