feat: add /context slash command with a centered context modal

Typing /context in the composer opens a centered dialog with the current
composition (provider-anchored headline, stacked bar, legend) and the
last-10-turn trend chart — the same pushed contextTimeline projection the
tab renders, no RPC, no polling.

The command is the plugin's own '/' trigger source, fully client-side:
no host descriptor, no session log records, nothing model-visible. The
composer token is consumed only when the modal closes (deferred
slash/input-consume-token with span / bare-token guards, fail-soft if the
draft changed meanwhile). Esc, backdrop click, and the close button all
close it and restore composer focus.

Also extracts the provider-anchored headline derivation shared by the tab
and the modal (headline.ts).
This commit is contained in:
bowenliang123 committed 2026-08-16 22:26:46 +08:00
1 parent 6cb61a413b
commit bf4cdd1e58
10 files changed
+558 -35

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
/**
* `/context` — a client-owned slash command that opens the context modal
* (current composition + recent trend) in the center of the page.
*
* Implemented as the plugin's own '/' trigger source instead of a host
* command: nothing is dispatched to the host, no session log records are
* written, and nothing becomes model-visible — the invocation never enters
* the message history. Both paths answer `'handled'` and open the modal,
* leaving the `/context` token in the composer while it is open; the modal's
* close path consumes the token then (see modalStore.ts).
*/
import { modalStoreOf, setPendingConsume } from './modalStore'
import type { ClientCtx, InputTriggersFace } from './services'
import type { ViewKit } from './viewkit'
const COMMAND = 'context'
const LINE = '/' + COMMAND
export function registerContextCommand(ctx: ClientCtx, kit: ViewKit): void {
ctx.effect(() => {
// Soft dependency (ctx.get, not the inject list): a harness without the
// input-trigger service keeps the tab working and only the command
// absent.
const inputTriggers = ctx.get('inputTriggers') as InputTriggersFace | undefined
if (inputTriggers === undefined) return () => {}
return inputTriggers.registerSource({
trigger: '/',
name: COMMAND,
order: 1,
candidates: (_session, req) => {
if (req.position !== 'leading') return Promise.resolve([])
const query = req.query.trim().toLowerCase()
if (query !== '' && !COMMAND.startsWith(query)) return Promise.resolve([])
return Promise.resolve([{ name: COMMAND, description: kit.t('cmd.desc') }])
},
onPick: (pick) => {
// Open and remember the token span: the modal's close path consumes
// it (span CAS — a draft changed meanwhile is left untouched).
setPendingConsume(pick.session.sessionId, { kind: 'span', span: pick.span })
modalStoreOf(pick.session.sessionId).set(true)
return 'handled'
},
matchEnter: (session, line) => {
if (line !== LINE) return Promise.resolve(undefined)
// Same deferred consume, bare-token guard (the trimmed draft must
// still equal the command line when the modal closes).
setPendingConsume(session.sessionId, { kind: 'bare-token', token: LINE })
modalStoreOf(session.sessionId).set(true)
return Promise.resolve<'handled'>('handled')
},
})
}, 'dsh-context: /context command')
}
+157
View File
@@ -0,0 +1,157 @@
/**
* ContextModal — the /context command's centered dialog: the same data as
* the Context tab (the pushed `contextTimeline` projection) distilled to
* the overview (headline + composition bar + legend) and the last-10-turn
* trend chart. Rendered from the `conversation.input.overlay` slot; opens
* and closes through the per-session modal store (the trigger source flips
* it, so no message ever enters the session history).
*/
import type * as ReactNS from 'react'
import type { ContextEventRecord, RequestRecord } from '../../shared/types'
import { headlineOf } from '../headline'
import { modalStoreOf, takePendingConsume } from '../modalStore'
import type { ClientCtx, SessionStandardProps, SessionsFace } from '../services'
import { timelineOf } from '../services'
import type { ViewKit } from '../viewkit'
import { makeRequestDetail } from './requestDetail'
import { makeLegend, makeStackedBar } from './stackedBar'
import { aggregateByTurn, attachMarkers, makeTrendChart } from './trendChart'
import { React } from '../react'
/** How many of the most recent turns the modal's trend chart shows. */
const TREND_TURNS = 10
export interface ContextModalProps extends SessionStandardProps {
/** Bound selector hook over the per-session open flag (hooks compartment). */
useContextModal?: (sel: (open: boolean) => boolean) => boolean
}
export function makeContextModal(ctx: ClientCtx, kit: ViewKit): (props: ContextModalProps) => ReactNS.ReactElement | null {
const { t, tr, fmt } = kit
const sessions = ctx.get('sessions') as SessionsFace | undefined
const StackedBar = makeStackedBar(kit)
const Legend = makeLegend(kit)
const TrendChart = makeTrendChart(kit)
const RequestDetail = makeRequestDetail(kit, StackedBar)
return function ContextModal(props: ContextModalProps): ReactNS.ReactElement | null {
const sessionId = typeof props.sessionId === 'string' ? props.sessionId : ''
const open = typeof props.useContextModal === 'function' ? props.useContextModal(s => s) : false
const data = typeof props.useProjection === 'function'
? timelineOf(props.useProjection('contextTimeline'))
: null
const [selectedSeq, setSelectedSeq] = React.useState<number | null>(null)
const [hoveredSeq, setHoveredSeq] = React.useState<number | null>(null)
const [hoverCat, setHoverCat] = React.useState<string | null>(null)
const close = React.useCallback(() => {
if (sessionId === '') return
modalStoreOf(sessionId).set(false)
// Deferred token consumption: the `/context` token stayed in the
// composer while the modal was open; consume it now through the scoped
// input event (a stale guard — the user typed meanwhile — fails soft
// inside the shell and leaves the draft untouched).
const guard = takePendingConsume(sessionId)
if (guard === undefined || sessions === undefined) return
const scope = sessions.scope(sessionId)
if (scope !== undefined) scope.bail(scope, 'slash/input-consume-token', { guard })
}, [sessionId])
// Escape closes; on open the current focus (the composer) is captured
// and restored on close, so typing resumes exactly where it stopped.
React.useEffect(() => {
if (!open) return undefined
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null
const onKey = (ev: KeyboardEvent) => {
if (ev.key !== 'Escape') return
ev.preventDefault()
ev.stopPropagation()
close()
}
window.addEventListener('keydown', onKey, true)
return () => {
window.removeEventListener('keydown', onKey, true)
if (previous !== null && document.contains(previous)) previous.focus()
}
}, [open, close])
if (!open) return null
const requests = data !== null ? data.requests || [] : []
const events = data !== null ? data.events || [] : []
// One bar per turn (each turn shown by its last step), newest 10 only.
const turns = aggregateByTurn(requests).slice(-TREND_TURNS)
const markers = attachMarkers(turns, events)
let pinnedReq: RequestRecord | null = null
for (const req of turns) if (req.seq === selectedSeq) pinnedReq = req
let activeReq: RequestRecord | null = null
if (hoveredSeq !== null) {
for (const req of turns) if (req.seq === hoveredSeq) activeReq = req
}
if (activeReq === null) activeReq = pinnedReq
if (activeReq === null && turns.length > 0) activeReq = turns[turns.length - 1]
const markerOf = (req: RequestRecord): ContextEventRecord | undefined => {
const i = turns.indexOf(req)
return i >= 0 ? markers[i] : undefined
}
const head = data !== null ? headlineOf(data) : null
return (
<div className="lc-modal-backdrop" onClick={close}>
<div className="lc-modal-card" onClick={ev => { ev.stopPropagation() }}>
<div className="lc-modal-head">
<span className="lc-modal-title">{t('tab')}</span>
{data !== null && (data.model || data.provider) ? (
<span className="lc-card-sub">
{(data.model ? data.model : '') + (data.provider ? ' · ' + data.provider : '')}
</span>
) : null}
<button className="lc-modal-close" aria-label={t('cmd.close')} onClick={close}>×</button>
</div>
{data === null || head === null ? (
<div className="lc-empty">{t('loading')}</div>
) : (
<div>
<div className="lc-overview-num">
<b>{fmt(head.tokens)}</b>
<span>
{head.window
? ' / ' + fmt(head.window) + ' ' + tr('overview.ofWindow', { p: head.pct ?? 0 })
: ' ' + t('overview.estimate')}
</span>
{!head.estimated ? <span className="lc-card-sub">{t('overview.splitEst')}</span> : null}
</div>
<StackedBar parts={head.parts} height={16} max={head.window} hoverKey={hoverCat} onHoverKey={setHoverCat} />
<Legend parts={head.parts} hoverKey={hoverCat} onHoverKey={setHoverCat} />
<div className="lc-card-title lc-modal-trend">{t('trend.title')}</div>
{turns.length === 0 ? (
<div className="lc-empty">{t('trend.empty')}</div>
) : (
<div>
<TrendChart
requests={turns}
markers={markers}
selectedSeq={pinnedReq !== null ? pinnedReq.seq : null}
hoveredSeq={hoveredSeq}
activeTurn={null}
granularity="turn"
onSelect={setSelectedSeq}
onHover={setHoveredSeq}
onHoverTurn={() => {}}
/>
<RequestDetail request={activeReq} marker={activeReq !== null ? markerOf(activeReq) : undefined} />
</div>
)}
</div>
)}
</div>
</div>
)
}
}
+10 -27
View File
@@ -10,8 +10,8 @@
*/
import type * as ReactNS from 'react'
import type { ContextEventRecord, RequestRecord, Snapshot } from '../../shared/types'
import { anchoredParts, partsOf } from '../categories'
import type { ContextEventRecord, RequestRecord } from '../../shared/types'
import { headlineOf } from '../headline'
import type { LocaleService, SessionStandardProps } from '../services'
import { timelineOf } from '../services'
import type { ClientCtx } from '../services'
@@ -111,7 +111,6 @@ export function makeContextView(ctx: ClientCtx, kit: ViewKit): (props: ContextVi
return <div className="lc-root" ref={rootRef}><div className="lc-empty">{t('loading')}</div></div>
}
const current = data.current
const requests = data.requests || []
const events = data.events || []
const nodes = data.nodes || []
@@ -154,24 +153,8 @@ export function makeContextView(ctx: ClientCtx, kit: ViewKit): (props: ContextVi
// the fixed 4-chars/token heuristic can undercount by ~10-15% on
// CJK-heavy sessions — so this is the headline, and the heuristic
// composition below is anchored to it (proportions stay heuristic).
const occ = data.occupancy
const projected = occ !== undefined && typeof occ.projectedTokens === 'number' ? occ.projectedTokens : undefined
const lastReq = displayRequests.length > 0 ? displayRequests[displayRequests.length - 1] : null
const derived = lastReq !== null && typeof lastReq.prompt === 'number'
? lastReq.prompt + (current.total - lastReq.total)
: undefined
const occupancyTokens = projected ?? derived ?? null
const occupancyWindow = occ !== undefined && typeof occ.contextWindow === 'number'
? occ.contextWindow
: data.contextWindow
const headline = occupancyTokens ?? current.total
const headlinePct = occupancyWindow
? Math.min(100, Math.round(headline / occupancyWindow * 100))
: null
// Composition anchored to the provider total: the six categories keep
// their heuristic RATIOS but sum to the headline, so the bar fill and
// legend agree with the official ring instead of the underpriced raw sum.
const parts = anchoredParts(partsOf(current), occupancyTokens !== null && headline > 0 ? headline : null)
// Shared with the /context popup (headline.ts).
const head = headlineOf(data)
return (
<div className="lc-root" ref={rootRef}>
@@ -188,16 +171,16 @@ export function makeContextView(ctx: ClientCtx, kit: ViewKit): (props: ContextVi
</span>
</div>
<div className="lc-overview-num">
<b>{fmt(headline)}</b>
<b>{fmt(head.tokens)}</b>
<span>
{occupancyWindow
? ' / ' + fmt(occupancyWindow) + ' ' + tr('overview.ofWindow', { p: headlinePct ?? 0 })
{head.window
? ' / ' + fmt(head.window) + ' ' + tr('overview.ofWindow', { p: head.pct ?? 0 })
: ' ' + t('overview.estimate')}
</span>
{occupancyTokens !== null ? <span className="lc-card-sub">{t('overview.splitEst')}</span> : null}
{!head.estimated ? <span className="lc-card-sub">{t('overview.splitEst')}</span> : null}
</div>
<StackedBar parts={parts} height={16} max={occupancyWindow} hoverKey={hoverCat} onHoverKey={setHoverCat} />
<Legend parts={parts} hoverKey={hoverCat} onHoverKey={setHoverCat} />
<StackedBar parts={head.parts} height={16} max={head.window} hoverKey={hoverCat} onHoverKey={setHoverCat} />
<Legend parts={head.parts} hoverKey={hoverCat} onHoverKey={setHoverCat} />
{(data.toolList && data.toolList.length > 0) ? (
<div className="lc-tools">
{t('tools.top')}
+42
View File
@@ -0,0 +1,42 @@
/**
* Provider-anchored headline derivation, shared by the Context tab and the
* /context popup: the best-known occupancy of the next request, the route
* capacity it scales against, and the composition parts anchored to that
* total (heuristic ratios, provider-anchored sum — the same trick the
* official ContextMeter uses).
*/
import type { ContextTimeline } from '../shared/types'
import { anchoredParts, partsOf, type PartsPart } from './categories'
export interface Headline {
/** Best-known occupancy of the next request (projected ?? derived ?? heuristic total). */
tokens: number
/** Route capacity the headline scales against (may be unknown). */
window?: number
/** tokens / window, clamped to 100; null without a window. */
pct: number | null
/** Composition parts anchored to the provider total when one exists. */
parts: PartsPart[]
/** True when no provider anchor applied (parts sum to the heuristic total). */
estimated: boolean
}
export function headlineOf(data: ContextTimeline): Headline {
const current = data.current
const occ = data.occupancy
const projected = occ !== undefined && typeof occ.projectedTokens === 'number' ? occ.projectedTokens : undefined
const requests = data.requests || []
const lastReq = requests.length > 0 ? requests[requests.length - 1] : null
// Fallback anchor: the newest request's provider prompt plus the heuristic
// surface movement since it was logged.
const derived = lastReq !== null && typeof lastReq.prompt === 'number'
? lastReq.prompt + (current.total - lastReq.total)
: undefined
const occupancyTokens = projected ?? derived ?? null
const window = occ !== undefined && typeof occ.contextWindow === 'number' ? occ.contextWindow : data.contextWindow
const tokens = occupancyTokens ?? current.total
const pct = window !== undefined && window > 0 ? Math.min(100, Math.round(tokens / window * 100)) : null
const parts = anchoredParts(partsOf(current), occupancyTokens !== null && tokens > 0 ? tokens : null)
return { tokens, window, pct, parts, estimated: occupancyTokens === null }
}
+4
View File
@@ -64,6 +64,8 @@ export const DICT_ZH: Record<string, string> = {
'node.empty': '(空回复)',
'node.nonText': '(非文本消息)',
'node.snapshot': '快照: ',
'cmd.desc': '查看上下文的构成和变化',
'cmd.close': '关闭',
}
export const DICT_EN: Record<string, string> = {
@@ -125,4 +127,6 @@ export const DICT_EN: Record<string, string> = {
'node.empty': '(empty reply)',
'node.nonText': '(non-text message)',
'node.snapshot': 'snapshot: ',
'cmd.desc': 'View context makeup and how it evolves',
'cmd.close': 'Close',
}
+18 -1
View File
@@ -22,6 +22,9 @@
*/
import { DICT_EN, DICT_ZH } from './i18n'
import { registerContextCommand } from './command'
import { makeContextModal } from './components/contextModal'
import { modalStoreOf } from './modalStore'
import type { ClientCtx } from './services'
import { STYLES } from './styles'
import { makeContextView } from './components/contextView'
@@ -53,7 +56,8 @@ function apply(ctx: ClientCtx): void {
}
}, 'dsh-context: styles')
const ContextView = makeContextView(ctx, makeViewKit(t))
const kit = makeViewKit(t)
const ContextView = makeContextView(ctx, kit)
ctx.slots.inject('conversation.view', () => {
return ctx.slots.register(
// order 20 renders right of Chat (0) and Trajectory (10); the locale
@@ -62,6 +66,19 @@ function apply(ctx: ClientCtx): void {
props => h(ContextView, props),
)
})
// `/context` slash command: opens the context modal (see command.ts for
// the trigger source). The modal itself renders from the input overlay
// slot, opened per session through the hooks-compartment store.
registerContextCommand(ctx, kit)
const ContextModal = makeContextModal(ctx, kit)
ctx.slots.inject('conversation.input.overlay', () => {
return ctx.slots.register(
{ name: 'conversation.input.overlay', id: 'context-modal', order: 10, locale: NS,
inject: (sessionId: string) => ({ hooks: { contextModal: modalStoreOf(sessionId) } }) },
props => h(ContextModal, props),
)
})
}
module.exports = {
+63
View File
@@ -0,0 +1,63 @@
/**
* Per-session open-state stores for the /context modal, plus the deferred
* token-consume guards. Module-level so the state survives overlay remounts;
* the trigger source (pick/enter) opens it, the overlay component subscribes
* and renders. Implements the ObservableSnapshot pair the slot `hooks`
* compartment binds as a `useContextModal` selector hook.
*/
import type { TokenSpan } from './services'
export interface ModalStore {
subscribe(listener: () => void): () => void
getSnapshot(): boolean
set(open: boolean): void
}
const stores = new Map<string, ModalStore>()
export function modalStoreOf(sessionId: string): ModalStore {
const existing = stores.get(sessionId)
if (existing !== undefined) return existing
let open = false
const listeners = new Set<() => void>()
const store: ModalStore = {
subscribe(listener) {
listeners.add(listener)
return () => { listeners.delete(listener) }
},
getSnapshot: () => open,
set(next) {
if (next === open) return
open = next
for (const listener of listeners) listener()
},
}
stores.set(sessionId, store)
return store
}
// ---- deferred token consumption -------------------------------------------
// The `/context` token stays in the composer while the modal is open and is
// consumed only when the modal closes. Each open path records the guard the
// input shell understands (span CAS for menu picks, bare-token equality for
// enter); the close path takes it and dispatches the scoped
// `slash/input-consume-token` event. A stale guard (the user typed meanwhile)
// fails soft inside the shell — the draft is left untouched.
export type ConsumeGuard =
| { kind: 'span'; span: TokenSpan }
| { kind: 'bare-token'; token: string }
const pendingConsume = new Map<string, ConsumeGuard>()
export function setPendingConsume(sessionId: string, guard: ConsumeGuard): void {
pendingConsume.set(sessionId, guard)
}
/** Take (and clear) the guard recorded by the open path, if any. */
export function takePendingConsume(sessionId: string): ConsumeGuard | undefined {
const guard = pendingConsume.get(sessionId)
if (guard !== undefined) pendingConsume.delete(sessionId)
return guard
}
+63 -1
View File
@@ -25,7 +25,9 @@ export interface SlotRegistration {
order: number
/** optional dictionary namespace; the framework then synthesizes the `t` seat. */
locale?: string
label: () => string
label?: () => string
/** optional business face factory; a `hooks` compartment binds selector hooks onto props. */
inject?: (sessionId: string) => unknown
}
export interface SlotsService {
@@ -58,3 +60,63 @@ export function timelineOf(value: unknown): ContextTimeline | null {
if (value === null || value === undefined || typeof value !== 'object') return null
return value as ContextTimeline
}
// ---- /context command faces (framework `inputTriggers` service) ----
/** One menu candidate offered by a trigger source. */
export interface TriggerCandidate {
name: string
description?: string
}
/** Pick-moment snapshot of the trigger token span (draftRev CAS). */
export interface TokenSpan {
start: number
end: number
draftRev: number
}
/** Everything a source receives on a menu pick. */
export interface TriggerPick {
candidate: TriggerCandidate
session: { sessionId: string }
position: string
via: string
span: TokenSpan
}
/** The pick outcomes this plugin produces (see the framework's PickOutcome). */
export type SourcePickOutcome = 'handled' | undefined
/**
* The harness input-trigger service (`ctx.inputTriggers`), as far as this
* plugin consumes it: registering one '/' source whose candidates, picks,
* and enter adjudication all stay on the client.
*/
export interface InputTriggersFace {
registerSource(src: {
trigger: '/'
name: string
order?: number
candidates(
session: { sessionId: string },
req: { query: string; position: string; signal: AbortSignal },
): Promise<readonly TriggerCandidate[]>
onPick(pick: TriggerPick): SourcePickOutcome
matchEnter?(
session: { sessionId: string },
line: string,
signal: AbortSignal,
): Promise<SourcePickOutcome>
}): () => void
}
/** The session scope (`ctx.sessions.scope`), used to dispatch the scoped consume-token event. */
export interface SessionScopeFace {
bail(subject: unknown, event: string, payload: unknown): unknown
}
/** The session runtime (`ctx.sessions`), as consumed here. */
export interface SessionsFace {
scope(id: string): SessionScopeFace | undefined
}
+8
View File
@@ -116,4 +116,12 @@ export const STYLES = [
'.lc-nodes-more { color: var(--dsw-alias-label-secondary); padding: 3px 0; }',
'.lc-empty { color: var(--dsw-alias-label-secondary); padding: 18px 0; text-align: center; }',
'.lc-foot { color: var(--dsw-alias-label-secondary); font-size: 12px; margin-top: 4px; }',
// ---- /context modal (centered dialog; escapes the composer anchor via fixed positioning) ----
'.lc-modal-backdrop { position: fixed; inset: 0; z-index: 200; background: rgba(0, 0, 0, 0.45); display: flex; align-items: center; justify-content: center; }',
'.lc-modal-card { width: min(720px, calc(100vw - 48px)); max-height: min(82vh, 760px); overflow-y: auto; box-sizing: border-box; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1); border-radius: 12px; box-shadow: var(--dsw-shadow-lv3, 0 12px 32px rgba(0, 0, 0, 0.4)); padding: 16px 18px 18px; color: var(--dsw-alias-label-primary); font-size: 13px; }',
'.lc-modal-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 12px; }',
'.lc-modal-title { font-weight: 600; font-size: 14px; }',
'.lc-modal-close { margin-left: auto; border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 18px; line-height: 1; padding: 2px 6px; border-radius: 6px; cursor: pointer; font-family: inherit; }',
'.lc-modal-close:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); }',
'.lc-modal-trend { margin-top: 14px; }',
].join('\n')