dsh-code 1.0.3 → 1.0.4
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.md +291 -285
- package/bin/deepseek.mjs +26 -3
- package/lib/index.mjs +2749 -1783
- package/lib/types/app.d.ts +11 -2
- package/lib/types/commands.d.ts +13 -0
- package/lib/types/index.d.ts +28 -0
- package/lib/types/input-split.d.ts +54 -0
- package/lib/types/kernel-panels.d.ts +3 -1
- package/lib/types/keyboard.d.ts +8 -0
- package/lib/types/provider-settings.d.ts +77 -0
- package/lib/types/render/projection.d.ts +7 -1
- package/lib/types/render/status.d.ts +22 -15
- package/lib/types/skills.d.ts +1 -1
- package/package.json +1 -1
- package/src/app.ts +5459 -4900
- package/src/approval.ts +8 -3
- package/src/authorization-panel.ts +2 -4
- package/src/commands.ts +27 -3
- package/src/index.ts +153 -38
- package/src/input-split.ts +191 -0
- package/src/internals.ts +26 -8
- package/src/kernel-panels.ts +26 -10
- package/src/keyboard.ts +123 -88
- package/src/mentions.ts +42 -9
- package/src/provider-settings.ts +204 -0
- package/src/questions.ts +20 -0
- package/src/render/lines.ts +24 -12
- package/src/render/markdown.ts +15 -13
- package/src/render/projection.ts +99 -12
- package/src/render/status.ts +76 -71
- package/src/render/text.ts +9 -3
- package/src/skills.ts +19 -6
- package/src/theme-panel.ts +79 -72
package/src/render/status.ts
CHANGED
|
@@ -126,37 +126,31 @@ export const STATUS_ITEM_SEPARATOR = ' · '
|
|
|
126
126
|
export const STATUS_CYCLE_HINT = ' (shift+tab to cycle)'
|
|
127
127
|
|
|
128
128
|
/**
|
|
129
|
-
* Interior columns of the
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
* group
|
|
133
|
-
* (see CONTEXT_MIN_WIDTH) rather than asking the layout for more room.
|
|
129
|
+
* Interior columns of the context bar. The layout starts every bar at this
|
|
130
|
+
* width so the drop ladder can pre-measure the group, then degrades the
|
|
131
|
+
* readout and shrinks the bar inside a tighter budget before dropping the
|
|
132
|
+
* group (see CONTEXT_MIN_WIDTH) rather than asking the layout for more room.
|
|
134
133
|
*/
|
|
135
134
|
export const CONTEXT_BAR_WIDTH = 24
|
|
136
135
|
/** Occupancy at which the usage readout flips from brand blue to amber. */
|
|
137
136
|
const CONTEXT_WARN_PERCENT = 90
|
|
138
|
-
/** Free-tail floor in columns: wide enough for the bare percent readout, so
|
|
139
|
-
* the warning stays visible even at 100%+ occupancy. */
|
|
140
|
-
const CONTEXT_MIN_FREE = 5
|
|
141
137
|
/**
|
|
142
|
-
* Narrowest bar width the drop ladder
|
|
143
|
-
* group: the bar shrinks
|
|
144
|
-
*
|
|
138
|
+
* Narrowest bar width the drop ladder keeps before dropping the whole
|
|
139
|
+
* context group: the bar shrinks to this floor first (the absolute readout
|
|
140
|
+
* survives), and only past it does the readout degrade and the group go.
|
|
145
141
|
*/
|
|
146
142
|
const CONTEXT_MIN_WIDTH = 5
|
|
147
143
|
|
|
148
144
|
/**
|
|
149
|
-
* Render context occupancy as ONE stepless bar: a solid
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
* the
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
* The readout flips to amber once occupancy reaches the warning threshold.
|
|
157
|
-
* @param usedTokens - reported used tokens (drives the readout and percent).
|
|
145
|
+
* Render context occupancy as ONE stepless proportional bar: a solid
|
|
146
|
+
* DeepSeek-blue fill run tracking the occupancy and a dim dotted free
|
|
147
|
+
* track for the rest. Nothing else lives inside the bar — the usage
|
|
148
|
+
* readout rides outside it (see contextGroupSpans) — so the geometry
|
|
149
|
+
* always reads as the true remaining share. A given occupancy always
|
|
150
|
+
* renders the identical bar.
|
|
151
|
+
* @param usedTokens - reported used tokens.
|
|
158
152
|
* @param contextWindow - route capacity.
|
|
159
|
-
* @param width - total bar
|
|
153
|
+
* @param width - total bar columns.
|
|
160
154
|
* @returns tone-split spans for the footer to paint.
|
|
161
155
|
*/
|
|
162
156
|
export function contextBar(
|
|
@@ -166,27 +160,42 @@ export function contextBar(
|
|
|
166
160
|
): readonly StatusSpan[] {
|
|
167
161
|
if (width <= 0 || contextWindow <= 0) return []
|
|
168
162
|
const used = Math.max(0, usedTokens)
|
|
169
|
-
const
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
163
|
+
const fill = Math.min(width, Math.max(0, Math.round(used / contextWindow * width)))
|
|
164
|
+
const spans: StatusSpan[] = []
|
|
165
|
+
if (fill > 0) spans.push({ text: '█'.repeat(fill), tone: 'ctxFill' })
|
|
166
|
+
const free = width - fill
|
|
167
|
+
if (free > 0) spans.push({ text: '░'.repeat(free), tone: 'label' })
|
|
168
|
+
return spans
|
|
169
|
+
}
|
|
176
170
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const readout = freeColumns >= visibleColumns(`${total} ${percentText}`)
|
|
180
|
-
? `${total} ${percentText}`
|
|
181
|
-
: freeColumns >= visibleColumns(percentText)
|
|
182
|
-
? percentText
|
|
183
|
-
: ''
|
|
171
|
+
/** How much usage detail the context group's readout carries. */
|
|
172
|
+
export type ContextReadoutMode = 'full' | 'percent' | 'none'
|
|
184
173
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
174
|
+
/**
|
|
175
|
+
* Compose the context group: the proportional bar plus the usage readout
|
|
176
|
+
* OUTSIDE the bar, so the dotted track keeps its proportional meaning no
|
|
177
|
+
* matter how wide the readout is. `full` reads `12.3K/1.0M 25%`; `percent`
|
|
178
|
+
* drops the absolute pair; `none` is the bare bar. The readout turns amber
|
|
179
|
+
* once occupancy reaches the warning threshold.
|
|
180
|
+
*/
|
|
181
|
+
export function contextGroupSpans(
|
|
182
|
+
usedTokens: number,
|
|
183
|
+
contextWindow: number,
|
|
184
|
+
barWidth: number,
|
|
185
|
+
readout: ContextReadoutMode,
|
|
186
|
+
): readonly StatusSpan[] {
|
|
187
|
+
const spans: StatusSpan[] = [{ text: 'context ', tone: 'label' }]
|
|
188
|
+
spans.push(...contextBar(usedTokens, contextWindow, barWidth))
|
|
189
|
+
if (readout === 'none' || barWidth <= 0 || contextWindow <= 0) return spans
|
|
190
|
+
const used = Math.max(0, usedTokens)
|
|
191
|
+
const percent = Math.round(used / contextWindow * 100)
|
|
192
|
+
const text = readout === 'full'
|
|
193
|
+
? `${formatTokens(used)}/${formatTokens(contextWindow)} ${percent}%`
|
|
194
|
+
: `${percent}%`
|
|
195
|
+
spans.push(
|
|
196
|
+
{ text: ' ', tone: 'label' },
|
|
197
|
+
{ text, tone: percent >= CONTEXT_WARN_PERCENT ? 'warn' : 'value' },
|
|
198
|
+
)
|
|
190
199
|
return spans
|
|
191
200
|
}
|
|
192
201
|
|
|
@@ -459,17 +468,13 @@ function buildCandidates(
|
|
|
459
468
|
id: 'cache',
|
|
460
469
|
})
|
|
461
470
|
}
|
|
462
|
-
// Context occupancy as a
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
// capacity (the same figures the old bracket bar showed).
|
|
471
|
+
// Context occupancy as a purely proportional bar with the usage readout
|
|
472
|
+
// riding outside it: the used total is the most recent reported prompt
|
|
473
|
+
// size against the advertised route capacity.
|
|
466
474
|
if (stats.contextWindow > 0 && stats.lastPromptTokens > 0 && enabled.has('context')) {
|
|
467
475
|
left.push({
|
|
468
476
|
group: {
|
|
469
|
-
spans:
|
|
470
|
-
{ text: 'context ', tone: 'label' },
|
|
471
|
-
...contextBar(stats.lastPromptTokens, stats.contextWindow, contextWidth),
|
|
472
|
-
],
|
|
477
|
+
spans: contextGroupSpans(stats.lastPromptTokens, stats.contextWindow, contextWidth, 'full'),
|
|
473
478
|
},
|
|
474
479
|
rank: RANK_CONTEXT,
|
|
475
480
|
id: 'context',
|
|
@@ -569,19 +574,18 @@ export function layoutStatusBar(
|
|
|
569
574
|
const leftKept = [...orderedLeft]
|
|
570
575
|
const rightKept = [...orderedRight]
|
|
571
576
|
|
|
572
|
-
// Context
|
|
573
|
-
//
|
|
577
|
+
// Context degradation state: the readout drops its absolute pair first,
|
|
578
|
+
// then the bar shrinks inside its own budget, and only then is the whole
|
|
579
|
+
// group removed — the proportional meter outlives the auxiliary numbers.
|
|
574
580
|
// Rebuilding replaces the group's spans in place so width() re-measures it.
|
|
581
|
+
let contextReadout: ContextReadoutMode = 'full'
|
|
575
582
|
let contextWidth = maxContextWidth
|
|
576
583
|
const rebuildContext = (): void => {
|
|
577
584
|
const index = leftKept.findIndex(entry => entry.id === 'context')
|
|
578
585
|
if (index < 0) return
|
|
579
586
|
leftKept[index] = {
|
|
580
587
|
group: {
|
|
581
|
-
spans:
|
|
582
|
-
{ text: 'context ', tone: 'label' },
|
|
583
|
-
...contextBar(stats.lastPromptTokens, stats.contextWindow, contextWidth),
|
|
584
|
-
],
|
|
588
|
+
spans: contextGroupSpans(stats.lastPromptTokens, stats.contextWindow, contextWidth, contextReadout),
|
|
585
589
|
},
|
|
586
590
|
rank: RANK_CONTEXT,
|
|
587
591
|
id: 'context',
|
|
@@ -599,23 +603,24 @@ export function layoutStatusBar(
|
|
|
599
603
|
}
|
|
600
604
|
|
|
601
605
|
while (width() > budget) {
|
|
602
|
-
// Context is the lowest-priority visual group
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
606
|
+
// Context is the lowest-priority visual group: the bar shrinks inside
|
|
607
|
+
// its own budget first (the absolute readout survives), then the
|
|
608
|
+
// readout degrades to the bare percent, and only then does the whole
|
|
609
|
+
// group go — before the permission badge or its Shift+Tab affordance
|
|
610
|
+
// is touched.
|
|
611
|
+
if (leftKept.some(entry => entry.id === 'context')) {
|
|
612
|
+
if (contextWidth > CONTEXT_MIN_WIDTH) {
|
|
613
|
+
const overflow = width() - budget
|
|
614
|
+
contextWidth = Math.max(CONTEXT_MIN_WIDTH, contextWidth - overflow)
|
|
615
|
+
rebuildContext()
|
|
616
|
+
continue
|
|
617
|
+
}
|
|
618
|
+
if (contextReadout === 'full') {
|
|
619
|
+
contextReadout = 'percent'
|
|
620
|
+
rebuildContext()
|
|
621
|
+
continue
|
|
622
|
+
}
|
|
623
|
+
leftKept.splice(leftKept.findIndex(entry => entry.id === 'context'), 1)
|
|
619
624
|
continue
|
|
620
625
|
}
|
|
621
626
|
if (hint && rightKept.length > 0 && leftKept.length > 0) {
|
package/src/render/text.ts
CHANGED
|
@@ -145,8 +145,14 @@ export function displayTail(text: string, columns: number, rows: number): Displa
|
|
|
145
145
|
used += width
|
|
146
146
|
lastCluster = cluster
|
|
147
147
|
}
|
|
148
|
-
|
|
149
|
-
|
|
148
|
+
// A trailing newline means one deliberate empty caret row, but that row
|
|
149
|
+
// must never evict real content or fake a truncation marker: flush the
|
|
150
|
+
// content first, decide truncation on content alone, then append the blank
|
|
151
|
+
// row only when the whole tail still fits the budget.
|
|
152
|
+
const trailingBlank = current === '' && wrapped.length > 0 && text.endsWith('\n')
|
|
153
|
+
if (current !== '') flush()
|
|
150
154
|
const truncated = wrapped.length > rowLimit
|
|
151
|
-
|
|
155
|
+
const kept = truncated ? wrapped.slice(-rowLimit) : wrapped
|
|
156
|
+
const keptRows = trailingBlank && kept.length < rowLimit ? [...kept, ''] : kept
|
|
157
|
+
return { text: keptRows.join('\n'), truncated }
|
|
152
158
|
}
|
package/src/skills.ts
CHANGED
|
@@ -61,35 +61,48 @@ function toRows(skills: readonly SkillSummary[]): readonly SkillRow[] {
|
|
|
61
61
|
* @param ctx - context carrying the `skills` service (optional).
|
|
62
62
|
* @returns the view the completion menu subscribes to.
|
|
63
63
|
*/
|
|
64
|
-
export function watchSkills(ctx: Context): SkillsWatch {
|
|
64
|
+
export function watchSkills(ctx: Context, fallbackCwd?: string): SkillsWatch {
|
|
65
65
|
const skills = ctx.get('skills')
|
|
66
66
|
let agent: Agent | undefined
|
|
67
67
|
let rows: readonly SkillRow[] = []
|
|
68
68
|
let error: string | undefined
|
|
69
|
+
// The agent whose workspace the current rows were last successfully read
|
|
70
|
+
// from: a failure for an agent that never loaded must clear the rows, not
|
|
71
|
+
// keep another workspace's catalog answerable in this session.
|
|
72
|
+
let loadedFor: Agent | undefined
|
|
69
73
|
const listeners = new Set<() => void>()
|
|
70
74
|
|
|
71
75
|
const reload = (): void => {
|
|
72
76
|
const target = agent
|
|
73
77
|
if (skills === undefined || target === undefined) return
|
|
74
78
|
Promise.resolve().then(() => skills.list({
|
|
75
|
-
cwd: target.session.header.cwd,
|
|
79
|
+
cwd: target.session.header.cwd ?? fallbackCwd,
|
|
76
80
|
scope: target,
|
|
77
81
|
})).then((summaries: readonly SkillSummary[]) => {
|
|
78
82
|
// A retarget landed while this catalog was loading: the rows belong to
|
|
79
83
|
// another agent's workspace and must never overwrite the current view.
|
|
80
84
|
if (agent !== target) return
|
|
81
85
|
const next = toRows(summaries)
|
|
82
|
-
|
|
86
|
+
// Description and invocation-flag edits must surface too: a name-only
|
|
87
|
+
// comparison silently dropped those change notifications.
|
|
88
|
+
const unchanged = next.length === rows.length && next.every((row, index) =>
|
|
89
|
+
row.name === rows[index]?.name
|
|
90
|
+
&& row.description === rows[index]?.description
|
|
91
|
+
&& row.modelInvocable === rows[index]?.modelInvocable)
|
|
83
92
|
rows = next
|
|
93
|
+
loadedFor = target
|
|
84
94
|
const recovered = error !== undefined
|
|
85
95
|
error = undefined
|
|
86
96
|
if (unchanged && !recovered) return
|
|
87
97
|
for (const listener of listeners) listener()
|
|
88
98
|
}).catch((cause: unknown) => {
|
|
89
99
|
if (agent !== target) return
|
|
90
|
-
// Discovery failure keeps the last good rows
|
|
91
|
-
// notification is the retry surface
|
|
92
|
-
|
|
100
|
+
// Discovery failure keeps the last good rows for the SAME agent (the
|
|
101
|
+
// next skills/change notification is the retry surface, mirroring the
|
|
102
|
+
// web directory); an agent that never loaded starts from empty rows —
|
|
103
|
+
// stale rows from a previous workspace must not keep completing here.
|
|
104
|
+
if (loadedFor !== target) rows = []
|
|
105
|
+
else rows = [...rows]
|
|
93
106
|
error = cause instanceof Error ? cause.message : String(cause)
|
|
94
107
|
for (const listener of listeners) listener()
|
|
95
108
|
})
|
package/src/theme-panel.ts
CHANGED
|
@@ -1,72 +1,79 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The `/theme` picker (the Codex `/theme` contract): one bounded list over
|
|
3
|
-
* the three color themes — dark, light, and auto (terminal-sensed; auto
|
|
4
|
-
* falls back to dark until OSC-11 detection lands). Enter applies the row
|
|
5
|
-
* and the runner persists it; Esc closes without changing the theme.
|
|
6
|
-
*
|
|
7
|
-
* @module @deepseek-ai/dsh-tui/theme-panel
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { createElement, useState, type ReactElement } from 'react'
|
|
11
|
-
import { Box, Text, useInput, useStdout } from 'ink'
|
|
12
|
-
import { panelViewport } from './render/inspector.ts'
|
|
13
|
-
import { truncateColumns } from './render/text.ts'
|
|
14
|
-
import { getPalette, inkColor, type ThemeName } from './theme.ts'
|
|
15
|
-
|
|
16
|
-
/** The three theme rows in canonical order (the /theme selection surface). */
|
|
17
|
-
const THEME_ROWS: readonly { id: ThemeName; label: string; description: string }[] = [
|
|
18
|
-
{ id: 'dark', label: 'dark', description: 'DeepSeek dark palette (default)' },
|
|
19
|
-
{ id: 'light', label: 'light', description: 'light palette for bright terminals' },
|
|
20
|
-
{ id: 'auto', label: 'auto', description: 'follow the terminal; dark until detection lands' },
|
|
21
|
-
]
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* The /theme list: one row per theme, the current one marked with ●, the
|
|
25
|
-
* focused one with ›. Enter applies the focused theme (the runner persists
|
|
26
|
-
* it), Esc/q closes without changing anything. Colors read the ACTIVE
|
|
27
|
-
* palette, so the panel itself adapts to a light theme once applied.
|
|
28
|
-
*/
|
|
29
|
-
export function ThemePanel({ current, select, close }: {
|
|
30
|
-
/** Theme name in force (the requested name; 'auto' included). */
|
|
31
|
-
current: ThemeName
|
|
32
|
-
/** Accept one theme name: applied immediately and persisted by the runner. */
|
|
33
|
-
select(name: ThemeName): void
|
|
34
|
-
/** Close without changing the theme. */
|
|
35
|
-
close(): void
|
|
36
|
-
}): ReactElement {
|
|
37
|
-
const [cursor, setCursor] = useState(() => {
|
|
38
|
-
const index = THEME_ROWS.findIndex(theme => theme.id === current)
|
|
39
|
-
return index < 0 ? 0 : index
|
|
40
|
-
})
|
|
41
|
-
const stdout = useStdout().stdout
|
|
42
|
-
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
43
|
-
useInput((input, key) => {
|
|
44
|
-
if (key.escape || input === 'q') return close()
|
|
45
|
-
if (key.upArrow) return setCursor(value => (value + THEME_ROWS.length - 1) % THEME_ROWS.length)
|
|
46
|
-
if (key.downArrow) return setCursor(value => (value + 1) % THEME_ROWS.length)
|
|
47
|
-
if (key.return) return select(THEME_ROWS[cursor]!.id)
|
|
48
|
-
})
|
|
49
|
-
if (viewport.maxHeight === 0
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The `/theme` picker (the Codex `/theme` contract): one bounded list over
|
|
3
|
+
* the three color themes — dark, light, and auto (terminal-sensed; auto
|
|
4
|
+
* falls back to dark until OSC-11 detection lands). Enter applies the row
|
|
5
|
+
* and the runner persists it; Esc closes without changing the theme.
|
|
6
|
+
*
|
|
7
|
+
* @module @deepseek-ai/dsh-tui/theme-panel
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createElement, useState, type ReactElement } from 'react'
|
|
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 { getPalette, inkColor, type ThemeName } from './theme.ts'
|
|
15
|
+
|
|
16
|
+
/** The three theme rows in canonical order (the /theme selection surface). */
|
|
17
|
+
const THEME_ROWS: readonly { id: ThemeName; label: string; description: string }[] = [
|
|
18
|
+
{ id: 'dark', label: 'dark', description: 'DeepSeek dark palette (default)' },
|
|
19
|
+
{ id: 'light', label: 'light', description: 'light palette for bright terminals' },
|
|
20
|
+
{ id: 'auto', label: 'auto', description: 'follow the terminal; dark until detection lands' },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The /theme list: one row per theme, the current one marked with ●, the
|
|
25
|
+
* focused one with ›. Enter applies the focused theme (the runner persists
|
|
26
|
+
* it), Esc/q closes without changing anything. Colors read the ACTIVE
|
|
27
|
+
* palette, so the panel itself adapts to a light theme once applied.
|
|
28
|
+
*/
|
|
29
|
+
export function ThemePanel({ current, select, close }: {
|
|
30
|
+
/** Theme name in force (the requested name; 'auto' included). */
|
|
31
|
+
current: ThemeName
|
|
32
|
+
/** Accept one theme name: applied immediately and persisted by the runner. */
|
|
33
|
+
select(name: ThemeName): void
|
|
34
|
+
/** Close without changing the theme. */
|
|
35
|
+
close(): void
|
|
36
|
+
}): ReactElement {
|
|
37
|
+
const [cursor, setCursor] = useState(() => {
|
|
38
|
+
const index = THEME_ROWS.findIndex(theme => theme.id === current)
|
|
39
|
+
return index < 0 ? 0 : index
|
|
40
|
+
})
|
|
41
|
+
const stdout = useStdout().stdout
|
|
42
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
43
|
+
useInput((input, key) => {
|
|
44
|
+
if (key.escape || input === 'q') return close()
|
|
45
|
+
if (key.upArrow) return setCursor(value => (value + THEME_ROWS.length - 1) % THEME_ROWS.length)
|
|
46
|
+
if (key.downArrow) return setCursor(value => (value + 1) % THEME_ROWS.length)
|
|
47
|
+
if (key.return) return select(THEME_ROWS[cursor]!.id)
|
|
48
|
+
})
|
|
49
|
+
if (viewport.maxHeight === 0 || viewport.compact) {
|
|
50
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/theme · esc close', viewport.contentColumns))
|
|
51
|
+
}
|
|
52
|
+
// The theme rows share the panel's body budget like every other panel: an
|
|
53
|
+
// unsliced three-row list reached terminal-height equality on short
|
|
54
|
+
// terminals, where Ink rewrites the whole Static region every frame.
|
|
55
|
+
// Reveal-cursor slicing keeps the focused row visible instead.
|
|
56
|
+
const rowBudget = Math.max(1, viewport.bodyRows)
|
|
57
|
+
const first = clampScroll(cursor, THEME_ROWS.length, rowBudget)
|
|
58
|
+
const visibleThemes = THEME_ROWS.slice(first, first + rowBudget)
|
|
59
|
+
const hiddenThemes = THEME_ROWS.length - visibleThemes.length
|
|
60
|
+
return createElement(
|
|
61
|
+
Box,
|
|
62
|
+
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
|
|
63
|
+
createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns('/theme — color palette', viewport.contentColumns)),
|
|
64
|
+
...visibleThemes.map((theme, index) => {
|
|
65
|
+
const selected = first + index === cursor
|
|
66
|
+
const active = theme.id === current
|
|
67
|
+
return createElement(
|
|
68
|
+
Text,
|
|
69
|
+
{
|
|
70
|
+
key: theme.id,
|
|
71
|
+
color: selected ? inkColor(getPalette().brandBright) : undefined,
|
|
72
|
+
wrap: 'truncate-end',
|
|
73
|
+
},
|
|
74
|
+
truncateColumns(`${selected ? '› ' : ' '}${active ? '● ' : '○ '}${theme.label}${active ? ' · current' : ''} · ${theme.description}`, viewport.contentColumns),
|
|
75
|
+
)
|
|
76
|
+
}),
|
|
77
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(`↑↓ choose · enter apply · esc/q close${hiddenThemes > 0 ? ` · +${hiddenThemes} more` : ''}`, viewport.contentColumns)),
|
|
78
|
+
)
|
|
79
|
+
}
|