Files
deepseek-harness/packages/ui/tui/src/prompt.ts
Turtle 4c5f92e0fd feat(tui): personal TUI rework, integrating upstream model reasoning-effort selection
Consolidates the personal dsh-tui customizations (module split into
components/session/extension, prompt template + running-glyph indicator,
copyable transcript, tool-card headers, timing placement, XML tool output,
status/footer rework) and ports upstream's model reasoning-effort selector
(Shift+Tab effort cycling, effort-aware /model, footer, and /status) onto
the personal module layout.
2026-07-27 18:55:13 +08:00

218 lines
8.3 KiB
TypeScript

/**
* Mutable terminal-prompt value registry consumed by the TUI template renderer.
* Values are trusted presentation fragments and may contain ANSI control sequences.
* @module @deepseek-ai/dsh-tui/prompt
*/
import { Context, Service } from 'cordis'
import { errorChain } from '@deepseek-ai/dsh-llm'
export const name = 'tui-prompt'
const VALUE_NAME = /^[a-z][a-z0-9_-]*(?:\/[a-z][a-z0-9_-]*)*$/u
/** Handle owned by one prompt-value registration. */
export interface TuiPromptValueHandle {
/**
* Replace the current fragment and schedule a coalesced change notification
* so the owning renderer redraws. Setting the current value again is a no-op.
* @param value - Trusted ANSI-capable fragment, or `undefined` while unavailable.
*/
set(value: string | undefined): void
/** Unregister this value; subsequent {@link TuiPromptValueHandle.set} calls fail. */
dispose(): void
}
interface RegisteredValue {
value: string | undefined
}
declare module 'cordis' {
interface Context {
tuiPrompt: TuiPromptService
}
}
/** Removes a change subscription registered with {@link TuiPromptService.subscribe}. */
export type TuiPromptUnsubscribe = () => void
/** One literal or variable token in a parsed TUI prompt template. */
export type TuiPromptTemplateToken =
| { readonly kind: 'literal'; readonly value: string }
| { readonly kind: 'value'; readonly name: string }
/**
* Parse a prompt template into immutable literal and value tokens.
* @param template - Text containing `${name}` references.
* @returns Tokens consumed by {@link renderTuiPromptTemplate}.
*/
export function parseTuiPromptTemplate(template: string): readonly TuiPromptTemplateToken[] {
const tokens: TuiPromptTemplateToken[] = []
const pattern = /\$\{([^}]*)\}/gu
let offset = 0
for (const match of template.matchAll(pattern)) {
const index = match.index
const name = match[1]
/* v8 ignore next -- the sole capture always exists when this pattern matches. */
if (name === undefined) continue
if (index > offset) tokens.push(Object.freeze({ kind: 'literal', value: template.slice(offset, index) }))
tokens.push(Object.freeze({ kind: 'value', name }))
offset = index + match[0].length
}
if (offset < template.length) tokens.push(Object.freeze({ kind: 'literal', value: template.slice(offset) }))
return Object.freeze(tokens)
}
/**
* Interpolate one parsed prompt while removing horizontal separators adjacent
* only to unavailable values.
* @param tokens - Parsed template tokens.
* @param resolve - Current value lookup.
* @returns ANSI-capable rendered prompt text.
*/
export function renderTuiPromptTemplate(
tokens: readonly TuiPromptTemplateToken[],
resolve: (name: string) => string | undefined,
): string {
const rendered: string[] = []
let omitLeadingWhitespace = false
for (const token of tokens) {
if (token.kind === 'value') {
const value = resolve(token.name)
if (value === undefined) {
omitLeadingWhitespace = true
} else {
rendered.push(value)
omitLeadingWhitespace = false
}
continue
}
rendered.push(omitLeadingWhitespace ? token.value.replace(/^[\t ]+/u, '') : token.value)
omitLeadingWhitespace = false
}
return rendered.join('')
}
/**
* Context-global mutable values interpolated by TUI theme prompt templates.
* A registration, mutation, or disposal schedules one coalesced notification to
* the renderer subscribed with {@link TuiPromptService.subscribe}, so a value
* that changes on its own schedule (not only in response to a UI event) still
* redraws. Notification is a direct in-service callback, not a Cordis event.
*/
export class TuiPromptService extends Service {
private readonly values = new Map<string, RegisteredValue>()
// Per-subscription record identity, not callback identity: two fibers may
// subscribe the same function, and disposing one must not remove the other's.
private readonly listeners = new Set<{ readonly listener: () => unknown }>()
private notificationQueued = false
constructor(ctx: Context) {
super(ctx, 'tuiPrompt')
}
/**
* Register one globally unique template value under the calling Cordis effect.
* @param name - Lowercase slash-separated template name.
* @param initialValue - Initial trusted ANSI-capable fragment.
* @returns A mutable handle whose disposal unregisters the name.
*/
register(name: string, initialValue?: string): TuiPromptValueHandle {
if (!VALUE_NAME.test(name)) {
throw new TypeError(`TUI prompt value name "${name}" must match ${String(VALUE_NAME)}`)
}
if (this.values.has(name)) throw new Error(`TUI prompt value "${name}" is already registered`)
const registered: RegisteredValue = { value: initialValue }
let active = true
const effectDisposer = this.ctx.effect(() => {
this.values.set(name, registered)
this.scheduleChange()
// Cordis runs this cleanup at most once per effect, and deleting an
// absent key is a no-op, so no re-entrancy guard is needed here; `active`
// exists only to reject a late {@link TuiPromptValueHandle.set}.
return () => {
active = false
this.values.delete(name)
this.scheduleChange()
}
}, `tuiPrompt.register(${name})`)
return Object.freeze({
set: (value: string | undefined): void => {
if (!active) throw new Error(`TUI prompt value "${name}" is disposed`)
if (registered.value === value) return
registered.value = value
this.scheduleChange()
},
dispose: (): void => { void effectDisposer() },
})
}
/**
* Read a registered fragment without evaluating plugin code.
* @param name - Exact registered template name.
* @returns The current fragment, or `undefined` when unknown or unavailable.
*/
get(name: string): string | undefined {
return this.values.get(name)?.value
}
/**
* Observe registration and value changes. The listener runs after a coalesced
* microtask following any burst of mutations; the renderer re-reads current
* values on that callback. The subscription is owned by the calling Cordis
* effect, so it is removed when the subscriber's fiber disposes; the returned
* disposer removes it early. Listener failures are contained — a synchronous
* throw or a rejected returned promise cannot starve the other observers.
* @param listener - Invoked once per coalesced change burst. Delivery does
* not wait on a returned promise; its rejection is only observed and logged,
* never left unhandled, so an async listener cannot order later observers.
* @returns A disposer that removes the subscription.
*/
subscribe(listener: () => unknown): TuiPromptUnsubscribe {
const record = { listener }
const disposeEffect = this.ctx.effect(() => {
this.listeners.add(record)
return () => { this.listeners.delete(record) }
}, 'tuiPrompt.subscribe')
return () => { void disposeEffect() }
}
/** Coalesce mutation bursts into one notification while containing each observer. */
private scheduleChange(): void {
if (this.notificationQueued) return
this.notificationQueued = true
queueMicrotask(() => {
this.notificationQueued = false
// Snapshot so a listener may subscribe/unsubscribe during delivery, but
// re-check liveness: a listener that synchronously unsubscribes another
// observer earlier in the same burst must silence it now, keeping the
// subscription set authoritative during reentrant notification.
for (const record of [...this.listeners]) {
if (this.listeners.has(record)) this.notifyOne(record.listener)
}
})
}
/** Deliver one change notification, containing a synchronous throw or a rejected promise. */
private notifyOne(listener: () => unknown): void {
let returned: unknown
try {
returned = listener()
} catch (error: unknown) {
// errorChain never throws, even on a hostile toString/getter, so the
// notification microtask can never escape to starve later observers.
this.ctx.logger.warn(`tui-prompt change listener threw: ${errorChain(error)}`)
return
}
// A listener may be async; contain a rejected promise the same as a throw.
void Promise.resolve(returned).catch((error: unknown) => {
this.ctx.logger.warn(`tui-prompt change listener rejected: ${errorChain(error)}`)
})
}
}
export default TuiPromptService