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.
This commit is contained in:
217
packages/ui/tui/src/prompt.ts
Normal file
217
packages/ui/tui/src/prompt.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 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
|
||||
Reference in New Issue
Block a user