refactor: reduce cyclomatic complexity of settings, tool data and layout sanitizers

Add characterization tests first, then split the hot spots into small helpers:
- STT/TTS settings updates move to extensions/piagentui-speech-settings.ts (rule tables)
- defaultExtractData is split into diff/diagnostic/output extractors
- getToolSummaryCategory becomes an ordered rule table (toolSummary.ts)
- sanitizePersistedTerminalLayoutMap is split per level
- duplicated diff stats helpers are merged into utils/diffStats.ts
This commit is contained in:
evandro committed 2026-10-10 04:19:34 -03:00
1 parent da09846708
commit 2d2cdf3b19
13 files changed
+962 -404

No files matched your search

+9 -102
View File
@@ -21,6 +21,7 @@ import {
type StoredSttSettings,
type StoredTtsSettings,
} from './piagentui-settings-store.js'
import { applySttSettingsUpdate, applyTtsSettingsUpdate, isTtsResponseFormat } from './piagentui-speech-settings.js'
import {
buildExtensionCatalog,
executeExtensionInstallCommand,
@@ -774,56 +775,11 @@ export default function (pi: ExtensionAPI) {
function updateSttSettings(update: any): { ok: true; settings: SanitizedSttSettings } | { ok: false; error: string } {
const current = getLocalSettings()
const existing = current.stt || {}
const next: StoredSttSettings = { ...existing }
const result = applySttSettingsUpdate(current.stt || {}, update)
if (!result.ok) return result
if (update.enabled !== undefined) next.enabled = update.enabled === true
if (update.providerKind !== undefined) {
if (update.providerKind !== 'openai' && update.providerKind !== 'openai-compatible') {
return { ok: false, error: 'Invalid providerKind' }
}
next.providerKind = update.providerKind
}
if (update.mode !== undefined) {
if (update.mode !== 'file' && update.mode !== 'realtime') return { ok: false, error: 'Invalid mode' }
next.mode = update.mode
}
if (update.baseUrl !== undefined) {
if (typeof update.baseUrl !== 'string' || !validateSttBaseUrl(update.baseUrl)) {
return { ok: false, error: 'Invalid baseUrl' }
}
next.baseUrl = update.baseUrl.replace(/\/+$/, '')
}
if (update.apiKeyRequired !== undefined) next.apiKeyRequired = update.apiKeyRequired === true
if (update.transcriptionEndpoint !== undefined) {
if (typeof update.transcriptionEndpoint !== 'string' || !update.transcriptionEndpoint.startsWith('/')) {
return { ok: false, error: 'Invalid transcriptionEndpoint' }
}
next.transcriptionEndpoint = update.transcriptionEndpoint
}
if (update.transcriptionModel !== undefined) {
if (typeof update.transcriptionModel !== 'string' || !update.transcriptionModel.trim()) {
return { ok: false, error: 'Invalid transcriptionModel' }
}
next.transcriptionModel = update.transcriptionModel.trim()
}
if (update.language !== undefined) {
if (update.language === null || update.language === '') delete next.language
else if (typeof update.language === 'string') next.language = update.language.trim()
else return { ok: false, error: 'Invalid language' }
}
if (update.insertMode !== undefined) {
if (update.insertMode !== 'append' && update.insertMode !== 'replace')
return { ok: false, error: 'Invalid insertMode' }
next.insertMode = update.insertMode
}
if (Object.prototype.hasOwnProperty.call(update, 'apiKey')) {
if (update.apiKey === null) delete next.apiKey
else if (typeof update.apiKey === 'string' && update.apiKey.trim()) next.apiKey = update.apiKey.trim()
}
saveLocalSettings({ ...current, stt: next })
return { ok: true, settings: sanitizeSttSettings(next) }
saveLocalSettings({ ...current, stt: result.next })
return { ok: true, settings: sanitizeSttSettings(result.next) }
}
function getEffectiveTtsSettings(): StoredTtsSettings {
@@ -857,62 +813,13 @@ export default function (pi: ExtensionAPI) {
}
}
function isTtsResponseFormat(value: unknown): value is NonNullable<StoredTtsSettings['responseFormat']> {
return (
value === 'mp3' || value === 'opus' || value === 'aac' || value === 'flac' || value === 'wav' || value === 'pcm'
)
}
function updateTtsSettings(update: any): { ok: true; settings: SanitizedTtsSettings } | { ok: false; error: string } {
const current = getLocalSettings()
const existing = current.tts || {}
const next: StoredTtsSettings = { ...existing }
const result = applyTtsSettingsUpdate(current.tts || {}, update)
if (!result.ok) return result
if (update.enabled !== undefined) next.enabled = update.enabled === true
if (update.providerKind !== undefined) {
if (update.providerKind !== 'openai' && update.providerKind !== 'openai-compatible') {
return { ok: false, error: 'Invalid providerKind' }
}
next.providerKind = update.providerKind
}
if (update.baseUrl !== undefined) {
if (typeof update.baseUrl !== 'string' || !validateSttBaseUrl(update.baseUrl)) {
return { ok: false, error: 'Invalid baseUrl' }
}
next.baseUrl = update.baseUrl.replace(/\/+$/, '')
}
if (update.apiKeyRequired !== undefined) next.apiKeyRequired = update.apiKeyRequired === true
if (update.speechEndpoint !== undefined) {
if (typeof update.speechEndpoint !== 'string' || !update.speechEndpoint.startsWith('/')) {
return { ok: false, error: 'Invalid speechEndpoint' }
}
next.speechEndpoint = update.speechEndpoint
}
if (update.model !== undefined) {
if (typeof update.model !== 'string' || !update.model.trim()) return { ok: false, error: 'Invalid model' }
next.model = update.model.trim()
}
if (update.voice !== undefined) {
if (typeof update.voice !== 'string' || !update.voice.trim()) return { ok: false, error: 'Invalid voice' }
next.voice = update.voice.trim()
}
if (update.responseFormat !== undefined) {
if (!isTtsResponseFormat(update.responseFormat)) return { ok: false, error: 'Invalid responseFormat' }
next.responseFormat = update.responseFormat
}
if (update.speed !== undefined) {
if (typeof update.speed !== 'number' || update.speed < 0.25 || update.speed > 4) {
return { ok: false, error: 'Invalid speed' }
}
next.speed = update.speed
}
if (Object.prototype.hasOwnProperty.call(update, 'apiKey')) {
if (update.apiKey === null) delete next.apiKey
else if (typeof update.apiKey === 'string' && update.apiKey.trim()) next.apiKey = update.apiKey.trim()
}
saveLocalSettings({ ...current, tts: next })
return { ok: true, settings: sanitizeTtsSettings(next) }
saveLocalSettings({ ...current, tts: result.next })
return { ok: true, settings: sanitizeTtsSettings(result.next) }
}
async function readRequestBuffer(req: http.IncomingMessage): Promise<Buffer> {
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest'
import { applySttSettingsUpdate, applyTtsSettingsUpdate, isTtsResponseFormat } from './piagentui-speech-settings.js'
function stt(update: Record<string, unknown>, existing = {}) {
return applySttSettingsUpdate(existing, update)
}
function tts(update: Record<string, unknown>, existing = {}) {
return applyTtsSettingsUpdate(existing, update)
}
describe('applySttSettingsUpdate', () => {
it('returns the existing settings untouched for an empty update', () => {
const existing = { enabled: true, language: 'pt' }
expect(stt({}, existing)).toEqual({ ok: true, next: existing })
})
it('applies a valid update of every field', () => {
const result = stt({
enabled: true,
providerKind: 'openai',
mode: 'realtime',
baseUrl: 'https://api.example.com/v1///',
apiKeyRequired: true,
transcriptionEndpoint: '/audio/transcriptions',
transcriptionModel: ' whisper-1 ',
language: ' en ',
insertMode: 'replace',
apiKey: ' secret ',
})
expect(result).toEqual({
ok: true,
next: {
enabled: true,
providerKind: 'openai',
mode: 'realtime',
baseUrl: 'https://api.example.com/v1',
apiKeyRequired: true,
transcriptionEndpoint: '/audio/transcriptions',
transcriptionModel: 'whisper-1',
language: 'en',
insertMode: 'replace',
apiKey: 'secret',
},
})
})
it('does not mutate the existing settings', () => {
const existing = { enabled: false }
stt({ enabled: true }, existing)
expect(existing).toEqual({ enabled: false })
})
it.each([
['providerKind', { providerKind: 'other' }],
['mode', { mode: 'stream' }],
['baseUrl', { baseUrl: 'ftp://host' }],
['baseUrl', { baseUrl: 42 }],
['transcriptionEndpoint', { transcriptionEndpoint: 'audio' }],
['transcriptionModel', { transcriptionModel: ' ' }],
['language', { language: 5 }],
['insertMode', { insertMode: 'prepend' }],
])('rejects an invalid %s', (field, update) => {
expect(stt(update)).toEqual({ ok: false, error: `Invalid ${field}` })
})
it('coerces enabled/apiKeyRequired to booleans', () => {
expect(stt({ enabled: 'yes', apiKeyRequired: 1 })).toEqual({
ok: true,
next: { enabled: false, apiKeyRequired: false },
})
})
it('clears language with null or empty string', () => {
expect(stt({ language: null }, { language: 'pt' })).toEqual({ ok: true, next: {} })
expect(stt({ language: '' }, { language: 'pt' })).toEqual({ ok: true, next: {} })
})
it('handles apiKey: null clears, blank string keeps, non-string keeps', () => {
expect(stt({ apiKey: null }, { apiKey: 'old' })).toEqual({ ok: true, next: {} })
expect(stt({ apiKey: ' ' }, { apiKey: 'old' })).toEqual({ ok: true, next: { apiKey: 'old' } })
expect(stt({ apiKey: 12 }, { apiKey: 'old' })).toEqual({ ok: true, next: { apiKey: 'old' } })
})
})
describe('applyTtsSettingsUpdate', () => {
it('returns the existing settings untouched for an empty update', () => {
const existing = { voice: 'alloy' }
expect(tts({}, existing)).toEqual({ ok: true, next: existing })
})
it('applies a valid update of every field', () => {
const result = tts({
enabled: true,
providerKind: 'openai-compatible',
baseUrl: 'http://localhost:8880/v1/',
apiKeyRequired: true,
speechEndpoint: '/audio/speech',
model: ' tts-1-hd ',
voice: ' nova ',
responseFormat: 'wav',
speed: 1.5,
apiKey: ' k ',
})
expect(result).toEqual({
ok: true,
next: {
enabled: true,
providerKind: 'openai-compatible',
baseUrl: 'http://localhost:8880/v1',
apiKeyRequired: true,
speechEndpoint: '/audio/speech',
model: 'tts-1-hd',
voice: 'nova',
responseFormat: 'wav',
speed: 1.5,
apiKey: 'k',
},
})
})
it.each([
['providerKind', { providerKind: 'x' }],
['baseUrl', { baseUrl: 'not a url' }],
['speechEndpoint', { speechEndpoint: 'speech' }],
['model', { model: '' }],
['voice', { voice: 3 }],
['responseFormat', { responseFormat: 'ogg' }],
['speed', { speed: '1' }],
['speed', { speed: 0.1 }],
['speed', { speed: 4.5 }],
])('rejects an invalid %s', (field, update) => {
expect(tts(update)).toEqual({ ok: false, error: `Invalid ${field}` })
})
it('accepts speed at both bounds', () => {
expect(tts({ speed: 0.25 })).toEqual({ ok: true, next: { speed: 0.25 } })
expect(tts({ speed: 4 })).toEqual({ ok: true, next: { speed: 4 } })
})
it('handles apiKey: null clears, blank string keeps', () => {
expect(tts({ apiKey: null }, { apiKey: 'old' })).toEqual({ ok: true, next: {} })
expect(tts({ apiKey: ' ' }, { apiKey: 'old' })).toEqual({ ok: true, next: { apiKey: 'old' } })
})
})
describe('isTtsResponseFormat', () => {
it('accepts supported formats only', () => {
for (const f of ['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm']) expect(isTtsResponseFormat(f)).toBe(true)
expect(isTtsResponseFormat('ogg')).toBe(false)
expect(isTtsResponseFormat(undefined)).toBe(false)
})
})
+126
View File
@@ -0,0 +1,126 @@
import { validateSttBaseUrl } from './piagentui-server-core.js'
import type { StoredSttSettings, StoredTtsSettings } from './piagentui-settings-store.js'
export type SettingsUpdateResult<T> = { ok: true; next: T } | { ok: false; error: string }
type Fields = Record<string, unknown>
/** Assigns a validated value to `next`; returns false when the value is invalid. */
type FieldRule = (value: unknown, next: Fields) => boolean
const TTS_RESPONSE_FORMATS = ['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm'] as const
export function isTtsResponseFormat(value: unknown): value is NonNullable<StoredTtsSettings['responseFormat']> {
return typeof value === 'string' && (TTS_RESPONSE_FORMATS as readonly string[]).includes(value)
}
const booleanField =
(key: string): FieldRule =>
(value, next) => {
next[key] = value === true
return true
}
const oneOfField =
(key: string, allowed: readonly string[]): FieldRule =>
(value, next) => {
if (typeof value !== 'string' || !allowed.includes(value)) return false
next[key] = value
return true
}
const endpointField =
(key: string): FieldRule =>
(value, next) => {
if (typeof value !== 'string' || !value.startsWith('/')) return false
next[key] = value
return true
}
const trimmedField =
(key: string): FieldRule =>
(value, next) => {
if (typeof value !== 'string' || !value.trim()) return false
next[key] = value.trim()
return true
}
const baseUrlField: FieldRule = (value, next) => {
if (typeof value !== 'string' || !validateSttBaseUrl(value)) return false
next.baseUrl = value.replace(/\/+$/, '')
return true
}
const languageField: FieldRule = (value, next) => {
if (value === null || value === '') delete next.language
else if (typeof value === 'string') next.language = value.trim()
else return false
return true
}
const speedField: FieldRule = (value, next) => {
if (typeof value !== 'number' || value < 0.25 || value > 4) return false
next.speed = value
return true
}
const PROVIDER_KINDS = ['openai', 'openai-compatible'] as const
const STT_RULES: Array<[string, FieldRule]> = [
['enabled', booleanField('enabled')],
['providerKind', oneOfField('providerKind', PROVIDER_KINDS)],
['mode', oneOfField('mode', ['file', 'realtime'])],
['baseUrl', baseUrlField],
['apiKeyRequired', booleanField('apiKeyRequired')],
['transcriptionEndpoint', endpointField('transcriptionEndpoint')],
['transcriptionModel', trimmedField('transcriptionModel')],
['language', languageField],
['insertMode', oneOfField('insertMode', ['append', 'replace'])],
]
const TTS_RULES: Array<[string, FieldRule]> = [
['enabled', booleanField('enabled')],
['providerKind', oneOfField('providerKind', PROVIDER_KINDS)],
['baseUrl', baseUrlField],
['apiKeyRequired', booleanField('apiKeyRequired')],
['speechEndpoint', endpointField('speechEndpoint')],
['model', trimmedField('model')],
['voice', trimmedField('voice')],
['responseFormat', oneOfField('responseFormat', TTS_RESPONSE_FORMATS)],
['speed', speedField],
]
function applyUpdate<T extends object>(
existing: T,
update: Record<string, unknown>,
rules: Array<[string, FieldRule]>,
): SettingsUpdateResult<T> {
const next: Fields = { ...(existing as Fields) }
for (const [field, rule] of rules) {
if (update[field] === undefined) continue
if (!rule(update[field], next)) return { ok: false, error: `Invalid ${field}` }
}
if (Object.prototype.hasOwnProperty.call(update, 'apiKey')) {
if (update.apiKey === null) delete next.apiKey
else if (typeof update.apiKey === 'string' && update.apiKey.trim()) next.apiKey = update.apiKey.trim()
}
return { ok: true, next: next as T }
}
/** Validates a PATCH-style STT update against the stored settings. Pure: does not persist anything. */
export function applySttSettingsUpdate(
existing: StoredSttSettings,
update: Record<string, unknown>,
): SettingsUpdateResult<StoredSttSettings> {
return applyUpdate(existing, update, STT_RULES)
}
/** Validates a PATCH-style TTS update against the stored settings. Pure: does not persist anything. */
export function applyTtsSettingsUpdate(
existing: StoredTtsSettings,
update: Record<string, unknown>,
): SettingsUpdateResult<StoredTtsSettings> {
return applyUpdate(existing, update, TTS_RULES)
}
+3 -93
View File
@@ -1,6 +1,5 @@
import { memo, useState, useRef, useEffect, useLayoutEffect, useMemo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { diffLines } from 'diff'
import { animate } from 'motion/mini'
import { ChevronDownIcon, ChevronRightIcon, SplitIcon, SpinnerIcon, UndoIcon } from '../../components/Icons'
import { CopyButton, SmoothHeight } from '../../components/ui'
@@ -25,6 +24,8 @@ import {
MessageErrorView,
} from './parts'
import { extractToolData } from './tools'
import { getToolSummaryCategory, type ToolSummaryCategory } from './toolSummary'
import { computeDiffStats } from '../../utils/diffStats'
import type {
Message,
Part,
@@ -597,7 +598,7 @@ const ToolGroup = memo(function ToolGroup({
for (const part of parts) {
if (part.state?.status === 'error') continue
const data = extractToolData(part)
const stats = data.diffStats || computePartDiffStats(data)
const stats = data.diffStats || computeDiffStats(data)
if (stats) {
additions += stats.additions
deletions += stats.deletions
@@ -774,21 +775,6 @@ function formatTokens(
return `${total} ${t('tokens')}`
}
type ToolSummaryCategory =
| 'execute'
| 'write'
| 'edit'
| 'read'
| 'search'
| 'list'
| 'network'
| 'task'
| 'todo'
| 'question'
| 'skill'
| 'think'
| 'other'
type ToolSummaryPhase = 'done' | 'active' | 'failed'
interface SummarySegment {
@@ -897,45 +883,6 @@ function formatToolSummarySegment(
return t(key, { count })
}
function getToolSummaryCategory(toolName: string): ToolSummaryCategory {
const lower = toolName.toLowerCase()
if (lower.includes('todo')) return 'todo'
if (lower === 'task') return 'task'
if (lower.includes('question') || lower.includes('ask')) return 'question'
if (lower.includes('skill')) return 'skill'
if (
lower.includes('bash') ||
lower === 'sh' ||
lower.includes('cmd') ||
lower.includes('terminal') ||
lower.includes('shell')
) {
return 'execute'
}
if (lower.includes('write') || lower.includes('save')) {
return 'write'
}
if (lower.includes('edit') || lower.includes('replace') || lower.includes('patch')) {
return 'edit'
}
if (
lower.includes('web') ||
lower.includes('fetch') ||
lower.includes('http') ||
lower.includes('browse') ||
lower.includes('network') ||
lower.includes('exa')
) {
return 'network'
}
if (lower.includes('read') || lower.includes('cat')) return 'read'
if (lower.includes('grep') || lower.includes('search')) return 'search'
if (lower.includes('glob') || lower.includes('find')) return 'list'
if (lower.includes('think') || lower.includes('reason') || lower.includes('plan')) return 'think'
return 'other'
}
function isToolPartActive(part: ToolPart): boolean {
return part.state?.status === 'running' || part.state?.status === 'pending'
}
@@ -946,43 +893,6 @@ function getTaskChildSessionId(part: ToolPart): string | undefined {
return metadata?.sessionId as string | undefined
}
/** 从 extractToolData 的结果计算 diff stats(当 metadata 没给 diffStats 时) */
function computePartDiffStats(data: {
diff?: { before: string; after: string } | string
files?: Array<{ before?: string; after?: string; additions?: number; deletions?: number }>
}): { additions: number; deletions: number } | undefined {
if (data.files?.length) {
let a = 0,
d = 0
for (const f of data.files) {
if (f.additions !== undefined) a += f.additions
if (f.deletions !== undefined) d += f.deletions
if (f.additions === undefined && f.before !== undefined && f.after !== undefined) {
const s = diffPairStats(f.before, f.after)
a += s.additions
d += s.deletions
}
}
return a || d ? { additions: a, deletions: d } : undefined
}
if (data.diff && typeof data.diff === 'object') {
const s = diffPairStats(data.diff.before, data.diff.after)
return s.additions || s.deletions ? s : undefined
}
return undefined
}
function diffPairStats(before: string, after: string): { additions: number; deletions: number } {
const changes = diffLines(before, after)
let additions = 0,
deletions = 0
for (const c of changes) {
if (c.added) additions += c.count || 0
if (c.removed) deletions += c.count || 0
}
return { additions, deletions }
}
// ============================================
// Helper: Group parts for rendering
// ============================================
+2 -43
View File
@@ -1,6 +1,6 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { diffLines } from 'diff'
import { computeDiffStats } from '../../../utils/diffStats'
import { ChevronDownIcon, ChevronRightIcon } from '../../../components/Icons'
import type { ToolPart } from '../../../types/message'
import { useDelayedRender } from '../../../hooks'
@@ -203,7 +203,7 @@ export const ToolPartView = memo(function ToolPartView({
if (descriptive) {
const hasDiffFiles = !!toolData.files?.length
// diffStats 可能从 metadata 来,也可能需要从 diff 数据计算
const diffStats = toolData.diffStats || computeDiffStatsFromData(toolData)
const diffStats = toolData.diffStats || computeDiffStats(toolData)
return (
<div className="group py-0.5">
@@ -451,47 +451,6 @@ function isReadableTool(toolName: string): boolean {
return READABLE_TOOL_PATTERNS.test(toolName.toLowerCase())
}
/** 从 diff 数据计算 diffStats(当 metadata 没给 diffStats 时用) */
function computeDiffStatsFromData(data: {
diff?: { before: string; after: string } | string
files?: Array<{ before?: string; after?: string; additions?: number; deletions?: number }>
}): { additions: number; deletions: number } | undefined {
// 多文件
if (data.files?.length) {
let additions = 0,
deletions = 0
for (const f of data.files) {
if (f.additions !== undefined) additions += f.additions
if (f.deletions !== undefined) deletions += f.deletions
if (f.additions === undefined && f.before !== undefined && f.after !== undefined) {
const s = computeDiffPair(f.before, f.after)
additions += s.additions
deletions += s.deletions
}
}
return additions || deletions ? { additions, deletions } : undefined
}
// 单个 diff
if (data.diff && typeof data.diff === 'object') {
const s = computeDiffPair(data.diff.before, data.diff.after)
return s.additions || s.deletions ? s : undefined
}
return undefined
}
function computeDiffPair(before: string, after: string): { additions: number; deletions: number } {
const changes = diffLines(before, after)
let additions = 0,
deletions = 0
for (const c of changes) {
if (c.added) additions += c.count || 0
if (c.removed) deletions += c.count || 0
}
return { additions, deletions }
}
// ============================================
// ToolBody - 根据工具类型选择渲染器
// ============================================
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { getToolSummaryCategory } from './toolSummary'
describe('getToolSummaryCategory', () => {
it.each([
['TodoWrite', 'todo'],
['task', 'task'],
['AskUserQuestion', 'question'],
['skill', 'skill'],
['bash', 'execute'],
['sh', 'execute'],
['run_cmd', 'execute'],
['terminal', 'execute'],
['shell_exec', 'execute'],
['write', 'write'],
['save_file', 'write'],
['edit', 'edit'],
['str_replace', 'edit'],
['apply_patch', 'edit'],
['webfetch', 'network'],
['http_get', 'network'],
['browse', 'network'],
['exa_search', 'network'],
['read', 'read'],
['cat', 'read'],
['grep', 'search'],
['search', 'search'],
['glob', 'list'],
['find_files', 'list'],
['think', 'think'],
['plan_mode', 'think'],
['unknown_tool', 'other'],
])('maps %s to %s', (tool, category) => {
expect(getToolSummaryCategory(tool)).toBe(category)
})
it('is case insensitive', () => {
expect(getToolSummaryCategory('BASH')).toBe('execute')
})
it('matches "task" and "sh" only exactly', () => {
expect(getToolSummaryCategory('tasks')).toBe('question') // contains "ask", not an exact "task"
expect(getToolSummaryCategory('push')).toBe('other')
})
it('keeps the precedence of earlier rules', () => {
// "todo" beats "write"; "ask" beats "read" in "ask_read"; "shell" (execute) beats "cat" in "shell_cat"
expect(getToolSummaryCategory('todowrite')).toBe('todo')
expect(getToolSummaryCategory('ask_read')).toBe('question')
expect(getToolSummaryCategory('shell_cat')).toBe('execute')
// "web" (network) beats "search"
expect(getToolSummaryCategory('websearch')).toBe('network')
})
})
+47
View File
@@ -0,0 +1,47 @@
export type ToolSummaryCategory =
| 'execute'
| 'write'
| 'edit'
| 'read'
| 'search'
| 'list'
| 'network'
| 'task'
| 'todo'
| 'question'
| 'skill'
| 'think'
| 'other'
interface CategoryRule {
category: ToolSummaryCategory
/** Exact (lowercase) tool names. */
exact?: readonly string[]
/** Substrings looked up in the lowercase tool name. */
includes?: readonly string[]
}
/** Order matters: the first matching rule wins (e.g. "todowrite" is a todo, not a write). */
const CATEGORY_RULES: readonly CategoryRule[] = [
{ category: 'todo', includes: ['todo'] },
{ category: 'task', exact: ['task'] },
{ category: 'question', includes: ['question', 'ask'] },
{ category: 'skill', includes: ['skill'] },
{ category: 'execute', exact: ['sh'], includes: ['bash', 'cmd', 'terminal', 'shell'] },
{ category: 'write', includes: ['write', 'save'] },
{ category: 'edit', includes: ['edit', 'replace', 'patch'] },
{ category: 'network', includes: ['web', 'fetch', 'http', 'browse', 'network', 'exa'] },
{ category: 'read', includes: ['read', 'cat'] },
{ category: 'search', includes: ['grep', 'search'] },
{ category: 'list', includes: ['glob', 'find'] },
{ category: 'think', includes: ['think', 'reason', 'plan'] },
]
function matchesRule(lowerName: string, rule: CategoryRule): boolean {
return !!rule.exact?.includes(lowerName) || !!rule.includes?.some(keyword => lowerName.includes(keyword))
}
export function getToolSummaryCategory(toolName: string): ToolSummaryCategory {
const lower = toolName.toLowerCase()
return CATEGORY_RULES.find(rule => matchesRule(lower, rule))?.category ?? 'other'
}
+138 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { ToolPart } from '../../../types/message'
import { defaultExtractData } from './registry'
import { defaultExtractData, extractToolData } from './registry'
describe('defaultExtractData', () => {
it('extracts files and diagnostics from metadata', () => {
@@ -43,3 +43,140 @@ describe('defaultExtractData', () => {
])
})
})
function makePart(state: Record<string, unknown> | undefined): ToolPart {
return {
type: 'tool',
tool: 'custom',
id: 'tool-1',
callID: 'call-1',
sessionID: 'session-1',
messageID: 'message-1',
state,
} as unknown as ToolPart
}
describe('defaultExtractData branches', () => {
it('returns an empty result for a part without state', () => {
expect(defaultExtractData(makePart(undefined))).toEqual({})
})
it('serializes input as JSON only when it has keys', () => {
expect(defaultExtractData(makePart({ input: { a: 1 } }))).toMatchObject({
input: JSON.stringify({ a: 1 }, null, 2),
inputLang: 'json',
})
expect(defaultExtractData(makePart({ input: {} })).input).toBeUndefined()
})
it('extracts the error as a string', () => {
expect(defaultExtractData(makePart({ error: 'boom' })).error).toBe('boom')
})
it('prefers metadata.filepath over input.filePath and detects the output language', () => {
const fromMetadata = defaultExtractData(
makePart({ input: { filePath: 'b.py' }, metadata: { filepath: 'a.ts' }, output: 'x' }),
)
expect(fromMetadata.filePath).toBe('a.ts')
expect(fromMetadata.outputLang).toBeTruthy()
expect(defaultExtractData(makePart({ input: { filePath: 'b.py' } })).filePath).toBe('b.py')
})
it('extracts a numeric exit code only', () => {
expect(defaultExtractData(makePart({ metadata: { exit: 2 } })).exitCode).toBe(2)
expect(defaultExtractData(makePart({ metadata: { exit: '2' } })).exitCode).toBeUndefined()
})
it('maps metadata.files with the unknown fallback for the name', () => {
const data = defaultExtractData(makePart({ metadata: { files: [{ file: 'f.ts' }, {}] } }))
expect(data.files?.map(f => f.filePath)).toEqual(['f.ts', 'unknown'])
})
it('uses a unified metadata.diff with filediff stats', () => {
const data = defaultExtractData(
makePart({ metadata: { diff: '@@', filediff: { additions: 3 } }, output: 'ignored' }),
)
expect(data.diff).toBe('@@')
expect(data.diffStats).toEqual({ additions: 3, deletions: 0 })
expect(data.output).toBeUndefined()
})
it('keeps a unified metadata.diff without stats when filediff has no counters', () => {
const data = defaultExtractData(makePart({ metadata: { diff: '@@', filediff: {} } }))
expect(data.diffStats).toBeUndefined()
})
it('reads filediff.patch, falling back to before/after', () => {
const patch = defaultExtractData(makePart({ metadata: { filediff: { patch: 'P', additions: 1, deletions: 2 } } }))
expect(patch.diff).toBe('P')
expect(patch.diffStats).toEqual({ additions: 1, deletions: 2 })
const pair = defaultExtractData(makePart({ metadata: { filediff: { before: 'a', after: 'b' } } }))
expect(pair.diff).toEqual({ before: 'a', after: 'b' })
expect(pair.diffStats).toBeUndefined()
expect(defaultExtractData(makePart({ metadata: { filediff: { before: 'a' } } })).diff).toBeUndefined()
})
it('keeps only error and warning diagnostics and skips malformed entries', () => {
const data = defaultExtractData(
makePart({
metadata: {
diagnostics: {
'C:\\proj\\a.ts': [
{ severity: 2, message: 'warn' },
{ severity: 3, message: 'info' },
{ severity: 9, message: 'unknown severity' },
{ message: 'no severity' },
null,
],
'b.ts': 'not-an-array',
},
},
}),
)
expect(data.diagnostics).toEqual([{ file: 'a.ts', severity: 'warning', message: 'warn', line: 0, column: 0 }])
})
it('omits diagnostics when nothing is an error or warning', () => {
const data = defaultExtractData(makePart({ metadata: { diagnostics: { 'a.ts': [{ severity: 4 }] } } }))
expect(data.diagnostics).toBeUndefined()
})
it('uses state.output, stringifying non-string values and inferring json', () => {
expect(defaultExtractData(makePart({ output: 'plain' }))).toMatchObject({ output: 'plain' })
expect(defaultExtractData(makePart({ output: 'plain' })).outputLang).toBeUndefined()
expect(defaultExtractData(makePart({ output: ' {"a":1} ' })).outputLang).toBe('json')
expect(defaultExtractData(makePart({ output: '[1]' })).outputLang).toBe('json')
expect(defaultExtractData(makePart({ output: { a: 1 } })).output).toBe(JSON.stringify({ a: 1 }, null, 2))
})
it('falls back to the interrupted metadata output only when interrupted', () => {
expect(defaultExtractData(makePart({ metadata: { interrupted: true, output: 'partial' } })).output).toBe('partial')
expect(defaultExtractData(makePart({ metadata: { output: 'partial' } })).output).toBeUndefined()
})
it('does not set output when files are present', () => {
const data = defaultExtractData(makePart({ output: 'x', metadata: { files: [{ filePath: 'a' }] } }))
expect(data.output).toBeUndefined()
})
})
describe('extractToolData for bash', () => {
const bash = (state: Record<string, unknown>) => extractToolData({ ...makePart(state), tool: 'bash' } as ToolPart)
it('shows the command as bash input and trims the working directory', () => {
const data = bash({ input: { command: 'ls -la', workdir: ' /tmp ' } })
expect(data).toMatchObject({ input: 'ls -la', inputLang: 'bash', cwd: '/tmp' })
})
it('falls back to metadata cwd and ignores blank or non-string values', () => {
expect(bash({ input: { command: 'pwd' }, metadata: { cwd: '/srv' } }).cwd).toBe('/srv')
expect(bash({ input: { command: 'pwd', workdir: ' ' } }).cwd).toBeUndefined()
expect(bash({ input: { command: 'pwd', cwd: 5 } }).cwd).toBeUndefined()
})
it('keeps the JSON input when there is no command', () => {
expect(bash({ input: { a: 1 } }).inputLang).toBe('json')
})
})
+167 -116
View File
@@ -60,6 +60,156 @@ interface MetadataDiagnosticEntry {
// Default Data Extractor
// ============================================
type DiffStats = { additions: number; deletions: number }
type MetadataFileDiff = {
patch?: string
before?: string
after?: string
additions?: number
deletions?: number
}
function toDiffStats(fd: { additions?: number; deletions?: number }): DiffStats | undefined {
if (fd.additions === undefined && fd.deletions === undefined) return undefined
return { additions: fd.additions || 0, deletions: fd.deletions || 0 }
}
function isObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object'
}
function toFileEntries(files: MetadataFileEntry[]): NonNullable<ExtractedToolData['files']> {
return files.map(file => ({
filePath: file.filePath || file.file || 'unknown',
diff: file.diff,
patch: file.patch,
before: file.before,
after: file.after,
additions: file.additions,
deletions: file.deletions,
}))
}
/** 上游 v1.4.0+ metadata.filediff 用 patch 格式;否则回退到 before/after */
function diffFromFileDiff(filediff: MetadataFileDiff): ExtractedToolData['diff'] {
if (filediff.patch) return filediff.patch
if (filediff.before !== undefined && filediff.after !== undefined) {
return { before: filediff.before, after: filediff.after }
}
return undefined
}
/** Extracts files / diff / diffStats from tool metadata (files > unified diff > filediff). */
function extractDiffData(metadata: Record<string, unknown>): Pick<ExtractedToolData, 'files' | 'diff' | 'diffStats'> {
if (Array.isArray(metadata.files) && metadata.files.length > 0) {
return { files: toFileEntries(metadata.files as MetadataFileEntry[]) }
}
const filediff = isObject(metadata.filediff) ? (metadata.filediff as MetadataFileDiff) : undefined
const diffStats = filediff && toDiffStats(filediff)
// 优先使用 unified diff,统计取自 filediff
if (typeof metadata.diff === 'string') return { diff: metadata.diff, diffStats }
if (!filediff) return {}
return { diff: diffFromFileDiff(filediff), diffStats }
}
// severity: 1=error, 2=warning, 3=info, 4=hint
const DIAGNOSTIC_SEVERITIES: Record<number, DiagnosticInfo['severity']> = {
1: 'error',
2: 'warning',
3: 'info',
4: 'hint',
}
function toSeverity(value: unknown): DiagnosticInfo['severity'] {
return (typeof value === 'number' && DIAGNOSTIC_SEVERITIES[value]) || 'info'
}
function toDiagnostic(file: string, item: MetadataDiagnosticEntry): DiagnosticInfo {
const start = item.range?.start
return {
file: file.split(/[/\\]/).pop() || file,
severity: toSeverity(item.severity),
message: item.message || '',
line: start?.line ?? 0,
column: start?.character ?? 0,
}
}
/** Extracts only the error/warning diagnostics from tool metadata. */
function extractDiagnostics(raw: unknown): DiagnosticInfo[] | undefined {
if (!isObject(raw)) return undefined
const diagnostics: DiagnosticInfo[] = []
for (const [file, items] of Object.entries(raw as Record<string, MetadataDiagnosticEntry[]>)) {
if (!Array.isArray(items)) continue
for (const item of items) {
if (isObject(item)) diagnostics.push(toDiagnostic(file, item))
}
}
// 只保留 error 和 warning
const filtered = diagnostics.filter(d => d.severity === 'error' || d.severity === 'warning')
return filtered.length > 0 ? filtered : undefined
}
function looksLikeJson(text: string): boolean {
const trimmed = text.trim()
return (trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))
}
function extractFilePath(
inputObj: Record<string, unknown> | undefined,
metadata: Record<string, unknown> | undefined,
): string | undefined {
if (typeof metadata?.filepath === 'string' && metadata.filepath) return metadata.filepath
return inputObj?.filePath ? String(inputObj.filePath) : undefined
}
/** Tool output (or the output kept by an interrupted run), stringified. */
function extractOutputText(
state: ToolPart['state'] | undefined,
metadata: Record<string, unknown> | undefined,
): string | undefined {
const stateOutput = state && 'output' in state ? state.output : undefined
const interrupted =
metadata?.interrupted === true && typeof metadata.output === 'string' ? metadata.output : undefined
const output = stateOutput ?? interrupted
if (!output) return undefined
return typeof output === 'string' ? output : JSON.stringify(output, null, 2)
}
/** Fields derived from `metadata`: exit code, files / diff / diffStats and diagnostics. */
function extractMetadataFields(metadata: Record<string, unknown> | undefined): ExtractedToolData {
if (!metadata) return {}
const result: ExtractedToolData = {}
if (typeof metadata.exit === 'number') result.exitCode = metadata.exit
const diffData = extractDiffData(metadata)
if (diffData.files) result.files = diffData.files
if (diffData.diff !== undefined) result.diff = diffData.diff
if (diffData.diffStats) result.diffStats = diffData.diffStats
const diagnostics = extractDiagnostics(metadata.diagnostics)
if (diagnostics) result.diagnostics = diagnostics
return result
}
/** Output text plus its language (inferred as json when it looks like a JSON document). */
function extractOutputFields(
result: ExtractedToolData,
state: ToolPart['state'] | undefined,
metadata: Record<string, unknown> | undefined,
): ExtractedToolData {
const output = extractOutputText(state, metadata)
if (result.files || result.diff || !output) return {}
if (!result.outputLang && looksLikeJson(output)) return { output, outputLang: 'json' }
return { output }
}
export function defaultExtractData(part: ToolPart): ExtractedToolData {
const { state } = part
const inputObj = state?.input as Record<string, unknown> | undefined
@@ -78,120 +228,15 @@ export function defaultExtractData(part: ToolPart): ExtractedToolData {
result.error = String(state.error)
}
// FilePath
if (metadata && typeof metadata.filepath === 'string') {
result.filePath = metadata.filepath
}
if (!result.filePath && inputObj?.filePath) {
result.filePath = String(inputObj.filePath)
// FilePath (+ output language derived from it)
const filePath = extractFilePath(inputObj, metadata)
if (filePath) {
result.filePath = filePath
result.outputLang = detectLanguage(filePath)
}
// Exit code
if (metadata && typeof metadata.exit === 'number') {
result.exitCode = metadata.exit
}
// Diff / Files (from metadata)
if (metadata) {
if (Array.isArray(metadata.files) && metadata.files.length > 0) {
result.files = (metadata.files as MetadataFileEntry[]).map(file => ({
filePath: file.filePath || file.file || 'unknown',
diff: file.diff,
patch: file.patch,
before: file.before,
after: file.after,
additions: file.additions,
deletions: file.deletions,
}))
} else if (typeof metadata.diff === 'string') {
// 优先使用 unified diff
result.diff = metadata.diff
// 从 filediff 获取统计
if (metadata.filediff && typeof metadata.filediff === 'object') {
const fd = metadata.filediff as { additions?: number; deletions?: number }
if (fd.additions !== undefined || fd.deletions !== undefined) {
result.diffStats = {
additions: fd.additions || 0,
deletions: fd.deletions || 0,
}
}
}
} else if (metadata.filediff && typeof metadata.filediff === 'object') {
const fd = metadata.filediff as {
patch?: string
before?: string
after?: string
additions?: number
deletions?: number
}
// 上游 v1.4.0+ metadata.filediff 用 patch 格式
if (fd.patch) {
result.diff = fd.patch
} else if (fd.before !== undefined && fd.after !== undefined) {
result.diff = { before: fd.before, after: fd.after }
}
if (fd.additions !== undefined || fd.deletions !== undefined) {
result.diffStats = {
additions: fd.additions || 0,
deletions: fd.deletions || 0,
}
}
}
// 提取 diagnostics
if (metadata.diagnostics && typeof metadata.diagnostics === 'object') {
const diagMap = metadata.diagnostics as Record<string, MetadataDiagnosticEntry[]>
const diagnostics: DiagnosticInfo[] = []
for (const [file, items] of Object.entries(diagMap)) {
if (!Array.isArray(items)) continue
for (const item of items) {
if (!item || typeof item !== 'object') continue
// severity: 1=error, 2=warning, 3=info, 4=hint
const severityMap: Record<number, DiagnosticInfo['severity']> = {
1: 'error',
2: 'warning',
3: 'info',
4: 'hint',
}
diagnostics.push({
file: file.split(/[/\\]/).pop() || file,
severity: typeof item.severity === 'number' ? (severityMap[item.severity] ?? 'info') : 'info',
message: item.message || '',
line: item.range?.start?.line ?? 0,
column: item.range?.start?.character ?? 0,
})
}
}
// 只保留 error 和 warning
const filtered = diagnostics.filter(d => d.severity === 'error' || d.severity === 'warning')
if (filtered.length > 0) {
result.diagnostics = filtered
}
}
}
// Output language from filePath
if (result.filePath) {
result.outputLang = detectLanguage(result.filePath)
}
// Output
const stateOutput = state && 'output' in state ? state.output : undefined
const interruptedOutput = metadata?.interrupted === true && typeof metadata.output === 'string' ? metadata.output : undefined
const output = stateOutput ?? interruptedOutput
if (!result.files && !result.diff && output) {
result.output = typeof output === 'string' ? output : JSON.stringify(output, null, 2)
// 推断语言
if (!result.outputLang && result.output) {
const trimmed = result.output.trim()
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
result.outputLang = 'json'
}
}
}
Object.assign(result, extractMetadataFields(metadata))
Object.assign(result, extractOutputFields(result, state, metadata))
return result
}
@@ -200,6 +245,14 @@ export function defaultExtractData(part: ToolPart): ExtractedToolData {
// Tool-Specific Data Extractors
// ============================================
function resolveCwd(
inputObj: Record<string, unknown> | undefined,
metadata: Record<string, unknown> | undefined,
): string | undefined {
const cwd = inputObj?.workdir ?? inputObj?.cwd ?? metadata?.workdir ?? metadata?.cwd
return typeof cwd === 'string' && cwd.trim() ? cwd.trim() : undefined
}
function bashExtractData(part: ToolPart): ExtractedToolData {
const base = defaultExtractData(part)
const inputObj = part.state?.input as Record<string, unknown> | undefined
@@ -210,10 +263,8 @@ function bashExtractData(part: ToolPart): ExtractedToolData {
base.inputLang = 'bash'
}
const cwd = inputObj?.workdir ?? inputObj?.cwd ?? metadata?.workdir ?? metadata?.cwd
if (typeof cwd === 'string' && cwd.trim()) {
base.cwd = cwd.trim()
}
const cwd = resolveCwd(inputObj, metadata)
if (cwd) base.cwd = cwd
return base
}
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest'
import { sanitizePersistedTerminalLayoutMap } from './layoutStore'
const EMPTY = { version: 1, directories: {} }
function entry(overrides: Record<string, unknown> = {}) {
return {
order: { bottom: ['a'], right: ['b'] },
activeTabId: { bottom: 'a', right: 'b' },
...overrides,
}
}
function sanitize(directories: Record<string, unknown>) {
return sanitizePersistedTerminalLayoutMap({ version: 1, directories })
}
describe('sanitizePersistedTerminalLayoutMap', () => {
it.each([null, undefined, 'x', 3, { version: 2, directories: {} }, { version: 1 }, { version: 1, directories: 'x' }])(
'falls back to an empty map for invalid root %j',
raw => {
expect(sanitizePersistedTerminalLayoutMap(raw)).toEqual(EMPTY)
},
)
it('keeps a well-formed directory entry', () => {
expect(sanitize({ '/proj': entry() })).toEqual({
version: 1,
directories: {
'/proj': { order: { bottom: ['a'], right: ['b'] }, activeTabId: { bottom: 'a', right: 'b' }, sessions: {} },
},
})
})
it('skips entries with an empty key, a non-object value, or missing order / activeTabId', () => {
const result = sanitize({
'': entry(),
'/null': null,
'/string': 'x',
'/no-order': { activeTabId: { bottom: null, right: null } },
'/order-not-object': entry({ order: 'x' }),
'/no-active': { order: { bottom: [], right: [] } },
'/active-not-object': entry({ activeTabId: 'x' }),
'/ok': entry(),
})
expect(Object.keys(result.directories)).toEqual(['/ok'])
})
it('filters the order lists down to non-empty strings and tolerates non-arrays', () => {
const result = sanitize({
'/a': entry({ order: { bottom: ['x', '', 5, null, 'y'], right: 'nope' } }),
})
expect(result.directories['/a'].order).toEqual({ bottom: ['x', 'y'], right: [] })
})
it('nulls non-string active tab ids independently', () => {
const result = sanitize({
'/a': entry({ activeTabId: { bottom: 'a', right: 7 } }),
'/b': entry({ activeTabId: { bottom: 7, right: 'b' } }),
})
expect(result.directories['/a'].activeTabId).toEqual({ bottom: 'a', right: null })
expect(result.directories['/b'].activeTabId).toEqual({ bottom: null, right: 'b' })
})
it('keeps typed session fields, drops mistyped ones and skips invalid sessions', () => {
const result = sanitize({
'/a': entry({
sessions: {
t1: { title: 'T', shellTitle: 's', customTitle: 'c', buffer: 'b', scrollY: 1, cursor: 2, rows: 3, cols: 4 },
t2: { title: 5, shellTitle: 5, customTitle: 5, buffer: 5, scrollY: '1', cursor: '2', rows: '3', cols: '4' },
'': { title: 'ignored' },
t3: null,
t4: 'x',
},
}),
})
expect(result.directories['/a'].sessions).toEqual({
t1: { title: 'T', shellTitle: 's', customTitle: 'c', buffer: 'b', scrollY: 1, cursor: 2, rows: 3, cols: 4 },
t2: {
title: undefined,
shellTitle: undefined,
customTitle: undefined,
buffer: undefined,
scrollY: undefined,
cursor: undefined,
rows: undefined,
cols: undefined,
},
})
})
it('uses an empty sessions map when sessions is not an object', () => {
expect(sanitize({ '/a': entry({ sessions: 'x' }) }).directories['/a'].sessions).toEqual({})
expect(sanitize({ '/a': entry() }).directories['/a'].sessions).toEqual({})
})
})
+61 -49
View File
@@ -230,61 +230,73 @@ function sanitizePersistedPanelLayout(raw: unknown): PersistedPanelLayout | null
}
}
function sanitizePersistedTerminalLayoutMap(raw: unknown): PersistedTerminalLayoutMap {
if (!raw || typeof raw !== 'object') {
return { version: 1, directories: {} }
}
function sanitizeIdList(raw: unknown): string[] {
return Array.isArray(raw) ? raw.filter((id): id is string => typeof id === 'string' && id.length > 0) : []
}
const data = raw as Partial<PersistedTerminalLayoutMap>
if (data.version !== 1 || !data.directories || typeof data.directories !== 'object') {
function sanitizeActiveTabId(raw: { bottom?: unknown; right?: unknown }): Record<PanelPosition, string | null> {
return {
bottom: typeof raw.bottom === 'string' ? raw.bottom : null,
right: typeof raw.right === 'string' ? raw.right : null,
}
}
function optionalString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined
}
function optionalNumber(value: unknown): number | undefined {
return typeof value === 'number' ? value : undefined
}
function sanitizeTerminalSessionState(raw: Partial<PersistedTerminalSessionState>): PersistedTerminalSessionState {
return {
title: optionalString(raw.title),
shellTitle: optionalString(raw.shellTitle),
customTitle: optionalString(raw.customTitle),
buffer: optionalString(raw.buffer),
scrollY: optionalNumber(raw.scrollY),
cursor: optionalNumber(raw.cursor),
rows: optionalNumber(raw.rows),
cols: optionalNumber(raw.cols),
}
}
function sanitizeTerminalSessions(raw: unknown): Record<string, PersistedTerminalSessionState> {
const sessions: Record<string, PersistedTerminalSessionState> = {}
if (!raw || typeof raw !== 'object') return sessions
for (const [id, session] of Object.entries(raw)) {
if (!id || !session || typeof session !== 'object') continue
sessions[id] = sanitizeTerminalSessionState(session as Partial<PersistedTerminalSessionState>)
}
return sessions
}
function sanitizeTerminalDirectoryLayout(raw: unknown): PersistedTerminalDirectoryLayout | null {
if (!raw || typeof raw !== 'object') return null
const entry = raw as Partial<PersistedTerminalDirectoryLayout>
const { order, activeTabId } = entry
if (!order || typeof order !== 'object' || !activeTabId || typeof activeTabId !== 'object') return null
return {
order: { bottom: sanitizeIdList(order.bottom), right: sanitizeIdList(order.right) },
activeTabId: sanitizeActiveTabId(activeTabId),
sessions: sanitizeTerminalSessions(entry.sessions),
}
}
export function sanitizePersistedTerminalLayoutMap(raw: unknown): PersistedTerminalLayoutMap {
const data = raw && typeof raw === 'object' ? (raw as Partial<PersistedTerminalLayoutMap>) : undefined
if (data?.version !== 1 || !data.directories || typeof data.directories !== 'object') {
return { version: 1, directories: {} }
}
const directories: Record<string, PersistedTerminalDirectoryLayout> = {}
for (const [directory, value] of Object.entries(data.directories)) {
if (!directory || !value || typeof value !== 'object') continue
const entry = value as Partial<PersistedTerminalDirectoryLayout>
const rawOrder = entry.order
const rawActiveTabId = entry.activeTabId
if (!rawOrder || typeof rawOrder !== 'object' || !rawActiveTabId || typeof rawActiveTabId !== 'object') continue
const order = {
bottom: Array.isArray(rawOrder.bottom)
? rawOrder.bottom.filter((id): id is string => typeof id === 'string' && id.length > 0)
: [],
right: Array.isArray(rawOrder.right)
? rawOrder.right.filter((id): id is string => typeof id === 'string' && id.length > 0)
: [],
}
const sessions: Record<string, PersistedTerminalSessionState> = {}
const rawSessions = entry.sessions
if (rawSessions && typeof rawSessions === 'object') {
for (const [id, session] of Object.entries(rawSessions)) {
if (!id || !session || typeof session !== 'object') continue
const data = session as Partial<PersistedTerminalSessionState>
sessions[id] = {
title: typeof data.title === 'string' ? data.title : undefined,
shellTitle: typeof data.shellTitle === 'string' ? data.shellTitle : undefined,
customTitle: typeof data.customTitle === 'string' ? data.customTitle : undefined,
buffer: typeof data.buffer === 'string' ? data.buffer : undefined,
scrollY: typeof data.scrollY === 'number' ? data.scrollY : undefined,
cursor: typeof data.cursor === 'number' ? data.cursor : undefined,
rows: typeof data.rows === 'number' ? data.rows : undefined,
cols: typeof data.cols === 'number' ? data.cols : undefined,
}
}
}
directories[directory] = {
order,
activeTabId: {
bottom: typeof rawActiveTabId.bottom === 'string' ? rawActiveTabId.bottom : null,
right: typeof rawActiveTabId.right === 'string' ? rawActiveTabId.right : null,
},
sessions,
}
const layout = directory ? sanitizeTerminalDirectoryLayout(value) : null
if (layout) directories[directory] = layout
}
return { version: 1, directories }
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { computeDiffPairStats, computeDiffStats } from './diffStats'
describe('computeDiffPairStats', () => {
it('counts added and removed lines', () => {
expect(computeDiffPairStats('a\nb\n', 'a\nc\nd\n')).toEqual({ additions: 2, deletions: 1 })
})
it('returns zeros for identical text', () => {
expect(computeDiffPairStats('a\n', 'a\n')).toEqual({ additions: 0, deletions: 0 })
})
})
describe('computeDiffStats', () => {
it('returns undefined without files or a before/after pair', () => {
expect(computeDiffStats({})).toBeUndefined()
expect(computeDiffStats({ files: [] })).toBeUndefined()
expect(computeDiffStats({ diff: '@@ -1 +1 @@' })).toBeUndefined()
})
it('sums the counters reported per file', () => {
expect(
computeDiffStats({
files: [{ additions: 2, deletions: 1 }, { additions: 1 }, { deletions: 3 }],
}),
).toEqual({ additions: 3, deletions: 4 })
})
it('computes missing counters from before/after, only when additions is absent', () => {
expect(
computeDiffStats({
files: [
{ before: 'a\n', after: 'a\nb\n' },
{ before: 'x\n', after: 'y\n', additions: 5 },
{ before: 'only-before\n' },
],
}),
).toEqual({ additions: 6, deletions: 0 })
})
it('returns undefined when files contribute no changes', () => {
expect(computeDiffStats({ files: [{ additions: 0, deletions: 0 }] })).toBeUndefined()
})
it('uses a single before/after diff and ignores it when nothing changed', () => {
expect(computeDiffStats({ diff: { before: 'a\n', after: 'b\n' } })).toEqual({ additions: 1, deletions: 1 })
expect(computeDiffStats({ diff: { before: 'a\n', after: 'a\n' } })).toBeUndefined()
})
it('prefers files over the single diff', () => {
expect(computeDiffStats({ files: [{ additions: 1 }], diff: { before: 'a\n', after: 'b\nc\n' } })).toEqual({
additions: 1,
deletions: 0,
})
})
})
+50
View File
@@ -0,0 +1,50 @@
import { diffLines } from 'diff'
export interface DiffStats {
additions: number
deletions: number
}
interface DiffStatsInput {
diff?: { before: string; after: string } | string
files?: Array<{ before?: string; after?: string; additions?: number; deletions?: number }>
}
/** Line additions/deletions between two texts. */
export function computeDiffPairStats(before: string, after: string): DiffStats {
let additions = 0
let deletions = 0
for (const change of diffLines(before, after)) {
if (change.added) additions += change.count || 0
if (change.removed) deletions += change.count || 0
}
return { additions, deletions }
}
/**
* Diff stats from extracted tool data, for when the metadata did not provide them.
* Returns undefined when there is nothing to count (no files, no before/after pair, or no changes).
*/
export function computeDiffStats(data: DiffStatsInput): DiffStats | undefined {
if (data.files?.length) {
let additions = 0
let deletions = 0
for (const file of data.files) {
if (file.additions !== undefined) additions += file.additions
if (file.deletions !== undefined) deletions += file.deletions
if (file.additions === undefined && file.before !== undefined && file.after !== undefined) {
const stats = computeDiffPairStats(file.before, file.after)
additions += stats.additions
deletions += stats.deletions
}
}
return additions || deletions ? { additions, deletions } : undefined
}
if (data.diff && typeof data.diff === 'object') {
const stats = computeDiffPairStats(data.diff.before, data.diff.after)
return stats.additions || stats.deletions ? stats : undefined
}
return undefined
}