Merge remote-tracking branch 'origin/master' into codex/pr-1037-resolution

# Conflicts:
#	packages/ui/tui/README.i18n.yaml
This commit is contained in:
Turtle
2026-08-03 16:49:18 +08:00
1391 changed files with 126134 additions and 10332 deletions
+74
View File
@@ -34,6 +34,7 @@ import type {
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,
@@ -432,6 +433,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)
}
}
/** The provider/model route recovered from a resume candidate's log. */
export interface ResumeRoute {
provider: string
+24 -8
View File
@@ -46,6 +46,8 @@ export type AttributeRole = <T extends string>(text: T) => T
*/
export interface Palette {
accent: ColorRole
/** DeepSeek brand ink; exact gradient callers may override it on truecolor terminals. */
brand: ColorRole
/** The terminal's own default foreground; still a color, so it does not stack. */
text: ColorRole
/** The one recessed tone, below `text`: tool-card bodies, chrome, reasoning, footers. */
@@ -63,7 +65,7 @@ export interface Palette {
}
/** Names of the palette's color roles, in the order `/palette` prints them. */
export const COLOR_ROLES = ['text', 'dim', 'accent', 'code', 'success', 'warning', 'error'] as const
export const COLOR_ROLES = ['text', 'dim', 'accent', 'brand', 'code', 'success', 'warning', 'error'] as const
/** Names of the palette's attribute roles, in the order `/palette` prints them. */
export const ATTRIBUTE_ROLES = ['bold', 'italic', 'underline', 'strike', 'selected'] as const
@@ -86,8 +88,9 @@ export interface RoleSpec {
*
* Only the standard 16-color set and SGR attributes appear here. Terminals remap
* those to the user's active theme, so the TUI stays legible on any background;
* a fixed 24-bit color would not. The brand gradient is the one deliberate
* exception ({@link gradientText}).
* a fixed 24-bit color would not. The startup gradient and exact official mark
* color are the two deliberate brand exceptions ({@link gradientText},
* {@link brandText}).
*
* @param scheme - Active terminal color scheme; only `code` differs between them.
* @returns The SGR spec for every color and attribute role.
@@ -109,6 +112,7 @@ export function paletteSpec(scheme: TerminalColorScheme): {
// prominent text on screen.
dim: { open: '2;39', close: '22;39', purpose: 'The one recessed tone: tool bodies, chrome, footers' },
accent: { open: '95', close: '39', purpose: 'The one emphasis color: role headers, prompt, borders' },
brand: { open: '34', close: '39', purpose: 'DeepSeek brand art when truecolor is unavailable' },
// ANSI 36 (cyan) is difficult to read on a light background — use ANSI 34
// (blue) which is legible on both light and dark schemes.
code: scheme === 'light'
@@ -168,6 +172,19 @@ const BRAND_GRADIENT = [
[36, 152, 255], // #2498FF
] as const
/** Official DeepSeek icon ink from the shipped 24x24 SVG. */
const DEEPSEEK_BRAND_RGB = BRAND_GRADIENT[0]
/**
* Paint trusted static DeepSeek brand art with the official `#4D6BFE` ink.
* @param text - Static brand text or raster cells.
* @returns text wrapped in the official truecolor foreground and a foreground reset.
*/
export function brandText(text: string): string {
const [r, g, b] = DEEPSEEK_BRAND_RGB
return `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`
}
/**
* Sample {@link BRAND_GRADIENT} at fraction `t` via piecewise-linear
* interpolation across its stops.
@@ -199,13 +216,12 @@ function brandColorAt(t: number): readonly [number, number, number] {
* @returns `text` wrapped in truecolor SGR foreground codes.
*/
export function gradientText(text: string): string {
// The sole caller passes the ASCII product name, so UTF-16 unit iteration
// samples exactly one color per visible letter.
const last = Math.max(1, text.length - 1)
const glyphs = Array.from(text)
const last = Math.max(1, glyphs.length - 1)
let painted = ''
for (let index = 0; index < text.length; index += 1) {
for (let index = 0; index < glyphs.length; index += 1) {
const [r, g, b] = brandColorAt(index / last)
painted += `\x1b[38;2;${r};${g};${b}m${text.charAt(index)}`
painted += `\x1b[38;2;${r};${g};${b}m${glyphs[index]}`
}
return `${painted}\x1b[39m`
}
+77 -31
View File
@@ -186,20 +186,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 }),
@@ -257,6 +265,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
@@ -265,7 +274,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,
@@ -336,18 +346,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)
}
}
@@ -405,6 +446,7 @@ export class ToolCardComponent implements Component {
* @param event - The `tool/result` event payload.
*/
updateResult(event: Extract<SessionEvent, { type: 'tool/result' }>['data']): void {
this.diffBodyCache = undefined
const result = event.message.content[0]
this.result = {
content: [...result.content],
@@ -441,23 +483,26 @@ export class ToolCardComponent implements Component {
const glyph = this.result === undefined ? '○' : '●'
const rawBody = this.renderBody()
const view = this.resultView ?? this.callView
// A generic card's own content, or a read card's `content` fallback (the
// A generic card's own content, a read card's `content` fallback (the
// envelope-stripped file text — the TUI has no dedicated read rendering, so a
// read renders exactly as before the read card existed), or a web card's
// fallback to the raw result content (the `web` view carries no `content`
// copy), all render as one dim Markdown block below, so links/lists/headings
// keep the unified dim styling rather than reading as bare text. Terminal and
// diff cards own their body styling, so they are excluded (mirrors
// renderBody's post-terminal/diff fallback).
// read renders exactly as before the read card existed), or a search/web
// card's fallback to the raw result content (neither the `search` nor the
// `web` view carries a `content` copy), all render as one dim Markdown block
// below, so links/lists/headings keep the unified dim styling rather than
// reading as bare text. A search card thus stays byte-identical to the
// pre-search-card generic fallback. Terminal and diff cards own their body
// styling, so they are excluded (mirrors renderBody's post-terminal/diff fallback).
const markdownContent = view.card === 'generic' || view.card === 'read'
? view.content ?? this.result?.content
: view.card === 'web'
// A web resultView is only assigned alongside this.result (the result
// handler sets both) and the pending callView is never a web card, so
// the optional-chain undefined side is unreachable here.
/* v8 ignore next */
: view.card === 'search'
? this.result?.content
: undefined
: view.card === 'web'
// A web resultView is only assigned alongside this.result (the result
// handler sets both) and the pending callView is never a web card, so
// the optional-chain undefined side is unreachable here.
/* v8 ignore next */
? this.result?.content
: undefined
const unknownXml = this.definition === undefined && markdownContent !== undefined
? renderUnknownXml(
displayText(contentText(markdownContent)),
@@ -578,11 +623,12 @@ export class ToolCardComponent implements Component {
this.diffBodyCache = { view, body }
return body
}
// A generic or read card carries its own envelope-stripped `content`; a `web`
// card carries no `content` copy and falls back to the raw result content
// here. (Mirrors the `markdownContent` selection in render(); a read card has
// no dedicated TUI rendering, so its `content` takes the same body path,
// keeping read output as it was before the read card existed.)
// A generic or read card carries its own envelope-stripped `content`; a
// search or web card carries no `content` copy and falls back to the raw
// result content here. (Mirrors the `markdownContent` selection in render();
// a read card has no dedicated TUI rendering, so its `content` takes the same
// body path, keeping read output as it was before the read card existed, and
// a search card stays byte-identical to the pre-search-card fallback.)
const content = (view.card === 'generic' || view.card === 'read' ? view.content : undefined) ?? this.result?.content
const prelude: string[] = []
const lines: string[] = []