@toddzheng024/dscode-bundle 0.7.19 → 0.7.21
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/cordis.patch.yml +2 -0
- package/package.json +2 -1
- package/plugins/jev/approval.mjs +17 -3
- package/plugins/session-bridge/communication.mjs +2 -1
- package/plugins/session-bridge/server.mjs +1 -1
- package/plugins/session-metrics/view.mjs +41 -5
- package/plugins/time-marks/index.mjs +73 -0
- package/plugins/time-marks/marks.mjs +151 -0
- package/vendor/tui/lib/app.mjs +109 -11
- package/vendor/tui/lib/btw.mjs +152 -0
- package/vendor/tui/lib/index.mjs +92 -3
- package/vendor/tui/lib/locales/en.mjs +10 -0
- package/vendor/tui/lib/locales/zh.mjs +10 -0
- package/vendor/tui/lib/render/projection.mjs +1 -1
- package/vendor/tui/lib/render/status.mjs +123 -65
package/cordis.patch.yml
CHANGED
|
@@ -844,6 +844,8 @@
|
|
|
844
844
|
name: "@toddzheng024/dscode-bundle/session-bridge"
|
|
845
845
|
- id: dscode-memory
|
|
846
846
|
name: "@toddzheng024/dscode-bundle/memory"
|
|
847
|
+
- id: dscode-time-marks
|
|
848
|
+
name: "@toddzheng024/dscode-bundle/time-marks"
|
|
847
849
|
- id: dscode-tui-tools
|
|
848
850
|
name: "@toddzheng024/dscode-bundle/tui-tools"
|
|
849
851
|
- id: dscode-email-tools
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.7.
|
|
2
|
+
"version": "0.7.21",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Todd Zheng",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"./memory": "./plugins/memory/index.mjs",
|
|
36
36
|
"./session-bridge": "./plugins/session-bridge/index.mjs",
|
|
37
37
|
"./session-cards": "./plugins/session-cards/index.mjs",
|
|
38
|
+
"./time-marks": "./plugins/time-marks/index.mjs",
|
|
38
39
|
"./subagent": "./vendor/subagent/index.js",
|
|
39
40
|
"./subagent-core": "./vendor/subagent-core/index.js",
|
|
40
41
|
"./subagent-driver": "./vendor/subagent-driver/index.js",
|
package/plugins/jev/approval.mjs
CHANGED
|
@@ -38,12 +38,18 @@ export function approvalQuestions() {
|
|
|
38
38
|
criteria: {
|
|
39
39
|
allow: 'Read-only, or a change the retained user instruction already asked for.',
|
|
40
40
|
ask: 'Plausibly within the task but not clearly authorized, or the blast radius is unclear.',
|
|
41
|
-
deny: 'Outside the user instruction, or it would disclose data, destroy unrelated work, or publish something.',
|
|
41
|
+
deny: 'Outside the user instruction, or it would disclose data, destroy unrelated work, or publish something the instruction did not ask for.',
|
|
42
42
|
},
|
|
43
43
|
},
|
|
44
44
|
authorized: {
|
|
45
45
|
type: 'noul',
|
|
46
|
-
|
|
46
|
+
// Scored on its own, never as a function of how risky the action is: the
|
|
47
|
+
// deployment uses this number to decide whether a risk guard may defer to
|
|
48
|
+
// the model reviewer, and collapsing authorization into apparent safety
|
|
49
|
+
// made user-requested work (a release that must leave the sandbox) read as
|
|
50
|
+
// unauthorized. An instruction almost never names the exact command, so the
|
|
51
|
+
// question is about the task it asks for, not about a literal match.
|
|
52
|
+
instructions: 'Judge the authorization alone, separately from how risky the action is: an action the instruction asks for scores high even when it is dangerous, and a risky action nobody asked for scores low. The instruction rarely names a command, so read it as the task it asks for and decide whether this call is a required or ordinary step of that task, including its target and effect. Score 0.9 or more when the instruction directly asks for this step; 0.6 up to but not including 0.9 when the step is necessary to carry out a stated task, or the instruction names the target but not the means; 0.2 up to but not including 0.6 when the call is plausibly part of the task but optional or unclear; below 0.2 when the instruction does not cover it or it serves a different goal.',
|
|
47
53
|
},
|
|
48
54
|
destructive: {
|
|
49
55
|
type: 'score',
|
|
@@ -67,8 +73,16 @@ const clip = (value, limit) => {
|
|
|
67
73
|
// call, and the retained direct user instruction. It is posted to OpenRouter, so
|
|
68
74
|
// it is bounded and never carries credentials.
|
|
69
75
|
export function approvalState({ action, context } = {}) {
|
|
76
|
+
// `contextFor` hands these over as `{ seq, text }`; a caller may also pass raw
|
|
77
|
+
// message objects. Reading only `content` left `userInstructions` empty, so Jev
|
|
78
|
+
// judged every escalation without ever seeing what the user had asked for.
|
|
70
79
|
const instructions = (context?.userMessages ?? [])
|
|
71
|
-
.map(message =>
|
|
80
|
+
.map(message => {
|
|
81
|
+
if (typeof message?.text === 'string') return message.text.trim();
|
|
82
|
+
const content = message?.content;
|
|
83
|
+
if (typeof content === 'string') return content.trim();
|
|
84
|
+
return Array.isArray(content) ? content.map(block => block?.text ?? '').join(' ').trim() : '';
|
|
85
|
+
})
|
|
72
86
|
.filter(Boolean)
|
|
73
87
|
.join('\n---\n');
|
|
74
88
|
return {
|
|
@@ -102,7 +102,8 @@ export class CommunicationService {
|
|
|
102
102
|
// Identity must survive retries even when the prior native inbox insertion did not persist.
|
|
103
103
|
return freezeMessage({ ...createUserMessage({ content: [{ type: 'text', text:
|
|
104
104
|
`[External source: ${e.from.kind === 'session' ? `session:${e.from.sessionId}` : e.from.source}] [${e.kind}/${e.mode}]\nMessage ID: ${e.messageId}${e.inReplyTo ? `; reply to: ${e.inReplyTo}` : ''}\n${e.text}` }],
|
|
105
|
-
source: { kind: 'plugin', plugin, form: 'relay', communicationId: e.messageId, requestId: e.idempotencyKey
|
|
105
|
+
source: { kind: 'plugin', plugin, form: 'relay', communicationId: e.messageId, requestId: e.idempotencyKey,
|
|
106
|
+
label: e.from.kind === 'session' ? `session:${e.from.sessionId}` : e.from.source, mode: e.mode, composedAt: e.createdAt } }), id: e.messageId });
|
|
106
107
|
}
|
|
107
108
|
async confirm(state) {
|
|
108
109
|
const receipts = [...state.receipts];
|
|
@@ -82,7 +82,7 @@ export class SessionBridge {
|
|
|
82
82
|
let messageId = previous?.messageId;
|
|
83
83
|
if (!previous) {
|
|
84
84
|
const message = createUserMessage({ content: [{ type: 'text', text: `[External source: ${source}]\n${text}` }],
|
|
85
|
-
source: { kind: 'plugin', plugin: BRIDGE_SOURCE, form: 'relay', requestId, digest, label: source } });
|
|
85
|
+
source: { kind: 'plugin', plugin: BRIDGE_SOURCE, form: 'relay', requestId, digest, label: source, mode } });
|
|
86
86
|
// Explicit naming uses the native title service, including sanitization,
|
|
87
87
|
// persistence, UI events and cancellation of stale automatic title work.
|
|
88
88
|
// A duplicate request must never undo a newer title.
|
|
@@ -81,17 +81,53 @@ function resetStamp(iso, now) {
|
|
|
81
81
|
const date = new Date(at);
|
|
82
82
|
return pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + ' ' + pad(date.getHours()) + ':' + pad(date.getMinutes());
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Fixed figure columns for the live footer. The status line re-reads these
|
|
87
|
+
* figures every second, so a growing digit count must never move the segments
|
|
88
|
+
* after it: each figure is right-aligned inside its own columns, and only the
|
|
89
|
+
* terminal width decides which segments the drop ladder keeps.
|
|
90
|
+
*/
|
|
91
|
+
const FIGURE = {
|
|
92
|
+
/** `~999.9` decode rates; the average reads the same scale without the tilde. */
|
|
93
|
+
rate: 6,
|
|
94
|
+
/** `100%` context occupancy. */
|
|
95
|
+
percent: 4,
|
|
96
|
+
/** `100.0%` cache share. */
|
|
97
|
+
share: 6,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Columns {@link formatFooter} needs on top of the budget it is handed. The
|
|
102
|
+
* fixed figures above only stop moving once their columns are really reserved,
|
|
103
|
+
* so a caller that budgets the footer's width adds these columns first —
|
|
104
|
+
* otherwise the padding evicts a figure the width would have seated. Six
|
|
105
|
+
* measures the padding a live reading adds across the rate, context and cache
|
|
106
|
+
* figures at the widths the drop ladder actually decides on.
|
|
107
|
+
*/
|
|
108
|
+
export const FOOTER_FIGURE_RESERVE = 6;
|
|
109
|
+
|
|
110
|
+
/** Right-align one figure inside its columns; a wider reading keeps its own width. */
|
|
111
|
+
function figure(text, width) {
|
|
112
|
+
const padding = width - displayWidth(text);
|
|
113
|
+
return padding > 0 ? ' '.repeat(padding) + text : text;
|
|
114
|
+
}
|
|
115
|
+
|
|
84
116
|
export function formatFooter(metrics, context, columns = 80, rates, locale = 'en', header = '', provider = providerOfHeader(header) ?? 'deepseek-official') {
|
|
85
117
|
const label = key => t(locale, key);
|
|
86
|
-
const ctx = Number.isFinite(context) ? `${Math.round(context)}%` : '--';
|
|
87
|
-
const cache = metrics.cache === null ? '--' : `${metrics.cache.toFixed(1)}
|
|
118
|
+
const ctx = figure(Number.isFinite(context) ? `${Math.round(context)}%` : '--', FIGURE.percent);
|
|
119
|
+
const cache = figure(metrics.cache === null ? '--' : `${metrics.cache.toFixed(1)}%`, FIGURE.share);
|
|
88
120
|
// The balance belongs to the provider the header names; only DeepSeek's official route bills by a peak window.
|
|
89
121
|
const balance = balanceNow(provider);
|
|
90
122
|
const spend = metrics.unknown && metrics.cost === 0 ? '--' : `$${metrics.cost.toFixed(2)}${metrics.unknown ? '+' : ''}${metrics.pending ? '…' : ''}`;
|
|
91
|
-
|
|
123
|
+
// The money pair is the one live figure left unpadded: its digits cross a
|
|
124
|
+
// column a handful of times per session, and reserving those columns would
|
|
125
|
+
// evict a per-second figure at the widths the footer actually runs at.
|
|
126
|
+
const dollars = provider === 'grok' ? grokFooterFact(grokSubscriptionNow(), locale)
|
|
127
|
+
: `${spend} / ${balance === null ? '$--' : '$' + balance.toFixed(2)}${provider === 'deepseek-official' ? ' ' + peakEmoji(trustedNow()) : ''}`;
|
|
92
128
|
const base = rates ? [
|
|
93
|
-
`${label('footer.current')}: ${Number.isFinite(rates.current) ? '~' + rates.current.toFixed(1) : '--'} tps`,
|
|
94
|
-
`${label('footer.average')}: ${Number.isFinite(rates.average) ? rates.average.toFixed(1) : '--'} tps`,
|
|
129
|
+
`${label('footer.current')}: ${figure(Number.isFinite(rates.current) ? '~' + rates.current.toFixed(1) : '--', FIGURE.rate)} tps`,
|
|
130
|
+
`${label('footer.average')}: ${figure(Number.isFinite(rates.average) ? rates.average.toFixed(1) : '--', FIGURE.rate)} tps`,
|
|
95
131
|
`${label('footer.context')}: ${ctx}`, dollars, `${label('footer.cache')}: ${cache}`,
|
|
96
132
|
] : [`${label('footer.context')}: ${ctx}`, dollars, `${label('footer.cache')}: ${cache}`];
|
|
97
133
|
// Narrow terminals shed the quietest figures first: average, current, context. The
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clock marks in the model's own context: every message a step admits, and
|
|
3
|
+
* every turn that closed before it, carries one reading of the host clock. A
|
|
4
|
+
* mark is its own plugin-sourced message rather than an edit of the prompt, so
|
|
5
|
+
* message bodies, session cards and title selection read exactly what they
|
|
6
|
+
* read before. Like the time-context reading it is durable: it spends model
|
|
7
|
+
* context from then on, replays on resume, and the terminal hides it by
|
|
8
|
+
* producer.
|
|
9
|
+
*
|
|
10
|
+
* @module dscode-time-marks
|
|
11
|
+
*/
|
|
12
|
+
import z from '@deepseek-ai/schemastery';
|
|
13
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
14
|
+
import { arrivalMark, resolveTimeZone, turnEndMark } from './marks.mjs';
|
|
15
|
+
export const name = 'dscode-time-marks';
|
|
16
|
+
export const Config = z.object({
|
|
17
|
+
/** Fallback display zone; empty uses the host zone. */
|
|
18
|
+
timeZone: z.string().default(''),
|
|
19
|
+
});
|
|
20
|
+
/** Marks carried by one request at most, so a delivery burst cannot flood a step. */
|
|
21
|
+
const MARK_LIMIT = 8;
|
|
22
|
+
/**
|
|
23
|
+
* Per-agent mark state: closed turns still waiting for a step to carry them,
|
|
24
|
+
* and the opening time of the turn each one closed.
|
|
25
|
+
*/
|
|
26
|
+
const marks = new WeakMap();
|
|
27
|
+
const stateFor = agent => {
|
|
28
|
+
let state = marks.get(agent);
|
|
29
|
+
if (state === undefined) marks.set(agent, state = { turns: [], starts: new Map() });
|
|
30
|
+
return state;
|
|
31
|
+
};
|
|
32
|
+
export function apply(ctx, config = {}) {
|
|
33
|
+
const timeZone = resolveTimeZone(config.timeZone);
|
|
34
|
+
ctx.on('agent/pre-step', async (payload, next) => {
|
|
35
|
+
const state = stateFor(payload.agent);
|
|
36
|
+
// The first step of a turn is the only moment its opening is observable.
|
|
37
|
+
if (payload.step === 1) state.starts.set(payload.turn, Date.now());
|
|
38
|
+
const decision = await next();
|
|
39
|
+
if (decision.kind !== 'enter') return decision;
|
|
40
|
+
const at = Date.now();
|
|
41
|
+
const lines = [];
|
|
42
|
+
for (const message of payload.messages) {
|
|
43
|
+
// Marks are injected context, never an inbox arrival; skipping our own
|
|
44
|
+
// producer keeps a future delivery path from marking itself.
|
|
45
|
+
if (message.source?.plugin === name) continue;
|
|
46
|
+
const line = arrivalMark(message, at, timeZone);
|
|
47
|
+
if (line !== null && lines.length < MARK_LIMIT) lines.push(line);
|
|
48
|
+
}
|
|
49
|
+
// An arrival burst that fills the limit leaves the remaining turn marks
|
|
50
|
+
// queued: the next step carries them instead of losing them here.
|
|
51
|
+
while (lines.length < MARK_LIMIT && state.turns.length > 0) {
|
|
52
|
+
const ended = state.turns.shift();
|
|
53
|
+
lines.push(turnEndMark(ended.turn, ended.endedAt, ended.startedAt, timeZone));
|
|
54
|
+
}
|
|
55
|
+
if (lines.length === 0) return decision;
|
|
56
|
+
return {
|
|
57
|
+
...decision,
|
|
58
|
+
// Ahead of the batch, never behind it: whatever reads the request's last
|
|
59
|
+
// user message (an echo fixture, a title or topic pass) must still find
|
|
60
|
+
// the prompt there, not this mark.
|
|
61
|
+
messages: [createUserMessage({
|
|
62
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
63
|
+
source: { kind: 'plugin', plugin: name, form: 'snapshot' },
|
|
64
|
+
}), ...decision.messages],
|
|
65
|
+
};
|
|
66
|
+
}, { prepend: true });
|
|
67
|
+
ctx.on('agent/turn-stopping', payload => {
|
|
68
|
+
const state = stateFor(payload.agent);
|
|
69
|
+
state.turns.push({ turn: payload.turn, endedAt: Date.now(), startedAt: state.starts.get(payload.turn) });
|
|
70
|
+
state.starts.delete(payload.turn);
|
|
71
|
+
if (state.turns.length > MARK_LIMIT) state.turns.length = MARK_LIMIT;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-clock readings for the model's own context: one mark for a message the
|
|
3
|
+
* step just admitted, one for a turn that closed before it. Pure formatting
|
|
4
|
+
* over one clock, so the plugin's glue stays thin and the marks stay testable
|
|
5
|
+
* without a live agent.
|
|
6
|
+
*
|
|
7
|
+
* @module dscode-time-marks/marks
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Shortest queue wait worth naming; below it the message simply arrived. */
|
|
11
|
+
const WAIT_FLOOR_MS = 1_000
|
|
12
|
+
|
|
13
|
+
/** One cached `Intl.DateTimeFormat` per zone: construction dominates formatting. */
|
|
14
|
+
const formatters = new Map()
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Zone the host process runs in, used when no zone is configured.
|
|
18
|
+
* @returns an IANA zone name; UTC when the runtime cannot name one.
|
|
19
|
+
*/
|
|
20
|
+
export function hostTimeZone() {
|
|
21
|
+
try {
|
|
22
|
+
return new Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
|
23
|
+
} catch {
|
|
24
|
+
return 'UTC'
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the display zone, failing load on a zone this runtime cannot use.
|
|
30
|
+
* @param configured - configured zone; empty means the host zone.
|
|
31
|
+
* @returns the IANA zone name to format marks in.
|
|
32
|
+
* @throws when the configured zone is not a zone `Intl` accepts.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveTimeZone(configured) {
|
|
35
|
+
if (typeof configured !== 'string' || configured === '') return hostTimeZone()
|
|
36
|
+
try {
|
|
37
|
+
new Intl.DateTimeFormat('en-US', { timeZone: configured })
|
|
38
|
+
} catch {
|
|
39
|
+
throw Error(`Unknown time zone: ${configured}`)
|
|
40
|
+
}
|
|
41
|
+
return configured
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One field read off a message source, flattened to a single bounded line: a
|
|
46
|
+
* mark is joined line by line, so a newline inside a label would forge the
|
|
47
|
+
* next mark rather than describe this message.
|
|
48
|
+
* @param value - the raw field, when it is a string at all.
|
|
49
|
+
* @param limit - longest run kept.
|
|
50
|
+
* @returns the flattened field.
|
|
51
|
+
*/
|
|
52
|
+
export function plain(value, limit = 64) {
|
|
53
|
+
if (typeof value !== 'string') return ''
|
|
54
|
+
return value.replace(/[\p{Cc}\p{Cf}\u2028\u2029]+/gu, ' ').replace(/\s+/g, ' ').trim().slice(0, limit)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Cached formatter for one zone. */
|
|
58
|
+
function formatter(timeZone) {
|
|
59
|
+
let cached = formatters.get(timeZone)
|
|
60
|
+
if (cached === undefined) {
|
|
61
|
+
cached = new Intl.DateTimeFormat('en-CA', {
|
|
62
|
+
timeZone, year: 'numeric', month: '2-digit', day: '2-digit',
|
|
63
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZoneName: 'longOffset',
|
|
64
|
+
})
|
|
65
|
+
formatters.set(timeZone, cached)
|
|
66
|
+
}
|
|
67
|
+
return cached
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One clock reading in the shape the system prompt already uses:
|
|
72
|
+
* `2026-09-19T01:58:34-07:00[America/Los_Angeles]`. The numeric offset keeps
|
|
73
|
+
* the reading unambiguous and the bracketed zone keeps it interpretable.
|
|
74
|
+
* @param at - Unix epoch milliseconds.
|
|
75
|
+
* @param timeZone - IANA zone to render in.
|
|
76
|
+
* @returns the formatted reading.
|
|
77
|
+
*/
|
|
78
|
+
export function clockText(at, timeZone) {
|
|
79
|
+
const parts = formatter(timeZone).formatToParts(new Date(at))
|
|
80
|
+
const field = type => parts.find(part => part.type === type)?.value ?? ''
|
|
81
|
+
const match = /^GMT([+-])(\d{1,2})(?::(\d{2}))?$/.exec(field('timeZoneName'))
|
|
82
|
+
const offset = match === null ? '+00:00' : `${match[1]}${match[2].padStart(2, '0')}:${match[3] ?? '00'}`
|
|
83
|
+
// `hour12: false` renders midnight as 24 in some ICU builds.
|
|
84
|
+
const hour = field('hour') === '24' ? '00' : field('hour')
|
|
85
|
+
return `${field('year')}-${field('month')}-${field('day')}T${hour}:${field('minute')}:${field('second')}${offset}[${timeZone}]`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Compact elapsed time: tenths of a second under a minute, `6m24s` from there
|
|
90
|
+
* on, hours once minutes stop being readable.
|
|
91
|
+
* @param ms - elapsed milliseconds.
|
|
92
|
+
* @returns the display string; `--` when the value is unusable.
|
|
93
|
+
*/
|
|
94
|
+
export function elapsedText(ms) {
|
|
95
|
+
if (!Number.isFinite(ms) || ms < 0) return '--'
|
|
96
|
+
const seconds = ms / 1_000
|
|
97
|
+
if (seconds < 60) return `${Math.round(seconds * 10) / 10}s`
|
|
98
|
+
const whole = Math.round(seconds)
|
|
99
|
+
if (whole < 3_600) return `${Math.floor(whole / 60)}m${whole % 60}s`
|
|
100
|
+
return `${Math.floor(whole / 3_600)}h${Math.floor(whole % 3_600 / 60)}m`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Attribution for one arriving message, read off its durable source rather
|
|
105
|
+
* than its body: a relay names its mode and origin, anything else names its
|
|
106
|
+
* producer.
|
|
107
|
+
* @param source - the message's source record.
|
|
108
|
+
* @returns the attribution clause.
|
|
109
|
+
*/
|
|
110
|
+
export function describeSource(source) {
|
|
111
|
+
const kind = plain(source?.kind)
|
|
112
|
+
if (kind === 'user') return 'user message'
|
|
113
|
+
if (kind !== 'plugin') return kind === '' ? 'message' : `message via ${kind}`
|
|
114
|
+
if (source.form === 'relay') {
|
|
115
|
+
const mode = plain(source.mode) || 'queue'
|
|
116
|
+
const label = plain(source.label) || 'another session'
|
|
117
|
+
return `${mode} relay from ${label}`
|
|
118
|
+
}
|
|
119
|
+
return `message from ${plain(source.plugin) || 'a plugin'}`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* One mark for a message the step just admitted. Injected context carries the
|
|
124
|
+
* `snapshot` form and is never a prompt, so it earns no mark.
|
|
125
|
+
* @param message - the admitted user message.
|
|
126
|
+
* @param at - Unix epoch milliseconds the mark is written.
|
|
127
|
+
* @param timeZone - IANA zone to render in.
|
|
128
|
+
* @returns the mark line, or null when the message is context rather than input.
|
|
129
|
+
*/
|
|
130
|
+
export function arrivalMark(message, at, timeZone) {
|
|
131
|
+
const source = message?.source
|
|
132
|
+
if (source?.form === 'snapshot') return null
|
|
133
|
+
const waited = Number(source?.composedAt)
|
|
134
|
+
const queue = Number.isFinite(waited) && at - waited >= WAIT_FLOOR_MS
|
|
135
|
+
? `, waited ${elapsedText(at - waited)} (composed ${clockText(waited, timeZone)})`
|
|
136
|
+
: ''
|
|
137
|
+
return `Time mark: ${clockText(at, timeZone)} — ${describeSource(source)} arrived${queue}.`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* One mark for a turn that closed before this step opened.
|
|
142
|
+
* @param turn - the closed turn's number.
|
|
143
|
+
* @param endedAt - Unix epoch milliseconds the turn stopped.
|
|
144
|
+
* @param startedAt - when that turn opened, when the plugin saw it open.
|
|
145
|
+
* @param timeZone - IANA zone to render in.
|
|
146
|
+
* @returns the mark line.
|
|
147
|
+
*/
|
|
148
|
+
export function turnEndMark(turn, endedAt, startedAt, timeZone) {
|
|
149
|
+
const ran = Number.isFinite(startedAt) && startedAt <= endedAt ? `, ran ${elapsedText(endedAt - startedAt)}` : ''
|
|
150
|
+
return `Time mark: ${clockText(endedAt, timeZone)} — turn ${turn} ended${ran}.`
|
|
151
|
+
}
|
package/vendor/tui/lib/app.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import { imeCursorRowsUp, useImeCursorAnchor } from './render/ime-cursor.mjs';
|
|
|
30
30
|
import { readClipboardImage } from './dscode/clipboard-image/index.mjs';
|
|
31
31
|
import { dscodeChatLines } from './dscode/chat.mjs';
|
|
32
32
|
import { readFileSync } from 'node:fs';
|
|
33
|
-
import { footerFor as dscodeFooterFor } from '../../../plugins/session-metrics/view.mjs';
|
|
33
|
+
import { FOOTER_FIGURE_RESERVE, footerFor as dscodeFooterFor } from '../../../plugins/session-metrics/view.mjs';
|
|
34
34
|
import { newerVersion as dscodeNewerVersion } from '../../../plugins/tui-tools/update.mjs';
|
|
35
35
|
import { languageName as dscodeLanguageName, normalizeLanguage as dscodeNormalizeLanguage, t as dscodeMessage } from '../../../plugins/i18n/messages.mjs';
|
|
36
36
|
import { dscodeTelemetryNodes } from './dscode/telemetry.mjs';
|
|
@@ -92,6 +92,7 @@ import { isSlashLine, submissionPayload } from './commands.mjs';
|
|
|
92
92
|
import { rankByName } from './render/fuzzy.mjs';
|
|
93
93
|
import { isDeclaredReasoningEfforts, parseReasoningEffortsDraft, serializeReasoningEfforts, } from './provider-settings.mjs';
|
|
94
94
|
import { isPathLikeMentionQuery } from './mentions.mjs';
|
|
95
|
+
import { createTranscriptStore } from './store.mjs';
|
|
95
96
|
import { AgentsPanel, editQuery, EffortPanel as NativeEffortPanel, HistoryPanel, JobsPanel, ModePanel, PermissionPanel, PluginPanel, ResumePanel, ReviewPickerPanel, SchedulePanel, SearchPanel, StatuslinePanel, runClock, SubagentPanel, UsagePanel } from './kernel-panels.mjs';
|
|
96
97
|
import { beginRecall, recallEntries, recallNewer, recallOlder, recordLocalEntry, } from './history.mjs';
|
|
97
98
|
import { authorizationForProvider, providerAuthorizationStatus, } from './authorization.mjs';
|
|
@@ -127,7 +128,7 @@ const RESIZE_REFLOW_CLEAR = '\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H';
|
|
|
127
128
|
const SYNCHRONIZED_UPDATE_BEGIN = '\x1b[?2026h';
|
|
128
129
|
/** Release the held frame after Ink has replayed the source-backed Static rows. */
|
|
129
130
|
const SYNCHRONIZED_UPDATE_END = '\x1b[?2026l';
|
|
130
|
-
import { layoutStatusBar, parseStatuslineItems, statusCycleHint, STATUS_GROUP_SEPARATOR, STATUS_ITEM_SEPARATOR, STATUS_ROW2_INDENT, } from './render/status.mjs';
|
|
131
|
+
import { layoutStatusBar, padValue, parseStatuslineItems, statusCycleHint, STATUS_GROUP_SEPARATOR, STATUS_ITEM_SEPARATOR, STATUS_ROW2_INDENT, } from './render/status.mjs';
|
|
131
132
|
import { displayTail, displayText, singleLineText, truncateColumns } from './render/text.mjs';
|
|
132
133
|
import { isVsCodeTerminalEnv, normalizeKeyboardChunk, PASTE_BRACKET_TIMEOUT_MS, PASTE_END_MARKER, PASTE_START_MARKER, stripPasteMarkers, stripTerminalFocusEvents, tokenizeRawEditorChunk, } from './keyboard.mjs';
|
|
133
134
|
import { clampScroll, followInspectorCursor, inspectorViewport, layoutGutterRows, liveRegionBudget, moveScroll, panelViewport, revealRow, selectionWindow, } from './render/inspector.mjs';
|
|
@@ -154,6 +155,7 @@ const LOCAL_COMMANDS = [
|
|
|
154
155
|
{ label: '/animation', descriptionKey: 'cmd.animation' },
|
|
155
156
|
{ label: '/history', descriptionKey: 'cmd.history' },
|
|
156
157
|
{ label: '/queue', descriptionKey: 'cmd.queue' },
|
|
158
|
+
{ label: '/btw', descriptionKey: 'cmd.btw' },
|
|
157
159
|
{ label: '/usage', descriptionKey: 'cmd.usage' },
|
|
158
160
|
{ label: '/agents', descriptionKey: 'cmd.agents' },
|
|
159
161
|
{ label: '/todos', descriptionKey: 'cmd.todos' },
|
|
@@ -725,6 +727,63 @@ export function queuedInboxRows(entries, ids) {
|
|
|
725
727
|
});
|
|
726
728
|
}
|
|
727
729
|
/** A bounded, keyboard-owned management surface for the durable next-turn inbox. */
|
|
730
|
+
/** Fallback transcript for a panel with no run yet: the store hook must always run. */
|
|
731
|
+
const EMPTY_BTW_STORE = createTranscriptStore();
|
|
732
|
+
/** One localized word per run status. */
|
|
733
|
+
const BTW_STATUS_KEYS = {
|
|
734
|
+
running: 'panel.btw.running',
|
|
735
|
+
done: 'panel.btw.done',
|
|
736
|
+
failed: 'panel.btw.failed',
|
|
737
|
+
cancelled: 'panel.btw.cancelled',
|
|
738
|
+
};
|
|
739
|
+
/**
|
|
740
|
+
* The /btw panel: side questions answered beside the main conversation. Each
|
|
741
|
+
* run renders its own transcript, so a full answer is readable while the
|
|
742
|
+
* exchange stays out of the main transcript and out of its model context.
|
|
743
|
+
*/
|
|
744
|
+
export function BtwPanel({ feed, runs, selected, onSelect, onClose }) {
|
|
745
|
+
const stdout = useStdout().stdout;
|
|
746
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
|
|
747
|
+
const run = runs.find(entry => entry.id === selected) ?? runs[0];
|
|
748
|
+
const store = (run === undefined ? undefined : feed.store(run.id)) ?? EMPTY_BTW_STORE;
|
|
749
|
+
const subscribe = useCallback((listener) => store.subscribe(listener), [store]);
|
|
750
|
+
const read = useCallback(() => store.getView(), [store]);
|
|
751
|
+
const view = useSyncExternalStore(subscribe, read);
|
|
752
|
+
// The panel follows the newest rows: an answer longer than the pane stays
|
|
753
|
+
// readable as it streams, and the composer below is never covered.
|
|
754
|
+
const lines = useMemo(() => view.entries.flatMap(entry => transcriptEntryLines(entry, viewport.contentColumns)), [view.entries, viewport.contentColumns]);
|
|
755
|
+
const step = (delta) => {
|
|
756
|
+
if (runs.length < 2)
|
|
757
|
+
return;
|
|
758
|
+
const at = runs.findIndex(entry => entry.id === run?.id);
|
|
759
|
+
onSelect(runs[(at + delta + runs.length) % runs.length].id);
|
|
760
|
+
};
|
|
761
|
+
useStableInput((input, key) => {
|
|
762
|
+
if (key.escape || input === 'q') {
|
|
763
|
+
onClose();
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
if (key.leftArrow)
|
|
767
|
+
step(1);
|
|
768
|
+
if (key.rightArrow)
|
|
769
|
+
step(-1);
|
|
770
|
+
});
|
|
771
|
+
const accent = panelAccent('btw', getPalette().brand);
|
|
772
|
+
const title = run === undefined
|
|
773
|
+
? t('panel.btw.title')
|
|
774
|
+
: `${t('panel.btw.title')} · ${t(BTW_STATUS_KEYS[run.status])} · ${run.title}`;
|
|
775
|
+
const body = lines.slice(-Math.max(1, viewport.bodyRows));
|
|
776
|
+
return createElement(Box, { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(accent.border) }, createElement(Text, { color: inkColor(accent.title), bold: true, wrap: 'truncate-end' }, truncateColumns(title, viewport.contentColumns)), createElement(PanelGap, { visible: viewport.gapRows > 0 }), body.length === 0
|
|
777
|
+
? createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(run === undefined ? t('panel.btw.empty') : t('panel.btw.waiting'), viewport.contentColumns))
|
|
778
|
+
: createElement(StyledRows, { lines: body }), view.streaming === ''
|
|
779
|
+
? undefined
|
|
780
|
+
: createElement(StreamTail, {
|
|
781
|
+
text: view.streaming,
|
|
782
|
+
dim: false,
|
|
783
|
+
maxRows: Math.max(3, Math.floor(viewport.bodyRows / 2)),
|
|
784
|
+
prefix: ' ',
|
|
785
|
+
}), createElement(PanelGap, { visible: viewport.gapRows > 0 }), createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(runs.length > 1 ? t('panel.btw.footerMany', { count: runs.length }) : t('panel.btw.footer'), viewport.contentColumns)));
|
|
786
|
+
}
|
|
728
787
|
function QueuePanel({ rows, busy, update, onClose }) {
|
|
729
788
|
const stdout = useStdout().stdout;
|
|
730
789
|
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
|
|
@@ -960,8 +1019,14 @@ export function StatusLine({ facts, stats, busy, columns, items, onRows, animate
|
|
|
960
1019
|
return () => clearInterval(timer);
|
|
961
1020
|
}, []);
|
|
962
1021
|
// dscode: the telemetry segment carries the header and only appears once the
|
|
963
|
-
// terminal can seat it; below that the identity row keeps the model.
|
|
964
|
-
|
|
1022
|
+
// terminal can seat it; below that the identity row keeps the model. Its
|
|
1023
|
+
// slot is padded to the width it was laid out for, so the live figures
|
|
1024
|
+
// decide the text the row shows without ever changing the row's geometry.
|
|
1025
|
+
const telemetryWidth = Math.max(1, Math.min(columns - 8, Math.max(40, Math.floor(columns * 0.8) - 4) + FOOTER_FIGURE_RESERVE));
|
|
1026
|
+
const telemetry = columns >= 48
|
|
1027
|
+
? dscodeFooterFor(facts.fullSessionId, stats, telemetryWidth, dscodeFooterHeader(facts, stats), getLanguage())
|
|
1028
|
+
: '';
|
|
1029
|
+
facts = { ...facts, telemetry: telemetry === '' ? '' : padValue(telemetry, telemetryWidth) };
|
|
965
1030
|
// Flowing-theme busy flow: the identity cluster's live dot cycles the
|
|
966
1031
|
// anchor walk while a turn runs; static themes never start the timer.
|
|
967
1032
|
const flow = themeFlow();
|
|
@@ -1020,15 +1085,21 @@ export function StatusLine({ facts, stats, busy, columns, items, onRows, animate
|
|
|
1020
1085
|
if (key === 's2' && row.left.length > 0 && row.right.length > 0) {
|
|
1021
1086
|
rightParts.push(createElement(Text, { key: key + 'divider', color: inkColor(getPalette().dim) }, '| '));
|
|
1022
1087
|
}
|
|
1088
|
+
// dscode: the cycle hint rides LEFT of the right cluster, so the
|
|
1089
|
+
// right-anchored badge holds its columns whether or not the hint is
|
|
1090
|
+
// painted; layoutStatusBar reserves the hint's width in both states.
|
|
1091
|
+
if (row.hint) {
|
|
1092
|
+
rightParts.push(createElement(Text, { key: key + 'hint', color: inkColor(getPalette().dim) }, statusCycleHint()));
|
|
1093
|
+
if (row.right.length > 0) {
|
|
1094
|
+
rightParts.push(createElement(Text, { key: key + 'hintSep', color: inkColor(getPalette().dim) }, STATUS_ITEM_SEPARATOR));
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1023
1097
|
row.right.forEach((span, index) => {
|
|
1024
1098
|
if (index > 0) {
|
|
1025
1099
|
rightParts.push(createElement(Text, { key: key + 'rs' + index, color: inkColor(getPalette().dim) }, STATUS_ITEM_SEPARATOR));
|
|
1026
1100
|
}
|
|
1027
1101
|
rightParts.push(createElement(Text, { key: key + 'r' + index, wrap: 'truncate-end', ...statusToneProps(span.tone, flowMs) }, key === 's2' && index === 0 ? dscodeTelemetryNodes(span.text, key + 'r' + index) : span.text));
|
|
1028
1102
|
});
|
|
1029
|
-
if (row.hint) {
|
|
1030
|
-
rightParts.push(createElement(Text, { key: key + 'hint', color: inkColor(getPalette().dim) }, statusCycleHint()));
|
|
1031
|
-
}
|
|
1032
1103
|
// Each row already fits the column budget; truncate-end stays as the
|
|
1033
1104
|
// terminal-measurement backstop so a drifting cell count clips instead
|
|
1034
1105
|
// of wrapping.
|
|
@@ -3623,7 +3694,7 @@ export function DscodeEffortPanel(props) {
|
|
|
3623
3694
|
barIds.every(id => ids.includes(id)) && ids.every(id => id === 'off' || barIds.includes(id));
|
|
3624
3695
|
return createElement(barCatalog ? DscodeEffortBar : NativeEffortPanel, props);
|
|
3625
3696
|
}
|
|
3626
|
-
function Input({ effortSurface, ultraPulse, active, frozen, frozenHint, busy, descriptors, skills, dispatch, steer, submitMode, cycleSubmitMode, interrupt, quit, openEmail, openLogin, openProvider, openOpenRouter, openModel, openEffort, openHelp, openMode, openPermission, openResume, openSearch, openPlugin, openUpdate, openSchedule, openJobs, openStatusline, openTheme, openLanguage, saveLanguage, openHistory, openQueue, openAgents, openSubagent, openTodos, openUsage, openDelete, openDiff, openReviewPicker, reviewChanges, deleteConfirm, confirmDelete, cancelDelete, createSession, forkSession, cancelSessionSwitch, notify, applyEditorKeys, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, inspectImages, prepareImages, inspectFiles, prepareFiles, readClipboardImage, cycleMode, exportTranscript, renameTitle, copyLastResponse, recallSpace, recordLocal, recordHistory, queued, updateQueued, historyFill, historyConsumed, animations, applyAnimations, applyRainbow, rainbowBurstId, waveTier, waveStyle, maxRows, anchorRowsBelow, tabTitle, onEditorRows, onMenuRows, sessionKey }) {
|
|
3697
|
+
function Input({ effortSurface, ultraPulse, active, frozen, frozenHint, busy, descriptors, skills, dispatch, steer, submitMode, cycleSubmitMode, interrupt, quit, openEmail, openLogin, openProvider, openOpenRouter, openModel, openEffort, openHelp, openMode, openPermission, openResume, openSearch, openPlugin, openUpdate, openSchedule, openJobs, openStatusline, openTheme, openLanguage, saveLanguage, openHistory, openQueue, openBtw, openAgents, openSubagent, openTodos, openUsage, openDelete, openDiff, openReviewPicker, reviewChanges, deleteConfirm, confirmDelete, cancelDelete, createSession, forkSession, cancelSessionSwitch, notify, applyEditorKeys, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, inspectImages, prepareImages, inspectFiles, prepareFiles, readClipboardImage, cycleMode, exportTranscript, renameTitle, copyLastResponse, recallSpace, recordLocal, recordHistory, queued, updateQueued, historyFill, historyConsumed, animations, applyAnimations, applyRainbow, rainbowBurstId, waveTier, waveStyle, maxRows, anchorRowsBelow, tabTitle, onEditorRows, onMenuRows, sessionKey }) {
|
|
3627
3698
|
const { stdout: inputStdout } = useStdout();
|
|
3628
3699
|
const columns = inputStdout?.columns ?? 80;
|
|
3629
3700
|
const inputTerminalRows = inputStdout?.rows ?? 30;
|
|
@@ -4640,6 +4711,10 @@ function Input({ effortSurface, ultraPulse, active, frozen, frozenHint, busy, de
|
|
|
4640
4711
|
openQueue();
|
|
4641
4712
|
return;
|
|
4642
4713
|
}
|
|
4714
|
+
if (text === '/btw' || text.startsWith('/btw ')) {
|
|
4715
|
+
openBtw(text.slice('/btw'.length).trim());
|
|
4716
|
+
return;
|
|
4717
|
+
}
|
|
4643
4718
|
if (text === '/usage') {
|
|
4644
4719
|
openUsage();
|
|
4645
4720
|
return;
|
|
@@ -5210,8 +5285,13 @@ export function App(props) {
|
|
|
5210
5285
|
controller.abort();
|
|
5211
5286
|
};
|
|
5212
5287
|
}, [gmail, imap]);
|
|
5213
|
-
// A session switch never leaves the previous session's inbox
|
|
5214
|
-
|
|
5288
|
+
// A session switch never leaves the previous session's inbox or side
|
|
5289
|
+
// questions open: the runs belong to the session that asked them.
|
|
5290
|
+
useEffect(() => {
|
|
5291
|
+
setEmailOpen(false);
|
|
5292
|
+
setBtwOpen(false);
|
|
5293
|
+
setBtwSelected(undefined);
|
|
5294
|
+
}, [props.sessionKey]);
|
|
5215
5295
|
// The stores are closure-backed singletons whose methods never touch `this`,
|
|
5216
5296
|
// but a bare method reference still detaches it from its receiver. One stable
|
|
5217
5297
|
// wrapper per store keeps both the receiver and the reference identity the
|
|
@@ -5437,6 +5517,8 @@ export function App(props) {
|
|
|
5437
5517
|
const budgetWarnRef = useRef(undefined);
|
|
5438
5518
|
const [verboseOpen, setVerboseOpen] = useState(false);
|
|
5439
5519
|
const [queueOpen, setQueueOpen] = useState(false);
|
|
5520
|
+
const [btwOpen, setBtwOpen] = useState(false);
|
|
5521
|
+
const [btwSelected, setBtwSelected] = useState(undefined);
|
|
5440
5522
|
/**
|
|
5441
5523
|
* How the composer delivers its next submission: `queue` waits for the next
|
|
5442
5524
|
* turn, `steer` joins the turn already running. Tab on an empty composer
|
|
@@ -5523,6 +5605,9 @@ export function App(props) {
|
|
|
5523
5605
|
const approvalSnapshot = useSyncExternalStore(subscribeApproval, readApprovalSnapshot);
|
|
5524
5606
|
const questionSnapshot = useSyncExternalStore(subscribeQuestions, readQuestionSnapshot);
|
|
5525
5607
|
const agentRows = useSyncExternalStore(subscribeSubagents, readAgentRows);
|
|
5608
|
+
const subscribeBtw = useCallback((listener) => props.btw.subscribe(listener), [props.btw]);
|
|
5609
|
+
const readBtw = useCallback(() => props.btw.list(), [props.btw]);
|
|
5610
|
+
const btwRuns = useSyncExternalStore(subscribeBtw, readBtw);
|
|
5526
5611
|
const approvalPending = approvalSnapshot.pending !== undefined;
|
|
5527
5612
|
const questionPending = questionSnapshot.pending !== undefined;
|
|
5528
5613
|
// While any modal owns the keys, the prompt box passes everything through.
|
|
@@ -5532,7 +5617,7 @@ export function App(props) {
|
|
|
5532
5617
|
const inputActive = deleteConfirmId !== undefined
|
|
5533
5618
|
? !approvalPending && !questionPending
|
|
5534
5619
|
: !emailOpen && !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !updateOpen && !scheduleOpen && !jobsOpen && !statuslineOpen && !themeOpen && !languageOpen && !historyOpen && !queueOpen && !agentsOpen && !subagentOpen && !todosOpen && !usageOpen && !verboseOpen && diffView === undefined && !reviewPickerOpen && !approvalPending && !questionPending;
|
|
5535
|
-
const transcriptVisible = !emailOpen && !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !updateOpen && !scheduleOpen && !jobsOpen && !statuslineOpen && !themeOpen && !languageOpen && !historyOpen && !queueOpen && !agentsOpen && !subagentOpen && !todosOpen && !usageOpen && !verboseOpen && diffView === undefined && !reviewPickerOpen && !approvalPending && !questionPending;
|
|
5620
|
+
const transcriptVisible = !btwOpen && !emailOpen && !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !updateOpen && !scheduleOpen && !jobsOpen && !statuslineOpen && !themeOpen && !languageOpen && !historyOpen && !queueOpen && !agentsOpen && !subagentOpen && !todosOpen && !usageOpen && !verboseOpen && diffView === undefined && !reviewPickerOpen && !approvalPending && !questionPending;
|
|
5536
5621
|
// Human questions outrank local inspectors. Close the lower modal instead
|
|
5537
5622
|
// of leaving an approval/question visible but keyboard-locked behind it.
|
|
5538
5623
|
useEffect(() => {
|
|
@@ -6285,6 +6370,14 @@ export function App(props) {
|
|
|
6285
6370
|
setTodosOpen(false);
|
|
6286
6371
|
},
|
|
6287
6372
|
})
|
|
6373
|
+
: undefined, btwOpen && !approvalPending && !questionPending
|
|
6374
|
+
? createElement(BtwPanel, {
|
|
6375
|
+
feed: props.btw,
|
|
6376
|
+
runs: btwRuns,
|
|
6377
|
+
selected: btwSelected,
|
|
6378
|
+
onSelect: setBtwSelected,
|
|
6379
|
+
onClose: () => setBtwOpen(false),
|
|
6380
|
+
})
|
|
6288
6381
|
: undefined, queueOpen && !approvalPending && !questionPending
|
|
6289
6382
|
? createElement(QueuePanel, {
|
|
6290
6383
|
rows: queuedRows,
|
|
@@ -6612,6 +6705,11 @@ export function App(props) {
|
|
|
6612
6705
|
saveLanguage: props.saveLanguage,
|
|
6613
6706
|
openHistory: () => setHistoryOpen(true),
|
|
6614
6707
|
openQueue: () => setQueueOpen(true),
|
|
6708
|
+
openBtw: (question) => {
|
|
6709
|
+
if (question !== '')
|
|
6710
|
+
props.startBtw(question);
|
|
6711
|
+
setBtwOpen(true);
|
|
6712
|
+
},
|
|
6615
6713
|
openAgents: () => setAgentsOpen(true),
|
|
6616
6714
|
openSubagent: () => setSubagentOpen(true),
|
|
6617
6715
|
openTodos: () => setTodosOpen(true),
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Side-question runs (`/btw`): a child session seeded from the main one answers
|
|
3
|
+
* one question beside it, folded into its own transcript store so the panel can
|
|
4
|
+
* render the answer while the main conversation keeps running. The run's text
|
|
5
|
+
* lives only in the child's session — never the main transcript, never its
|
|
6
|
+
* model context.
|
|
7
|
+
*
|
|
8
|
+
* @module @deepseek-ai/dsh-tui/btw
|
|
9
|
+
*/
|
|
10
|
+
import { selectForkSeed } from './fork.mjs';
|
|
11
|
+
import { createTranscriptStore } from './store.mjs';
|
|
12
|
+
import { singleLineText, truncateColumns } from './render/text.mjs';
|
|
13
|
+
/** Longest prefix `/btw` seeds a child with before it degrades to a brief. */
|
|
14
|
+
export const BTW_SEED_EVENT_LIMIT = 400;
|
|
15
|
+
/** Longest background brief kept when the prefix is too long to seed. */
|
|
16
|
+
export const BTW_BRIEF_LIMIT = 1_200;
|
|
17
|
+
/** Longest question text a run is named by. */
|
|
18
|
+
export const BTW_TITLE_LIMIT = 160;
|
|
19
|
+
/** Settled runs kept for the panel's switcher; the oldest settled run retires. */
|
|
20
|
+
export const BTW_RUN_LIMIT = 12;
|
|
21
|
+
/**
|
|
22
|
+
* Select the child's inherited history. A complete-turn prefix must stay
|
|
23
|
+
* contiguous from seq 0, so an over-long conversation cannot be trimmed: it
|
|
24
|
+
* degrades to an unseeded child carrying {@link btwBrief} instead.
|
|
25
|
+
* @param events - the main session's events in seq order.
|
|
26
|
+
* @param limit - longest prefix worth handing a child.
|
|
27
|
+
* @returns the inherited events and whether any were inherited.
|
|
28
|
+
*/
|
|
29
|
+
export function btwSeed(events, limit = BTW_SEED_EVENT_LIMIT) {
|
|
30
|
+
let seed;
|
|
31
|
+
try {
|
|
32
|
+
seed = selectForkSeed(events);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// No completed turn yet: a side question asked mid-turn still runs, it just
|
|
36
|
+
// has nothing of the main conversation to inherit.
|
|
37
|
+
return { events: [], inherited: false };
|
|
38
|
+
}
|
|
39
|
+
return seed.events.length <= limit ? { events: seed.events, inherited: true } : { events: [], inherited: false };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Text blocks of one message event. A `user/message` carries the message
|
|
43
|
+
* directly; an `assistant/message` carries it under `message` (the projection
|
|
44
|
+
* reads the same two shapes).
|
|
45
|
+
*/
|
|
46
|
+
function messageText(data) {
|
|
47
|
+
const record = data;
|
|
48
|
+
const content = record?.message?.content ?? record?.content;
|
|
49
|
+
if (!Array.isArray(content))
|
|
50
|
+
return '';
|
|
51
|
+
return content.filter(block => block?.type === 'text').map(block => String(block.text ?? '')).join('\n').trim();
|
|
52
|
+
}
|
|
53
|
+
/** Clip one brief line to its share of the budget, marking the cut. */
|
|
54
|
+
function clip(text, limit) {
|
|
55
|
+
const single = text.replace(/\s+/gu, ' ').trim();
|
|
56
|
+
return single.length <= limit ? single : single.slice(0, Math.max(0, limit - 1)) + '…';
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Build the background a side question runs with when the main conversation is
|
|
60
|
+
* too long to seed: the last thing the user asked and the last answer given.
|
|
61
|
+
* @param events - the main session's events in seq order.
|
|
62
|
+
* @param limit - longest brief kept, columns approximated as characters.
|
|
63
|
+
* @returns the brief, empty when the log carries neither message.
|
|
64
|
+
*/
|
|
65
|
+
export function btwBrief(events, limit = BTW_BRIEF_LIMIT) {
|
|
66
|
+
const last = (type) => {
|
|
67
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
68
|
+
const event = events[index];
|
|
69
|
+
if (event?.type !== type)
|
|
70
|
+
continue;
|
|
71
|
+
const text = messageText(event.data);
|
|
72
|
+
if (text !== '')
|
|
73
|
+
return text;
|
|
74
|
+
}
|
|
75
|
+
return '';
|
|
76
|
+
};
|
|
77
|
+
const request = last('user/message');
|
|
78
|
+
const answer = last('assistant/message');
|
|
79
|
+
const header = 'Background from the main conversation; answer only the question at the end.';
|
|
80
|
+
const fields = [
|
|
81
|
+
...(request === '' ? [] : [{ label: 'User last asked: ', text: request }]),
|
|
82
|
+
...(answer === '' ? [] : [{ label: 'The agent last answered: ', text: answer }]),
|
|
83
|
+
];
|
|
84
|
+
if (fields.length === 0)
|
|
85
|
+
return '';
|
|
86
|
+
// Wording and line breaks come out of the same budget as the quoted text,
|
|
87
|
+
// so the brief honors its limit instead of exceeding it by the fixed parts.
|
|
88
|
+
const fixed = header.length + fields.reduce((total, field) => total + field.label.length + 1, 0);
|
|
89
|
+
const share = Math.max(40, Math.floor((limit - fixed) / fields.length));
|
|
90
|
+
return [header, ...fields.map(field => field.label + clip(field.text, share))].join('\n');
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* One line naming a run in the panel and the switcher.
|
|
94
|
+
* @param question - the typed side question.
|
|
95
|
+
* @param columns - budget for the naming line.
|
|
96
|
+
* @returns the single-line title.
|
|
97
|
+
*/
|
|
98
|
+
export function btwTitle(question, columns = BTW_TITLE_LIMIT) {
|
|
99
|
+
return truncateColumns(singleLineText(question).trim(), columns);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Create the side-question feed the kernel drives and the panel reads.
|
|
103
|
+
* @param limit - settled runs kept before the oldest retires.
|
|
104
|
+
* @returns the feed.
|
|
105
|
+
*/
|
|
106
|
+
export function createBtwFeed(limit = BTW_RUN_LIMIT) {
|
|
107
|
+
const runs = new Map();
|
|
108
|
+
const listeners = new Set();
|
|
109
|
+
let snapshot = [];
|
|
110
|
+
const publish = () => {
|
|
111
|
+
snapshot = [...runs.values()].map(entry => entry.run).reverse();
|
|
112
|
+
for (const listener of listeners)
|
|
113
|
+
listener();
|
|
114
|
+
};
|
|
115
|
+
/** Retire the OLDEST settled runs past the limit before announcing, so the list never carries one. */
|
|
116
|
+
const retire = () => {
|
|
117
|
+
const settled = [...runs.values()].filter(entry => entry.run.status !== 'running');
|
|
118
|
+
for (const entry of settled.slice(0, Math.max(0, settled.length - limit)))
|
|
119
|
+
runs.delete(entry.run.id);
|
|
120
|
+
};
|
|
121
|
+
return {
|
|
122
|
+
list: () => snapshot,
|
|
123
|
+
subscribe: (listener) => {
|
|
124
|
+
listeners.add(listener);
|
|
125
|
+
return () => { listeners.delete(listener); };
|
|
126
|
+
},
|
|
127
|
+
store: id => runs.get(id)?.store,
|
|
128
|
+
begin: ({ id, question, at }) => {
|
|
129
|
+
if (runs.has(id))
|
|
130
|
+
return;
|
|
131
|
+
runs.set(id, {
|
|
132
|
+
run: { id, question, title: btwTitle(question), status: 'running', startedAt: at },
|
|
133
|
+
store: createTranscriptStore(),
|
|
134
|
+
});
|
|
135
|
+
publish();
|
|
136
|
+
},
|
|
137
|
+
apply: (id, event) => { runs.get(id)?.store.apply(event); },
|
|
138
|
+
applyStreamFrame: (id, frame) => { runs.get(id)?.store.applyStreamFrame(frame); },
|
|
139
|
+
settle: (id, status, error) => {
|
|
140
|
+
const entry = runs.get(id);
|
|
141
|
+
if (entry === undefined || entry.run.status !== 'running')
|
|
142
|
+
return;
|
|
143
|
+
entry.run = { ...entry.run, status, endedAt: Date.now(), ...(error === undefined ? {} : { error }) };
|
|
144
|
+
retire();
|
|
145
|
+
publish();
|
|
146
|
+
},
|
|
147
|
+
drop: id => {
|
|
148
|
+
if (runs.delete(id))
|
|
149
|
+
publish();
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
package/vendor/tui/lib/index.mjs
CHANGED
|
@@ -29,11 +29,13 @@ import { grokStatusSnapshot } from '../../../plugins/grok/status.mjs';
|
|
|
29
29
|
import { compactionPreview as dscodeCompactionPreview, effectiveContextWindow as dscodeEffectiveContextWindow, pricedThresholdRatio as dscodePricedThresholdRatio } from '../../../plugins/compaction/threshold.mjs';
|
|
30
30
|
import { dscodeLoadOpenRouterAccountFor, dscodeManagementKeyStatus, dscodeSaveManagementKey } from './app.mjs';
|
|
31
31
|
import { buildModelSelection, applyModelSelectionToConfig, loadModelDirectory, modelSelectionLabel, pendingModelSelection, resolveEffectiveSelection } from './models.mjs';
|
|
32
|
+
import { effortFor as dscodeEffortFor } from '../../../plugins/providers/effort.mjs';
|
|
32
33
|
import { discoverProviderModels, loadProviderSettings, removeProviderSettings, saveProviderCredential, saveProviderConfiguration, subscribeProviderSettings, unsetProviderCredential, } from './provider-settings.mjs';
|
|
33
34
|
import { createMentions } from './mentions.mjs';
|
|
34
35
|
import { mountQuestionProvider } from './questions.mjs';
|
|
35
36
|
import { createTranscriptStore } from './store.mjs';
|
|
36
37
|
import { createSubagentFeed } from './subagents.mjs';
|
|
38
|
+
import { btwBrief, btwSeed, createBtwFeed } from './btw.mjs';
|
|
37
39
|
import { parseStatuslineItems } from './render/status.mjs';
|
|
38
40
|
import { historyLine, HISTORY_MAX_ENTRIES, needsCompaction, parseHistoryFile, serializeHistoryList } from './history.mjs';
|
|
39
41
|
import { watchSkills } from './skills.mjs';
|
|
@@ -562,6 +564,19 @@ async function run(ctx, startup, io) {
|
|
|
562
564
|
// Live subagent activity (child sessions of the current root): one bounded
|
|
563
565
|
// row per child, folded from the same event bus the transcript feeds on.
|
|
564
566
|
const subagents = createSubagentFeed();
|
|
567
|
+
// Side-question runs (/btw): a seeded read-only child whose answer renders in
|
|
568
|
+
// its own panel and never in this transcript or its model context.
|
|
569
|
+
const btw = createBtwFeed();
|
|
570
|
+
/** Live side-question children, disposed with the session that spawned them. */
|
|
571
|
+
const btwHandles = new Map();
|
|
572
|
+
/** Stop every live side question; the panel keeps the answers it already has. */
|
|
573
|
+
const disposeBtw = () => {
|
|
574
|
+
for (const [id, handle] of btwHandles) {
|
|
575
|
+
btw.drop(id);
|
|
576
|
+
void handle.dispose().catch(() => { });
|
|
577
|
+
}
|
|
578
|
+
btwHandles.clear();
|
|
579
|
+
};
|
|
565
580
|
// Pre-session @file completion runs the official search over the launch
|
|
566
581
|
// cwd (model- and session-independent); the prepare/activate paths replace
|
|
567
582
|
// this with the agent-scoped instance once a session exists.
|
|
@@ -684,8 +699,15 @@ async function run(ctx, startup, io) {
|
|
|
684
699
|
// the only durable transcript truth while a running subagent remains
|
|
685
700
|
// visible. Lineage comes from the child header, same field the session
|
|
686
701
|
// directory uses to tag `↳` rows.
|
|
687
|
-
if (subject.header.parentSession === session.id && subject.header.origin === 'subagent')
|
|
702
|
+
if (subject.header.parentSession === session.id && subject.header.origin === 'subagent') {
|
|
688
703
|
subagents.apply(subject.id, event);
|
|
704
|
+
// A side question folds into its own run store and settles at turn end.
|
|
705
|
+
if (btw.store(subject.id) !== undefined) {
|
|
706
|
+
btw.apply(subject.id, event);
|
|
707
|
+
if (event.type === 'turn/end')
|
|
708
|
+
btw.settle(subject.id, 'done');
|
|
709
|
+
}
|
|
710
|
+
}
|
|
689
711
|
});
|
|
690
712
|
// Live assistant typing (session-log v2+): durable logs are settlement-only,
|
|
691
713
|
// so the streaming tails ride the process-local `agent/assistant-stream`
|
|
@@ -693,9 +715,12 @@ async function run(ctx, startup, io) {
|
|
|
693
715
|
// they land (always before a committed end frame); an abandoned attempt's
|
|
694
716
|
// partial tail is dropped by the store on its end frame.
|
|
695
717
|
ctx.on('agent/assistant-stream', ({ agent: source, frame }) => {
|
|
696
|
-
if (agent
|
|
718
|
+
if (agent !== undefined && source.id === agent.id) {
|
|
719
|
+
store.applyStreamFrame(frame);
|
|
697
720
|
return;
|
|
698
|
-
|
|
721
|
+
}
|
|
722
|
+
// A side question streams into its own panel transcript, never the main one.
|
|
723
|
+
btw.applyStreamFrame(source.id, frame);
|
|
699
724
|
});
|
|
700
725
|
const commands = watchCommands(ctx);
|
|
701
726
|
if (agent !== undefined)
|
|
@@ -966,6 +991,7 @@ async function run(ctx, startup, io) {
|
|
|
966
991
|
// exit wait below cannot hang (upstream rolls the creation back).
|
|
967
992
|
abortPendingControllers();
|
|
968
993
|
quitAbort.abort();
|
|
994
|
+
disposeBtw();
|
|
969
995
|
epoch += 1;
|
|
970
996
|
off();
|
|
971
997
|
for (const dispose of offCapabilitySync)
|
|
@@ -1800,6 +1826,7 @@ async function run(ctx, startup, io) {
|
|
|
1800
1826
|
catch (error) {
|
|
1801
1827
|
cleanupWarning = `previous session flush failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1802
1828
|
}
|
|
1829
|
+
disposeBtw();
|
|
1803
1830
|
try {
|
|
1804
1831
|
await previous.handle.dispose();
|
|
1805
1832
|
}
|
|
@@ -1919,6 +1946,66 @@ async function run(ctx, startup, io) {
|
|
|
1919
1946
|
bridge.notify(t('notice.reviewFailed', { message: error instanceof Error ? error.message : String(error) }), 'error');
|
|
1920
1947
|
});
|
|
1921
1948
|
};
|
|
1949
|
+
/**
|
|
1950
|
+
* Run one side question beside the main conversation: a child session that
|
|
1951
|
+
* inherits this log (or a bounded brief when the log is too long), runs
|
|
1952
|
+
* read-only on the cheapest supported effort, and answers into the /btw
|
|
1953
|
+
* panel. The exchange never enters this transcript or its model context.
|
|
1954
|
+
* @param question - the typed side question.
|
|
1955
|
+
*/
|
|
1956
|
+
const startBtw = (question) => {
|
|
1957
|
+
const text = question.trim();
|
|
1958
|
+
if (text === '' || active === undefined || agent === undefined || session === undefined)
|
|
1959
|
+
return;
|
|
1960
|
+
const parent = session;
|
|
1961
|
+
const parentAgent = agent;
|
|
1962
|
+
const mode = active.mode;
|
|
1963
|
+
const selection = resolveEffectiveSelection(active.selection.picked, parent.requestHeader()?.config, currentDefaults());
|
|
1964
|
+
const seed = btwSeed(parent.snapshotEvents());
|
|
1965
|
+
const brief = seed.inherited ? '' : btwBrief(parent.snapshotEvents());
|
|
1966
|
+
const id = SessionId(`session-${randomUUID()}`);
|
|
1967
|
+
btw.begin({ id, question: text, at: Date.now() });
|
|
1968
|
+
void (async () => {
|
|
1969
|
+
try {
|
|
1970
|
+
const effort = await dscodeEffortFor(ctx.get('llm'), { provider: selection.provider, model: selection.model }, 'low', quitAbort.signal);
|
|
1971
|
+
const handle = await agents.create({
|
|
1972
|
+
sessionId: id,
|
|
1973
|
+
parentAgent,
|
|
1974
|
+
meta: {
|
|
1975
|
+
cwd: parent.header.cwd ?? cwd,
|
|
1976
|
+
agentPreset: mode,
|
|
1977
|
+
parentSession: parent.id,
|
|
1978
|
+
origin: 'subagent',
|
|
1979
|
+
delegationDepth: (parent.header.delegationDepth ?? 0) + 1,
|
|
1980
|
+
},
|
|
1981
|
+
...(seed.inherited ? { seed: seed.events, inheritedEventCount: SessionLogOffset(seed.events.length) } : {}),
|
|
1982
|
+
agentOptions: {
|
|
1983
|
+
provider: selection.provider,
|
|
1984
|
+
model: selection.model,
|
|
1985
|
+
...(effort === undefined ? {} : { reasoningEffort: effort }),
|
|
1986
|
+
},
|
|
1987
|
+
signal: quitAbort.signal,
|
|
1988
|
+
setup: async (childCtx) => {
|
|
1989
|
+
await presets.mount(childCtx, mode);
|
|
1990
|
+
},
|
|
1991
|
+
});
|
|
1992
|
+
// Applied after creation, the same order the main session uses: setup
|
|
1993
|
+
// composes the child's world, it never drives it.
|
|
1994
|
+
if (permissionPresets !== undefined && permissionPresets.names.includes('read-only')) {
|
|
1995
|
+
selectPermission(permissionPresets, handle.agent.session, 'read-only');
|
|
1996
|
+
}
|
|
1997
|
+
btwHandles.set(id, handle);
|
|
1998
|
+
handle.agent.followup(createUserMessage({
|
|
1999
|
+
content: [{ type: 'text', text: brief === '' ? text : `${brief}\n\nQuestion: ${text}` }],
|
|
2000
|
+
source: { kind: 'user' },
|
|
2001
|
+
}));
|
|
2002
|
+
}
|
|
2003
|
+
catch (error) {
|
|
2004
|
+
btwHandles.delete(id);
|
|
2005
|
+
btw.settle(id, quitAbort.signal.aborted ? 'cancelled' : 'failed', error instanceof Error ? error.message : String(error));
|
|
2006
|
+
}
|
|
2007
|
+
})();
|
|
2008
|
+
};
|
|
1922
2009
|
const forkSession = (argument) => {
|
|
1923
2010
|
if (session === undefined || active === undefined) {
|
|
1924
2011
|
bridge.notify('no session yet - submit a message to start', 'warning');
|
|
@@ -2008,6 +2095,8 @@ async function run(ctx, startup, io) {
|
|
|
2008
2095
|
approval,
|
|
2009
2096
|
questions,
|
|
2010
2097
|
subagents,
|
|
2098
|
+
btw,
|
|
2099
|
+
startBtw,
|
|
2011
2100
|
commands,
|
|
2012
2101
|
skills,
|
|
2013
2102
|
model,
|
|
@@ -116,6 +116,7 @@ export const en = {
|
|
|
116
116
|
'cmd.rainbow': 'reroll or pin the rainbow palette (/rainbow [seed])',
|
|
117
117
|
'cmd.animation': 'toggle timed animations (/animation [on|off])',
|
|
118
118
|
'cmd.history': 'search and recall past prompts',
|
|
119
|
+
'cmd.btw': 'ask a side question beside this session (/btw <question>)',
|
|
119
120
|
'cmd.queue': 'manage messages queued for the next turn',
|
|
120
121
|
'cmd.usage': 'show this session token usage',
|
|
121
122
|
'cmd.agents': 'inspect subagent sessions of this conversation',
|
|
@@ -143,6 +144,15 @@ export const en = {
|
|
|
143
144
|
'help.skillsTitle': ' skills',
|
|
144
145
|
'help.footer': '↑↓ scroll · pgup/pgdn page · g/G ends · esc/q close',
|
|
145
146
|
'help.compact': '/help · esc/q close',
|
|
147
|
+
'panel.btw.title': 'Side questions (btw)',
|
|
148
|
+
'panel.btw.running': 'asking',
|
|
149
|
+
'panel.btw.done': 'answered',
|
|
150
|
+
'panel.btw.failed': 'failed',
|
|
151
|
+
'panel.btw.cancelled': 'stopped',
|
|
152
|
+
'panel.btw.waiting': 'Waiting for the answer...',
|
|
153
|
+
'panel.btw.empty': 'No side question yet. Ask one with /btw <question>.',
|
|
154
|
+
'panel.btw.footer': 'esc closes - the main conversation is untouched',
|
|
155
|
+
'panel.btw.footerMany': '{count} side questions - left/right switches - esc closes',
|
|
146
156
|
'panel.queue.title': '/queue — {count} queued · rows {from}-{to}',
|
|
147
157
|
'panel.queue.empty': ' no messages queued for the next turn',
|
|
148
158
|
'panel.queue.attachments': '[attachments: read-only]',
|
|
@@ -116,6 +116,7 @@ export const zh = {
|
|
|
116
116
|
'cmd.rainbow': '重掷或指定彩虹配色(/rainbow [seed])',
|
|
117
117
|
'cmd.animation': '开关计时动画(/animation [on|off])',
|
|
118
118
|
'cmd.history': '搜索并复用历史提示词',
|
|
119
|
+
'cmd.btw': '在本会话旁边问一个旁支问题(/btw <问题>)',
|
|
119
120
|
'cmd.queue': '管理等待下一轮的消息',
|
|
120
121
|
'cmd.usage': '查看本会话的 token 用量',
|
|
121
122
|
'cmd.agents': '查看本会话的子代理',
|
|
@@ -143,6 +144,15 @@ export const zh = {
|
|
|
143
144
|
'help.skillsTitle': ' 技能',
|
|
144
145
|
'help.footer': '↑↓ 滚动 · pgup/pgdn 翻页 · g/G 首尾 · esc/q 关闭',
|
|
145
146
|
'help.compact': '/help · esc/q 关闭',
|
|
147
|
+
'panel.btw.title': '旁注提问(btw)',
|
|
148
|
+
'panel.btw.running': '回答中',
|
|
149
|
+
'panel.btw.done': '已回答',
|
|
150
|
+
'panel.btw.failed': '失败',
|
|
151
|
+
'panel.btw.cancelled': '已停止',
|
|
152
|
+
'panel.btw.waiting': '等待回答…',
|
|
153
|
+
'panel.btw.empty': '还没有旁注提问。用 /btw <问题> 问一个。',
|
|
154
|
+
'panel.btw.footer': 'esc 关闭 - 主线对话不受影响',
|
|
155
|
+
'panel.btw.footerMany': '{count} 条旁注 - 左右方向键切换 - esc 关闭',
|
|
146
156
|
'panel.queue.title': '/queue — {count} 条排队消息 · 行 {from}-{to}',
|
|
147
157
|
'panel.queue.empty': ' 下一轮没有排队消息',
|
|
148
158
|
'panel.queue.attachments': '[含附件:只读]',
|
|
@@ -124,7 +124,7 @@ export function nextEveryTarget(previousTarget, acceptedAt, everySeconds) {
|
|
|
124
124
|
return previousTarget + missed * interval;
|
|
125
125
|
}
|
|
126
126
|
/** Plugin snapshot sources folded into token stats but never rendered as rows. */
|
|
127
|
-
const HIDDEN_SNAPSHOT_PLUGINS = new Set(['time-context', 'tmux-context']);
|
|
127
|
+
const HIDDEN_SNAPSHOT_PLUGINS = new Set(['time-context', 'tmux-context', 'dscode-time-marks']);
|
|
128
128
|
/** Plugin prompt sources rendered as full user rows (they ARE the conversation). */
|
|
129
129
|
const REMINDER_PLUGINS = new Set(['schedule']);
|
|
130
130
|
/** Assemble the effective system prompt from surface nodes: head text plus every later non-empty node. */
|
|
@@ -40,6 +40,38 @@ function formatRate(n) {
|
|
|
40
40
|
return String(Math.round(n));
|
|
41
41
|
return String(Math.round(n / 100) / 10) + 'K';
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Fixed value columns for the footer's live figures. A running turn changes
|
|
45
|
+
* these numbers every second, so each one renders right-aligned inside its
|
|
46
|
+
* own columns: a growing value then reshapes its digits without reflowing the
|
|
47
|
+
* groups after it, and the row's geometry follows terminal width alone. The
|
|
48
|
+
* widths cover the figures a session reaches in practice (999 turns, 999K
|
|
49
|
+
* tokens, 120m0s of wall time); a value past its columns simply grows the
|
|
50
|
+
* group rather than being clipped into a wrong reading.
|
|
51
|
+
*/
|
|
52
|
+
const VALUE_WIDTH = {
|
|
53
|
+
/** Turn and step counters. */
|
|
54
|
+
count: 3,
|
|
55
|
+
/** `12.3K` token totals. */
|
|
56
|
+
tokens: 5,
|
|
57
|
+
/** `45.2s` / `2m42s` wall times. */
|
|
58
|
+
duration: 6,
|
|
59
|
+
/** `15.3` / `124` / `1.2K` decode rates. */
|
|
60
|
+
rate: 4,
|
|
61
|
+
/** `100%` context occupancy. */
|
|
62
|
+
percentContext: 4,
|
|
63
|
+
/** `100.0%` cache shares. */
|
|
64
|
+
percentShare: 6,
|
|
65
|
+
};
|
|
66
|
+
/** Right-align a figure inside its fixed value columns (wider text is left alone). */
|
|
67
|
+
export function padValue(text, width) {
|
|
68
|
+
const padding = width - visibleColumns(text);
|
|
69
|
+
return padding > 0 ? ' '.repeat(padding) + text : text;
|
|
70
|
+
}
|
|
71
|
+
/** The `--` placeholder for a figure with no reading yet, in the same columns. */
|
|
72
|
+
function pendingValue(width) {
|
|
73
|
+
return padValue('--', width);
|
|
74
|
+
}
|
|
43
75
|
/**
|
|
44
76
|
* Cache-hit share of billed prompt-side input. The denominator is the same
|
|
45
77
|
* billed total the /usage panel shows (uncached input plus both cache
|
|
@@ -109,18 +141,27 @@ export function contextBar(usedTokens, contextWindow, width) {
|
|
|
109
141
|
* OUTSIDE the bar, so the dotted track keeps its proportional meaning no
|
|
110
142
|
* matter how wide the readout is. `full` reads `12.3K/1.0M 25%`; `percent`
|
|
111
143
|
* drops the absolute pair; `none` is the bare bar. The readout turns amber
|
|
112
|
-
* once occupancy reaches the warning threshold.
|
|
144
|
+
* once occupancy reaches the warning threshold. Before a route advertises a
|
|
145
|
+
* window the empty track still draws and the readout is the `--` placeholder,
|
|
146
|
+
* so the group keeps its columns from the first frame on.
|
|
113
147
|
*/
|
|
114
148
|
export function contextGroupSpans(usedTokens, contextWindow, barWidth, readout) {
|
|
115
149
|
const spans = [{ text: t('status.label.context') + ' ', tone: 'label' }];
|
|
116
|
-
|
|
117
|
-
|
|
150
|
+
const track = contextWindow > 0
|
|
151
|
+
? contextBar(usedTokens, contextWindow, barWidth)
|
|
152
|
+
: barWidth > 0 ? [{ text: '░'.repeat(barWidth), tone: 'label' }] : [];
|
|
153
|
+
spans.push(...track);
|
|
154
|
+
if (readout === 'none' || barWidth <= 0)
|
|
155
|
+
return spans;
|
|
156
|
+
if (contextWindow <= 0) {
|
|
157
|
+
spans.push({ text: ' ', tone: 'label' }, { text: pendingValue(VALUE_WIDTH.tokens), tone: 'value' });
|
|
118
158
|
return spans;
|
|
159
|
+
}
|
|
119
160
|
const used = Math.max(0, usedTokens);
|
|
120
161
|
const percent = Math.round(used / contextWindow * 100);
|
|
121
162
|
const text = readout === 'full'
|
|
122
|
-
? `${formatTokens(used)}/${formatTokens(contextWindow)} ${percent}
|
|
123
|
-
:
|
|
163
|
+
? `${padValue(formatTokens(used), VALUE_WIDTH.tokens)}/${formatTokens(contextWindow)} ${padValue(percent + '%', VALUE_WIDTH.percentContext)}`
|
|
164
|
+
: padValue(percent + '%', VALUE_WIDTH.percentContext);
|
|
124
165
|
spans.push({ text: ' ', tone: 'label' }, { text, tone: percent >= CONTEXT_WARN_PERCENT ? 'warn' : 'value' });
|
|
125
166
|
return spans;
|
|
126
167
|
}
|
|
@@ -289,62 +330,69 @@ function buildCandidates(facts, stats, busy, enabled, contextWidth) {
|
|
|
289
330
|
];
|
|
290
331
|
const right = [];
|
|
291
332
|
const row2 = [];
|
|
292
|
-
if (
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
counts.push(
|
|
300
|
-
};
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
}
|
|
333
|
+
if (enabled.has('turns')) {
|
|
334
|
+
// Label/value pairs join through explicit dim separators. The counters ride
|
|
335
|
+
// the footer from the first frame on, so a turn opening one never moves the
|
|
336
|
+
// row.
|
|
337
|
+
const counts = [];
|
|
338
|
+
const pair = (label, value) => {
|
|
339
|
+
if (counts.length > 0)
|
|
340
|
+
counts.push(sep());
|
|
341
|
+
counts.push({ text: label + ' ', tone: 'label' }, { text: value, tone: 'value' });
|
|
342
|
+
};
|
|
343
|
+
pair(t('status.label.turns'), padValue(String(stats.turns), VALUE_WIDTH.count));
|
|
344
|
+
pair(t('status.label.steps'), padValue(String(stats.steps), VALUE_WIDTH.count));
|
|
345
|
+
row2.push({ group: { spans: counts }, rank: RANK_COUNTS, id: 'turns' });
|
|
346
|
+
}
|
|
347
|
+
if (enabled.has('durations')) {
|
|
348
|
+
// Model round-trip, first-token latency, decode rate, and tool wall
|
|
349
|
+
// time; the label keeps its one trailing space so each reads as one
|
|
350
|
+
// figure ('model 45.2s'). Named in full — no single-letter codes. A figure
|
|
351
|
+
// without a reading yet keeps its columns as `--`.
|
|
352
|
+
const durations = [];
|
|
353
|
+
const pair = (label, value) => {
|
|
354
|
+
if (durations.length > 0)
|
|
355
|
+
durations.push(sep());
|
|
356
|
+
durations.push({ text: label + ' ', tone: 'label' }, { text: value, tone: 'value' });
|
|
357
|
+
};
|
|
358
|
+
const wall = (ms) => ms > 0
|
|
359
|
+
? padValue(formatDuration(ms), VALUE_WIDTH.duration)
|
|
360
|
+
: pendingValue(VALUE_WIDTH.duration);
|
|
361
|
+
pair(t('status.label.modelTime'), wall(stats.llmMs));
|
|
362
|
+
pair(t('status.label.latency'), stats.ttftSteps > 0 ? wall(stats.ttftMs / stats.ttftSteps) : pendingValue(VALUE_WIDTH.duration));
|
|
363
|
+
if (durations.length > 0)
|
|
364
|
+
durations.push(sep());
|
|
365
|
+
durations.push({
|
|
366
|
+
text: stats.decodeMs > 0 && stats.decodeTokens > 0
|
|
367
|
+
? padValue(formatRate(stats.decodeTokens / (stats.decodeMs / 1_000)), VALUE_WIDTH.rate)
|
|
368
|
+
: pendingValue(VALUE_WIDTH.rate),
|
|
369
|
+
tone: 'value',
|
|
370
|
+
}, { text: t('status.label.tokensPerSec'), tone: 'label' });
|
|
371
|
+
pair(t('status.label.tool'), wall(stats.toolMs));
|
|
372
|
+
row2.push({ group: { spans: durations }, rank: RANK2_DURATIONS, id: 'durations' });
|
|
330
373
|
}
|
|
331
374
|
// The cache group carries both facts about cached prompt tokens: how many
|
|
332
375
|
// were read and what share of the billed prompt that was. The read count
|
|
333
376
|
// lives here rather than in the tokens group so the tokens group keeps
|
|
334
377
|
// meaning "what the provider billed outside the cache".
|
|
378
|
+
// An unread cache keeps its columns as the `--` placeholder.
|
|
335
379
|
const cacheHit = cacheHitPercent(stats.usage);
|
|
336
|
-
if (
|
|
380
|
+
if (enabled.has('cache')) {
|
|
337
381
|
const spans = [{ text: t('status.label.cache') + ' ', tone: 'label' }];
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
382
|
+
spans.push({
|
|
383
|
+
text: stats.usage.cacheReadTokens > 0
|
|
384
|
+
? padValue(formatTokens(stats.usage.cacheReadTokens), VALUE_WIDTH.tokens)
|
|
385
|
+
: pendingValue(VALUE_WIDTH.tokens),
|
|
386
|
+
tone: 'value',
|
|
387
|
+
});
|
|
388
|
+
spans.push(sep());
|
|
389
|
+
spans.push({ text: cacheHit === null ? pendingValue(VALUE_WIDTH.percentShare) : padValue(cacheHit + '%', VALUE_WIDTH.percentShare), tone: 'value' });
|
|
342
390
|
row2.push({ group: { spans }, rank: RANK2_CACHE, id: 'cache' });
|
|
343
391
|
}
|
|
344
392
|
// Context occupancy as a purely proportional bar with the usage readout
|
|
345
393
|
// riding outside it: the used total is the most recent reported prompt
|
|
346
394
|
// size against the advertised route capacity.
|
|
347
|
-
if (
|
|
395
|
+
if (enabled.has('context')) {
|
|
348
396
|
left.push({
|
|
349
397
|
group: {
|
|
350
398
|
spans: contextGroupSpans(stats.lastPromptTokens, stats.contextWindow, contextWidth, 'full'),
|
|
@@ -353,15 +401,17 @@ function buildCandidates(facts, stats, busy, enabled, contextWidth) {
|
|
|
353
401
|
id: 'context',
|
|
354
402
|
});
|
|
355
403
|
}
|
|
356
|
-
|
|
404
|
+
// The token totals keep their columns from the first frame on: an unread
|
|
405
|
+
// total shows its zero in the same width instead of taking the group out.
|
|
406
|
+
if (enabled.has('tokens')) {
|
|
357
407
|
const tokens = [];
|
|
358
408
|
const pair = (label, value) => {
|
|
359
409
|
if (tokens.length > 0)
|
|
360
410
|
tokens.push(sep());
|
|
361
411
|
tokens.push({ text: label + ' ', tone: 'label' }, { text: value, tone: 'value' });
|
|
362
412
|
};
|
|
363
|
-
pair(t('status.label.in'), formatTokens(stats.usage.uncachedInputTokens));
|
|
364
|
-
pair(t('status.label.out'), formatTokens(stats.usage.outputTokens));
|
|
413
|
+
pair(t('status.label.in'), padValue(formatTokens(stats.usage.uncachedInputTokens), VALUE_WIDTH.tokens));
|
|
414
|
+
pair(t('status.label.out'), padValue(formatTokens(stats.usage.outputTokens), VALUE_WIDTH.tokens));
|
|
365
415
|
row2.push({ group: { spans: tokens }, rank: RANK_TOKENS, id: 'tokens' });
|
|
366
416
|
}
|
|
367
417
|
// dscode: the session title moved to row 1's identity lead (dscodeStatusLead),
|
|
@@ -394,19 +444,18 @@ function buildCandidates(facts, stats, busy, enabled, contextWidth) {
|
|
|
394
444
|
// any other preset (a typed /plan mid-session) stays orthogonal: the badge
|
|
395
445
|
// keeps naming the preset and row 2 carries the green plan marker.
|
|
396
446
|
const planStation = facts.plan && permissionTone(permission) === 'success';
|
|
397
|
-
// dscode: the permission badge
|
|
398
|
-
//
|
|
447
|
+
// dscode: the permission badge anchors row 1's right edge, so the left
|
|
448
|
+
// clusters (title, cwd, mode, branch, context) grow and shrink under it
|
|
449
|
+
// without ever moving it.
|
|
399
450
|
if (permission !== '' && enabled.has('permission')) {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
: [{ text: permission, tone: permissionTone(permission) }],
|
|
405
|
-
},
|
|
451
|
+
right.push({
|
|
452
|
+
span: planStation
|
|
453
|
+
? { text: 'plan on', tone: 'plan' }
|
|
454
|
+
: { text: permission, tone: permissionTone(permission) },
|
|
406
455
|
rank: RANK_BADGE,
|
|
407
456
|
id: 'permission',
|
|
408
457
|
});
|
|
409
|
-
badge =
|
|
458
|
+
badge = right.length - 1;
|
|
410
459
|
}
|
|
411
460
|
if (facts.plan && enabled.has('plan')) {
|
|
412
461
|
row2.push({ group: { spans: [{ text: '⧉ plan', tone: 'accent' }] }, rank: RANK2_PLAN, id: 'plan' });
|
|
@@ -446,9 +495,17 @@ export function layoutStatusBar(facts, stats, columns, options = {}) {
|
|
|
446
495
|
const orderedLeft = [left[0], ...left.slice(1).sort(byPosition)];
|
|
447
496
|
const orderedRight = right.slice().sort(byPosition);
|
|
448
497
|
const orderedRow2 = row2.slice().sort(byPosition);
|
|
498
|
+
// The cycle hint keeps its columns reserved whether or not it is painted:
|
|
499
|
+
// the badge anchors the right edge, so a turn opening or closing must not
|
|
500
|
+
// change what the left clusters may occupy.
|
|
449
501
|
let hint = badge >= 0 && !busy;
|
|
502
|
+
let hintWidth = badge >= 0 ? visibleColumns(statusCycleHint()) : 0;
|
|
450
503
|
const leftKept = [...orderedLeft];
|
|
451
504
|
const rightKept = [...orderedRight];
|
|
505
|
+
// Columns the hint reserves: its own text plus the item separator that
|
|
506
|
+
// joins it to a right cluster, so an idle and a running turn measure the
|
|
507
|
+
// row identically.
|
|
508
|
+
const hintSlot = () => hintWidth > 0 && rightKept.length > 0 ? hintWidth + itemSeparator : hintWidth;
|
|
452
509
|
// Context degradation state: the readout drops its absolute pair first,
|
|
453
510
|
// then the bar shrinks inside its own budget, and only then is the whole
|
|
454
511
|
// group removed — the proportional meter outlives the auxiliary numbers.
|
|
@@ -470,7 +527,7 @@ export function layoutStatusBar(facts, stats, columns, options = {}) {
|
|
|
470
527
|
const width = () => {
|
|
471
528
|
const leftWidth = joinWidth(leftKept.map(entry => spansWidth(entry.group.spans)), groupSeparator);
|
|
472
529
|
const rightWidth = joinWidth(rightKept.map(entry => visibleColumns(entry.span.text)), itemSeparator)
|
|
473
|
-
+ (
|
|
530
|
+
+ hintSlot();
|
|
474
531
|
return rightWidth > 0 ? leftWidth + LEFT_RIGHT_GAP + rightWidth : leftWidth;
|
|
475
532
|
};
|
|
476
533
|
while (width() > budget) {
|
|
@@ -494,11 +551,11 @@ export function layoutStatusBar(facts, stats, columns, options = {}) {
|
|
|
494
551
|
leftKept.splice(leftKept.findIndex(entry => entry.id === 'context'), 1);
|
|
495
552
|
continue;
|
|
496
553
|
}
|
|
497
|
-
if (
|
|
554
|
+
if (hintWidth > 0 && rightKept.length > 0 && leftKept.length > 0) {
|
|
498
555
|
const identity = leftKept[0];
|
|
499
556
|
const identityText = identity.group.spans.map(span => span.text).join('');
|
|
500
557
|
const rightWidth = joinWidth(rightKept.map(entry => visibleColumns(entry.span.text)), itemSeparator);
|
|
501
|
-
const identityBudget = budget - rightWidth - LEFT_RIGHT_GAP -
|
|
558
|
+
const identityBudget = budget - rightWidth - LEFT_RIGHT_GAP - hintSlot();
|
|
502
559
|
if (identityBudget > 0 && visibleColumns(identityText) > identityBudget) {
|
|
503
560
|
leftKept[0] = {
|
|
504
561
|
...identity,
|
|
@@ -507,8 +564,9 @@ export function layoutStatusBar(facts, stats, columns, options = {}) {
|
|
|
507
564
|
continue;
|
|
508
565
|
}
|
|
509
566
|
}
|
|
510
|
-
if (
|
|
567
|
+
if (hintWidth > 0) {
|
|
511
568
|
hint = false;
|
|
569
|
+
hintWidth = 0;
|
|
512
570
|
continue;
|
|
513
571
|
}
|
|
514
572
|
let dropLeft = -1;
|