@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
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live cross-session communication feed: a bounded, display-only projection of
|
|
3
|
+
* the root session's session-bridge traffic. The transcript store folds tool
|
|
4
|
+
* cards and hides their arguments, so a `send_session` that is waiting on
|
|
5
|
+
* another session looks exactly like any other tool call; this module keeps the
|
|
6
|
+
* direction, the peer, the delivery mode and the outstanding request visible.
|
|
7
|
+
*
|
|
8
|
+
* Two event families feed it, both already durable:
|
|
9
|
+
* - `tool/call` / `tool/result` for `send_session` and `reply_session`, with
|
|
10
|
+
* the call arguments carrying target, kind and mode (a result carries no such
|
|
11
|
+
* fields, so a failing call is reported only by its paired error).
|
|
12
|
+
* - `user/message` relayed by the session bridge, which is an inbound message
|
|
13
|
+
* from another session or an external source.
|
|
14
|
+
*
|
|
15
|
+
* Rows are advisory display state rebuilt from events; nothing here persists or
|
|
16
|
+
* replays, and a resume re-derives the history from the log.
|
|
17
|
+
*
|
|
18
|
+
* @module @deepseek-ai/dsh-code/communication
|
|
19
|
+
*/
|
|
20
|
+
/** Hard row cap for `/tasks`; the newest rows survive. */
|
|
21
|
+
export const MAX_COMMUNICATION_ROWS = 32;
|
|
22
|
+
/** Bound on outstanding requests tracked at once (display state, not a budget). */
|
|
23
|
+
const MAX_WAITING_REQUESTS = 16;
|
|
24
|
+
/** Bounded body preview, matching the activity line's display budget. */
|
|
25
|
+
const MAX_PREVIEW_CHARS = 72;
|
|
26
|
+
/** The plugin name the session bridge stamps on its relayed messages. */
|
|
27
|
+
const BRIDGE_PLUGIN = 'dscode-session-bridge';
|
|
28
|
+
/** Tool names that address another session. */
|
|
29
|
+
const SEND_TOOLS = new Set(['send_session', 'reply_session']);
|
|
30
|
+
/**
|
|
31
|
+
* One peer identity across both directions. Outbound rows carry the bare
|
|
32
|
+
* session id from the tool arguments; an inbound relay's bridge label is
|
|
33
|
+
* `session:<id>`, so the prefix is stripped before they are compared.
|
|
34
|
+
*/
|
|
35
|
+
function peerKey(peer) {
|
|
36
|
+
return peer.startsWith('session:') ? peer.slice('session:'.length) : peer;
|
|
37
|
+
}
|
|
38
|
+
/** Fold one tool-call argument JSON string into the fields a row needs. */
|
|
39
|
+
function callFields(raw) {
|
|
40
|
+
if (typeof raw !== 'string' || raw.length > 8192)
|
|
41
|
+
return { preview: '' };
|
|
42
|
+
let parsed;
|
|
43
|
+
try {
|
|
44
|
+
parsed = JSON.parse(raw);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return { preview: '' };
|
|
48
|
+
}
|
|
49
|
+
if (parsed === null || typeof parsed !== 'object')
|
|
50
|
+
return { preview: '' };
|
|
51
|
+
const fields = parsed;
|
|
52
|
+
const text = (value) => (typeof value === 'string' ? value.replace(/\s+/gu, ' ').trim() : '');
|
|
53
|
+
const peer = text(fields.session_id) || text(fields.request_message_id);
|
|
54
|
+
const preview = text(fields.text);
|
|
55
|
+
return {
|
|
56
|
+
...(peer === '' ? {} : { peer }),
|
|
57
|
+
...(text(fields.kind) === '' ? {} : { kind: text(fields.kind) }),
|
|
58
|
+
...(text(fields.mode) === '' ? {} : { mode: text(fields.mode) }),
|
|
59
|
+
preview: preview.length > MAX_PREVIEW_CHARS ? `${preview.slice(0, MAX_PREVIEW_CHARS - 1)}…` : preview,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* One relayed message's peer, message kind and bounded text. The bridge stamps
|
|
64
|
+
* the kind only inside the body header (`[External source: …] [kind/mode]`),
|
|
65
|
+
* which is its documented presentation contract, so an unrecognised header
|
|
66
|
+
* leaves the kind undefined and the caller stays conservative: an unknown
|
|
67
|
+
* inbound message never counts as an answer.
|
|
68
|
+
*/
|
|
69
|
+
function relayFields(data) {
|
|
70
|
+
if (data === null || typeof data !== 'object')
|
|
71
|
+
return undefined;
|
|
72
|
+
const message = data;
|
|
73
|
+
const source = message.source;
|
|
74
|
+
if (source === null || typeof source !== 'object')
|
|
75
|
+
return undefined;
|
|
76
|
+
const stamp = source;
|
|
77
|
+
if (stamp.kind !== 'plugin' || stamp.plugin !== BRIDGE_PLUGIN || stamp.form !== 'relay')
|
|
78
|
+
return undefined;
|
|
79
|
+
const label = typeof stamp.label === 'string' && stamp.label !== '' ? stamp.label : undefined;
|
|
80
|
+
const id = typeof stamp.communicationId === 'string' && stamp.communicationId !== '' ? stamp.communicationId : undefined;
|
|
81
|
+
if (label === undefined && id === undefined)
|
|
82
|
+
return undefined;
|
|
83
|
+
const texts = [];
|
|
84
|
+
if (Array.isArray(message.content)) {
|
|
85
|
+
for (const block of message.content) {
|
|
86
|
+
if (typeof block === 'object' && block !== null) {
|
|
87
|
+
const { type, text } = block;
|
|
88
|
+
if (type === 'text' && typeof text === 'string')
|
|
89
|
+
texts.push(text);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const body = texts.join(' ').replace(/\s+/gu, ' ').trim();
|
|
94
|
+
// The body opens with the bridge's own header line (`[External source: …]
|
|
95
|
+
// [kind/mode]` plus the message id); the preview is the message under it.
|
|
96
|
+
const kind = body.match(/\[([a-z]+)\/[a-z]+\]/u)?.[1];
|
|
97
|
+
const joinLines = texts.join('\n');
|
|
98
|
+
const joined = joinLines
|
|
99
|
+
.replace(/^\[External source:[^\]]*\](\s*\[[a-z]+\/[a-z]+\])?\s*/u, '')
|
|
100
|
+
.replace(/^Message ID:[^\n]*\n?/u, '')
|
|
101
|
+
.replace(/\s+/gu, ' ')
|
|
102
|
+
.trim();
|
|
103
|
+
return {
|
|
104
|
+
peer: label ?? id ?? 'unknown',
|
|
105
|
+
...(kind === undefined ? {} : { kind }),
|
|
106
|
+
preview: joined.length > MAX_PREVIEW_CHARS ? `${joined.slice(0, MAX_PREVIEW_CHARS - 1)}…` : joined,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Fold one root-session event into the feed, returning the same view identity
|
|
111
|
+
* when the event is not communication traffic.
|
|
112
|
+
* @param view - the current view.
|
|
113
|
+
* @param event - one session event from the root session.
|
|
114
|
+
* @returns the next view, or the same object when nothing changed.
|
|
115
|
+
*/
|
|
116
|
+
export function foldCommunication(view, event) {
|
|
117
|
+
if (event.type === 'tool/call') {
|
|
118
|
+
const data = event.data;
|
|
119
|
+
if (!SEND_TOOLS.has(data.name))
|
|
120
|
+
return view;
|
|
121
|
+
const fields = callFields(data.arguments);
|
|
122
|
+
const row = {
|
|
123
|
+
id: outboundId(data.callId),
|
|
124
|
+
direction: 'sent',
|
|
125
|
+
peer: fields.peer ?? 'unknown',
|
|
126
|
+
...(fields.kind === undefined ? {} : { kind: fields.kind }),
|
|
127
|
+
...(fields.mode === undefined ? {} : { mode: fields.mode }),
|
|
128
|
+
preview: fields.preview,
|
|
129
|
+
at: event.time,
|
|
130
|
+
pending: true,
|
|
131
|
+
failed: false,
|
|
132
|
+
};
|
|
133
|
+
const rows = bound([...view.rows, row]);
|
|
134
|
+
// A send in flight is not an outstanding request yet: it becomes one when
|
|
135
|
+
// the call settles and the message was a request.
|
|
136
|
+
return { rows, waiting: view.waiting };
|
|
137
|
+
}
|
|
138
|
+
if (event.type === 'tool/result') {
|
|
139
|
+
const data = event.data;
|
|
140
|
+
const callId = resultCallId(data);
|
|
141
|
+
if (callId === undefined)
|
|
142
|
+
return view;
|
|
143
|
+
const id = outboundId(callId);
|
|
144
|
+
const index = view.rows.findIndex(row => row.id === id);
|
|
145
|
+
if (index === -1)
|
|
146
|
+
return view;
|
|
147
|
+
const settled = { ...view.rows[index], pending: false, failed: resultFailed(data) };
|
|
148
|
+
const rows = view.rows.map((row, at) => (at === index ? settled : row));
|
|
149
|
+
const answers = settled.kind === 'request' && !settled.failed;
|
|
150
|
+
return {
|
|
151
|
+
rows,
|
|
152
|
+
waiting: answers ? markWaiting(view.waiting, settled) : view.waiting,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (event.type === 'user/message') {
|
|
156
|
+
const relay = relayFields(event.data);
|
|
157
|
+
if (relay === undefined)
|
|
158
|
+
return view;
|
|
159
|
+
const row = { id: inboundId(event.data, event.seq), direction: 'received', ...(relay.kind === undefined ? {} : { kind: relay.kind }), peer: relay.peer, preview: relay.preview, at: event.time, pending: false, failed: false };
|
|
160
|
+
return {
|
|
161
|
+
rows: bound([...view.rows, row]),
|
|
162
|
+
// Only the single final reply answers a request; a bare notify from the
|
|
163
|
+
// same peer must not clear a turn that is still blocked on an answer.
|
|
164
|
+
waiting: relay.kind === 'reply' ? clearWaiting(view.waiting, row.peer) : view.waiting,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return view;
|
|
168
|
+
}
|
|
169
|
+
/** Outbound row identity, namespaced so an inbound id can never collide with it. */
|
|
170
|
+
function outboundId(callId) {
|
|
171
|
+
return `sent:${callId}`;
|
|
172
|
+
}
|
|
173
|
+
/** Inbound row identity, namespaced and falling back to the event sequence. */
|
|
174
|
+
function inboundId(data, seq) {
|
|
175
|
+
return `recv:${relayId(data) ?? seq}`;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Record one settled request as awaiting its answer. Keyed by the outbound row,
|
|
179
|
+
* not by peer: a session may have two requests open to one session, and a
|
|
180
|
+
* peer-keyed entry would let the second send overwrite the first.
|
|
181
|
+
*/
|
|
182
|
+
function markWaiting(waiting, request) {
|
|
183
|
+
if (waiting.some(entry => entry.id === request.id))
|
|
184
|
+
return waiting;
|
|
185
|
+
const next = [...waiting, { id: request.id, peer: request.peer, since: request.at }];
|
|
186
|
+
return next.length <= MAX_WAITING_REQUESTS ? next : next.slice(next.length - MAX_WAITING_REQUESTS);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Clear the outstanding requests a reply answers. One reply answers one of the
|
|
190
|
+
* requests sent to that peer; without a request-id correlation in the relay,
|
|
191
|
+
* every open request to that peer retires together, which is the conservative
|
|
192
|
+
* direction — it never leaves the terminal claiming to await an answered peer.
|
|
193
|
+
*/
|
|
194
|
+
function clearWaiting(waiting, peer) {
|
|
195
|
+
const next = waiting.filter(entry => peerKey(entry.peer) !== peerKey(peer));
|
|
196
|
+
return next.length === waiting.length ? waiting : next;
|
|
197
|
+
}
|
|
198
|
+
/** Keep only the newest rows. */
|
|
199
|
+
function bound(rows) {
|
|
200
|
+
return rows.length <= MAX_COMMUNICATION_ROWS ? rows : rows.slice(rows.length - MAX_COMMUNICATION_ROWS);
|
|
201
|
+
}
|
|
202
|
+
/** The peer of a reply, which the tool result does not name: the call it answers. */
|
|
203
|
+
function resultCallId(data) {
|
|
204
|
+
if (!Array.isArray(data.message?.content))
|
|
205
|
+
return undefined;
|
|
206
|
+
for (const block of data.message.content) {
|
|
207
|
+
if (typeof block === 'object' && block !== null) {
|
|
208
|
+
const { toolCallId } = block;
|
|
209
|
+
if (typeof toolCallId === 'string')
|
|
210
|
+
return toolCallId;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
/** True when the tool result's block carries `isError`. */
|
|
216
|
+
function resultFailed(data) {
|
|
217
|
+
if (!Array.isArray(data.message?.content))
|
|
218
|
+
return false;
|
|
219
|
+
return data.message.content.some(block => typeof block === 'object' && block !== null && block.isError === true);
|
|
220
|
+
}
|
|
221
|
+
/** The relay's stable message identity, when the bridge stamped one. */
|
|
222
|
+
function relayId(data) {
|
|
223
|
+
const source = data?.source;
|
|
224
|
+
return typeof source?.communicationId === 'string' && source.communicationId !== '' ? source.communicationId : undefined;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Render the `/tasks` listing: one line per communication, newest last, with
|
|
228
|
+
* the same arrows the activity line and the notices use. A request that is
|
|
229
|
+
* still open is marked so a blocked turn is readable without opening anything.
|
|
230
|
+
* @param view - the folded view.
|
|
231
|
+
* @param limit - the newest rows to show.
|
|
232
|
+
* @returns the panel text, or a short explanation when there is no traffic.
|
|
233
|
+
*/
|
|
234
|
+
export function communicationPanel(view, limit = 20) {
|
|
235
|
+
if (view.rows.length === 0)
|
|
236
|
+
return 'No cross-session messages in this session.';
|
|
237
|
+
const waiting = new Set(view.waiting.map(entry => peerKey(entry.peer)));
|
|
238
|
+
const shown = view.rows.slice(-limit);
|
|
239
|
+
const lines = shown.map(row => {
|
|
240
|
+
const arrow = row.direction === 'received' ? '←' : '→';
|
|
241
|
+
const state = row.direction === 'received' ? 'received' : row.failed ? 'failed' : row.pending ? 'sending' : waiting.has(peerKey(row.peer)) ? 'awaiting reply' : 'sent';
|
|
242
|
+
const kind = row.kind === undefined ? '' : ` ${row.kind}`;
|
|
243
|
+
const mode = row.mode === undefined ? '' : `/${row.mode}`;
|
|
244
|
+
const text = row.preview === '' ? '' : ` — ${row.preview}`;
|
|
245
|
+
return `${arrow} ${row.peer}${kind}${mode} · ${state}${text}`;
|
|
246
|
+
});
|
|
247
|
+
const hidden = view.rows.length - shown.length;
|
|
248
|
+
return [
|
|
249
|
+
...(hidden > 0 ? [`(${hidden} older message${hidden === 1 ? '' : 's'} hidden)`] : []),
|
|
250
|
+
...lines,
|
|
251
|
+
].join('\n');
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Build the empty feed and its folder.
|
|
255
|
+
* @returns a feed that folds one event at a time through {@link foldCommunication}.
|
|
256
|
+
* @example
|
|
257
|
+
* let view = createCommunicationFeed()
|
|
258
|
+
* view = foldCommunication(view, event)
|
|
259
|
+
*/
|
|
260
|
+
export function createCommunicationFeed() {
|
|
261
|
+
return { rows: [], waiting: [] };
|
|
262
|
+
}
|
|
@@ -41,6 +41,40 @@ function thinkingLines(reasoning, width) {
|
|
|
41
41
|
? lines
|
|
42
42
|
: [...lines.slice(0, cap), ...textLines(' … ' + (lines.length - cap) + ' more lines · Ctrl+O opens the full history', width, 'dim')];
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Memoize the live-region render by ENTRY IDENTITY. Streaming carried the whole
|
|
46
|
+
* live region into a re-render per frame (`view.entries` changes identity on
|
|
47
|
+
* every event), so the same settled-but-unflushed entries were re-wrapped from
|
|
48
|
+
* scratch ~60 times a second while only the streaming text — which lives in
|
|
49
|
+
* `view.streaming`, not in an entry — actually changed. The projection replaces
|
|
50
|
+
* an entry with a new object whenever its content changes (`{...entry}` at every
|
|
51
|
+
* mutation site), so identity is a sound cache key, and the wrap depends only on
|
|
52
|
+
* the entry, the column budget and `verbose`.
|
|
53
|
+
* @returns a `(entry, columns, verbose) => lines` function with a WeakMap cache.
|
|
54
|
+
*/
|
|
55
|
+
export function createChatLinesCache() {
|
|
56
|
+
// Per wrap context: a width or `verbose` change re-wraps every row. Keying
|
|
57
|
+
// the maps instead of resetting one map keeps interleaved contexts (two
|
|
58
|
+
// regions rendering at different widths in one frame) from thrashing.
|
|
59
|
+
const contexts = new Map();
|
|
60
|
+
return (entry, columns, verbose = false) => {
|
|
61
|
+
const width = Math.max(1, Math.floor(columns));
|
|
62
|
+
const key = `${width}:${verbose ? 1 : 0}`;
|
|
63
|
+
let cache = contexts.get(key);
|
|
64
|
+
if (cache === undefined) {
|
|
65
|
+
cache = new WeakMap();
|
|
66
|
+
contexts.set(key, cache);
|
|
67
|
+
}
|
|
68
|
+
const hit = cache.get(entry);
|
|
69
|
+
if (hit !== undefined)
|
|
70
|
+
return hit;
|
|
71
|
+
// The wrapped width is the same normalized value the key names, so a
|
|
72
|
+
// fractional column count cannot key one width and wrap at another.
|
|
73
|
+
const lines = dscodeChatLines(entry, width, verbose);
|
|
74
|
+
cache.set(entry, lines);
|
|
75
|
+
return lines;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
44
78
|
/**
|
|
45
79
|
* Render one transcript entry the DSCODE way: tool rows are quiet unless verbose
|
|
46
80
|
* is on, assistant rows fold their thinking above the answer, and user prompts
|
|
@@ -12,14 +12,14 @@
|
|
|
12
12
|
export function dscodeFilterModels(rows, query, provider) {
|
|
13
13
|
// One provider at a time: the picker lists the routes the session can actually select.
|
|
14
14
|
const directory = provider === void 0 ? rows : rows.filter(row => row.provider === provider);
|
|
15
|
-
const label = row => String(row.modelName ?? row.model ?? "");
|
|
15
|
+
const label = (row) => String(row.modelName ?? row.model ?? "");
|
|
16
16
|
// Display order is the label itself, so the list reads alphabetically and digits inside a
|
|
17
17
|
// name compare naturally ("GLM 5.2" before "GLM 5.3"); searching narrows rows, never re-ranks them.
|
|
18
18
|
const byLabel = (left, right) => label(left).localeCompare(label(right), void 0, {
|
|
19
19
|
numeric: true,
|
|
20
20
|
sensitivity: "base"
|
|
21
21
|
}) || String(left.model ?? "").localeCompare(String(right.model ?? "")) || String(left.provider ?? "").localeCompare(String(right.provider ?? ""));
|
|
22
|
-
const tokenize = text => String(text ?? '').normalize('NFKC').toLowerCase().match(/[a-z]+|[0-9]+|[^\s\x00-\x7f]+/g) ?? [];
|
|
22
|
+
const tokenize = (text) => String(text ?? '').normalize('NFKC').toLowerCase().match(/[a-z]+|[0-9]+|[^\s\x00-\x7f]+/g) ?? [];
|
|
23
23
|
const raw = String(query ?? '');
|
|
24
24
|
const words = tokenize(raw);
|
|
25
25
|
if (words.length === 0)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dscode: the command palette's memory. The completion menu ranks by where a
|
|
3
|
+
* name matches; the palette additionally remembers what this user actually
|
|
4
|
+
* runs, so the third time `/review` is opened it is already the first row.
|
|
5
|
+
*
|
|
6
|
+
* Usage lives in one user-level JSON file (`~/.dsh/dsh-code/palette.json`),
|
|
7
|
+
* written through the same crash-atomic chain as the statusline file. A missing
|
|
8
|
+
* file is an empty history and a corrupt one is treated the same way instead of
|
|
9
|
+
* failing the TUI: losing the sort order must never cost a session.
|
|
10
|
+
*
|
|
11
|
+
* @module @deepseek-ai/dsh-code/dscode/palette
|
|
12
|
+
*/
|
|
13
|
+
import { rankByName } from '../render/fuzzy.mjs';
|
|
14
|
+
/** A description longer than this is clipped in the row, never dropped. */
|
|
15
|
+
const MAX_DESCRIPTION = 160;
|
|
16
|
+
/** Parse the persisted usage object; anything unrecognized is dropped. */
|
|
17
|
+
export function parsePaletteUsage(raw) {
|
|
18
|
+
if (raw === null || typeof raw !== 'object')
|
|
19
|
+
return {};
|
|
20
|
+
const source = raw.usage;
|
|
21
|
+
if (source === null || typeof source !== 'object' || Array.isArray(source))
|
|
22
|
+
return {};
|
|
23
|
+
const usage = {};
|
|
24
|
+
for (const [name, value] of Object.entries(source)) {
|
|
25
|
+
if (name === '' || value === null || typeof value !== 'object')
|
|
26
|
+
continue;
|
|
27
|
+
const entry = value;
|
|
28
|
+
const lastUsed = typeof entry.lastUsed === 'number' && Number.isFinite(entry.lastUsed) ? entry.lastUsed : 0;
|
|
29
|
+
const uses = typeof entry.uses === 'number' && Number.isFinite(entry.uses) && entry.uses > 0 ? Math.floor(entry.uses) : 0;
|
|
30
|
+
if (uses === 0)
|
|
31
|
+
continue;
|
|
32
|
+
usage[name] = { lastUsed, uses };
|
|
33
|
+
}
|
|
34
|
+
return usage;
|
|
35
|
+
}
|
|
36
|
+
/** Serialize the usage map back to the file's shape. */
|
|
37
|
+
export function serializePaletteUsage(usage) {
|
|
38
|
+
const sorted = {};
|
|
39
|
+
// Stable key order keeps the file readable and diffable across sessions.
|
|
40
|
+
for (const name of Object.keys(usage).sort())
|
|
41
|
+
sorted[name] = usage[name];
|
|
42
|
+
return JSON.stringify({ usage: sorted }, null, 2) + '\n';
|
|
43
|
+
}
|
|
44
|
+
/** Record one use: the newest timestamp wins, the count accumulates. */
|
|
45
|
+
export function recordPaletteUse(usage, name, now = Date.now()) {
|
|
46
|
+
if (name === '')
|
|
47
|
+
return usage;
|
|
48
|
+
const previous = usage[name];
|
|
49
|
+
return { ...usage, [name]: { lastUsed: now, uses: (previous?.uses ?? 0) + 1 } };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Usage weight for sorting. Recency and frequency trade off on a square root,
|
|
53
|
+
* so a single fresh run overtakes an old favorite but a well-worn command does
|
|
54
|
+
* not sink the moment another one is used once.
|
|
55
|
+
*/
|
|
56
|
+
function weight(entry, now) {
|
|
57
|
+
if (entry === undefined || entry.uses <= 0)
|
|
58
|
+
return 0;
|
|
59
|
+
const ageDays = Math.max(0, now - entry.lastUsed) / 86400000;
|
|
60
|
+
return Math.sqrt(entry.uses) / (1 + ageDays);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Every runnable row for the palette, before a query.
|
|
64
|
+
*
|
|
65
|
+
* User-invocable skills are included: the palette is the one place a skill that
|
|
66
|
+
* the model cannot invoke is still reachable by hand, so hiding it here would
|
|
67
|
+
* make those skills undiscoverable. Model-only skills keep their full
|
|
68
|
+
* description so the row states which is which.
|
|
69
|
+
*/
|
|
70
|
+
export function paletteEntries(local, descriptors, skills) {
|
|
71
|
+
const rows = [];
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
const push = (name, description, origin) => {
|
|
74
|
+
if (name === '' || seen.has(name))
|
|
75
|
+
return;
|
|
76
|
+
seen.add(name);
|
|
77
|
+
rows.push({ name, label: `/${name}`, description: description.slice(0, MAX_DESCRIPTION), origin });
|
|
78
|
+
};
|
|
79
|
+
// Local commands shadow the registry (the same precedence dispatch uses).
|
|
80
|
+
for (const command of local)
|
|
81
|
+
push(command.name, command.description, 'command');
|
|
82
|
+
for (const descriptor of descriptors)
|
|
83
|
+
push(descriptor.name, descriptor.description, 'command');
|
|
84
|
+
for (const skill of skills)
|
|
85
|
+
push(skill.name, `skill · ${skill.description}`, 'skill');
|
|
86
|
+
return rows;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Rank rows for one query: fuzzy match first, then the usage weight, then the
|
|
90
|
+
* source order. An empty query is the personal order — most-used, most-recent
|
|
91
|
+
* commands first — with the untouched catalog keeping its declared order.
|
|
92
|
+
*/
|
|
93
|
+
export function rankPalette(entries, query, usage, now = Date.now()) {
|
|
94
|
+
// rankByName keeps the source order for equally good matches; stamping each
|
|
95
|
+
// row once lets the usage sort stay stable without an indexOf per comparison.
|
|
96
|
+
const matched = rankByName(entries, query).map((entry, order) => ({ entry, order }));
|
|
97
|
+
return matched
|
|
98
|
+
.sort((left, right) => weight(usage[right.entry.name], now) - weight(usage[left.entry.name], now) || left.order - right.order)
|
|
99
|
+
.map(item => item.entry);
|
|
100
|
+
}
|
|
@@ -32,7 +32,7 @@ export function dscodeCacheTone(rate) {
|
|
|
32
32
|
return 'blue';
|
|
33
33
|
}
|
|
34
34
|
/** Every label the cache figure can carry, across the shipped interface languages. */
|
|
35
|
-
const DSCode_CACHE_LABELS = ['cache
|
|
35
|
+
const DSCode_CACHE_LABELS = ['cache', '缓存', '快取', 'キャッシュ', '캐시', 'caché'];
|
|
36
36
|
/** The ink colour for a tier, theme-aware for the two ends of the scale. */
|
|
37
37
|
export function dscodeTpsInkColor(tone) {
|
|
38
38
|
const palette = getPalette();
|
|
@@ -54,22 +54,29 @@ export function dscodeTpsInkColor(tone) {
|
|
|
54
54
|
*/
|
|
55
55
|
export function dscodeTelemetryParts(value) {
|
|
56
56
|
const result = [];
|
|
57
|
-
const parts = String(value).split('
|
|
57
|
+
const parts = String(value).split(' · ');
|
|
58
58
|
for (const [index, part] of parts.entries()) {
|
|
59
59
|
if (index > 0)
|
|
60
|
-
result.push({ text: '
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
60
|
+
result.push({ text: ' · ', tone: null });
|
|
61
|
+
// A rate figure reserves its columns, so the padding stays outside the tinted
|
|
62
|
+
// run and only the reading carries the tier.
|
|
63
|
+
const rate = /^(\s*)(~?(?:\d+(?:\.\d+)?|--)) tps( \S+)?$/.exec(part);
|
|
64
|
+
if (rate) {
|
|
65
|
+
const display = rate[2];
|
|
66
|
+
result.push({ text: rate[1], tone: null }, { text: display + ' tps', tone: dscodeTpsTone(Number(display.startsWith('~') ? display.slice(1) : display)) });
|
|
67
|
+
// The average's trailing qualifier rides along untinted.
|
|
68
|
+
if (rate[3] !== undefined)
|
|
69
|
+
result.push({ text: rate[3], tone: null });
|
|
66
70
|
continue;
|
|
67
71
|
}
|
|
68
|
-
// Only a recognised cache label
|
|
69
|
-
|
|
70
|
-
const
|
|
72
|
+
// Only a recognised cache label on the very last figure is tinted; the label
|
|
73
|
+
// itself and every separator keep the surrounding colour.
|
|
74
|
+
const labelled = index === parts.length - 1 ? /^(\s*)(\d+(?:\.\d+)?|--)%( \S+)$/.exec(part) : null;
|
|
75
|
+
const cache = labelled && DSCode_CACHE_LABELS.includes(labelled[3].trim().toLowerCase()) ? labelled : null;
|
|
71
76
|
if (cache) {
|
|
72
77
|
result.push({ text: cache[1], tone: null }, { text: cache[2] + '%', tone: dscodeCacheTone(Number(cache[2])) });
|
|
78
|
+
if (cache[3] !== undefined)
|
|
79
|
+
result.push({ text: cache[3], tone: null });
|
|
73
80
|
continue;
|
|
74
81
|
}
|
|
75
82
|
result.push({ text: part, tone: null });
|