Merge remote-tracking branch 'origin/master' into worktree/fix-multi-select-custom-answer

# Conflicts:
#	apps/web/tests/snapshots/question-composer/answered.expected.md
#	apps/web/tests/snapshots/question-composer/session.jsonl
#	docs/core-data-structures/user-interaction.i18n.yaml
#	packages/client/ui-question/README.i18n.yaml
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.md
#	packages/host/apiproxy/README.zh.md
#	packages/ui/tui/README.i18n.yaml
#	packages/ui/user-interaction/README.i18n.yaml
This commit is contained in:
Yichen Jiang
2026-08-03 16:09:17 +08:00
2065 changed files with 168375 additions and 14240 deletions

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`
}

View File

@@ -52,15 +52,28 @@ function pretty(value: unknown): string {
return displayText(serialized ?? String(value))
}
/**
* A side's content lines under the terminator rule the Web DiffBlock also
* applies: empty text is zero lines (a full deletion's `newText`, a create's
* absent `oldText`), and a single trailing newline terminates the last line
* rather than adding an empty one. An interior blank line survives. Keeping the
* two front ends on the same rule holds their `+A -R` footers in step.
*/
function diffContentLines(text: string): string[] {
if (text === '') return []
const body = text.endsWith('\n') ? text.slice(0, -1) : text
return body.split('\n')
}
/** A file diff as colored `+`/`-` lines, optionally prefixed with its path. */
function diffLines(diff: FileDiff, palette: Palette): string[] {
// The card header is a fixed `Tool / <name>` frame that never names a file, so
// each hunk always carries its own path header (no redundancy to suppress).
const lines = [palette.bold(displayText(diff.path))]
if (diff.oldText !== null) {
for (const line of displayText(diff.oldText).split('\n')) lines.push(palette.error(`- ${line}`))
for (const line of diffContentLines(displayText(diff.oldText))) lines.push(palette.error(`- ${line}`))
}
for (const line of displayText(diff.newText).split('\n')) lines.push(palette.success(`+ ${line}`))
for (const line of diffContentLines(displayText(diff.newText))) lines.push(palette.success(`+ ${line}`))
return lines
}
@@ -389,10 +402,29 @@ export class ToolCardComponent implements Component {
const glyph = this.result === undefined ? '○' : '●'
const rawBody = this.renderBody()
const view = this.resultView ?? this.callView
const genericContent = view.card === 'generic' ? view.content ?? this.result?.content : undefined
const unknownXml = this.definition === undefined && genericContent !== undefined
// 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 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 === 'search'
? 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 */
? this.result?.content
: undefined
const unknownXml = this.definition === undefined && markdownContent !== undefined
? renderUnknownXml(
displayText(contentText(genericContent)),
displayText(contentText(markdownContent)),
this.maxOutputLines,
this.visibility === 'expanded',
displayText,
@@ -405,7 +437,7 @@ export class ToolCardComponent implements Component {
// A generic card renders title and result as one Markdown document, so the
// document's own block spacing is preserved, then dims every row — the whole
// card body reads as one dim block under the status-colored header.
const body = unknownXml ?? (genericContent !== undefined && rawBody.lines.length > 0
const body = unknownXml ?? (markdownContent !== undefined && rawBody.lines.length > 0
? this.dimBody(rawBody, width)
: [...rawBody.prelude, ...rawBody.lines])
const visibleBody = unknownXml !== undefined || this.visibility === 'expanded'
@@ -488,21 +520,31 @@ export class ToolCardComponent implements Component {
}
if (view.card === 'diff') {
// The header no longer names the file, so each diff keeps its own path
// header. A trailing footer summarizes the change (`+A -R · N file(s)`).
// header. A trailing footer summarizes the change (`+A -R · N file(s)`),
// on the same terminator rule and distinct-path count the Web DiffBlock
// uses, so the two front ends' footers agree.
let added = 0
let removed = 0
const paths = new Set<string>()
const hunks = view.diffs.flatMap((diff, index) => {
if (diff.oldText !== null) removed += displayText(diff.oldText).split('\n').length
added += displayText(diff.newText).split('\n').length
paths.add(diff.path)
if (diff.oldText !== null) removed += diffContentLines(displayText(diff.oldText)).length
added += diffContentLines(displayText(diff.newText)).length
return [...index > 0 ? [''] : [], ...diffLines(diff, this.palette)]
})
const files = view.diffs.length
const files = paths.size
const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`)
// A diff's own `+`/`-` colors carry its meaning, so it renders verbatim
// rather than under the dim result-output color.
return { prelude: [...hunks, footer], lines: [] }
}
const content = view.content ?? this.result?.content
// 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[] = []
// The presenter title headlines the body now that the header is a fixed