Feature packages now reach these domains through interface types only: ISession/SessionFace (identity + prompt/cancel/loadOlder + the useSession snapshot source), ISessions, IWorkspaces, ILayout, IConversation, and the existing SlashServiceContract now actually mounted on Context.slash. The concrete services implement their face; wire-pump and assembly entry points stay on the classes. The provide-channel materialization and current-projection logic moves into SessionProvideChannel so the production service and the client test runtime share one implementation. The workspaces service consumes sessions through the narrow SessionsPort.
146 lines
5.9 KiB
TypeScript
146 lines
5.9 KiB
TypeScript
/**
|
|
* InputHub: the InputService implementation (`ctx.conversation.input`) — one
|
|
* SessionInputShell per session, created inside the sessions provide
|
|
* materialization (decision 19: the 'input' standard-kit entry IS the
|
|
* creation trigger) and torn down by the scope disposer (instance-and-scope
|
|
* share one lifecycle). The hub registers the three scoped input-mutation
|
|
* listeners on each session's actx (the sole consumer side of the ui-slash
|
|
* bail events) and owns the default-sink choreography: every session is a
|
|
* real host entity, so the sink is one unconditional prompt path.
|
|
*/
|
|
import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client'
|
|
import { queueReadFaceOf } from '../queue/store.ts'
|
|
import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts'
|
|
import type { PopupDismissFace } from './facade.ts'
|
|
import { SessionInputShell } from './facade.ts'
|
|
|
|
/** Structural command face for per-session popup resolution. */
|
|
interface CommandFace {
|
|
popupFor(actx: ClientContext): PopupDismissFace
|
|
}
|
|
|
|
/** Session-addressed input facade registry (InputService face + composer-layer extras). */
|
|
export class InputHub implements InputService {
|
|
private readonly shells = new Map<SessionId, SessionInputShell>()
|
|
|
|
/** @param ctx - client root context (services resolved lazily per call — boot order stays free). */
|
|
constructor(private readonly rootCtx: ClientContext) {}
|
|
|
|
/**
|
|
* Resolve the facade for one session-scope ctx (InputService face).
|
|
* @param actx - session-scope context.
|
|
* @returns the resident per-session facade.
|
|
*/
|
|
for(actx: ClientContext): SessionInput {
|
|
const sessions = this.sessions()
|
|
const id = sessions.scopeOf(actx)
|
|
if (id === undefined) throw new Error('conversation.input.for requires a session scope')
|
|
return this.shell(id)
|
|
}
|
|
|
|
/**
|
|
* Resident shell for one session binding — the provide-channel entry
|
|
* (called during scope materialization, BEFORE the scope record is
|
|
* queryable, hence binding-fed and hence the thunked slash/popup deps).
|
|
* Wires the scoped event listeners + teardown into the session scope.
|
|
* @param binding - session assembly handle.
|
|
* @returns the shell.
|
|
*/
|
|
shellFor(binding: SessionBinding): SessionInputShell {
|
|
const existing = this.shells.get(binding.sessionId)
|
|
if (existing !== undefined) return existing
|
|
const { sessionId: id, session, ctx: actx } = binding
|
|
const shell = new SessionInputShell({
|
|
actx,
|
|
slash: () => this.controller(actx),
|
|
popup: () => this.popup(actx),
|
|
queue: queueReadFaceOf(session),
|
|
defaultSink: (text, mode) => { this.sink(session, text, mode) },
|
|
})
|
|
this.shells.set(id, shell)
|
|
// The one teardown axis: listeners, shell, and map entries all ride the
|
|
// scope fiber (decision 12 — nothing here outlives the scope).
|
|
actx.effect(() => {
|
|
const offs = [
|
|
actx.on('slash/input-begin-command', req =>
|
|
shell.beginCommand(req.claim, req.span) ? true : undefined),
|
|
actx.on('slash/input-insert-reference', req =>
|
|
shell.insertReference(req.reference, req.span) ? true : undefined),
|
|
actx.on('slash/input-consume-token', req =>
|
|
shell.consumeToken(req.guard) ? true : undefined),
|
|
actx.on('slash/input-insert-text', req =>
|
|
shell.insertText(req.text, req.span) ? true : undefined),
|
|
]
|
|
return () => {
|
|
for (const off of offs) off()
|
|
shell.dispose()
|
|
this.shells.delete(id)
|
|
}
|
|
}, 'conversation.input: session shell')
|
|
return shell
|
|
}
|
|
|
|
/**
|
|
* Resident shell by session id (service-face path; the provide channel has
|
|
* normally created it already — this covers direct id-addressed access).
|
|
* @param id - session id.
|
|
* @returns the shell.
|
|
*/
|
|
shell(id: SessionId): SessionInputShell {
|
|
const existing = this.shells.get(id)
|
|
if (existing !== undefined) return existing
|
|
const binding = this.sessions().binding(id)
|
|
if (binding === undefined) throw new Error(`conversation.input: session "${id}" resolved no binding`)
|
|
return this.shellFor(binding)
|
|
}
|
|
|
|
/**
|
|
* The InputBar-exclusive keyboard command face (decision 20): the shell
|
|
* satisfies it structurally; package-internal — handed through the
|
|
* composer-bar entry's inject, never across a plugin boundary.
|
|
* @param id - session id.
|
|
* @returns the shell as the keyboard face.
|
|
*/
|
|
keyboard(id: SessionId): ComposerKeyboard {
|
|
return this.shell(id)
|
|
}
|
|
|
|
/**
|
|
* Default sink: optimistic clear + prompt. The session is always a real
|
|
* host entity (materialized when its workspace was picked), so there is
|
|
* exactly one path; a failed first prompt is an ordinary prompt failure
|
|
* (error strip via promptError, draft restored only while untouched).
|
|
*/
|
|
private sink(session: SessionFace, text: string, mode: 'queue' | 'steer'): void {
|
|
if (text === '') return
|
|
const shell = this.shells.get(session.sessionId)
|
|
// Commit, not an editable clear: undo must not resurrect sent content.
|
|
shell?.commitSend()
|
|
void session.prompt([{ type: 'text', text }], mode).then(
|
|
(result) => {
|
|
if (!result.ok && shell?.snapshot.draft === '') shell.setDraft(text)
|
|
},
|
|
() => {
|
|
if (shell?.snapshot.draft === '') shell.setDraft(text)
|
|
},
|
|
)
|
|
}
|
|
|
|
private controller(actx: ClientContext): SlashController | undefined {
|
|
const slash = this.rootCtx.get('slash')
|
|
return slash?.sessionOf(actx)
|
|
}
|
|
|
|
private popup(actx: ClientContext): PopupDismissFace | undefined {
|
|
const command = this.rootCtx.get('command') as CommandFace | undefined
|
|
return command?.popupFor(actx)
|
|
}
|
|
|
|
private sessions(): ISessions {
|
|
const sessions = this.rootCtx.get('sessions')
|
|
if (sessions === undefined) throw new Error('conversation.input: sessions service unavailable')
|
|
return sessions
|
|
}
|
|
}
|