refactor(tui): split createTuiChat into chat/ sub-controllers
Extract model-command, questions, and resume sub-machines from the ~1600-line createTuiChat closure into src/chat/ factories that take explicit dependency bundles (shared ChatChannelDeps/ChannelNotice). Reorganize src/ so chat/ holds all chat-channel concerns (former input and session/ files move under it); xml-tool-output moves to components/; TuiRuntime/TuiResumeHost move to runtime.ts. index.ts drops 2067->~1530 lines. Behavior identical: 167 tests and all TUI snapshots pass unchanged.
This commit is contained in:
@@ -25,7 +25,7 @@ import type {
|
||||
ToolResultView,
|
||||
} from '@deepseek-ai/dsh-tools'
|
||||
import type { FileDiff } from '@deepseek-ai/dsh-tools'
|
||||
import { renderUnknownXml } from '../xml-tool-output.ts'
|
||||
import { renderUnknownXml } from './xml-tool-output.ts'
|
||||
import { displayInlineText, displayText } from './text.ts'
|
||||
import { gradientText, type Palette } from './theme.ts'
|
||||
import { contentText, type ParsedArguments } from './content.ts'
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
formatTimingTotals,
|
||||
stepTimingAt,
|
||||
type StepPosition,
|
||||
} from '../session/timing.ts'
|
||||
} from '../chat/timing.ts'
|
||||
|
||||
/** Concatenate the text of every block of one type, separated by blank lines. */
|
||||
function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string {
|
||||
|
||||
138
packages/ui/tui/src/components/xml-tool-output.ts
Normal file
138
packages/ui/tui/src/components/xml-tool-output.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/** Conservative readable-tree rendering for model-facing text containing one XML document. */
|
||||
|
||||
import { SaxesParser } from 'saxes'
|
||||
|
||||
interface XmlElement {
|
||||
readonly name: string
|
||||
readonly attributes: readonly XmlAttribute[]
|
||||
readonly children: XmlNode[]
|
||||
}
|
||||
|
||||
interface XmlAttribute {
|
||||
readonly name: string
|
||||
readonly value: string
|
||||
}
|
||||
|
||||
type XmlNode = XmlElement | string
|
||||
|
||||
function parseXml(source: string, display: (text: string) => string): XmlElement | undefined {
|
||||
const parser = new SaxesParser({ xmlns: false })
|
||||
const stack: XmlElement[] = []
|
||||
let root: XmlElement | undefined
|
||||
const state = { invalid: false }
|
||||
const reject = (): void => { state.invalid = true }
|
||||
parser.on('opentag', (tag) => {
|
||||
const element: XmlElement = {
|
||||
name: tag.name,
|
||||
// Attribute values and text pass through `display` because character references can
|
||||
// expand to valid-XML control characters (tab, CR, DEL, C1) that pre-parse escaping
|
||||
// of the raw source never saw. Element names cannot carry them: control characters
|
||||
// are not XML name characters and character references do not apply inside names.
|
||||
attributes: Object.entries(tag.attributes).map(([name, value]) => ({ name, value: display(value) })),
|
||||
children: [],
|
||||
}
|
||||
const parent = stack.at(-1)
|
||||
if (parent === undefined) {
|
||||
if (root !== undefined) reject()
|
||||
root = element
|
||||
} else {
|
||||
parent.children.push(element)
|
||||
}
|
||||
stack.push(element)
|
||||
})
|
||||
parser.on('text', (text) => {
|
||||
const parent = stack.at(-1)
|
||||
if (parent === undefined) {
|
||||
if (text.trim() !== '') reject()
|
||||
} else {
|
||||
parent.children.push(display(text))
|
||||
}
|
||||
})
|
||||
parser.on('cdata', (text) => {
|
||||
const parent = stack.at(-1)
|
||||
if (parent === undefined) reject()
|
||||
else parent.children.push(display(text))
|
||||
})
|
||||
parser.on('closetag', () => { stack.pop() })
|
||||
parser.on('xmldecl', reject)
|
||||
parser.on('processinginstruction', reject)
|
||||
parser.on('doctype', reject)
|
||||
parser.on('comment', reject)
|
||||
parser.on('error', reject)
|
||||
parser.write(source).close()
|
||||
return state.invalid ? undefined : root
|
||||
}
|
||||
|
||||
function elementLabel(element: XmlElement): string {
|
||||
const attributes = element.attributes.map(attribute => `${attribute.name}=${JSON.stringify(attribute.value)}`).join(' ')
|
||||
return attributes === '' ? element.name : `${element.name} (${attributes})`
|
||||
}
|
||||
|
||||
function meaningfulChildren(element: XmlElement): readonly XmlNode[] {
|
||||
return element.children.filter(child => typeof child !== 'string' || child.trim() !== '')
|
||||
}
|
||||
|
||||
function textBlock(text: string, depth: number): string[] {
|
||||
return text.replace(/^\n|\n$/gu, '').split('\n').map(line => `${' '.repeat(depth)}${line}`)
|
||||
}
|
||||
|
||||
function treeLines(element: XmlElement, depth: number, label: (text: string) => string): string[] {
|
||||
const indent = ' '.repeat(depth)
|
||||
const children = meaningfulChildren(element)
|
||||
if (children.length === 0) return [`${indent}${label(elementLabel(element))}`]
|
||||
if (children.length === 1 && typeof children[0] === 'string' && !children[0].includes('\n')) {
|
||||
return [`${indent}${label(`${elementLabel(element)}:`)} ${children[0].trim()}`]
|
||||
}
|
||||
const lines = [`${indent}${label(elementLabel(element))}`]
|
||||
for (const child of children) {
|
||||
if (typeof child === 'string') lines.push(...textBlock(child, depth + 1))
|
||||
else lines.push(...treeLines(child, depth + 1, label))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
function preview(lines: readonly string[], limit: number, omitted: (count: number) => string): string[] {
|
||||
if (lines.length <= limit) return [...lines]
|
||||
const head = Math.ceil(limit / 2)
|
||||
const tail = limit - head
|
||||
return [...lines.slice(0, head), omitted(lines.length - limit), ...lines.slice(lines.length - tail)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a complete XML document as an indented tree, or decline without changing partial/mixed text.
|
||||
* @param source - Raw model-facing text from a context message or unknown tool result.
|
||||
* @param maxChildLines - Collapsed budget independently applied to each top-level child's lines and
|
||||
* to the number of top-level children, so many siblings cannot grow the collapsed card without bound.
|
||||
* @param expanded - Whether to retain every rendered child line.
|
||||
* @param display - Escapes parsed text and attribute values for terminal output; character references
|
||||
* can expand to control characters that pre-parse escaping never saw.
|
||||
* @param label - Styles element names and attributes.
|
||||
* @param omitted - Renders the omitted-line marker for a collapsed child or child range.
|
||||
* @returns Tree rows, or `undefined` when `source` is not one supported complete XML document.
|
||||
*/
|
||||
export function renderUnknownXml(
|
||||
source: string,
|
||||
maxChildLines: number,
|
||||
expanded: boolean,
|
||||
display: (text: string) => string,
|
||||
label: (text: string) => string,
|
||||
omitted: (count: number) => string,
|
||||
): string[] | undefined {
|
||||
const root = parseXml(source, display)
|
||||
if (root === undefined) return undefined
|
||||
const blocks = meaningfulChildren(root).map(child =>
|
||||
typeof child === 'string' ? textBlock(child, 1) : treeLines(child, 1, label))
|
||||
const rootLine = label(elementLabel(root))
|
||||
if (expanded) return [rootLine, ...blocks.flat()]
|
||||
const previewed = blocks.map(block => preview(block, maxChildLines, omitted))
|
||||
if (previewed.length <= maxChildLines) return [rootLine, ...previewed.flat()]
|
||||
const head = Math.ceil(maxChildLines / 2)
|
||||
const tail = maxChildLines - head
|
||||
const hidden = blocks.slice(head, blocks.length - tail).reduce((total, block) => total + block.length, 0)
|
||||
return [
|
||||
rootLine,
|
||||
...previewed.slice(0, head).flat(),
|
||||
omitted(hidden),
|
||||
...previewed.slice(previewed.length - tail).flat(),
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user