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')
+138 -6
View File
@@ -98,13 +98,13 @@ const fakeCtx = {
}
pluginExports.apply(fakeCtx)
assert.equal(effects.length, 2, 'dictionaries + styles effects')
assert.equal(effects.length, 3, 'dictionaries + styles + /context command effects')
assert.deepEqual(localeRegistrations[0][0], 'dsh-context')
assert.ok(localeRegistrations[0][1].zh && localeRegistrations[0][1].en, 'bilingual dicts')
const styleTag = registered.get('dsh-context')
assert.ok(styleTag, 'plugin-owned <style data-plugin="dsh-context"> injected')
assert.ok(styleTag.textContent.includes('.lc-root'), 'styles content present')
assert.equal(slotInjections.length, 1)
assert.equal(slotInjections.length, 2, 'view tab + input overlay injections')
assert.equal(slotInjections[0][0], 'conversation.view')
const registeredOpts = slotInjections[0][1]() // slots.inject callback returns the register result
assert.equal(registeredOpts.name, 'conversation.view')
@@ -112,8 +112,65 @@ assert.equal(registeredOpts.id, 'context')
assert.equal(registeredOpts.order, 20)
assert.equal(typeof registeredOpts.label, 'function')
assert.equal(registeredOpts.label(), '上下文', 'tab label localized')
assert.equal(slotInjections[1][0], 'conversation.input.overlay')
const overlayOpts = slotInjections[1][1]()
assert.equal(overlayOpts.name, 'conversation.input.overlay')
assert.equal(overlayOpts.id, 'context-modal')
assert.ok(typeof overlayOpts.inject === 'function', 'overlay injects the modal store hook')
const injected = overlayOpts.inject('s1')
assert.equal(typeof injected.hooks.contextModal.getSnapshot, 'function', 'hooks compartment carries the modal store')
assert.equal(injected.hooks.contextModal.getSnapshot(), false, 'modal closed initially')
console.log('✔ client bundle test passed (handoff, require table, dicts, styles, slot registration)')
// ---- /context command: the plugin's own '/' trigger source ----
// The first fake ctx has no inputTriggers service: the command effect must
// stay inert (soft dependency), which the apply above already proved by not
// throwing. Now apply again with the trigger service present.
{
const sources = []
let localeDict = null
const ctx3 = {
get: (name) => name === 'inputTriggers'
? { registerSource: (s) => { sources.push(s); return () => {} } }
: undefined,
effect: (fn) => { fn(); return () => {} },
locale: {
register: (ns, dicts) => { localeDict = dicts.zh; return () => {} },
bind: () => (key, vars) => {
let s = localeDict !== null && localeDict[key] !== undefined ? localeDict[key] : key
if (vars) for (const k in vars) s = s.replace('{' + k + '}', String(vars[k]))
return s
},
subscribe: () => () => {},
},
slots: { inject: () => {}, register: () => ({}) },
}
pluginExports.apply(ctx3)
assert.equal(sources.length, 1, 'one /context trigger source registered')
const src = sources[0]
assert.equal(src.trigger, '/')
assert.equal(src.name, 'context')
// Candidates: leading-only, prefix-filtered, description localized.
const req = (query, position = 'leading') => ({ query, position, signal: new AbortController().signal })
assert.deepEqual(await src.candidates({ sessionId: 's1' }, req('')), [{ name: 'context', description: '查看上下文的构成和变化' }])
assert.deepEqual((await src.candidates({ sessionId: 's1' }, req('cont'))).length, 1, 'prefix match')
assert.deepEqual(await src.candidates({ sessionId: 's1' }, req('xyz')), [], 'non-prefix miss')
assert.deepEqual(await src.candidates({ sessionId: 's1' }, req('', 'inline')), [], 'inline positions never offer the command')
// Menu pick: opens the session's modal and answers 'handled'; the token
// stays in the composer while the modal is open (consumed on close).
const outcome = src.onPick({ candidate: { name: 'context' }, session: { sessionId: 's1' }, position: 'leading', via: 'menu', span: { start: 0, end: 8, draftRev: 1 } })
assert.equal(outcome, 'handled', 'menu pick is handled internally')
assert.equal(injected.hooks.contextModal.getSnapshot(), true, 'menu pick opens the modal')
// Bare enter: also 'handled' (nothing is submitted, the draft keeps the
// token until the modal closes).
assert.equal(await src.matchEnter({ sessionId: 's1' }, '/context', new AbortController().signal), 'handled', 'bare enter opens the modal without submitting')
assert.equal(await src.matchEnter({ sessionId: 's1' }, '/context now', new AbortController().signal), undefined, 'argued lines miss')
assert.equal(await src.matchEnter({ sessionId: 's1' }, '/compact', new AbortController().signal), undefined, 'other commands miss')
}
console.log('✔ client bundle test passed (handoff, require table, dicts, styles, slot registration, /context command)')
// ---- chart render test: fixed-width bars, horizontal scroll, turn ranges ----
// Materialize the bundle a second time with a STATEFUL fake React so the
@@ -125,6 +182,7 @@ let currentHooks = null
let hookCursor = 0
const statefulReact = {
createElement: (...args) => ({ kind: 'element', args }),
useCallback: (fn) => fn,
useState(init) {
const i = hookCursor++
const slots = currentHooks // captured by the setter: stable per fiber
@@ -158,10 +216,19 @@ const requireStateful = (spec) => {
const m2 = { exports: {} }
const pluginExports2 = factory(requireStateful, m2, globalThis.window, fakeDoc)
const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'x', 'detail.step': 'Turn {t} · Step {s}', 'gran.step': 'Step', 'gran.turn': 'Turn', 'detail.turn': 'Turn {t} · {n} steps', 'detail.lastStep': 'last step', 'overview.splitEst': '· category split is estimated', 'overview.ofWindow': 'tokens (~{p}%)', 'overview.free': 'Free window', 'events.at': 'Turn {t} · Step {s}', 'events.range': 'Turn {t} · Step {a}→{b}', 'events.rangeTo': 'Turn {a} · Step {as} → Turn {b} · Step {bs}', 'stats.recycleSub': '{c} compactions · {p} prunes', 'tip.step': 'Turn {t} · Step {s}', 'tip.turn': 'Turn {t} · {n} steps', 'tip.total': 'total ≈ {n}', 'tip.actual': ' (actual {n})' }
const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'x', 'detail.step': 'Turn {t} · Step {s}', 'gran.step': 'Step', 'gran.turn': 'Turn', 'detail.turn': 'Turn {t} · {n} steps', 'detail.lastStep': 'last step', 'overview.splitEst': '· category split is estimated', 'overview.ofWindow': 'tokens (~{p}%)', 'overview.free': 'Free window', 'events.at': 'Turn {t} · Step {s}', 'events.range': 'Turn {t} · Step {a}→{b}', 'events.rangeTo': 'Turn {a} · Step {as} → Turn {b} · Step {bs}', 'stats.recycleSub': '{c} compactions · {p} prunes', 'tip.step': 'Turn {t} · Step {s}', 'tip.turn': 'Turn {t} · {n} steps', 'tip.total': 'total ≈ {n}', 'tip.actual': ' (actual {n})', 'trend.title': 'History', 'trend.empty': 'empty trend', 'cmd.close': 'Close' }
let viewComponent = null
let modalComponent = null
let modalSource = null
const bailCalls = []
const fakeCtx2 = {
get: () => undefined,
get: (name) => {
if (name === 'inputTriggers') return { registerSource: (s) => { modalSource = s; return () => {} } }
if (name === 'sessions') return {
scope: (id) => id === 's1' ? { bail: (...args) => { bailCalls.push(args); return true } } : undefined,
}
return undefined
},
effect: (fn) => { fn(); return () => {} },
locale: {
register: () => () => {},
@@ -173,7 +240,11 @@ const fakeCtx2 = {
},
slots: {
inject: (name, fn) => { fn() }, // the register call inside captures the component
register: (opts, component) => { viewComponent = component; return opts },
register: (opts, component) => {
if (opts.name === 'conversation.view') viewComponent = component
if (opts.name === 'conversation.input.overlay') modalComponent = component
return opts
},
},
}
pluginExports2.apply(fakeCtx2)
@@ -636,3 +707,64 @@ dataValue = snapshot
tr = renderView()
console.log('✔ chart render test passed (context stats board, free window hover, fixed-width bars, scroll container, turn ranges, hover linking, overview tooltip, turn strip, granularity toggle, edge fades, full history, right-anchored default, message times, event range labels, detail marker chip, overview actual)')
// ---- /context modal render: centered dialog with the same overview +
// last-10-turn trend, driven by the modal store hook ----
assert.ok(modalComponent !== null, 'overlay component captured')
// 14 turns of growing totals; the modal must show exactly the last 10.
const modalRequests = []
for (let turn = 1; turn <= 14; turn++) {
modalRequests.push({
turn, step: 0, time: 1000 * turn, seq: turn * 10,
system: 10, tools: 20, user: 10 * turn, inject: 0, assistant: 15, tool: 20, total: 65 + 10 * turn,
})
}
const modalData = {
...snapshot,
current: { system: 10, tools: 20, user: 140, inject: 0, assistant: 15, tool: 20, total: 205 },
requests: modalRequests,
events: [{ seq: 95, time: 9500, kind: 'compaction', tokens: 50, count: 2 }],
}
let modalOpen = false
const renderModal = () => evaluate(modalComponent({
sessionId: 's1',
useProjection: (key) => (key === 'contextTimeline' ? modalData : undefined),
useContextModal: (sel) => sel(modalOpen),
}))
assert.equal(renderModal(), null, 'modal renders nothing while closed')
modalOpen = true
const modalTree = renderModal()
assert.equal(byClass(modalTree, 'lc-modal-backdrop').length, 1, 'centered backdrop rendered')
const modalBars = byClass(modalTree, 'lc-bar')
assert.equal(modalBars.length, 10, 'trend shows exactly the last 10 turns')
// First visible bar is turn 5 (seq 50); turns 1-4 are cut.
const modalTurns = byClass(modalTree, 'lc-turn')
assert.deepEqual(modalTurns.map(t => t.args[2]), ['T5', 'T6', 'T7', 'T8', 'T9', 'T10', 'T11', 'T12', 'T13', 'T14'], 'turn ticks 5..14')
// The compaction (seq 95) attaches to the first request after it (turn 10).
assert.equal(byClass(modalTree, 'lc-bar-marker').length, 1, '✂ marker rides the turn after the compaction')
const modalOverview = byClass(modalTree, 'lc-overview-num')[0]
assert.match(textOf(modalOverview), /205/, 'modal headline falls back to the heuristic total (no prompt on last request)')
assert.match(textOf(modalOverview), /tokens \(~0%\)/, 'percent against the 128k window')
assert.equal(byClass(modalTree, 'lc-modal-trend').length, 1, 'trend section title present')
const closeBtn = byClass(modalTree, 'lc-modal-close')[0]
assert.equal(closeBtn.args[1]['aria-label'], 'Close', 'close button localized')
// Deferred token consumption: the enter path left `/context` in the draft
// and recorded a bare-token guard; closing the modal dispatches the scoped
// consume-token event through the session scope.
assert.ok(modalSource !== null, 'trigger source registered on this instance')
await modalSource.matchEnter({ sessionId: 's1' }, '/context', new AbortController().signal)
const backdrop = byClass(modalTree, 'lc-modal-backdrop')[0]
backdrop.args[1].onClick()
assert.equal(bailCalls.length, 1, 'closing the modal dispatches consume-token')
assert.equal(bailCalls[0][1], 'slash/input-consume-token')
assert.deepEqual(bailCalls[0][2], { guard: { kind: 'bare-token', token: '/context' } }, 'enter-path guard consumed on close')
// A second close without a prior open records nothing.
backdrop.args[1].onClick()
assert.equal(bailCalls.length, 1, 'no pending guard -> no dispatch')
modalOpen = false
assert.equal(renderModal(), null, 'modal closes again')
console.log('✔ modal render test passed (open/close, last-10-turn window, ✂ marker, headline, localized chrome, deferred token consume on close)')