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.
- package/README.en.md +5 -5
- package/README.md +5 -5
- package/lib/index.mjs +6679 -5232
- package/lib/startup.mjs +1 -1
- package/lib/{theme-7u5Qo3dF.mjs → theme-B3orFUYz.mjs} +8 -0
- package/lib/types/app.d.ts +32 -45
- package/lib/types/attachments.d.ts +16 -7
- package/lib/types/completion.d.ts +29 -0
- package/lib/types/composer.d.ts +150 -0
- package/lib/types/git-workflow.d.ts +6 -0
- package/lib/types/index.d.ts +6 -188
- package/lib/types/locales/en.d.ts +65 -5
- package/lib/types/{authorization-panel.d.ts → panels/authorization-panel.d.ts} +6 -1
- package/lib/types/panels/completion-panel.d.ts +13 -0
- package/lib/types/panels/interaction-bars.d.ts +38 -0
- package/lib/types/{kernel-panels.d.ts → panels/kernel-panels.d.ts} +19 -38
- package/lib/types/{language-panel.d.ts → panels/language-panel.d.ts} +1 -1
- package/lib/types/panels/model-panels.d.ts +86 -0
- package/lib/types/{theme-panel.d.ts → panels/theme-panel.d.ts} +1 -1
- package/lib/types/{update-panel.d.ts → panels/update-panel.d.ts} +22 -1
- package/lib/types/provider-settings.d.ts +11 -0
- package/lib/types/render/inspector.d.ts +8 -0
- package/lib/types/render/status.d.ts +4 -1
- package/lib/types/render/text.d.ts +4 -0
- package/lib/types/runner/harness-gate.d.ts +83 -0
- package/lib/types/runner/input-history.d.ts +31 -0
- package/lib/types/runner/mode-cycle.d.ts +44 -0
- package/lib/types/runner/preferences.d.ts +40 -0
- package/lib/types/runner/quit.d.ts +27 -0
- package/lib/types/runner/search-rows.d.ts +38 -0
- package/lib/types/runner/session-io.d.ts +46 -0
- package/lib/types/runner/session-target.d.ts +42 -0
- package/lib/types/runner/startup-config.d.ts +33 -0
- package/lib/types/runner/submissions.d.ts +87 -0
- package/lib/types/session/attach.d.ts +39 -0
- package/lib/types/{history.d.ts → session/history.d.ts} +10 -0
- package/lib/types/{session-directory.d.ts → session/session-directory.d.ts} +25 -1
- package/lib/types/{session-switch.d.ts → session/session-switch.d.ts} +8 -0
- package/lib/types/{store.d.ts → session/store.d.ts} +1 -1
- package/lib/types/{subagents.d.ts → session/subagents.d.ts} +8 -1
- package/lib/types/settings-file.d.ts +10 -0
- package/lib/types/{panel-accent.d.ts → ui/panel-accent.d.ts} +1 -1
- package/lib/types/ui/panel-gap.d.ts +6 -0
- package/lib/types/ui/query-editor.d.ts +10 -0
- package/lib/types/ui/styled-rows.d.ts +8 -0
- package/lib/types/{terminal-title.d.ts → ui/terminal-title.d.ts} +1 -1
- package/lib/types/ui/ui-contract.d.ts +12 -0
- package/lib/types/ui/use-frames.d.ts +6 -0
- package/lib/types/ui/use-stable-input.d.ts +7 -0
- package/lib/types/version.d.ts +2 -0
- package/package.json +7 -5
- package/src/app.ts +707 -4108
- package/src/attachments.ts +65 -19
- package/src/completion.ts +117 -0
- package/src/composer.ts +1956 -0
- package/src/git-workflow.ts +18 -0
- package/src/index.ts +164 -645
- package/src/input-split.ts +24 -4
- package/src/internals.ts +1 -1
- package/src/locales/en.ts +66 -5
- package/src/locales/zh.ts +66 -5
- package/src/{authorization-panel.ts → panels/authorization-panel.ts} +30 -8
- package/src/panels/completion-panel.ts +79 -0
- package/src/panels/interaction-bars.ts +567 -0
- package/src/{kernel-panels.ts → panels/kernel-panels.ts} +142 -90
- package/src/{language-panel.ts → panels/language-panel.ts} +4 -4
- package/src/panels/model-panels.ts +1021 -0
- package/src/{theme-panel.ts → panels/theme-panel.ts} +5 -5
- package/src/{update-panel.ts → panels/update-panel.ts} +117 -10
- package/src/provider-settings.ts +38 -0
- package/src/rainbow.ts +13 -3
- package/src/render/inspector.ts +23 -0
- package/src/render/status.ts +80 -29
- package/src/render/text.ts +10 -1
- package/src/runner/harness-gate.ts +168 -0
- package/src/runner/input-history.ts +77 -0
- package/src/runner/mode-cycle.ts +49 -0
- package/src/runner/preferences.ts +67 -0
- package/src/runner/quit.ts +53 -0
- package/src/runner/search-rows.ts +55 -0
- package/src/runner/session-io.ts +206 -0
- package/src/runner/session-target.ts +81 -0
- package/src/runner/startup-config.ts +54 -0
- package/src/runner/submissions.ts +157 -0
- package/src/session/attach.ts +87 -0
- package/src/{fork.ts → session/fork.ts} +11 -7
- package/src/{history.ts → session/history.ts} +14 -0
- package/src/{session-directory.ts → session/session-directory.ts} +83 -4
- package/src/{session-switch.ts → session/session-switch.ts} +14 -0
- package/src/{store.ts → session/store.ts} +20 -2
- package/src/{subagents.ts → session/subagents.ts} +12 -1
- package/src/settings-file.ts +19 -1
- package/src/{panel-accent.ts → ui/panel-accent.ts} +1 -1
- package/src/ui/panel-gap.ts +9 -0
- package/src/ui/query-editor.ts +16 -0
- package/src/ui/styled-rows.ts +124 -0
- package/src/{terminal-title.ts → ui/terminal-title.ts} +1 -1
- package/src/ui/ui-contract.ts +10 -0
- package/src/ui/use-frames.ts +23 -0
- package/src/ui/use-stable-input.ts +17 -0
- package/src/version.ts +5 -0
- /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 '
|
|
13
|
-
import { truncateColumns } from '
|
|
14
|
-
import { panelAccent } from '
|
|
15
|
-
import { getPalette, inkColor, THEMES, type ThemeName } from '
|
|
16
|
-
import { t } from '
|
|
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 '
|
|
15
|
-
import { clampScroll, panelViewport } from '
|
|
16
|
-
import { singleLineText, truncateColumns } from '
|
|
17
|
-
import { panelAccent } from '
|
|
18
|
-
import { getPalette, inkColor } from '
|
|
19
|
-
import { t } from '
|
|
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 () => {
|
|
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(
|
|
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')
|
package/src/provider-settings.ts
CHANGED
|
@@ -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
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
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 }
|
package/src/render/inspector.ts
CHANGED
|
@@ -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)
|
package/src/render/status.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
296
|
-
*
|
|
297
|
-
*
|
|
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
|
-
? '
|
|
515
|
-
: '
|
|
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
|
|
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: '
|
|
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
|
|
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
|
-
//
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
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
|
-
|
|
697
|
+
}
|
|
698
|
+
if (hint) {
|
|
699
|
+
hint = false
|
|
644
700
|
continue
|
|
645
701
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
|
|
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 (
|
|
660
|
-
|
|
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
|
package/src/render/text.ts
CHANGED
|
@@ -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
|
-
|
|
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. */
|