@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 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.19",
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",
@@ -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
- instructions: 'Does the retained user instruction explicitly authorize this exact action, including its target and effect?',
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 => (message?.content ?? []).map(block => block?.text ?? '').join(' ').trim())
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 } }), id: e.messageId });
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
- const dollars = provider === 'grok' ? grokFooterFact(grokSubscriptionNow(), locale) : `${spend} / ${balance === null ? '$--' : '$' + balance.toFixed(2)}${provider === 'deepseek-official' ? ' ' + peakEmoji(trustedNow()) : ''}`;
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
+ }
@@ -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
- facts = { ...facts, telemetry: columns >= 48 ? dscodeFooterFor(facts.fullSessionId, stats, Math.max(1, Math.min(columns - 8, Math.max(40, Math.floor(columns * 0.8) - 4))), dscodeFooterHeader(facts, stats), getLanguage()) : '' };
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 open.
5214
- useEffect(() => { setEmailOpen(false); }, [props.sessionKey]);
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
+ }
@@ -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 === undefined || source.id !== agent.id)
718
+ if (agent !== undefined && source.id === agent.id) {
719
+ store.applyStreamFrame(frame);
697
720
  return;
698
- store.applyStreamFrame(frame);
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
- spans.push(...contextBar(usedTokens, contextWindow, barWidth));
117
- if (readout === 'none' || barWidth <= 0 || contextWindow <= 0)
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
- : `${percent}%`;
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 (stats.turns > 0 || stats.steps > 0) {
293
- if (enabled.has('turns')) {
294
- // Label/value pairs join through explicit dim separators.
295
- const counts = [];
296
- const pair = (label, value) => {
297
- if (counts.length > 0)
298
- counts.push(sep());
299
- counts.push({ text: label + ' ', tone: 'label' }, { text: value, tone: 'value' });
300
- };
301
- pair(t('status.label.turns'), String(stats.turns));
302
- pair(t('status.label.steps'), String(stats.steps));
303
- row2.push({ group: { spans: counts }, rank: RANK_COUNTS, id: 'turns' });
304
- }
305
- if (enabled.has('durations')) {
306
- // Model round-trip, first-token latency, decode rate, and tool wall
307
- // time; the label keeps its one trailing space so each reads as one
308
- // figure ('model 45.2s'). Named in full no single-letter codes.
309
- const durations = [];
310
- const pair = (label, value) => {
311
- if (durations.length > 0)
312
- durations.push(sep());
313
- durations.push({ text: label + ' ', tone: 'label' }, { text: value, tone: 'value' });
314
- };
315
- if (stats.llmMs > 0)
316
- pair(t('status.label.modelTime'), formatDuration(stats.llmMs));
317
- if (stats.ttftSteps > 0)
318
- pair(t('status.label.latency'), formatDuration(stats.ttftMs / stats.ttftSteps));
319
- if (stats.decodeMs > 0 && stats.decodeTokens > 0) {
320
- if (durations.length > 0)
321
- durations.push(sep());
322
- durations.push({ text: formatRate(stats.decodeTokens / (stats.decodeMs / 1_000)), tone: 'value' }, { text: t('status.label.tokensPerSec'), tone: 'label' });
323
- }
324
- if (stats.toolMs > 0)
325
- pair(t('status.label.tool'), formatDuration(stats.toolMs));
326
- if (durations.length > 0) {
327
- row2.push({ group: { spans: durations }, rank: RANK2_DURATIONS, id: 'durations' });
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 (cacheHit !== null && enabled.has('cache')) {
380
+ if (enabled.has('cache')) {
337
381
  const spans = [{ text: t('status.label.cache') + ' ', tone: 'label' }];
338
- if (stats.usage.cacheReadTokens > 0) {
339
- spans.push({ text: formatTokens(stats.usage.cacheReadTokens), tone: 'value' }, sep());
340
- }
341
- spans.push({ text: cacheHit + '%', tone: 'value' });
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 (stats.contextWindow > 0 && stats.lastPromptTokens > 0 && enabled.has('context')) {
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
- if ((stats.usage.uncachedInputTokens > 0 || stats.usage.outputTokens > 0) && enabled.has('tokens')) {
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 rides row 1 beside the title rather than
398
- // right-pinning itself on the identity row.
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
- left.push({
401
- group: {
402
- spans: planStation
403
- ? [{ text: 'plan on', tone: 'plan' }]
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 = left.length - 1;
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
- + (hint ? visibleColumns(statusCycleHint()) : 0);
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 (hint && rightKept.length > 0 && leftKept.length > 0) {
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 - visibleColumns(statusCycleHint());
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 (hint) {
567
+ if (hintWidth > 0) {
511
568
  hint = false;
569
+ hintWidth = 0;
512
570
  continue;
513
571
  }
514
572
  let dropLeft = -1;