Merge remote-tracking branch 'origin/master' into perf/tui-resume-scan
# Conflicts: # packages/ui/tui/README.i18n.yaml # packages/ui/tui/src/components/dialogs.ts
This commit is contained in:
@@ -28,6 +28,7 @@ import type { SessionRecord } from '@deepseek-ai/dsh-session-query'
|
||||
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction'
|
||||
import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, displayText, sanitizePastedText } from './text.ts'
|
||||
import { dialogSelectTheme, type Palette } from './theme.ts'
|
||||
import type { ToolCardVisibility } from './transcript.ts'
|
||||
import {
|
||||
renderTuiPromptTemplate,
|
||||
type TuiPromptTemplateToken,
|
||||
@@ -426,6 +427,79 @@ export class ModelDialog implements Component {
|
||||
}
|
||||
}
|
||||
|
||||
/** Both transcript-detail dimensions, applied immediately on each Tab. */
|
||||
export interface DetailsSelection {
|
||||
readonly visibility: ToolCardVisibility
|
||||
readonly showReasoning: boolean
|
||||
}
|
||||
|
||||
const TOOL_CARD_PHASES: readonly ToolCardVisibility[] = ['collapsed', 'expanded', 'hidden']
|
||||
|
||||
/**
|
||||
* Keyboard toggle over the two transcript-detail entries — tool-card
|
||||
* visibility and reasoning display. Tab cycles the highlighted entry's value
|
||||
* and applies it immediately, so the transcript behind the dialog is the live
|
||||
* preview; Enter, Esc, or Ctrl+C closes.
|
||||
*/
|
||||
export class DetailsDialog implements Component {
|
||||
private readonly list: SelectList
|
||||
private readonly toolsItem: SelectItem
|
||||
private readonly reasoningItem: SelectItem
|
||||
|
||||
constructor(
|
||||
private visibility: ToolCardVisibility,
|
||||
private showReasoning: boolean,
|
||||
private readonly palette: Palette,
|
||||
private readonly apply: (selection: DetailsSelection) => void,
|
||||
private readonly close: () => void,
|
||||
) {
|
||||
this.toolsItem = { value: 'tools', label: 'Tool cards', description: visibility }
|
||||
this.reasoningItem = { value: 'reasoning', label: 'Reasoning', description: this.reasoningLabel() }
|
||||
this.list = new SelectList([this.toolsItem, this.reasoningItem], 2, dialogSelectTheme(palette))
|
||||
this.list.onSelect = close
|
||||
}
|
||||
|
||||
private reasoningLabel(): string {
|
||||
return this.showReasoning ? 'shown' : 'hidden'
|
||||
}
|
||||
|
||||
/** Cycle the highlighted entry one step and apply the new state. */
|
||||
private cycle(): void {
|
||||
const selected = this.list.getSelectedItem()
|
||||
/* v8 ignore next -- the two-entry list always has a selection. */
|
||||
if (selected === null) return
|
||||
if (selected.value === 'tools') {
|
||||
const index = TOOL_CARD_PHASES.indexOf(this.visibility)
|
||||
this.visibility = TOOL_CARD_PHASES[(index + 1) % TOOL_CARD_PHASES.length] as ToolCardVisibility
|
||||
this.toolsItem.description = this.visibility
|
||||
} else {
|
||||
this.showReasoning = !this.showReasoning
|
||||
this.reasoningItem.description = this.reasoningLabel()
|
||||
}
|
||||
this.apply({ visibility: this.visibility, showReasoning: this.showReasoning })
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.list.invalidate()
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) this.close()
|
||||
else if (matchesKey(data, Key.tab)) this.cycle()
|
||||
else this.list.handleInput(data)
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const innerWidth = Math.max(1, width - 4)
|
||||
return renderDialog('Transcript details', [
|
||||
...this.list.render(innerWidth),
|
||||
'',
|
||||
this.palette.dim('↑/↓ move • Tab toggle • Enter/Esc close'),
|
||||
], width, this.palette)
|
||||
}
|
||||
}
|
||||
|
||||
/** A resume selector row summarizing one session from metadata and its folded title. */
|
||||
export interface ResumeCandidate {
|
||||
record: SessionRecord
|
||||
|
||||
@@ -149,20 +149,28 @@ export class UserMessageComponent extends Container {
|
||||
}
|
||||
}
|
||||
|
||||
/** Children of a settled assistant message: optional reasoning block then the response text. */
|
||||
/**
|
||||
* Children of a settled assistant message: optional reasoning block then the
|
||||
* response text. A folded continuation (a later step of a turn while tool cards
|
||||
* are hidden) drops the `Assistant` header and renders nothing when it has no
|
||||
* visible body, so tool-only steps leave no blank segment behind.
|
||||
*/
|
||||
function assistantMessageChildren(
|
||||
content: readonly ContentBlock[],
|
||||
showReasoning: boolean,
|
||||
foldedContinuation: boolean,
|
||||
palette: Palette,
|
||||
mdTheme: MarkdownTheme,
|
||||
): Component[] {
|
||||
const reasoning = displayText(textBlocks(content, 'reasoning').trim())
|
||||
const text = displayText(textBlocks(content, 'text').trim())
|
||||
const children: Component[] = [
|
||||
new Spacer(1),
|
||||
new Text(messageHeader('Assistant', palette.accent, palette), 0, 0),
|
||||
]
|
||||
if (reasoning && showReasoning) {
|
||||
const showsReasoning = reasoning !== '' && showReasoning
|
||||
if (foldedContinuation && !showsReasoning && text === '') return []
|
||||
const children: Component[] = [new Spacer(1)]
|
||||
if (!foldedContinuation) {
|
||||
children.push(new Text(messageHeader('Assistant', palette.accent, palette), 0, 0))
|
||||
}
|
||||
if (showsReasoning) {
|
||||
children.push(
|
||||
new Text(palette.italic(palette.dim('Reasoning')), 0, 0),
|
||||
new Markdown(reasoning, 0, 0, mdTheme, { color: value => palette.dim(value), italic: true }),
|
||||
@@ -220,6 +228,7 @@ interface StreamingBlock {
|
||||
export class StreamingAssistantComponent extends Container {
|
||||
private readonly blocks = new Map<number, StreamingBlock>()
|
||||
private settledContent: readonly ContentBlock[] | undefined
|
||||
private foldedContinuation = false
|
||||
/**
|
||||
* The step's timing footer. The renderer keeps it at the tail of the chat so
|
||||
* it trails any tool cards the step appends after this assistant message; it
|
||||
@@ -228,7 +237,8 @@ export class StreamingAssistantComponent extends Container {
|
||||
readonly timing: StepTimingComponent
|
||||
|
||||
constructor(
|
||||
position: StepPosition,
|
||||
/** The step's turn/step coordinates, used to group steps into their turn. */
|
||||
readonly position: StepPosition,
|
||||
events: () => readonly SessionEvent[],
|
||||
now: () => number,
|
||||
private showReasoning: boolean,
|
||||
@@ -299,18 +309,49 @@ export class StreamingAssistantComponent extends Container {
|
||||
this.rebuild()
|
||||
}
|
||||
|
||||
private rebuild(): void {
|
||||
this.clear()
|
||||
const content: readonly ContentBlock[] = this.settledContent ?? [...this.blocks.entries()]
|
||||
/**
|
||||
* Mark this step as a folded continuation of its turn: no `Assistant` header,
|
||||
* and no output at all while the step has no visible body. Used while tool
|
||||
* cards are hidden so a turn reads as one assistant message.
|
||||
* @param folded - Whether to render as a headerless continuation.
|
||||
*/
|
||||
setFoldedContinuation(folded: boolean): void {
|
||||
if (this.foldedContinuation === folded) return
|
||||
this.foldedContinuation = folded
|
||||
this.rebuild()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the step currently renders visible reasoning or text.
|
||||
* @returns `true` when a header-owning render would show a body.
|
||||
*/
|
||||
hasVisibleBody(): boolean {
|
||||
const content = this.presentedContent()
|
||||
return textBlocks(content, 'text').trim() !== ''
|
||||
|| (this.showReasoning && textBlocks(content, 'reasoning').trim() !== '')
|
||||
}
|
||||
|
||||
/** The settled content when available, otherwise the streamed blocks in model order. */
|
||||
private presentedContent(): readonly ContentBlock[] {
|
||||
return this.settledContent ?? [...this.blocks.entries()]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.flatMap<ContentBlock>(([, block]) => {
|
||||
if (block.type === 'text') return [{ type: 'text', text: block.text }]
|
||||
if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }]
|
||||
return []
|
||||
})
|
||||
for (const child of assistantMessageChildren(content, this.showReasoning, this.palette, this.mdTheme)) {
|
||||
this.addChild(child)
|
||||
}
|
||||
}
|
||||
|
||||
private rebuild(): void {
|
||||
this.clear()
|
||||
const children = assistantMessageChildren(
|
||||
this.presentedContent(),
|
||||
this.showReasoning,
|
||||
this.foldedContinuation,
|
||||
this.palette,
|
||||
this.mdTheme,
|
||||
)
|
||||
for (const child of children) this.addChild(child)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user