@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.
@@ -20,6 +20,8 @@ import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm';
20
20
  import { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session';
21
21
  import { deriveTurnTokenUsage } from '@deepseek-ai/dsh-token-meter/client';
22
22
  import { App } from './app.mjs';
23
+ import { evaluateBudget, parseBudget } from '../../../plugins/session-metrics/turns.mjs';
24
+ import { sessionSpend, turnCostsFor } from '../../../plugins/session-metrics/view.mjs';
23
25
  import { mountApprovalAnswerer } from './approval.mjs';
24
26
  import { isSlashLine, submissionPayload, watchCommands } from './commands.mjs';
25
27
  import { internals } from './internals.mjs';
@@ -35,6 +37,7 @@ import { createMentions } from './mentions.mjs';
35
37
  import { mountQuestionProvider } from './questions.mjs';
36
38
  import { createTranscriptStore } from './store.mjs';
37
39
  import { createSubagentFeed } from './subagents.mjs';
40
+ import { createCommunicationFeed, foldCommunication } from './communication.mjs';
38
41
  import { btwBrief, btwSeed, createBtwFeed } from './btw.mjs';
39
42
  import { parseStatuslineItems } from './render/status.mjs';
40
43
  import { historyLine, HISTORY_MAX_ENTRIES, needsCompaction, parseHistoryFile, serializeHistoryList } from './history.mjs';
@@ -55,7 +58,7 @@ import { applyLauncherUpdate, probeLauncherUpdate } from './update.mjs';
55
58
  import { parseAnimationsPref } from './render/animations.mjs';
56
59
  import { parseThemeName, setTheme } from './theme.mjs';
57
60
  import { parseLanguageName, setLanguage, t } from './i18n.mjs';
58
- import { isSubagentSession, matchSessionId, mergeSessionTitles, newestRootForCwd, isSessionArtifactName, jsonlSessionRoot, planSessionDeletion, projectSessionRows, sessionArtifactDirectory, sessionDirectoryFor, } from './session-directory.mjs';
61
+ import { isSubagentSession, matchSessionId, mergeSessionTitles, newestRootForCwd, sessionFolderMatches, isSessionArtifactName, jsonlSessionRoot, planSessionDeletion, projectSessionRows, sessionArtifactDirectory, sessionDirectoryFor, } from './session-directory.mjs';
59
62
  import { createUserSettingsPersistence, writeFileAtomically } from './settings-file.mjs';
60
63
  import { turnUsages } from './render/usage.mjs';
61
64
  /** Stable Cordis plugin name. */
@@ -375,6 +378,20 @@ export class StartupInputGate {
375
378
  }
376
379
  }
377
380
  }
