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:
1 parent
6cb61a413b
commit
bf4cdd1e58
10 files changed
+558
-35
No files matched your search
@@ -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')
|
||||
}
|
||||
@@ -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,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')}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
Reference in new issue
Block a user