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

This commit is contained in:
imccyu
2026-08-03 17:19:53 +08:00
24 changed files with 415 additions and 98 deletions

View File

@@ -15,6 +15,7 @@ import {
type Component,
type MarkdownTheme,
} from '@earendil-works/pi-tui'
import { diffLines as compareLines } from 'diff'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { JsonValue, SessionEvent, TodoItem } from '@deepseek-ai/dsh-session'
@@ -52,12 +53,17 @@ function pretty(value: unknown): string {
return displayText(serialized ?? String(value))
}
interface RenderedDiff {
lines: string[]
added: number
removed: number
approximate: boolean
}
/**
* 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.
* applies: empty text is zero lines, a trailing newline terminates the last
* line, and an interior blank line survives.
*/
function diffContentLines(text: string): string[] {
if (text === '') return []
@@ -65,16 +71,47 @@ function diffContentLines(text: string): string[] {
return body.split('\n')
}
/** A file diff as colored `+`/`-` lines, optionally prefixed with its path. */
function diffLines(diff: FileDiff, palette: Palette): string[] {
/**
* A file diff whose unchanged context stays neutral and does not affect exact
* change totals. Comparisons beyond the edit-distance budget fall back to
* whole-side rendering so a model-authored pending edit cannot stall the TUI.
*/
function renderDiff(diff: FileDiff, maxDiffEditLength: number, palette: Palette): RenderedDiff {
// 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 diffContentLines(displayText(diff.oldText))) lines.push(palette.error(`- ${line}`))
let added = 0
let removed = 0
if (diff.oldText === null) {
const newLines = diffContentLines(displayText(diff.newText))
added = newLines.length
for (const line of newLines) lines.push(palette.success(`+ ${line}`))
return { lines, added, removed, approximate: false }
}
for (const line of diffContentLines(displayText(diff.newText))) lines.push(palette.success(`+ ${line}`))
return lines
const changes = compareLines(diff.oldText, diff.newText, { maxEditLength: maxDiffEditLength })
if (changes === undefined) {
const oldLines = diffContentLines(displayText(diff.oldText))
const newLines = diffContentLines(displayText(diff.newText))
lines.push(palette.dim(`[exact line diff omitted: >${maxDiffEditLength} changed lines]`))
removed = oldLines.length
added = newLines.length
for (const line of oldLines) lines.push(palette.error(`- ${line}`))
for (const line of newLines) lines.push(palette.success(`+ ${line}`))
return { lines, added, removed, approximate: true }
}
for (const change of changes) {
const changedLines = diffContentLines(displayText(change.value))
if (change.added) {
added += changedLines.length
for (const line of changedLines) lines.push(palette.success(`+ ${line}`))
} else if (change.removed) {
removed += changedLines.length
for (const line of changedLines) lines.push(palette.error(`- ${line}`))
} else {
for (const line of changedLines) lines.push(palette.dim(` ${line}`))
}
}
return { lines, added, removed, approximate: false }
}
/**
@@ -378,12 +415,14 @@ export class ToolCardComponent implements Component {
private visibility: ToolCardVisibility = 'collapsed'
private callView: ToolCallView
private resultView: ToolResultView | undefined
private diffBodyCache: { view: ToolCallView | ToolResultView; body: CardBody } | undefined
constructor(
private readonly name: string,
private readonly parsed: ParsedArguments,
private readonly definition: ToolDefinition | undefined,
private readonly maxOutputLines: number,
private readonly maxDiffEditLength: number,
private readonly palette: Palette,
private readonly mdTheme: MarkdownTheme,
) {
@@ -407,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],
@@ -560,24 +600,28 @@ export class ToolCardComponent implements Component {
return { prelude: prelude.filter(Boolean), lines: lines.filter(Boolean) }
}
if (view.card === 'diff') {
if (this.diffBodyCache?.view === view) return this.diffBodyCache.body
// 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)`),
// 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) => {
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)]
// header. A trailing footer summarizes the exact changed rows when the
// bounded comparison succeeds (`+A -R · N file(s)`).
const renderedDiffs = view.diffs.map(diff =>
renderDiff(diff, this.maxDiffEditLength, this.palette),
)
const added = renderedDiffs.reduce((total, rendered) => total + rendered.added, 0)
const removed = renderedDiffs.reduce((total, rendered) => total + rendered.removed, 0)
const approximate = renderedDiffs.some(rendered => rendered.approximate)
const hunks = renderedDiffs.flatMap((rendered, index) => {
return [...index > 0 ? [''] : [], ...rendered.lines]
})
const files = paths.size
const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`)
const files = new Set(view.diffs.map(diff => diff.path)).size
const footer = this.palette.dim(
`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}${approximate ? ' · approximate' : ''}`,
)
// 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 body = { prelude: [...hunks, footer], lines: [] }
this.diffBodyCache = { view, body }
return body
}
// 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

View File

@@ -34,6 +34,8 @@ export interface TuiConfig {
showReasoning?: boolean
/** Maximum tool-card body lines retained in its collapsed head/tail preview. */
maxToolOutputLines?: number
/** Maximum added and removed lines explored while deriving an exact line diff. */
maxDiffEditLength?: number
/** Maximum options visible at once in a user-question panel. */
maxQuestionOptions?: number
/** Maximum models visible at once in the model selector. */
@@ -66,6 +68,7 @@ export interface TuiConfig {
const showReasoningSchema = z.boolean().default(true)
const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6)
const maxDiffEditLengthSchema = z.number().step(1).min(1).default(1000)
const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8)
const maxModelOptionsSchema = z.number().step(1).min(1).default(8)
const maxResumeOptionsSchema = z.number().step(1).min(1).default(8)
@@ -98,6 +101,7 @@ const titleSchema = z.string().default('DeepSeek Harness')
const tuiConfigSchemaFields = {
showReasoning: showReasoningSchema,
maxToolOutputLines: maxToolOutputLinesSchema,
maxDiffEditLength: maxDiffEditLengthSchema,
maxQuestionOptions: maxQuestionOptionsSchema,
maxModelOptions: maxModelOptionsSchema,
maxResumeOptions: maxResumeOptionsSchema,
@@ -139,6 +143,7 @@ export const Config: z<Config> = z.object({
initialSkill: z.string(),
showReasoning: tuiConfigSchemaFields.showReasoning,
maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines,
maxDiffEditLength: tuiConfigSchemaFields.maxDiffEditLength,
maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions,
maxModelOptions: tuiConfigSchemaFields.maxModelOptions,
maxResumeOptions: tuiConfigSchemaFields.maxResumeOptions,
@@ -169,6 +174,7 @@ export interface ResolvedTuiThemeConfig {
export interface ResolvedTuiConfig {
showReasoning: boolean
maxToolOutputLines: number
maxDiffEditLength: number
maxQuestionOptions: number
maxModelOptions: number
maxResumeOptions: number
@@ -195,6 +201,7 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf
return {
showReasoning: config?.showReasoning ?? true,
maxToolOutputLines: config?.maxToolOutputLines ?? 6,
maxDiffEditLength: config?.maxDiffEditLength ?? 1000,
maxQuestionOptions: config?.maxQuestionOptions ?? 8,
maxModelOptions: config?.maxModelOptions ?? 8,
maxResumeOptions: config?.maxResumeOptions ?? 8,

View File

@@ -643,6 +643,7 @@ export function createTuiChat(
parsed,
ctx.tools.get(event.data.name, agent),
resolved.maxToolOutputLines,
resolved.maxDiffEditLength,
palette,
mdTheme,
)
@@ -835,7 +836,15 @@ export function createTuiChat(
const callId = event.data.message.source.callId
let card = toolCards.get(callId)
if (card === undefined) {
card = new ToolCardComponent('tool', { value: {}, valid: true }, undefined, resolved.maxToolOutputLines, palette, mdTheme)
card = new ToolCardComponent(
'tool',
{ value: {}, valid: true },
undefined,
resolved.maxToolOutputLines,
resolved.maxDiffEditLength,
palette,
mdTheme,
)
card.setVisibility(toolsVisibility)
chat.addChild(card)
allToolCards.add(card)