381
+ /**
382
+ * The warning for a resume that leaves the launch folder, or undefined when
383
+ * it stays. A session binds to one folder when it is created, and the
384
+ * workspace, the shell and the durable log all follow that header cwd — so a
385
+ * cross-folder resume works, but the user must be told the window moved.
386
+ * @param pinnedCwd - the header's project directory, when it has one.
387
+ * @param launchCwd - the directory DSCODE was launched in.
388
+ * @returns the warning text, or undefined when the folders already agree.
389
+ */
390
+ export function resumeFolderWarning(pinnedCwd, launchCwd) {
391
+ if (sessionFolderMatches(pinnedCwd, launchCwd))
392
+ return undefined;
393
+ return t('notice.resumedOtherFolder', { folder: pinnedCwd, launch: launchCwd });
394
+ }
378
395
  /**
379
396
  * Resolve the invocation's target session against the persisted headers.
380
397
  * @param startup - the parsed startup flags.
@@ -505,6 +522,16 @@ async function run(ctx, startup, io) {
505
522
  const seedOptions = pendingSelection === undefined
506
523
  ? { provider: currentDefaults().provider, model: currentDefaults().model }
507
524
  : { provider: pendingSelection.provider, model: pendingSelection.model };
525
+ let folderWarning;
526
+ if (next.resume) {
527
+ // The header is the only durable folder binding; the resumed session
528
+ // works in that folder whichever entry point got here — the CLI
529
+ // --resume flag, the /resume picker, /search or a queued switch. Only
530
+ // the notice differs, so it is prepared here and displayed once the
531
+ // session is actually on screen.
532
+ const pinned = (await persistence?.list())?.find(record => record.header.id === next.sessionId)?.header.cwd;
533
+ folderWarning = resumeFolderWarning(pinned, cwd);
534
+ }
508
535
  const handle = next.resume
509
536
  ? await agents.resume({
510
537
  resumeSessionId: SessionId(next.sessionId),
@@ -554,6 +581,7 @@ async function run(ctx, startup, io) {
554
581
  mode: mode ?? 'standard',
555
582
  selection: selectionState,
556
583
  resumed: next.resume,
584
+ ...(folderWarning === undefined ? {} : { folderWarning }),
557
585
  catalogSeed: subagentCatalogSeed(seedEvents),
558
586
  };
559
587
  };
@@ -564,6 +592,60 @@ async function run(ctx, startup, io) {
564
592
  // Live subagent activity (child sessions of the current root): one bounded
565
593
  // row per child, folded from the same event bus the transcript feeds on.
566
594
  const subagents = createSubagentFeed();
595
+ // The bridge the React app registers on mount: local notices from the
596
+ // process side (unknown commands, switch confirmations, cancels).
597
+ const bridge = { notify: () => { }, confirmBudget: () => { } };
598
+ /**
599
+ * Notices emitted before the App mounted and registered its bridge; the
600
+ * first registration flushes them in order. Nothing else can drop a startup
601
+ * notice: the stub above is running before any React effect has committed.
602
+ */
603
+ const pendingNotices = [];
604
+ /** True from the FIRST bridge registration: a startup notice must land on it,
605
+ * never on the instance a later session switch is about to replace. */
606
+ let bridgeReady = false;
607
+ /**
608
+ * The session whose over-budget submission the user already approved. The
609
+ * gate asks once per session: a second prompt in the same session does not
610
+ * re-ask, and a new session starts with the question unanswered again.
611
+ */
612
+ let budgetAcknowledgedFor;
613
+ // dscode: live cross-session communication of the CURRENT root session. The
614
+ // feed is advisory display state rebuilt from the root log, so a resumed
615
+ // session re-derives its history instead of replaying stale notices.
616
+ let communication = createCommunicationFeed();
617
+ /** Row ids already reported through a notice, so a re-fold never repeats one. */
618
+ const communicationNoticed = new Set();
619
+ /**
620
+ * A peer's readable label: a bridge relay labels its source `session:<id>`,
621
+ * which is shortened to the tail the session directory shows.
622
+ */
623
+ const dscodePeerLabel = (peer) => peer.startsWith('session:') ? `session ${peer.slice('session:'.length).slice(-12)}` : peer;
624
+ /**
625
+ * Fold one root event into the cross-session feed and announce what is new.
626
+ * The activity line reads the view; a notice is the durable, scrollable
627
+ * record. Both use the relay tone and the ⇄ family, so this traffic never
628
+ * reads as the session's own shell or model output.
629
+ */
630
+ const observeCommunication = (event) => {
631
+ const previous = communication;
632
+ const next = foldCommunication(previous, event);
633
+ if (next === previous)
634
+ return;
635
+ communication = next;
636
+ for (const row of next.rows) {
637
+ if (row.pending || communicationNoticed.has(row.id))
638
+ continue;
639
+ communicationNoticed.add(row.id);
640
+ if (row.direction === 'received') {
641
+ bridge.notify(`← ${dscodePeerLabel(row.peer)}${row.preview === '' ? '' : ` · ${row.preview}`}`, 'relay');
642
+ continue;
643
+ }
644
+ const kind = row.kind === undefined ? '' : ` ${row.kind}`;
645
+ const status = row.failed ? ' — failed' : '';
646
+ bridge.notify(`→ ${dscodePeerLabel(row.peer)}${kind}${status}`, 'relay');
647
+ }
648
+ };
567
649
  // Side-question runs (/btw): a seeded read-only child whose answer renders in
568
650
  // its own panel and never in this transcript or its model context.
569
651
  const btw = createBtwFeed();
@@ -661,9 +743,12 @@ async function run(ctx, startup, io) {
661
743
  composing = turn.catch(() => { });
662
744
  return turn;
663
745
  };
