From bf4cdd1e587c4e89c304ca93f1dfd80b3e1bb0b4 Mon Sep 17 00:00:00 2001 From: bowenliang123 Date: Sun, 16 Aug 2026 22:26:46 +0800 Subject: [PATCH] feat: add /context slash command with a centered context modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/client/command.ts | 55 +++++++++ src/client/components/contextModal.tsx | 157 +++++++++++++++++++++++++ src/client/components/contextView.tsx | 37 ++---- src/client/headline.ts | 42 +++++++ src/client/i18n.ts | 4 + src/client/index.ts | 19 ++- src/client/modalStore.ts | 63 ++++++++++ src/client/services.ts | 64 +++++++++- src/client/styles.ts | 8 ++ tests/client.test.mjs | 144 ++++++++++++++++++++++- 10 files changed, 558 insertions(+), 35 deletions(-) create mode 100644 src/client/command.ts create mode 100644 src/client/components/contextModal.tsx create mode 100644 src/client/headline.ts create mode 100644 src/client/modalStore.ts diff --git a/src/client/command.ts b/src/client/command.ts new file mode 100644 index 0000000..4e2f1f3 --- /dev/null +++ b/src/client/command.ts @@ -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') +} diff --git a/src/client/components/contextModal.tsx b/src/client/components/contextModal.tsx new file mode 100644 index 0000000..108b8a0 --- /dev/null +++ b/src/client/components/contextModal.tsx @@ -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(null) + const [hoveredSeq, setHoveredSeq] = React.useState(null) + const [hoverCat, setHoverCat] = React.useState(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 ( +
+
{ ev.stopPropagation() }}> +
+ {t('tab')} + {data !== null && (data.model || data.provider) ? ( + + {(data.model ? data.model : '') + (data.provider ? ' · ' + data.provider : '')} + + ) : null} + +
+ + {data === null || head === null ? ( +
{t('loading')}
+ ) : ( +
+
+ {fmt(head.tokens)} + + {head.window + ? ' / ' + fmt(head.window) + ' ' + tr('overview.ofWindow', { p: head.pct ?? 0 }) + : ' ' + t('overview.estimate')} + + {!head.estimated ? {t('overview.splitEst')} : null} +
+ + + +
{t('trend.title')}
+ {turns.length === 0 ? ( +
{t('trend.empty')}
+ ) : ( +
+ {}} + /> + +
+ )} +
+ )} +
+
+ ) + } +} diff --git a/src/client/components/contextView.tsx b/src/client/components/contextView.tsx index 8582752..8278c8a 100644 --- a/src/client/components/contextView.tsx +++ b/src/client/components/contextView.tsx @@ -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
{t('loading')}
} - 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 (
@@ -188,16 +171,16 @@ export function makeContextView(ctx: ClientCtx, kit: ViewKit): (props: ContextVi
- {fmt(headline)} + {fmt(head.tokens)} - {occupancyWindow - ? ' / ' + fmt(occupancyWindow) + ' ' + tr('overview.ofWindow', { p: headlinePct ?? 0 }) + {head.window + ? ' / ' + fmt(head.window) + ' ' + tr('overview.ofWindow', { p: head.pct ?? 0 }) : ' ' + t('overview.estimate')} - {occupancyTokens !== null ? {t('overview.splitEst')} : null} + {!head.estimated ? {t('overview.splitEst')} : null}
- - + + {(data.toolList && data.toolList.length > 0) ? (
{t('tools.top')} diff --git a/src/client/headline.ts b/src/client/headline.ts new file mode 100644 index 0000000..4dd848d --- /dev/null +++ b/src/client/headline.ts @@ -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 } +} diff --git a/src/client/i18n.ts b/src/client/i18n.ts index d934904..ceb0c9a 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -64,6 +64,8 @@ export const DICT_ZH: Record = { 'node.empty': '(空回复)', 'node.nonText': '(非文本消息)', 'node.snapshot': '快照: ', + 'cmd.desc': '查看上下文的构成和变化', + 'cmd.close': '关闭', } export const DICT_EN: Record = { @@ -125,4 +127,6 @@ export const DICT_EN: Record = { 'node.empty': '(empty reply)', 'node.nonText': '(non-text message)', 'node.snapshot': 'snapshot: ', + 'cmd.desc': 'View context makeup and how it evolves', + 'cmd.close': 'Close', } diff --git a/src/client/index.ts b/src/client/index.ts index b991eea..776dd36 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -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 = { diff --git a/src/client/modalStore.ts b/src/client/modalStore.ts new file mode 100644 index 0000000..1723598 --- /dev/null +++ b/src/client/modalStore.ts @@ -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() + +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() + +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 +} diff --git a/src/client/services.ts b/src/client/services.ts index 6e260ab..16df6fb 100644 --- a/src/client/services.ts +++ b/src/client/services.ts @@ -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 + onPick(pick: TriggerPick): SourcePickOutcome + matchEnter?( + session: { sessionId: string }, + line: string, + signal: AbortSignal, + ): Promise + }): () => 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 +} diff --git a/src/client/styles.ts b/src/client/styles.ts index 267b061..9eba776 100644 --- a/src/client/styles.ts +++ b/src/client/styles.ts @@ -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') diff --git a/tests/client.test.mjs b/tests/client.test.mjs index e645ffb..b315f58 100644 --- a/tests/client.test.mjs +++ b/tests/client.test.mjs @@ -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