@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.
- package/package.json +4 -4
- package/plugins/dscode/index.mjs +10 -0
- package/plugins/i18n/messages.d.mts +21 -0
- package/plugins/i18n/messages.mjs +18 -6
- package/plugins/session-bridge/index.mjs +6 -0
- package/plugins/session-bridge/tasks.mjs +227 -0
- package/plugins/session-metrics/turns.mjs +134 -0
- package/plugins/session-metrics/view.mjs +85 -33
- package/plugins/tui-tools/workspace-discovery.mjs +11 -4
- package/presets/dscode/agent.cordis.yml +3 -2
- package/vendor/tui/lib/app.mjs +124 -36
- package/vendor/tui/lib/communication.mjs +262 -0
- package/vendor/tui/lib/dscode/chat.mjs +34 -0
- package/vendor/tui/lib/dscode/model-search.mjs +2 -2
- package/vendor/tui/lib/dscode/palette.mjs +100 -0
- package/vendor/tui/lib/dscode/telemetry.mjs +18 -11
- package/vendor/tui/lib/index.mjs +140 -6
- package/vendor/tui/lib/locales/en.mjs +3 -0
- package/vendor/tui/lib/locales/zh.mjs +3 -0
- package/vendor/tui/lib/mentions.mjs +1 -1
- package/vendor/tui/lib/render/status.mjs +56 -35
- package/vendor/tui/lib/render/text.mjs +33 -0
- package/vendor/tui/lib/render/usage.mjs +11 -2
- package/vendor/tui/lib/session-directory.mjs +25 -0
- package/vendor/tui/lib/skills.mjs +19 -7
package/vendor/tui/lib/index.mjs
CHANGED
|
@@ -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.
|
|
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) => {
|
|
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: '
|
|
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
|
-
/**
|
|
280
|
-
|
|
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
|
-
|
|
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
|
|
312
|
-
//
|
|
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:
|
|
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
|
|
467
|
-
* cwd, mode, branch, context, then the right-pinned permission
|
|
468
|
-
* hint. It drops hint, context, and permission before
|
|
469
|
-
* Row 2 fits all secondary figures
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|