746
+ /** The initial session's folder warning, held until the App can show it. */
747
+ let startupFolderWarning;
664
748
  if (!lazy) {
665
749
  const target = await resolveTarget(startup, persistence, cwd);
666
750
  const prepared = await prepare(target);
751
+ startupFolderWarning = prepared.folderWarning;
667
752
  active = prepared;
668
753
  agent = prepared.agent;
669
754
  session = prepared.session;
@@ -674,6 +759,13 @@ async function run(ctx, startup, io) {
674
759
  for (const event of prepared.catalogSeed)
675
760
  subagents.apply(event.data.childId, event);
676
761
  }
762
+ // The resumed root's own cross-session history is folded here, but its rows
763
+ // are marked seen: a restart reports new traffic, never the whole past.
764
+ if (session !== undefined) {
765
+ communication = session.snapshotEvents().reduce((view, event) => foldCommunication(view, event), createCommunicationFeed());
766
+ for (const row of communication.rows)
767
+ communicationNoticed.add(row.id);
768
+ }
677
769
  // Seed the transcript from the full session log: constructor seeds never
678
770
  // fire on `session/event`, so a resumed session paints its history once
679
771
  // before the first render. The handler reads the current session/store, so
@@ -692,6 +784,7 @@ async function run(ctx, startup, io) {
692
784
  // fact describes one child, so it feeds that child's live row.
693
785
  if (event.type === 'subagent/catalog' && event.data.childId !== '')
694
786
  subagents.apply(event.data.childId, event);
787
+ observeCommunication(event);
695
788
  return;
696
789
  }
697
790
  // Child sessions (subagent conversations this root spawned) fold into
@@ -762,9 +855,6 @@ async function run(ctx, startup, io) {
762
855
  // screen at a time. Plan reviews (exit_plan_mode) arrive through this same
763
856
  // pipe; sibling answerers stay usable through the claim/defer split.
764
857
  const questions = mountQuestionProvider(ctx, candidate => agent !== undefined && candidate.id === agent.id);
765
- // The bridge the React app registers on mount: local notices from the
766
- // process side (unknown commands, switch confirmations, cancels).
767
- const bridge = { notify: () => { } };
768
858
  // Same-id capability inheritance. Catalog capabilities flow by route key,
769
859
  // not model id, so a hand-declared relay model without an explicit
770
860
  // reasoningEfforts declaration serves no reasoning levels and offers no
@@ -1300,6 +1390,21 @@ async function run(ctx, startup, io) {
1300
1390
  ensureSession();
1301
1391
  return;
1302
1392
  }
1393
+ // dscode: the budget gate stops exactly one submission per session when the
1394
+ // recorded spend has reached `DSCODE_SESSION_BUDGET_USD`. The user decides;
1395
+ // an approval is remembered for the rest of the session, and a session with
1396
+ // no limit (or an unparseable one) never reaches this branch.
1397
+ const limit = parseBudget(process.env.DSCODE_SESSION_BUDGET_USD);
1398
+ if (limit !== null && budgetAcknowledgedFor !== session.id) {
1399
+ const decision = evaluateBudget(sessionSpend(session.id).cost, limit);
1400
+ if (decision.state === 'over') {
1401
+ bridge.confirmBudget({
1402
+ decision: { spent: decision.spent, limit: decision.limit, percent: decision.percent },
1403
+ text: line, images, mode,
1404
+ });
1405
+ return;
1406
+ }
1407
+ }
1303
1408
  deliverLine(line, images, mode);
1304
1409
  };
1305
1410
  // Startup serialization: input submitted while the startup prompt/images
@@ -1688,9 +1793,12 @@ async function run(ctx, startup, io) {
1688
1793
  if (current === undefined)
1689
1794
  return Promise.resolve({ turns: [] });
1690
1795
  const values = ctx.get('sessionProjections')?.snapshot(current, ['tokenUsage']).values;
1796
+ const events = current.snapshotEvents();
1797
+ // The token meter says what each turn billed; the cost ledger says what it
1798
+ // cost. Both read the same durable log, so the panel's rows line up.
1691
1799
  return Promise.resolve({
1692
1800
  totals: values?.tokenUsage,
1693
- turns: turnUsages(current.snapshotEvents(), deriveTurnTokenUsage),
1801
+ turns: turnUsages(events, deriveTurnTokenUsage, turnCostsFor(current.id, events)),
1694
1802
  });
1695
1803
  };
1696
1804
  const switchModeAction = async (id) => {
@@ -1756,6 +1864,14 @@ async function run(ctx, startup, io) {
1756
1864
  session = next.session;
1757
1865
  store = next.store;
1758
1866
  mentions = next.mentions;
1867
+ // Cross-session rows belong to the previous root. The incoming session's
1868
+ // own history is re-derived from its log and marked seen, so the activity
1869
+ // line keeps an unanswered request visible while only NEW traffic is
1870
+ // announced.
1871
+ communication = next.session.snapshotEvents().reduce((view, event) => foldCommunication(view, event), createCommunicationFeed());
1872
+ communicationNoticed.clear();
1873
+ for (const row of communication.rows)
1874
+ communicationNoticed.add(row.id);
1759
1875
  commands.setAgent(agent);
1760
1876
  skills.setAgent(agent);
1761
1877
  try {
@@ -1816,6 +1932,8 @@ async function run(ctx, startup, io) {
1816
1932
  // instance and React drops it silently. Defer past the commit.
1817
1933
  setTimeout(() => {
1818
1934
  bridge.notify(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`);
1935
+ if (next.folderWarning !== undefined)
1936
+ bridge.notify(next.folderWarning, 'warning');
1819
1937
  }, 0);
1820
1938
  return;
1821
1939
  }
@@ -1836,6 +1954,8 @@ async function run(ctx, startup, io) {
1836
1954
  bridge.notify(cleanupWarning === undefined
1837
1955
  ? `${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`
1838
1956
  : `switched to ${next.session.id.slice(-12)}, but ${cleanupWarning}`, cleanupWarning === undefined ? 'info' : 'warning');
1957
+ if (next.folderWarning !== undefined)
1958
+ bridge.notify(next.folderWarning, 'warning');
1839
1959
  });
1840
1960
  };
1841
1961
  const switchQueue = new SessionSwitchQueue(async (request) => { if (!quitting)
@@ -2095,6 +2215,7 @@ async function run(ctx, startup, io) {
2095
2215
  approval,
2096
2216
  questions,
2097
2217
  subagents,
2218
+ communication,
2098
2219
  btw,
2099
2220
  startBtw,
2100
2221
  commands,
@@ -2111,6 +2232,8 @@ async function run(ctx, startup, io) {
2111
2232
  /** Pre-session plan choice for the status badge until a session composes. */
2112
2233
  pendingPlan: session === undefined && pendingPlan,
2113
2234
  dispatch,
2235
+ /** dscode: the budget gate's approval covers the rest of the session. */
2236
+ dscodeAcknowledgeBudget: (sessionKey) => { budgetAcknowledgedFor = sessionKey; },
2114
2237
  steer,
2115
2238
  interrupt,
2116
2239
  quit,
@@ -2190,12 +2313,23 @@ async function run(ctx, startup, io) {
2190
2313
  history: inputHistory,
2191
2314
  recordHistory,
2192
2315
  updateQueued,
2193
- onBridgeReady: (instance) => { bridge.notify = instance.notify; },
2316
+ onBridgeReady: (instance) => {
2317
+ bridge.notify = instance.notify;
2318
+ if (bridgeReady)
2319
+ return;
2320
+ bridgeReady = true;
2321
+ for (const notice of pendingNotices.splice(0))
2322
+ bridge.notify(notice.text, notice.tone);
2323
+ },
2194
2324
  });
2195
2325
  };
2196
2326
  const renderCurrent = () => {
2197
2327
  mountRef.current?.rerender(appElement());
2198
2328
  };
2329
+ // Hold the cross-folder notice until a bridge exists: a resume that left the
2330
+ // launch folder says so once, on the first window that can show it.
2331
+ if (startupFolderWarning !== undefined)
2332
+ pendingNotices.push({ text: startupFolderWarning, tone: 'warning' });
2199
2333
  mountRef.current = io.mount(appElement());
2200
2334
  // Startup prompt/images use the same durable delivery path as composer
2201
2335
  // submissions. Image bytes are committed before the user/message event, and
@@ -175,6 +175,7 @@ export const en = {
175
175
  'panel.usage.colTurns': 'turns',
176
176
  'panel.usage.unknownModel': 'unattributed',
177
177
  'panel.usage.colTurn': 'turn',
178
+ 'panel.usage.colCost': 'cost',
178
179
  'panel.usage.colTotal': 'total',
179
180
  'panel.usage.colUncached': 'uncached',
180
181
  'panel.usage.colCacheRead': 'cache R',
@@ -250,6 +251,7 @@ export const en = {
250
251
  'status.label.out': 'out',
251
252
  'status.label.mode': '/mode',
252
253
  'status.label.context': 'context',
254
+ 'status.label.skills': 'skills',
253
255
  // Frozen band
254
256
  'frozen.keysGoTo': 'keys go to {owner} · esc {action}',
255
257
  'frozen.action.rejects': 'rejects',
@@ -268,6 +270,7 @@ export const en = {
268
270
  'notice.themeSaveFailed': 'theme save failed: {message}',
269
271
  'notice.languageSaveFailed': 'language save failed: {message}',
270
272
  'notice.alreadyActive': 'that session is already active',
273
+ 'notice.resumedOtherFolder': 'this session belongs to {folder}; its workspace and shell follow that folder (launched in {launch})',
271
274
  'notice.queueCancelled': 'queued message cancelled',
272
275
  'notice.queueActionFailed': 'queue action failed: {message}',
273
276
  'notice.queueUnavailable': 'queued message is no longer pending',
@@ -175,6 +175,7 @@ export const zh = {
175
175
  'panel.usage.colTurns': '回合数',
176
176
  'panel.usage.unknownModel': '未标注模型',
177
177
  'panel.usage.colTurn': '回合',
178
+ 'panel.usage.colCost': '费用',
178
179
  'panel.usage.colTotal': '合计',
179
180
  'panel.usage.colUncached': '未命中',
180
181
  'panel.usage.colCacheRead': '缓存读',
@@ -250,6 +251,7 @@ export const zh = {
250
251
  'status.label.out': '出',
251
252
  'status.label.mode': '/mode',
252
253
  'status.label.context': '上下文',
254
+ 'status.label.skills': '技能',
253
255
  // Frozen band
254
256
  'frozen.keysGoTo': '按键交给{owner} · esc {action}',
255
257
  'frozen.action.rejects': '拒绝',
@@ -268,6 +270,7 @@ export const zh = {
268
270
  'notice.themeSaveFailed': '主题保存失败:{message}',
269
271
  'notice.languageSaveFailed': '语言保存失败:{message}',
270
272
  'notice.alreadyActive': '该会话已是当前会话',
273
+ 'notice.resumedOtherFolder': '该会话属于 {folder};工作区与 shell 均跟随该目录(启动目录为 {launch})',
271
274
  'notice.queueCancelled': '已取消排队消息',
272
275
  'notice.queueActionFailed': '队列操作失败:{message}',
273
276
  'notice.queueUnavailable': '该消息已不在队列中',
@@ -114,7 +114,7 @@ export function createMentions(ctx, agent, cwd) {
114
114
  ? { path: isAbsolute(candidate.path) ? candidate.path : resolve(cwd, candidate.path) }
115
115
  : {},
116
116
  }));
117
- const sessionRows = sessions.map(candidate => ({
117
+ const sessionRows = sessions.map((candidate) => ({
118
118
  label: formatSessionReferenceMention(candidate),
119
119
  description: `Session · ${candidate.cwd ?? '(no cwd)'}`,
120
120
  kind: 'session',
@@ -86,9 +86,9 @@ export function cacheHitPercent(usage) {
86
86
  : Math.round(usage.cacheReadTokens / billed * 1_000) / 10;
87
87
  }
88
88
  /** Separator between leading clusters. */
89
- export const STATUS_GROUP_SEPARATOR = ' ';
89
+ export const STATUS_GROUP_SEPARATOR = ' · ';
90
90
  /** Separator between trailing state spans. */
91
- export const STATUS_ITEM_SEPARATOR = ' ';
91
+ export const STATUS_ITEM_SEPARATOR = ' · ';
92
92
  /** The Codex-style mode cycle hint appended to the permission badge. */
93
93
  /** English compatibility value for callers that only measure the default layout. */
94
94
  export const STATUS_CYCLE_HINT = ' (shift+tab to cycle)';
@@ -172,6 +172,7 @@ export const STATUS_ITEMS = [
172
172
  { id: 'mode', label: 'mode', description: 'agent preset composing the session', side: 'left' },
173
173
  { id: 'branch', label: 'branch', description: 'git branch inside a repository', side: 'left' },
174
174
  { id: 'context', label: 'context', description: 'context-window occupancy meter', side: 'left' },
175
+ { id: 'skills', label: 'skills', description: 'skills loaded in the session catalog', side: 'left' },
175
176
  { id: 'permission', label: 'permission', description: 'permission preset badge with cycle hint', side: 'right' },
176
177
  { id: 'plan', label: 'plan', description: 'plan-mode state mark', side: 'left' },
177
178
  { id: 'turns', label: 'turns', description: 'turn and step counters', side: 'left' },
@@ -186,7 +187,7 @@ export const STATUS_ITEMS = [
186
187
  * Default order: the whole catalog (matches the pre-customization bar).
187
188
  * The busy dot is not an item — it always leads the identity cluster.
188
189
  */
189
- export const DEFAULT_STATUSLINE_ITEMS = ['model', 'permission', 'title', 'plan', 'goal', 'sandbox'];
190
+ export const DEFAULT_STATUSLINE_ITEMS = ['model', 'permission', 'title', 'plan', 'goal', 'sandbox', 'skills'];
190
191
  /**
191
192
  * Parse a persisted statusline item list. The stored value is the ordered
192
193
  * set of ENABLED items (the Codex /statusline contract): unknown ids and
@@ -236,7 +237,10 @@ const RANK_IDENTITY = Number.POSITIVE_INFINITY;
236
237
  /** Row 2 drop ranks: title and durations go first; state and counts survive longest. */
237
238
  const RANK2_DURATIONS = 40;
238
239
  const RANK2_CACHE = 50;
240
+ const RANK2_SKILLS = 60;
239
241
  const RANK2_PLAN = 70;
242
+ /** The live telemetry cluster is the last row-2 group to go: it carries the running cost. */
243
+ const RANK2_TELEMETRY = 95;
240
244
  /**
241
245
  * Traffic-light tone for a permission preset: read-only stays success green,
242
246
  * full access reads error red, and every workspace-scoped middle ground
@@ -258,7 +262,7 @@ function safe(text) {
258
262
  }
259
263
  /** Dim junction separator span inside a cluster. */
260
264
  function sep() {
261
- return { text: ' ', tone: 'label' };
265
+ return { text: ' · ', tone: 'label' };
262
266
  }
263
267
  /** Total visible columns of a span list (separators ride inside the spans). */
264
268
  function spansWidth(spans) {
@@ -276,26 +280,12 @@ function joinWidth(parts, separator) {
276
280
  width += part;
277
281
  return width + separator * (parts.length - 1);
278
282
  }
279
- /** Build every candidate group/span with its drop rank and item id. */
280
- /** dscode: the footer telemetry header — `provider: model @ effort`. */
281
- export function dscodeFooterHeader(facts, stats) {
282
- const raw = typeof facts.model === 'string' ? facts.model : '';
283
- if (raw === '')
284
- return '';
285
- const cut = raw.indexOf('/');
286
- const provider = cut > 0 ? raw.slice(0, cut) : '';
287
- const model = cut > 0 ? raw.slice(cut + 1) : raw;
288
- const effort = typeof facts.effort === 'string' && facts.effort !== '' ? facts.effort : (typeof stats.reasoningEffort === 'string' ? stats.reasoningEffort : '');
289
- return (provider === '' ? model : provider + ': ' + model) + (effort === '' ? '' : ' @ ' + effort);
290
- }
291
- /** dscode: row 1's identity lead — the session title, else the model and its effort. */
292
- function dscodeStatusLead(facts, model, effort) {
283
+ /** dscode: row 1's identity lead the session title, else the session id. */
284
+ function dscodeStatusLead(facts) {
293
285
  const source = facts.title !== undefined && facts.title !== '' ? facts.title : facts.sessionId;
294
- const title = source === undefined || source === '' ? '' : truncateColumns(safe(source), TITLE_BUDGET);
295
- if (title !== '')
296
- return title;
297
- return effort === '' ? model : model + ' | ' + effort;
286
+ return source === undefined || source === '' ? '' : truncateColumns(safe(source), TITLE_BUDGET);
298
287
  }
288
+ /** Build every candidate group/span with its drop rank and item id. */
299
289
  function buildCandidates(facts, stats, busy, enabled, contextWidth) {
300
290
  const identity = [
301
291
  { text: busy ? '● ' : '○ ', tone: busy ? 'live' : 'meta' },
@@ -308,11 +298,16 @@ function buildCandidates(facts, stats, busy, enabled, contextWidth) {
308
298
  identity.push(span);
309
299
  };
310
300
  const model = safe(facts.model).split('/').at(-1) ?? '';
311
- // dscode: row 1 leads with the session title (falling back to the model), and the
312
- // full `provider: model @ effort` header rides the telemetry segment instead.
301
+ // dscode: row 1 names the session (title, else its id) and then the model with
302
+ // its effort, so row 2 stays free for the live figures.
313
303
  if ((model !== '' && enabled.has('model')) || enabled.has('title')) {
304
+ const lead = dscodeStatusLead(facts);
305
+ if (lead !== '')
306
+ push({ text: lead, tone: 'model' });
307
+ }
308
+ if (model !== '' && enabled.has('model')) {
314
309
  const effort = safe(facts.effort ?? stats.reasoningEffort);
315
- push({ text: dscodeStatusLead(facts, model, effort), tone: 'model' });
310
+ push({ text: effort === '' ? model : model + ' @ ' + effort, tone: 'accent' });
316
311
  }
317
312
  const cwd = safe(facts.cwd);
318
313
  if (cwd !== '' && enabled.has('cwd'))
@@ -414,6 +409,27 @@ function buildCandidates(facts, stats, busy, enabled, contextWidth) {
414
409
  pair(t('status.label.out'), padValue(formatTokens(stats.usage.outputTokens), VALUE_WIDTH.tokens));
415
410
  row2.push({ group: { spans: tokens }, rank: RANK_TOKENS, id: 'tokens' });
416
411
  }
412
+ // dscode: the catalog size the session actually sees, read from the live
413
+ // skills view, so the footer answers "how many skills are loaded" without
414
+ // opening /skills. Before the first read settles the figure keeps its
415
+ // columns as the `--` placeholder, like every other live figure.
416
+ if (enabled.has('skills')) {
417
+ row2.push({
418
+ group: {
419
+ spans: [
420
+ { text: t('status.label.skills') + ' ', tone: 'label' },
421
+ {
422
+ text: facts.skills === undefined
423
+ ? pendingValue(VALUE_WIDTH.count)
424
+ : padValue(String(facts.skills), VALUE_WIDTH.count),
425
+ tone: 'value',
426
+ },
427
+ ],
428
+ },
429
+ rank: RANK2_SKILLS,
430
+ id: 'skills',
431
+ });
432
+ }
417
433
  // dscode: the session title moved to row 1's identity lead (dscodeStatusLead),
418
434
  // so row 2 no longer carries it.
419
435
  // Secondary state rides row 2; permission alone remains right-pinned on row 1.
@@ -460,13 +476,20 @@ function buildCandidates(facts, stats, busy, enabled, contextWidth) {
460
476
  if (facts.plan && enabled.has('plan')) {
461
477
  row2.push({ group: { spans: [{ text: '⧉ plan', tone: 'accent' }] }, rank: RANK2_PLAN, id: 'plan' });
462
478
  }
479
+ // dscode: the live figures close row 2 as one ordinary left-hand group, so the
480
+ // row reads as a single cluster instead of a pinned right edge with a gap. It
481
+ // drops last: the running cost is the last thing the width may take away.
482
+ if (facts.telemetry !== undefined && facts.telemetry !== '') {
483
+ row2.push({ group: { spans: [{ text: facts.telemetry, tone: 'meta' }] }, rank: RANK2_TELEMETRY, id: 'telemetry' });
484
+ }
463
485
  return { left, right, badge, row2 };
464
486
  }
465
487
  /**
466
- * Compose the two-row footer layout under a column budget. Row 1 keeps model,
467
- * cwd, mode, branch, context, then the right-pinned permission badge and cycle
468
- * hint. It drops hint, context, and permission before ellipsizing identity.
469
- * Row 2 fits all secondary figures and state within its own budget.
488
+ * Compose the two-row footer layout under a column budget. Row 1 keeps the
489
+ * session identity, cwd, mode, branch, context, then the right-pinned permission
490
+ * badge and cycle hint. It drops hint, context, and permission before
491
+ * ellipsizing identity. Row 2 fits all secondary figures, the live telemetry
492
+ * cluster and state within its own budget.
470
493
  * @param facts - identity facts resolved by the runner.
471
494
  * @param stats - session figures folded from the durable log.
472
495
  * @param columns - usable columns for each row (before their left padding).
@@ -619,9 +642,7 @@ export function layoutStatusBar(facts, stats, columns, options = {}) {
619
642
  // group drops first until the row fits or nothing is left. An empty row2 is
620
643
  // a valid state — the footer degrades back to a single status row.
621
644
  const row2Kept = [...orderedRow2];
622
- // dscode: the telemetry segment owns row 2's right edge, so its width comes out
623
- // of the left groups' budget first.
624
- const row2Budget = Math.max(0, budget - 1 - (facts.telemetry ? visibleColumns(facts.telemetry) + 3 : 0));
645
+ const row2Budget = Math.max(0, budget - 1);
625
646
  const row2Width = () => joinWidth(row2Kept.map(entry => spansWidth(entry.group.spans)), groupSeparator);
626
647
  while (row2Width() > row2Budget && row2Kept.length > 0) {
627
648
  let dropIndex = 0;
@@ -636,13 +657,13 @@ export function layoutStatusBar(facts, stats, columns, options = {}) {
636
657
  }
637
658
  return {
638
659
  row1: {
639
- left: leftKept.map(entry => entry.group),
660
+ left: leftKept.map(entry => ({ ...entry.group, id: entry.id })),
640
661
  right: rightKept.map(entry => entry.span),
641
662
  hint,
642
663
  },
643
664
  row2: {
644
- left: row2Kept.map(entry => entry.group),
645
- right: facts.telemetry ? [{ text: facts.telemetry, tone: 'meta' }] : [],
665
+ left: row2Kept.map(entry => ({ ...entry.group, id: entry.id })),
666
+ right: [],
646
667
  hint: false,
647
668
  },
648
669
  };
@@ -77,6 +77,39 @@ export function truncateColumns(text, columns) {
77
77
  }
78
78
  return `${result}…`;
79
79
  }
80
+ /**
81
+ * Hard-wrap display-safe text into physical rows of at most `columns` cells, except
82
+ * for a cluster wider than that budget, which gets a row to itself.
83
+ * Wrapping runs forward so a row already produced never re-flows, and the cut
84
+ * walks grapheme clusters so emoji and combining sequences stay whole; a single
85
+ * cluster wider than the budget keeps a row of its own rather than looping.
86
+ * Newlines in the input start a new row, so the result is the row list a panel
87
+ * pages through. Rows still get truncated at render time: this bounds the row
88
+ * count, it does not promise the terminal paints every cell.
89
+ * @param text - display-safe text (see {@link displayText}).
90
+ * @param columns - available terminal columns.
91
+ * @returns one string per physical row, in order.
92
+ */
93
+ export function wrapText(text, columns) {
94
+ const limit = Math.max(1, Math.floor(columns));
95
+ const rows = [];
96
+ for (const line of text.split('\n')) {
97
+ let row = '';
98
+ let used = 0;
99
+ for (const cluster of splitGraphemes(line)) {
100
+ const width = graphemeWidth(cluster);
101
+ if (used > 0 && used + width > limit) {
102
+ rows.push(row);
103
+ row = '';
104
+ used = 0;
105
+ }
106
+ row += cluster;
107
+ used += width;
108
+ }
109
+ rows.push(row);
110
+ }
111
+ return rows;
112
+ }
80
113
  /** Punctuation that must never START a physical row (CJK kinsoku tail set). */
81
114
  const ROW_START_FORBIDDEN = ',。、;:!?)】」』〉》…‥';
82
115
  /** Punctuation that must never END a physical row (CJK kinsoku head set). */
@@ -64,13 +64,15 @@ export function completedTurns(events) {
64
64
  * not all report usage are omitted rather than estimated, and so is a turn
65
65
  * that billed nothing at all — an empty row is noise in a usage table.
66
66
  */
67
- export function turnUsages(events, derive) {
67
+ export function turnUsages(events, derive, costs) {
68
+ const byTurn = new Map((costs ?? []).map(entry => [entry.turn, { cost: entry.cost, unknown: entry.unknown }]));
68
69
  const rows = [];
69
70
  for (const slice of completedTurns(events)) {
70
71
  const usage = derive(slice.events);
71
72
  if (usage === undefined || usage.totalTokens === 0)
72
73
  continue;
73
- rows.push({ turn: slice.turn, usage, model: turnModel(slice, usage) });
74
+ const cost = byTurn.get(slice.turn);
75
+ rows.push({ turn: slice.turn, usage, model: turnModel(slice, usage), ...(cost === undefined ? {} : { cost }) });
74
76
  }
75
77
  return rows;
76
78
  }
@@ -329,6 +331,13 @@ export function usageLines(view, columns) {
329
331
  const withTurn = [
330
332
  { label: () => t('panel.usage.colTurn'), value: (row) => `#${row.turn}`, width: 7 },
331
333
  ...BUCKET_COLUMNS.map(column => ({ ...column, value: (row) => column.value(row.usage) })),
334
+ // The cost column answers what the token columns cannot: which turn spent
335
+ // the money. `+` marks a turn whose settled calls were not all priced.
336
+ {
337
+ label: () => t('panel.usage.colCost'),
338
+ value: (row) => row.cost === undefined ? '--' : `$${row.cost.cost.toFixed(4)}${row.cost.unknown ? '+' : ''}`,
339
+ width: 10,
340
+ },
332
341
  ];
333
342
  lines.push(...table(withTurn, [...view.turns].reverse(), row => modelLabel(row.model), width, false));
334
343
  }
@@ -29,6 +29,31 @@ function samePath(left, right) {
29
29
  return false;
30
30
  return comparablePath(left) === comparablePath(right);
31
31
  }
32
+ /**
33
+ * The comparison form of a project directory: canonical `realpath` when the
34
+ * path exists, then Unicode-normalized so two filesystems that spell the same
35
+ * folder differently still agree.
36
+ */
37
+ function comparisonPath(value) {
38
+ return comparablePath(value.normalize('NFC'));
39
+ }
40
+ /**
41
+ * Whether a resume may proceed from the directory DSCODE was launched in. A
42
+ * session chooses its project directory once, at creation, and the header
43
+ * keeps it durable; resuming from anywhere else would append to the log from
44
+ * a directory the session never bound to. A header without a cwd (a pre-cwd
45
+ * log) stays resumable anywhere. macOS and Windows keep both case and
46
+ * distinct Unicode normalization forms apart, so both paths take the
47
+ * comparison form (canonical `realpath`, then NFC) before they are compared.
48
+ * @param pinnedCwd - the header's project directory, when it has one.
49
+ * @param launchCwd - the directory DSCODE was launched in.
50
+ * @returns true when the folder matches, or the session has no pinned folder.
51
+ */
52
+ export function sessionFolderMatches(pinnedCwd, launchCwd) {
53
+ if (pinnedCwd === undefined || pinnedCwd === '')
54
+ return true;
55
+ return comparisonPath(pinnedCwd) === comparisonPath(launchCwd);
56
+ }
32
57
  /**
33
58
  * Unique header match by exact id or unique id prefix (root and subagent
34
59
  * headers alike); the caller applies any lineage gate.