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.
@@ -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 segmented context bar (content-type segments plus
130
- * the free tail whose right edge carries the usage readout). The layout
131
- * starts every bar at this width so the drop ladder can pre-measure the
132
- * group, then shrinks the bar inside a tighter budget before dropping it
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 tries before giving up on the context
143
- * group: the bar shrinks inside its own budget first (a few columns still
144
- * show the bare percent readout) and only then drops as a whole.
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 DeepSeek-blue fill
150
- * run, a dim dotted free track, and the usage readout riding the track's
151
- * right edge (`12.3K/1.0M 25%`, shrinking to the bare percent as the track
152
- * narrows). No per-content-type segmentation. Column split is deterministic:
153
- * the free share is `Math.round(free/window*width)` clamped to at least
154
- * CONTEXT_MIN_FREE columns and at most the full width; the fill takes every
155
- * remaining column, so a given occupancy always renders the identical bar.
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 interior columns.
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 percent = Math.round(used / contextWindow * 100)
170
- const warning = percent >= CONTEXT_WARN_PERCENT
171
- const readoutTone: StatusTone = warning ? 'warn' : 'value'
172
-
173
- const freeShare = Math.round(Math.max(0, contextWindow - used) / contextWindow * width)
174
- const freeColumns = Math.min(width, Math.max(freeShare, CONTEXT_MIN_FREE))
175
- const usedColumns = Math.max(0, width - freeColumns)
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
- const total = `${formatTokens(used)}/${formatTokens(contextWindow)}`
178
- const percentText = `${percent}%`
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
- const spans: StatusSpan[] = []
186
- if (usedColumns > 0) spans.push({ text: '█'.repeat(usedColumns), tone: 'ctxFill' })
187
- const pad = freeColumns - visibleColumns(readout)
188
- if (pad > 0) spans.push({ text: '░'.repeat(pad), tone: 'label' })
189
- if (readout !== '') spans.push({ text: readout, tone: readoutTone })
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 segmented bar: per-content-type runs colored by
463
- // their own blue shade with a right-aligned usage readout. The used total
464
- // is the most recent reported prompt size against the advertised route
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 shrink state: the bar starts at its full budget and is rebuilt at
573
- // ever narrower widths before the drop ladder is allowed to discard it.
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. Shrink or remove it before
603
- // sacrificing the permission badge or its Shift+Tab affordance.
604
- // Shrink the context bar instead of dropping it: reserve everything else
605
- // and hand the deficit to the bar, clamped to CONTEXT_MIN_WIDTH. The
606
- // fixed label ('context ') and the bar's own readout keep shrinking to
607
- // the bare percent, so a tight terminal keeps context visible longer.
608
- if (leftKept.some(entry => entry.id === 'context') && contextWidth > CONTEXT_MIN_WIDTH) {
609
- const overflow = width() - budget
610
- contextWidth = Math.max(CONTEXT_MIN_WIDTH, contextWidth - overflow)
611
- rebuildContext()
612
- continue
613
- }
614
- const contextIndex = leftKept.findIndex(entry => entry.id === 'context')
615
- if (contextIndex >= 0) {
616
- // Once the meter reaches its minimum useful width, remove the whole
617
- // group before touching the permission badge or its keyboard hint.
618
- leftKept.splice(contextIndex, 1)
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) {
@@ -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
- if (current !== '' || (wrapped.length > 0 && text.endsWith('\n'))) flush()
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
- return { text: (truncated ? wrapped.slice(-rowLimit) : wrapped).join('\n'), truncated }
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
- const unchanged = next.length === rows.length && next.every((row, index) => row.name === rows[index]?.name)
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; the next skills/change
91
- // notification is the retry surface (mirrors the web directory).
92
- rows = [...rows]
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
  })
@@ -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) return createElement(Box, { display: 'none' })
50
- if (viewport.compact) {
51
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/theme · esc close', viewport.contentColumns))
52
- }
53
- return createElement(
54
- Box,
55
- { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
56
- createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns('/theme — color palette', viewport.contentColumns)),
57
- ...THEME_ROWS.map((theme, index) => {
58
- const selected = index === cursor
59
- const active = theme.id === current
60
- return createElement(
61
- Text,
62
- {
63
- key: theme.id,
64
- color: selected ? inkColor(getPalette().brandBright) : undefined,
65
- wrap: 'truncate-end',
66
- },
67
- truncateColumns(`${selected ? '› ' : ' '}${active ? '● ' : '○ '}${theme.label}${active ? ' · current' : ''} · ${theme.description}`, viewport.contentColumns),
68
- )
69
- }),
70
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns('↑↓ choose · enter apply · esc/q close', viewport.contentColumns)),
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
+ }