@toddzheng024/dscode-bundle 0.7.21 → 0.7.23

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.
@@ -4,7 +4,7 @@ import { estimateCost, peakEmoji } from './pricing.mjs';
4
4
  import { balanceNow, trustedNow } from './balance.mjs';
5
5
  import { grokSubscriptionNow } from '../grok/billing.mjs';
6
6
  import { sessionAverageTps } from './rate.mjs';
7
- import { providerOfHeader } from '../providers/catalog.mjs';
7
+ import { attributeCostByTurn, evaluateBudget, parseBudget } from './turns.mjs';
8
8
  let source;
9
9
  export function setMetricSource(next) { source = next; return () => { if (source === next) source = undefined; }; }
10
10
  export function summarize(rows, events = [], corrupt = false) {
@@ -43,7 +43,29 @@ export function summarize(rows, events = [], corrupt = false) {
43
43
  // OpenRouter reports cache reads only when there are some: a missing count is zero, not unknown.
44
44
  input += total; hit += u.cacheReadTokens ?? 0;
45
45
  }
46
- return { cost, unknown, calls, pending, cache: input > 0 && !cacheUnknown ? Math.min(100, hit / input * 100) : null };
46
+ // Per-turn attribution needs the ledger's own rows (the backfilled history has
47
+ // no turn boundary), so it reads them directly instead of the merged map.
48
+ const attribution = attributeCostByTurn(rows, events);
49
+ return {
50
+ cost, unknown, calls, pending,
51
+ cache: input > 0 && !cacheUnknown ? Math.min(100, hit / input * 100) : null,
52
+ turns: attribution.turns,
53
+ lastTurn: attribution.lastTurn,
54
+ unattributed: attribution.unattributed,
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Per-turn cost for one live session, straight from its ledger, keyed by the
60
+ * durable turn number. The /usage panel prices its turns with this: the token
61
+ * meter says what each turn billed, this says what it cost.
62
+ * @param id - the session id.
63
+ * @param events - the session's durable events, for the turn windows.
64
+ * @returns `{ turn, cost, calls, unknown }` per completed or running turn.
65
+ */
66
+ export function turnCostsFor(id, events = []) {
67
+ const entries = id && process.env.DSH_HOME ? readMetrics(process.env.DSH_HOME, id).rows : [];
68
+ return attributeCostByTurn(entries, events).turns;
47
69
  }
48
70
  /**
49
71
  * Terminal columns of a string: East Asian wide characters (such as the cache label's
@@ -113,50 +135,77 @@ function figure(text, width) {
113
135
  return padding > 0 ? ' '.repeat(padding) + text : text;
114
136
  }
115
137
 
116
- export function formatFooter(metrics, context, columns = 80, rates, locale = 'en', header = '', provider = providerOfHeader(header) ?? 'deepseek-official') {
138
+ export function formatFooter(metrics, context, columns = 80, rates, locale = 'en', provider = 'deepseek-official') {
117
139
  const label = key => t(locale, key);
118
140
  const ctx = figure(Number.isFinite(context) ? `${Math.round(context)}%` : '--', FIGURE.percent);
119
141
  const cache = figure(metrics.cache === null ? '--' : `${metrics.cache.toFixed(1)}%`, FIGURE.share);
120
- // The balance belongs to the provider the header names; only DeepSeek's official route bills by a peak window.
142
+ // The balance belongs to the provider serving the route; only DeepSeek's official one bills by a peak window.
121
143
  const balance = balanceNow(provider);
122
144
  const spend = metrics.unknown && metrics.cost === 0 ? '--' : `$${metrics.cost.toFixed(2)}${metrics.unknown ? '+' : ''}${metrics.pending ? '…' : ''}`;
123
- // The money pair is the one live figure left unpadded: its digits cross a
124
- // column a handful of times per session, and reserving those columns would
125
- // evict a per-second figure at the widths the footer actually runs at.
145
+ // The money is the one live figure whose digits are not reserved: they cross a
146
+ // column a handful of times per session, and reserving them would evict a
147
+ // per-second figure at the widths the footer actually runs at. A balance the
148
+ // provider cannot report is left out instead of parked as `$--`.
126
149
  const dollars = provider === 'grok' ? grokFooterFact(grokSubscriptionNow(), locale)
127
- : `${spend} / ${balance === null ? '$--' : '$' + balance.toFixed(2)}${provider === 'deepseek-official' ? ' ' + peakEmoji(trustedNow()) : ''}`;
150
+ : spend + (balance === null ? '' : ' / $' + balance.toFixed(2)) + (provider === 'deepseek-official' ? ' ' + peakEmoji(trustedNow()) : '');
151
+ // The budget slot rides the money figure: it answers "how much of the session's
152
+ // limit is spent", which is a fact about the cost, not a second cost. A session
153
+ // with no limit renders exactly as before, so the footer only grows by choice.
154
+ const budget = metrics.budget;
155
+ const budgeted = budget === undefined || budget.limit === null ? ''
156
+ : `/${budget.limit.toFixed(2)}${budget.state === 'over' ? '⚠!' : budget.state === 'warn' ? '⚠' : ''}`;
157
+ const money = dollars + budgeted;
158
+ // The last completed turn is the one figure the whole-session total cannot
159
+ // give: `$1.23 / $9.86 · #12 $0.04` reads "the session so far, of which the
160
+ // last turn cost this". `+` marks a turn whose settled calls were not all
161
+ // priceable, the same mark the total uses.
162
+ const turn = metrics.lastTurn === undefined || !Number.isFinite(metrics.lastTurn.cost) ? ''
163
+ : `#${metrics.lastTurn.turn} $${metrics.lastTurn.cost.toFixed(2)}${metrics.lastTurn.unknown ? '+' : ''}`;
164
+ // Every figure reads value first and carries its own short qualifier, so the
165
+ // cluster stays scannable without a `label:` prefix in front of each number.
128
166
  const base = rates ? [
129
- `${label('footer.current')}: ${figure(Number.isFinite(rates.current) ? '~' + rates.current.toFixed(1) : '--', FIGURE.rate)} tps`,
130
- `${label('footer.average')}: ${figure(Number.isFinite(rates.average) ? rates.average.toFixed(1) : '--', FIGURE.rate)} tps`,
131
- `${label('footer.context')}: ${ctx}`, dollars, `${label('footer.cache')}: ${cache}`,
132
- ] : [`${label('footer.context')}: ${ctx}`, dollars, `${label('footer.cache')}: ${cache}`];
133
- // Narrow terminals shed the quietest figures first: average, current, context. The
134
- // model header then falls back to its bare `model @ effort` form, then context goes,
135
- // and only then the header itself the running cost is the last thing standing.
136
- const drops = rates ? [1, 0, 2, 4] : [0, 2];
137
- const offset = header === '' ? 0 : 1;
138
- const parts = header === '' ? base : [header, ...base];
139
- const short = header.replace(/^[^:]+: /, '');
140
- const heads = header === '' ? [''] : short === header ? [header] : [header, short];
141
- const render = ({ omit, head }) => parts
142
- .map((part, index) => (index === 0 && header !== '' ? head : part))
143
- .filter((part, index) => part !== '' && !omit.has(index))
144
- .join(' | ');
167
+ `${figure(Number.isFinite(rates.current) ? '~' + rates.current.toFixed(1) : '--', FIGURE.rate)} tps`,
168
+ `${figure(Number.isFinite(rates.average) ? rates.average.toFixed(1) : '--', FIGURE.rate)} tps ${label('footer.average')}`,
169
+ `${ctx} ${label('footer.context')}`, money, `${cache} ${label('footer.cache')}`,
170
+ ] : [`${ctx} ${label('footer.context')}`, money, `${cache} ${label('footer.cache')}`];
171
+ // The slot is reserved even when no turn has cost anything yet: the drop
172
+ // ladder's indices are fixed against this array, and an unfilled slot renders
173
+ // as '' (skipped by `render`) exactly like the absent figure would.
174
+ base.splice(rates ? 3 : 1, 0, turn);
175
+ // Narrow terminals shed the quietest figures first: the last turn, then
176
+ // average, current and context, and only then the cache. The running cost is
177
+ // the last thing standing, and its slot is index 4 (or 2 without rates) now
178
+ // that the turn slot holds index 3 (or 1) open.
179
+ const drops = rates ? [3, 1, 0, 2, 5, 4] : [1, 0, 3, 2];
180
+ const render = omit => base.filter((part, index) => part !== '' && !omit.has(index)).join(' · ');
145
181
  for (let dropped = 0; dropped <= drops.length; dropped++) {
146
- const omit = new Set(drops.slice(0, dropped).map(index => index + offset));
147
- for (const head of heads) {
148
- const value = render({ omit, head });
149
- if (displayWidth(value) <= columns) return value;
150
- }
182
+ const value = render(new Set(drops.slice(0, dropped)));
183
+ if (displayWidth(value) <= columns) return value;
151
184
  }
152
- const floor = render({ omit: new Set(drops.map(index => index + offset)), head: '' });
185
+ const floor = render(new Set(drops));
153
186
  let clipped = '';
154
187
  for (const char of floor) { if (displayWidth(clipped + char) > columns) break; clipped += char; }
155
188
  return clipped;
156
189
  }
190
+ /**
191
+ * The session's recorded spend and whether any of it is unpriceable, for a host
192
+ * that must decide something (the budget gate) rather than only render it. Same
193
+ * ledger and same rules as {@link footerFor}, so the figure the gate compares
194
+ * and the figure the footer shows can never disagree.
195
+ */
196
+ export function sessionSpend(id) {
197
+ try {
198
+ const ledger = id && process.env.DSH_HOME ? readMetrics(process.env.DSH_HOME, id) : { rows: [], corrupt: false };
199
+ const data = id ? source?.(id) : undefined;
200
+ const summary = summarize(ledger.rows, data?.events ?? [], ledger.corrupt);
201
+ return { cost: summary.cost, unknown: summary.unknown, pending: summary.pending };
202
+ } catch { return { cost: 0, unknown: true, pending: 0 }; }
203
+ }
204
+
157
205
  /** Per-events memo: the status line renders up to once a second, and summarize/average are O(events). */
158
206
  const footerCache = new WeakMap();
159
- export function footerFor(id, stats, columns, header = '', locale = 'en') {
207
+ export function footerFor(id, stats, columns, provider = 'deepseek-official', locale = 'en') {
208
+ const limit = parseBudget(process.env.DSCODE_SESSION_BUDGET_USD);
160
209
  try {
161
210
  const data = id ? source?.(id) : undefined;
162
211
  const ledger = id && process.env.DSH_HOME ? readMetrics(process.env.DSH_HOME, id) : { rows: [], corrupt: false };
@@ -170,6 +219,9 @@ export function footerFor(id, stats, columns, header = '', locale = 'en') {
170
219
  const capacity = data?.capacity ?? stats.contextWindow;
171
220
  const average = fresh ? hit.average : sessionAverageTps(events);
172
221
  if (events.length > 0 && !fresh) footerCache.set(events, { key: ledger.rows, length: events.length, tail, summary, average });
173
- return formatFooter(summary, Number.isFinite(used) && capacity > 0 ? used / capacity * 100 : undefined, columns, { current: data?.currentTps, average }, locale, header);
174
- } catch { return formatFooter({ cost: 0, unknown: true, cache: null }, undefined, columns, { current: null, average: null }, locale, header); }
222
+ // The budget comes from the environment for this process only: it is a
223
+ // per-machine spending guard, not a session property worth persisting.
224
+ const budget = limit === null ? undefined : evaluateBudget(summary.cost, limit);
225
+ return formatFooter({ ...summary, ...(budget === undefined ? {} : { budget }) }, Number.isFinite(used) && capacity > 0 ? used / capacity * 100 : undefined, columns, { current: data?.currentTps, average }, locale, provider);
226
+ } catch { return formatFooter({ cost: 0, unknown: true, cache: null }, undefined, columns, { current: null, average: null }, locale, provider); }
175
227
  }
@@ -10,11 +10,15 @@ export const instructionFileCandidates = Object.freeze(['AGENTS.md', 'CLAUDE.md'
10
10
  export const projectRootMarkers = Object.freeze(['.git']);
11
11
 
12
12
  export const ENABLED_VALUES = Object.freeze(['1', 'true', 'on', 'yes']);
13
- // Fail closed: only the documented enable spellings arm a switch, so an operator
14
- // writing "no", "none" or "disable" cannot accidentally turn on a feature that
15
- // reads untrusted project content.
13
+ // Fail closed: only the documented enable spellings read as on, so an operator
14
+ // writing "no", "none" or "disable" cannot accidentally arm a feature that reads
15
+ // untrusted project content.
16
16
  export const enabledFlag = (value) => ENABLED_VALUES.includes(String(value ?? '').trim().toLowerCase());
17
- export const skillAncestorsEnabled = (env = process.env) => enabledFlag(env.DSCODE_SKILL_ANCESTORS);
17
+ // Ancestor skill discovery defaults to on, so its switch is the inverse: only an
18
+ // explicit off spelling disables it and an unrecognised value keeps the default.
19
+ export const DISABLED_VALUES = Object.freeze(['0', 'false', 'off', 'no', 'none', 'disable']);
20
+ export const disabledFlag = (value) => DISABLED_VALUES.includes(String(value ?? '').trim().toLowerCase());
21
+ export const skillAncestorsEnabled = (env = process.env) => !disabledFlag(env.DSCODE_SKILL_ANCESTORS);
18
22
 
19
23
  // A symlinked $HOME (or a /Volumes mount) must not break the boundary test: the
20
24
  // launcher passes an already-resolved session directory while os.homedir() returns
@@ -52,6 +56,9 @@ export function projectRootOf({ cwd, markers = projectRootMarkers }) {
52
56
  }
53
57
  }
54
58
 
59
+ // Nearest first, which is also precedence: the provider scans `customSkillDirs` in
60
+ // order and the first definition of a duplicate name wins. The provider's own rank
61
+ // 100/200 roots at the project root still outrank everything registered here.
55
62
  export function ancestorSkillDirs({ cwd, home, env = process.env }) {
56
63
  if (!skillAncestorsEnabled(env)) return [];
57
64
  const projectRoot = projectRootOf({ cwd });
@@ -135,8 +135,9 @@
135
135
  - id: skill-filesystem
136
136
  name: '@deepseek-ai/dsh-skill-filesystem'
137
137
  config:
138
- # DSCODE_SKILL_ANCESTORS=1 adds the skill roots of directories between the
139
- # project root and home at rank 300: headers enter the catalog, bodies stay lazy.
138
+ # Ancestor skill discovery is on by default: the skill roots of directories
139
+ # between the working directory and home register at rank 300, nearest first,
140
+ # so headers enter the catalog and bodies stay lazy. =0 turns it off.
140
141
  customSkillDirs: !!js 'JSON.parse(process.env.DSCODE_SKILL_ANCESTOR_DIRS ?? "[]")'
141
142
 
142
143
  - id: tool-skill
@@ -28,13 +28,12 @@ import { DEFAULT_TERMINAL_TITLE, sanitizeTerminalTitle, terminalTitleSequence, u
28
28
  import { settledEntryCount } from './render/projection.mjs';
29
29
  import { imeCursorRowsUp, useImeCursorAnchor } from './render/ime-cursor.mjs';
30
30
  import { readClipboardImage } from './dscode/clipboard-image/index.mjs';
31
- import { dscodeChatLines } from './dscode/chat.mjs';
31
+ import { createChatLinesCache, dscodeChatLines } from './dscode/chat.mjs';
32
32
  import { readFileSync } from 'node:fs';
33
33
  import { FOOTER_FIGURE_RESERVE, footerFor as dscodeFooterFor } from '../../../plugins/session-metrics/view.mjs';
34
34
  import { newerVersion as dscodeNewerVersion } from '../../../plugins/tui-tools/update.mjs';
35
35
  import { languageName as dscodeLanguageName, normalizeLanguage as dscodeNormalizeLanguage, t as dscodeMessage } from '../../../plugins/i18n/messages.mjs';
36
36
  import { dscodeTelemetryNodes } from './dscode/telemetry.mjs';
37
- import { dscodeFooterHeader } from './render/status.mjs';
38
37
  import { dscodePadEnd, welcomeArtRows, welcomePath, WELCOME_ART, WELCOME_ART_SMALL } from './dscode/welcome.mjs';
39
38
  import { TETRIS_TICK_MS as DSCODE_TETRIS_TICK_MS, TETRIS_WIDTH as DSCODE_TETRIS_WIDTH, tetrisFrame as dscodeTetrisFrame } from '../../../plugins/compaction/tetris.mjs';
40
39
  import { PROVIDERS as DSCODE_PROVIDERS, providerSpec as dscodeProviderSpec, providerArgument as dscodeProviderArgument, providerOfLabel as dscodeProviderOfLabel, pickModel as dscodePickModel, credentialState as dscodeCredentialState, waitForModels as dscodeWaitForModels, } from '../../../plugins/providers/catalog.mjs';
@@ -128,8 +127,8 @@ const RESIZE_REFLOW_CLEAR = '\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H';
128
127
  const SYNCHRONIZED_UPDATE_BEGIN = '\x1b[?2026h';
129
128
  /** Release the held frame after Ink has replayed the source-backed Static rows. */
130
129
  const SYNCHRONIZED_UPDATE_END = '\x1b[?2026l';
131
- import { layoutStatusBar, padValue, parseStatuslineItems, statusCycleHint, STATUS_GROUP_SEPARATOR, STATUS_ITEM_SEPARATOR, STATUS_ROW2_INDENT, } from './render/status.mjs';
132
- import { displayTail, displayText, singleLineText, truncateColumns } from './render/text.mjs';
130
+ import { layoutStatusBar, parseStatuslineItems, statusCycleHint, STATUS_GROUP_SEPARATOR, STATUS_ITEM_SEPARATOR, STATUS_ROW2_INDENT, } from './render/status.mjs';
131
+ import { displayTail, displayText, formatTokens, singleLineText, truncateColumns, wrapText } from './render/text.mjs';
133
132
  import { isVsCodeTerminalEnv, normalizeKeyboardChunk, PASTE_BRACKET_TIMEOUT_MS, PASTE_END_MARKER, PASTE_START_MARKER, stripPasteMarkers, stripTerminalFocusEvents, tokenizeRawEditorChunk, } from './keyboard.mjs';
134
133
  import { clampScroll, followInspectorCursor, inspectorViewport, layoutGutterRows, liveRegionBudget, moveScroll, panelViewport, revealRow, selectionWindow, } from './render/inspector.mjs';
135
134
  import { clampLiveAllocation, diffLineStyle, fillDiffLineBars, lineSegment, markdownLines, styledLines, textLines, transcriptEntryLines, } from './render/lines.mjs';
@@ -214,9 +213,10 @@ function useFrames(intervalMs, active = true) {
214
213
  * Ink re-subscribes its input effect whenever the handler identity changes.
215
214
  * Keep terminal input ownership stable while a local surface updates cursor,
216
215
  * scroll, or draft state; otherwise every key toggles raw mode and can make
217
- * Ink repeatedly repaint the live region.
216
+ * Ink repeatedly repaint the live region. `active` defaults to true: a panel
217
+ * that only mounts while it owns the keyboard omits the argument.
218
218
  */
219
- function useStableInput(handler, active) {
219
+ function useStableInput(handler, active = true) {
220
220
  const handlerRef = useRef(handler);
221
221
  handlerRef.current = handler;
222
222
  const stableHandler = useCallback((input, key) => {
@@ -349,6 +349,25 @@ function dscodeActivity(entries, streaming) {
349
349
  return dscodeT('activity.running') + ' · ' + singleLineText(tool.name) + (running.length > 1 ? ' +' + (running.length - 1) : '')
350
350
  + (description ? ' · ' + truncateColumns(singleLineText(description), 56) : '');
351
351
  }
352
+ /**
353
+ * dscode: what cross-session communication is doing, or undefined when none is.
354
+ * A send in flight and a request still awaiting its answer outrank the ordinary
355
+ * tool activity, because the turn is blocked on another session either way.
356
+ */
357
+ function dscodeCommunicationActivity(view) {
358
+ const waiting = view.waiting.at(-1);
359
+ if (waiting !== undefined)
360
+ return '⇄ ' + dscodeT('communication.waiting', { peer: shortPeer(waiting.peer) });
361
+ const sending = [...view.rows].reverse().find(row => row.direction === 'sent' && row.pending);
362
+ if (sending !== undefined)
363
+ return '⇄ ' + dscodeT('communication.sending', { peer: shortPeer(sending.peer) });
364
+ return undefined;
365
+ }
366
+ /** Bounded peer label: a full session id is too long for one activity row. */
367
+ function shortPeer(peer) {
368
+ const label = peer.startsWith('session:') ? `session ${peer.slice('session:'.length, 'session:'.length + 8)}` : peer;
369
+ return label.length > 32 ? `${label.slice(0, 31)}…` : label;
370
+ }
352
371
  // dscode: the compaction indicator — a small scripted Tetris bot that lines each
353
372
  // piece up, drops it and clears full rows, the way compaction clears older history.
354
373
  function dscodeTetrisCells(row, palette, key) {
@@ -374,7 +393,7 @@ export function DscodeCompactionLine({ since, rows, animated = true }) {
374
393
  }
375
394
  return createElement(Box, { flexDirection: 'column', paddingX: 2 }, ...board.map((row, y) => createElement(Box, { key: y }, wall('left'), ...dscodeTetrisCells(row, palette, 'cell'), wall('right'), y === 1 ? label(dscodeT('compaction.running'), palette.brandBright) : y === 2 ? label(clock, palette.dim) : undefined)), createElement(Text, { color: inkColor(palette.dim) }, '+' + '-'.repeat(DSCODE_TETRIS_WIDTH * 2) + '+'));
376
395
  }
377
- export function DscodeActivityLine({ entries, streaming, since, animated = true }) {
396
+ export function DscodeActivityLine({ entries, streaming, since, animated = true, communication }) {
378
397
  const columns = useStdout().stdout?.columns ?? 80;
379
398
  const tick = useFrames(animated ? 100 : 1000);
380
399
  const elapsed = since > 0 ? Math.max(0, Date.now() - since) : 0;
@@ -385,8 +404,9 @@ export function DscodeActivityLine({ entries, streaming, since, animated = true
385
404
  const spinner = animated
386
405
  ? dscodeSpinnerCells(tick, palette)
387
406
  : { left: { text: ' ', color: palette.brandMid }, flake: palette.brandBright, right: { text: ' ', color: palette.brandMid } };
388
- const label = truncateColumns(dscodeActivity(entries, streaming), Math.max(1, columns - 9 - visibleColumns(suffix)));
389
- return createElement(Box, { paddingX: 2 }, createElement(Text, { wrap: 'truncate-end' }, createElement(Text, { color: inkColor(spinner.left.color) }, spinner.left.text), createElement(Text, { color: inkColor(spinner.flake) }, '❄'), createElement(Text, { color: inkColor(spinner.right.color) }, spinner.right.text), createElement(Text, { color: inkColor(getPalette().brandBright) }, ' ' + label), createElement(Text, { color: inkColor(getPalette().dim) }, suffix)));
407
+ const crossSession = communication === undefined ? undefined : dscodeCommunicationActivity(communication);
408
+ const label = truncateColumns(crossSession ?? dscodeActivity(entries, streaming), Math.max(1, columns - 9 - visibleColumns(suffix)));
409
+ return createElement(Box, { paddingX: 2 }, createElement(Text, { wrap: 'truncate-end' }, createElement(Text, { color: inkColor(spinner.left.color) }, spinner.left.text), createElement(Text, { color: inkColor(spinner.flake) }, '❄'), createElement(Text, { color: inkColor(spinner.right.color) }, spinner.right.text), createElement(Text, { color: inkColor(crossSession === undefined ? getPalette().brandBright : getPalette().steered) }, ' ' + label), createElement(Text, { color: inkColor(getPalette().dim) }, suffix)));
390
410
  }
391
411
  function DeepDivingLine({ since, animated = true }) {
392
412
  const elapsed = since === 0 ? 0 : Date.now() - since;
@@ -1018,15 +1038,18 @@ export function StatusLine({ facts, stats, busy, columns, items, onRows, animate
1018
1038
  const timer = setInterval(() => dscodeRefreshMetrics(n => n + 1), 1000);
1019
1039
  return () => clearInterval(timer);
1020
1040
  }, []);
1021
- // dscode: the telemetry segment carries the header and only appears once the
1022
- // terminal can seat it; below that the identity row keeps the model. Its
1023
- // slot is padded to the width it was laid out for, so the live figures
1024
- // decide the text the row shows without ever changing the row's geometry.
1041
+ // dscode: the live figures close row 2 as one left-hand cluster and only appear
1042
+ // once the terminal can seat them; row 1 names the model itself. The cluster is
1043
+ // laid out against the same width budget as before, and its own ladder decides
1044
+ // which figures fit.
1025
1045
  const telemetryWidth = Math.max(1, Math.min(columns - 8, Math.max(40, Math.floor(columns * 0.8) - 4) + FOOTER_FIGURE_RESERVE));
1046
+ // The figures belong to the provider serving the route (`provider/model`).
1047
+ const slash = facts.model.indexOf('/');
1048
+ const provider = slash > 0 ? facts.model.slice(0, slash) : 'deepseek-official';
1026
1049
  const telemetry = columns >= 48
1027
- ? dscodeFooterFor(facts.fullSessionId, stats, telemetryWidth, dscodeFooterHeader(facts, stats), getLanguage())
1050
+ ? dscodeFooterFor(facts.fullSessionId, stats, telemetryWidth, provider, getLanguage())
1028
1051
  : '';
1029
- facts = { ...facts, telemetry: telemetry === '' ? '' : padValue(telemetry, telemetryWidth) };
1052
+ facts = { ...facts, telemetry };
1030
1053
  // Flowing-theme busy flow: the identity cluster's live dot cycles the
1031
1054
  // anchor walk while a turn runs; static themes never start the timer.
1032
1055
  const flow = themeFlow();
@@ -1055,6 +1078,7 @@ export function StatusLine({ facts, stats, busy, columns, items, onRows, animate
1055
1078
  facts.goal?.phase,
1056
1079
  facts.goal?.rounds,
1057
1080
  facts.goal?.max,
1081
+ facts.skills,
1058
1082
  stats,
1059
1083
  busy,
1060
1084
  columns,
@@ -1075,16 +1099,13 @@ export function StatusLine({ facts, stats, busy, columns, items, onRows, animate
1075
1099
  if (groupIndex > 0) {
1076
1100
  leftParts.push(createElement(Text, { key: key + 'gs' + groupIndex, color: inkColor(getPalette().dim) }, STATUS_GROUP_SEPARATOR));
1077
1101
  }
1102
+ const telemetryGroup = group.id === 'telemetry';
1078
1103
  group.spans.forEach((span, spanIndex) => {
1079
- leftParts.push(createElement(Text, { key: key + 'g' + groupIndex + 's' + spanIndex, wrap: 'truncate-end', ...statusToneProps(span.tone, flowMs) }, span.text));
1104
+ const spanKey = key + 'g' + groupIndex + 's' + spanIndex;
1105
+ leftParts.push(createElement(Text, { key: spanKey, wrap: 'truncate-end', ...statusToneProps(span.tone, flowMs) }, telemetryGroup ? dscodeTelemetryNodes(span.text, spanKey) : span.text));
1080
1106
  });
1081
1107
  });
1082
1108
  const rightParts = [];
1083
- // dscode: row 2 joins its left figures to the right-pinned telemetry with a
1084
- // quieter rule than the cluster separator.
1085
- if (key === 's2' && row.left.length > 0 && row.right.length > 0) {
1086
- rightParts.push(createElement(Text, { key: key + 'divider', color: inkColor(getPalette().dim) }, '| '));
1087
- }
1088
1109
  // dscode: the cycle hint rides LEFT of the right cluster, so the
1089
1110
  // right-anchored badge holds its columns whether or not the hint is
1090
1111
  // painted; layoutStatusBar reserves the hint's width in both states.
@@ -1098,7 +1119,7 @@ export function StatusLine({ facts, stats, busy, columns, items, onRows, animate
1098
1119
  if (index > 0) {
1099
1120
  rightParts.push(createElement(Text, { key: key + 'rs' + index, color: inkColor(getPalette().dim) }, STATUS_ITEM_SEPARATOR));
1100
1121
  }
1101
- rightParts.push(createElement(Text, { key: key + 'r' + index, wrap: 'truncate-end', ...statusToneProps(span.tone, flowMs) }, key === 's2' && index === 0 ? dscodeTelemetryNodes(span.text, key + 'r' + index) : span.text));
1122
+ rightParts.push(createElement(Text, { key: key + 'r' + index, wrap: 'truncate-end', ...statusToneProps(span.tone, flowMs) }, span.text));
1102
1123
  });
1103
1124
  // Each row already fits the column budget; truncate-end stays as the
1104
1125
  // terminal-measurement backstop so a drifting cell count clips instead
@@ -1119,12 +1140,17 @@ export function StatusLine({ facts, stats, busy, columns, items, onRows, animate
1119
1140
  * above the composer.
1120
1141
  */
1121
1142
  function NoticeLine({ text, tone, columns }) {
1143
+ // dscode: `relay` is cross-session traffic. It takes the violet the interface
1144
+ // reserves for steered/queued interactive input plus its own glyph, so it is
1145
+ // never mistaken for the session's own tool or shell output.
1122
1146
  const color = tone === 'error'
1123
1147
  ? getPalette().error
1124
1148
  : tone === 'warning'
1125
1149
  ? getPalette().warn
1126
- : getPalette().brandBright;
1127
- const mark = tone === 'error' ? '⨯' : tone === 'warning' ? '!' : '•';
1150
+ : tone === 'relay'
1151
+ ? getPalette().steered
1152
+ : getPalette().brandBright;
1153
+ const mark = tone === 'error' ? '⨯' : tone === 'warning' ? '!' : tone === 'relay' ? '⇄' : '•';
1128
1154
  return createElement(Box, { paddingLeft: 2 }, createElement(Text, { color: inkColor(color), wrap: 'truncate-end' }, truncateColumns(`${mark} ${singleLineText(text)}`, Math.max(1, columns - 2))));
1129
1155
  }
1130
1156
  /** The fixed decision list; answers stay in the binary answerer vocabulary. */
@@ -1987,7 +2013,7 @@ export function DscodeEmailPanel({ columns, rows, pick, close, gmail, imap }) {
1987
2013
  const listWidth = wide ? Math.max(26, Math.floor(columns * 0.4)) : columns;
1988
2014
  const previewWidth = wide ? Math.max(1, columns - listWidth - 1) : columns;
1989
2015
  const clean = value => dscodeEmailText(value).replace(/\n/g, ' ');
1990
- const bodyLines = mail ? wrapText(dscodeEmailText(mail.body), Math.max(1, previewWidth - 2), 'wrap').split('\n') : [];
2016
+ const bodyLines = mail ? wrapText(dscodeEmailText(mail.body), Math.max(1, previewWidth - 2)) : [];
1991
2017
  useStableInput((input, key) => {
1992
2018
  if (setup)
1993
2019
  return;
@@ -2878,6 +2904,40 @@ function ProviderDiscoveryPanel({ target, baseURL, apiKey, configured, discover,
2878
2904
  return createElement(Text, { key: model.id, color: added ? inkColor(getPalette().dim) : active ? inkColor(getPalette().brandBright) : inkColor(getPalette().text), wrap: 'truncate-end' }, truncateColumns(label, viewport.contentColumns));
2879
2905
  }), createElement(PanelGap, { visible: viewport.gapRows > 0 }), createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(t('panel.discovery.footer'), viewport.contentColumns)));
2880
2906
  }
2907
+ /**
2908
+ * dscode: the budget gate. The runner checks the session's recorded spend
2909
+ * against `DSCODE_SESSION_BUDGET_USD` before it delivers a prompt and hands the
2910
+ * submission here instead of sending it; the user decides whether the turn
2911
+ * still runs. It deliberately mirrors the compaction confirmation (`y`/`n`/Esc)
2912
+ * so the two stops share one reflex, and it never sends the prompt itself — the
2913
+ * runner owns delivery, this panel only answers.
2914
+ */
2915
+ export function DscodeBudgetConfirmPanel({ decision, confirm, back }) {
2916
+ const stdout = useStdout().stdout;
2917
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
2918
+ useStableInput((input, key) => {
2919
+ if (key.escape || input === 'n') {
2920
+ back();
2921
+ return;
2922
+ }
2923
+ if (input === 'y')
2924
+ confirm();
2925
+ }, true);
2926
+ const palette = getPalette();
2927
+ const values = {
2928
+ spent: '$' + decision.spent.toFixed(2),
2929
+ limit: '$' + decision.limit.toFixed(2),
2930
+ percent: String(Math.round(decision.percent)) + '%',
2931
+ };
2932
+ const title = dscodeT('budget.confirm.title', values);
2933
+ const hint = dscodeT('budget.confirm.hint');
2934
+ if (viewport.maxHeight === 0 || viewport.compact)
2935
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(title + ' · ' + hint, viewport.contentColumns));
2936
+ const body = [dscodeT('budget.confirm.spent', values), dscodeT('budget.confirm.next', values)]
2937
+ .map((text, index) => createElement(Text, { key: 'body' + index, color: index === 0 ? void 0 : inkColor(palette.dim), wrap: 'truncate-end' }, truncateColumns(' ' + text, viewport.contentColumns)))
2938
+ .slice(0, viewport.bodyRows);
2939
+ return createElement(Box, { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(palette.warn) }, createElement(Text, { color: inkColor(palette.warn), bold: true, wrap: 'truncate-end' }, truncateColumns(title, viewport.contentColumns)), createElement(PanelGap, { visible: viewport.gapRows > 0 }), ...body, createElement(PanelGap, { visible: viewport.gapRows > 0 }), createElement(Text, { color: inkColor(palette.dim), wrap: 'truncate-end' }, truncateColumns(hint, viewport.contentColumns)));
2940
+ }
2881
2941
  /** Bounded destructive-action confirmation for credential or provider removal. */
2882
2942
  export function DscodeCompactionConfirmPanel({ preview, confirm, back }) {
2883
2943
  const stdout = useStdout().stdout;
@@ -3993,8 +4053,8 @@ function Input({ effortSurface, ultraPulse, active, frozen, frozenHint, busy, de
3993
4053
  // path-like query keeps the upstream order entirely.
3994
4054
  let rankedMentionRows = visibleMentionRows;
3995
4055
  if (mentionToken !== undefined && !isPathLikeMentionQuery(mentionToken.query) && mentionToken.query !== '') {
3996
- const hits = rankByName(visibleMentionRows.map(row => ({ name: row.label.replace(/^@/u, ''), row })), mentionToken.query)
3997
- .map(entry => entry.row);
4056
+ const mentionMatches = visibleMentionRows.map(row => ({ name: row.label.replace(/^@/u, ''), row }));
4057
+ const hits = rankByName(mentionMatches, mentionToken.query).map(entry => entry.row);
3998
4058
  const hitSet = new Set(hits);
3999
4059
  rankedMentionRows = [...hits, ...visibleMentionRows.filter(row => !hitSet.has(row))];
4000
4060
  }
@@ -4329,7 +4389,7 @@ function Input({ effortSurface, ultraPulse, active, frozen, frozenHint, busy, de
4329
4389
  }
4330
4390
  if (liveValue.startsWith('!')) {
4331
4391
  // Dropping the bang shifts every character left: keep the caret on the same letter.
4332
- applyEdit({ value: liveValue.slice(1), cursor: Math.max(0, liveCursor - 1) });
4392
+ applyEdit({ value: liveValue.slice(1), cursor: Math.max(0, liveCursor - 1), killed: undefined });
4333
4393
  return;
4334
4394
  }
4335
4395
  if (hasNotice) {
@@ -5195,7 +5255,7 @@ export function computeSettledRows(previous, entries, settled, showReasoning, re
5195
5255
  if (record.before !== undefined)
5196
5256
  window.unshift(record.before);
5197
5257
  }
5198
- const header = createElement(Header, { key: 'header', ...headerFacts, resumed });
5258
+ const header = createElement(Header, { key: 'header', ...headerFacts });
5199
5259
  const flat = droppedEntries > 0
5200
5260
  ? [header, settledTrimHint(droppedEntries, columns), ...window]
5201
5261
  : [header, ...window];
@@ -5270,6 +5330,12 @@ export function App(props) {
5270
5330
  // dscode: the email inbox owns the surface while it is open; a picked message
5271
5331
  // steers into the running turn (or starts one) and closes the panel.
5272
5332
  const [emailOpen, setEmailOpen] = useState(false);
5333
+ /**
5334
+ * dscode: a submission the budget gate stopped. The runner parks it here
5335
+ * instead of delivering it; the panel's `y` hands the exact line back, and
5336
+ * `n`/Esc clears it without sending. Only one prompt waits at a time.
5337
+ */
5338
+ const [budgetPending, setBudgetPending] = useState(undefined);
5273
5339
  const gmail = useMemo(() => dscodeCreateGmailConnector(), []);
5274
5340
  const imap = useMemo(() => dscodeCreateImapConnector(), []);
5275
5341
  useEffect(() => {
@@ -5291,6 +5357,9 @@ export function App(props) {
5291
5357
  setEmailOpen(false);
5292
5358
  setBtwOpen(false);
5293
5359
  setBtwSelected(undefined);
5360
+ // A parked prompt belongs to the session that was over budget; carrying it
5361
+ // across a switch would deliver the old session's text into the new one.
5362
+ setBudgetPending(undefined);
5294
5363
  }, [props.sessionKey]);
5295
5364
  // The stores are closure-backed singletons whose methods never touch `this`,
5296
5365
  // but a bare method reference still detaches it from its receiver. One stable
@@ -5320,10 +5389,12 @@ export function App(props) {
5320
5389
  // process-stable, so one callback per view identity is enough.
5321
5390
  const readDescriptors = useCallback(() => props.commands.descriptors, [props.commands]);
5322
5391
  const readSkills = useCallback(() => props.skills.rows, [props.skills]);
5392
+ const readSkillCount = useCallback(() => props.skills.count, [props.skills]);
5323
5393
  const subscribeCommands = useCallback((listener) => props.commands.subscribe(listener), [props.commands]);
5324
5394
  const subscribeSkills = useCallback((listener) => props.skills.subscribe(listener), [props.skills]);
5325
5395
  const descriptors = useSyncExternalStore(subscribeCommands, readDescriptors);
5326
5396
  const skills = useSyncExternalStore(subscribeSkills, readSkills);
5397
+ const skillCount = useSyncExternalStore(subscribeSkills, readSkillCount);
5327
5398
  const [modelLabel, setModelLabel] = useState(props.model);
5328
5399
  const [modelOpen, setModelOpen] = useState(false);
5329
5400
  /** Nested /model stages; only one owns terminal input at a time. */
@@ -5419,8 +5490,8 @@ export function App(props) {
5419
5490
  // `/latest` dist-tag endpoint, which silently suppressed this notice.
5420
5491
  fetch(DSCODE_REGISTRY_URL, { headers: { accept: 'application/json' }, signal: controller.signal })
5421
5492
  .then(response => (response.ok ? response.json() : undefined))
5422
- .then(data => {
5423
- const latest = data?.version;
5493
+ .then((data) => {
5494
+ const latest = data !== null && typeof data === 'object' ? data.version : undefined;
5424
5495
  if (typeof latest === 'string' && dscodeNewerVersion(latest, DSCODE_VERSION))
5425
5496
  notify(t('update.available', { version: latest }), 'warning');
5426
5497
  })
@@ -5432,7 +5503,7 @@ export function App(props) {
5432
5503
  };
5433
5504
  }, []);
5434
5505
  useEffect(() => {
5435
- props.onBridgeReady({ notify });
5506
+ props.onBridgeReady({ notify, confirmBudget: setBudgetPending });
5436
5507
  }, []);
5437
5508
  useEffect(() => {
5438
5509
  if (!modelOpen)
@@ -5616,8 +5687,8 @@ export function App(props) {
5616
5687
  // panel keypress.
5617
5688
  const inputActive = deleteConfirmId !== undefined
5618
5689
  ? !approvalPending && !questionPending
5619
- : !emailOpen && !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !updateOpen && !scheduleOpen && !jobsOpen && !statuslineOpen && !themeOpen && !languageOpen && !historyOpen && !queueOpen && !agentsOpen && !subagentOpen && !todosOpen && !usageOpen && !verboseOpen && diffView === undefined && !reviewPickerOpen && !approvalPending && !questionPending;
5620
- const transcriptVisible = !btwOpen && !emailOpen && !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !updateOpen && !scheduleOpen && !jobsOpen && !statuslineOpen && !themeOpen && !languageOpen && !historyOpen && !queueOpen && !agentsOpen && !subagentOpen && !todosOpen && !usageOpen && !verboseOpen && diffView === undefined && !reviewPickerOpen && !approvalPending && !questionPending;
5690
+ : !emailOpen && !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !updateOpen && !scheduleOpen && !jobsOpen && !statuslineOpen && !themeOpen && !languageOpen && !historyOpen && !queueOpen && !agentsOpen && !subagentOpen && !todosOpen && !usageOpen && !verboseOpen && budgetPending === undefined && diffView === undefined && !reviewPickerOpen && !approvalPending && !questionPending;
5691
+ const transcriptVisible = !btwOpen && !emailOpen && !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !updateOpen && !scheduleOpen && !jobsOpen && !statuslineOpen && !themeOpen && !languageOpen && !historyOpen && !queueOpen && !agentsOpen && !subagentOpen && !todosOpen && !usageOpen && !verboseOpen && budgetPending === undefined && diffView === undefined && !reviewPickerOpen && !approvalPending && !questionPending;
5621
5692
  // Human questions outrank local inspectors. Close the lower modal instead
5622
5693
  // of leaving an approval/question visible but keyboard-locked behind it.
5623
5694
  useEffect(() => {
@@ -5774,10 +5845,15 @@ export function App(props) {
5774
5845
  // session title; cleared on unmount so the host shell regains its default.
5775
5846
  const tabTitle = view.title === '' ? DEFAULT_TERMINAL_TITLE : view.title;
5776
5847
  useTerminalTitle(tabTitle);
5848
+ // dscode: the live region is re-rendered for every event, but an entry that
5849
+ // did not change keeps its object identity, so its wrapped lines are reused
5850
+ // instead of re-flowed per frame. Only the streaming text (view.streaming)
5851
+ // changes between those frames, and it is rendered outside this list.
5852
+ const chatLines = useMemo(() => createChatLinesCache(), []);
5777
5853
  const allLiveLines = useMemo(() => view.entries.slice(settled).flatMap(
5778
5854
  // Width shrinks with the real terminal (no 10-column floor: on a
5779
5855
  // narrower terminal the floor silently overflowed every row).
5780
- entry => dscodeChatLines(entry, Math.max(1, terminalColumns - 2), showReasoning)), [view.entries, settled, terminalColumns, showReasoning]);
5856
+ entry => chatLines(entry, Math.max(1, terminalColumns - 2), showReasoning)), [view.entries, settled, terminalColumns, showReasoning, chatLines]);
5781
5857
  // Reserve the same stream slice from the moment a turn becomes busy. This
5782
5858
  // keeps the first thinking frame from changing the dynamic-tree geometry
5783
5859
  // underneath Ink's cursor ledger and avoids a start-of-thinking flash.
@@ -5820,7 +5896,7 @@ export function App(props) {
5820
5896
  const auditedReasoningRows = liveAudit.allocation.reasoning;
5821
5897
  const auditedAnswerRows = liveAudit.allocation.answer;
5822
5898
  const inspectorVisible = verboseOpen && !approvalPending && !questionPending;
5823
- const modalVisible = emailOpen || modelOpen || helpOpen && modeOpen || permissionOpen || resumeOpen || pluginOpen || updateOpen || scheduleOpen || jobsOpen || statuslineOpen || themeOpen || languageOpen || historyOpen || queueOpen || agentsOpen || subagentOpen || todosOpen || usageOpen || inspectorVisible || diffView !== undefined || reviewPickerOpen || approvalPending || questionPending;
5899
+ const modalVisible = budgetPending !== undefined || emailOpen || modelOpen || helpOpen && modeOpen || permissionOpen || resumeOpen || pluginOpen || updateOpen || scheduleOpen || jobsOpen || statuslineOpen || themeOpen || languageOpen || historyOpen || queueOpen || agentsOpen || subagentOpen || todosOpen || usageOpen || inspectorVisible || diffView !== undefined || reviewPickerOpen || approvalPending || questionPending;
5824
5900
  // The surface that currently owns the keyboard, named in the frozen band:
5825
5901
  // an empty composer under a panel must not advertise typing it cannot
5826
5902
  // accept — every key actually feeds the panel (which may or may not
@@ -6385,6 +6461,17 @@ export function App(props) {
6385
6461
  update: props.updateQueued,
6386
6462
  onClose: () => setQueueOpen(false),
6387
6463
  })
6464
+ : undefined, budgetPending !== undefined
6465
+ ? createElement(DscodeBudgetConfirmPanel, {
6466
+ decision: budgetPending.decision,
6467
+ confirm: () => {
6468
+ const parked = budgetPending;
6469
+ setBudgetPending(undefined);
6470
+ props.dscodeAcknowledgeBudget?.(props.sessionKey);
6471
+ props.dispatch(parked.text, parked.images, props.sessionKey);
6472
+ },
6473
+ back: () => setBudgetPending(undefined),
6474
+ })
6388
6475
  : undefined, createElement(QuestionBar, { store: props.questions, snapshot: questionSnapshot, locked: false }), createElement(ApprovalBar, { snapshot: approvalSnapshot, locked: questionPending, notify, interrupt: props.interrupt, summarize: questionPending }), effortFor !== undefined ? undefined : modelSurface, helpOpen && !approvalPending && !questionPending
6389
6476
  ? createElement(HelpPanel, {
6390
6477
  descriptors,
@@ -6801,6 +6888,7 @@ export function App(props) {
6801
6888
  permission: view.permission !== '' ? view.permission : props.permission,
6802
6889
  sandbox: view.sandbox,
6803
6890
  goal: view.goal === undefined ? undefined : { phase: view.goal.phase, rounds: view.goal.rounds, max: view.goal.max },
6891
+ skills: skillCount,
6804
6892
  },
6805
6893
  stats: view.stats,
6806
6894
  busy,