Compare commits

6 Commits
Author SHA1 Message Date
evandro 1314c5ab3e docs: update test counts and document the local server security model 2026-10-10 04:28:00 -03:00
evandro ceca5bf301 fix(extension): generate the API token with a CSPRNG and restrict the discovery file
The bearer token came from Math.random(), which is predictable. Use crypto.randomBytes
and write piagentui-port.json (which stores the token) with mode 0600.
2026-10-10 04:24:44 -03:00
evandro e8927ebf78 perf(extension): cache parsed session files by mtime and size
GET /api/sessions (and every session lookup) re-read and JSON.parsed every
.jsonl session file synchronously on each request, blocking the agent's
event loop. Parsed metadata is now cached per path (bounded to 500 entries)
and invalidated when mtime or size changes.
2026-10-10 04:23:16 -03:00
evandro 3dd3fa5d05 test: read SSE chunks until session.updated arrives
session.status and session.updated are two separate res.write calls, so the
test failed whenever TCP delivered them in different chunks.
2026-10-10 04:23:16 -03:00
evandro adf63ef9f7 chore: remove unused modules and ignore local env and coverage output
src/api/piContract.ts and src/hooks/usePanelResizeStatus.ts have no importers (knip and grep).
2026-10-10 04:20:27 -03:00
evandro 2d2cdf3b19 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
2026-10-10 04:19:34 -03:00
21 changed files with 1134 additions and 517 deletions

No files matched your search

+5
View File
@@ -7,3 +7,8 @@ tmp/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
.pi-lens/ .pi-lens/
# Local env and test output
.env
.env.local
coverage/
+3 -2
View File
@@ -173,13 +173,13 @@ Everything below was executed on a clean clone in this repository state:
| `npm run typecheck:extensions` | exit 0 | | `npm run typecheck:extensions` | exit 0 |
| `npm run lint` | exit 0 (0 errors, 109 warnings) | | `npm run lint` | exit 0 (0 errors, 109 warnings) |
| `npm run build` | exit 0, `dist/` about 14 MB | | `npm run build` | exit 0, `dist/` about 14 MB |
| `npm run test:run` | 89 files, 319 tests, all passing | | `npm run test:run` | 95 files, 426 tests, all passing (on Node 22 in CI; see the Node 25+ note below) |
| `npm run validate` | exit 0 (the same gate CI runs in `build.yml`) | | `npm run validate` | exit 0 (the same gate CI runs in `build.yml`) |
| `cargo check` in `src-router/` | exit 0 | | `cargo check` in `src-router/` | exit 0 |
| `cargo check --manifest-path src-tauri/Cargo.toml` | exit 0 (1 warning) | | `cargo check --manifest-path src-tauri/Cargo.toml` | exit 0 (1 warning) |
| extension server over HTTP | `/global/health` 200, `/` 200 with `PI_BOOT_DATA`, `/api/models` 200 with token and 401 without, `/api/sessions`, `/api/sessions/:id/messages`, `/api/sessions/:id/context`, `/api/skills`, `/api/mcp/status`, `/api/commands/list`, `/api/network/access` all 200, `/global/event` 200 `text/event-stream` | | extension server over HTTP | `/global/health` 200, `/` 200 with `PI_BOOT_DATA`, `/api/models` 200 with token and 401 without, `/api/sessions`, `/api/sessions/:id/messages`, `/api/sessions/:id/context`, `/api/skills`, `/api/mcp/status`, `/api/commands/list`, `/api/network/access` all 200, `/global/event` 200 `text/event-stream` |
Two things to know when running the suite: `npm run test:run` needs `dist/` to exist, because the extension server test fetches the served `index.html`, so `npm run build` must run first (that is the order in `npm run validate`); and `npm run format:check` currently reports 67 files that are not Prettier-formatted, so `npm run check` fails on that step even though the CI gate passes. Two things to know when running the suite: `npm run test:run` needs `dist/` to exist, because the extension server test fetches the served `index.html`, so `npm run build` must run first (that is the order in `npm run validate`); and `npm run format:check` currently reports 66 files that are not Prettier-formatted, so `npm run check` fails on that step even though the CI gate passes. On Node 25 and newer, Node's built-in `localStorage` shadows the jsdom one and two `settingsBackup` tests fail; running them with `NODE_OPTIONS=--no-experimental-webstorage` makes them pass.
## Current state and limitations ## Current state and limitations
@@ -190,6 +190,7 @@ Two things to know when running the suite: `npm run test:run` needs `dist/` to e
- `@mariozechner/pi-coding-agent` is a devDependency for types only (the runtime provides the real extension API), and npm marks that package line as deprecated in favour of `@earendil-works/pi-coding-agent`. - `@mariozechner/pi-coding-agent` is a devDependency for types only (the runtime provides the real extension API), and npm marks that package line as deprecated in favour of `@earendil-works/pi-coding-agent`.
- The front end talks to one runtime per window: the discovery file or a manually configured server. There is no multi-runtime aggregation. - The front end talks to one runtime per window: the discovery file or a manually configured server. There is no multi-runtime aggregation.
- Voice input exists in the UI (`src/hooks/useVoiceTranscription.ts`) and requires an OpenAI-compatible STT endpoint configured in settings; it is not covered by an automated browser test. - Voice input exists in the UI (`src/hooks/useVoiceTranscription.ts`) and requires an OpenAI-compatible STT endpoint configured in settings; it is not covered by an automated browser test.
- Security model of the local server: it binds `0.0.0.0` but answers only loopback `Host`/`Origin` values until LAN access is enabled, and `/api/*` needs the bearer token from the discovery file (generated with `crypto.randomBytes` and stored with mode `0600`). Once LAN access is enabled in settings, requests from private-network hosts are accepted without a token, so turn it on only on networks you trust: the API can read project files and manage Git and extensions. There is no per-user authentication.
- No published npm package and no hosted web demo; both README and code assume a local runtime. - No published npm package and no hosted web demo; both README and code assume a local runtime.
- `docker-frontend.yml` last ran on 2026-06-01 and failed at the build step, so `ghcr.io/evandrodevbr/opencodeui-frontend` was never published (verified against GHCR with the owner credentials); the compose files pull that one image from upstream until the workflow is fixed. - `docker-frontend.yml` last ran on 2026-06-01 and failed at the build step, so `ghcr.io/evandrodevbr/opencodeui-frontend` was never published (verified against GHCR with the owner credentials); the compose files pull that one image from upstream until the workflow is fixed.
+3 -2
View File
@@ -177,13 +177,13 @@ Tudo abaixo foi executado em um clone limpo neste estado do repositório:
| `npm run typecheck:extensions` | exit 0 | | `npm run typecheck:extensions` | exit 0 |
| `npm run lint` | exit 0 (0 erros, 109 avisos) | | `npm run lint` | exit 0 (0 erros, 109 avisos) |
| `npm run build` | exit 0, `dist/` com cerca de 14 MB | | `npm run build` | exit 0, `dist/` com cerca de 14 MB |
| `npm run test:run` | 89 arquivos, 319 testes, todos passando | | `npm run test:run` | 95 arquivos, 426 testes, todos passando (no Node 22 do CI; veja a nota sobre Node 25+ abaixo) |
| `npm run validate` | exit 0 (o mesmo gate que o CI roda em `build.yml`) | | `npm run validate` | exit 0 (o mesmo gate que o CI roda em `build.yml`) |
| `cargo check` em `src-router/` | exit 0 | | `cargo check` em `src-router/` | exit 0 |
| `cargo check --manifest-path src-tauri/Cargo.toml` | exit 0 (1 aviso) | | `cargo check --manifest-path src-tauri/Cargo.toml` | exit 0 (1 aviso) |
| servidor da extensão via HTTP | `/global/health` 200, `/` 200 com `PI_BOOT_DATA`, `/api/models` 200 com token e 401 sem, `/api/sessions`, `/api/sessions/:id/messages`, `/api/sessions/:id/context`, `/api/skills`, `/api/mcp/status`, `/api/commands/list`, `/api/network/access` todos 200, `/global/event` 200 `text/event-stream` | | servidor da extensão via HTTP | `/global/health` 200, `/` 200 com `PI_BOOT_DATA`, `/api/models` 200 com token e 401 sem, `/api/sessions`, `/api/sessions/:id/messages`, `/api/sessions/:id/context`, `/api/skills`, `/api/mcp/status`, `/api/commands/list`, `/api/network/access` todos 200, `/global/event` 200 `text/event-stream` |
Dois detalhes ao rodar a suíte: `npm run test:run` precisa do `dist/` existir, porque o teste do servidor da extensão busca o `index.html` servido, então `npm run build` deve rodar antes (é a ordem em `npm run validate`); e `npm run format:check` hoje acusa 67 arquivos fora do padrão do Prettier, então `npm run check` falha nesse passo mesmo com o gate do CI passando. Dois detalhes ao rodar a suíte: `npm run test:run` precisa do `dist/` existir, porque o teste do servidor da extensão busca o `index.html` servido, então `npm run build` deve rodar antes (é a ordem em `npm run validate`); e `npm run format:check` hoje acusa 66 arquivos fora do padrão do Prettier, então `npm run check` falha nesse passo mesmo com o gate do CI passando. No Node 25 e superior, o `localStorage` nativo do Node sobrepõe o do jsdom e dois testes de `settingsBackup` falham; rodá-los com `NODE_OPTIONS=--no-experimental-webstorage` faz passarem.
## Estado atual e limitações ## Estado atual e limitações
@@ -194,6 +194,7 @@ Dois detalhes ao rodar a suíte: `npm run test:run` precisa do `dist/` existir,
- `@mariozechner/pi-coding-agent` é devDependency só para tipos (o runtime fornece a API real da extensão) e o npm marca essa linha como depreciada em favor de `@earendil-works/pi-coding-agent`. - `@mariozechner/pi-coding-agent` é devDependency só para tipos (o runtime fornece a API real da extensão) e o npm marca essa linha como depreciada em favor de `@earendil-works/pi-coding-agent`.
- O front end conversa com um runtime por janela: o arquivo de descoberta ou um servidor configurado à mão. Não há agregação de múltiplos runtimes. - O front end conversa com um runtime por janela: o arquivo de descoberta ou um servidor configurado à mão. Não há agregação de múltiplos runtimes.
- A entrada de voz existe na UI (`src/hooks/useVoiceTranscription.ts`) e exige um endpoint STT compatível com OpenAI configurado nos settings; não há teste automatizado de navegador para ela. - A entrada de voz existe na UI (`src/hooks/useVoiceTranscription.ts`) e exige um endpoint STT compatível com OpenAI configurado nos settings; não há teste automatizado de navegador para ela.
- Modelo de segurança do servidor local: ele escuta em `0.0.0.0`, mas só responde a valores de `Host`/`Origin` de loopback até o acesso pela LAN ser ativado, e `/api/*` exige o token bearer do arquivo de descoberta (gerado com `crypto.randomBytes` e gravado com modo `0600`). Com o acesso pela LAN ativado nas configurações, requisições de hosts de rede privada são aceitas sem token; ative só em redes confiáveis, pois a API lê arquivos do projeto e gerencia Git e extensões. Não há autenticação por usuário.
- Sem pacote publicado no npm e sem demo web hospedada; o README e o código assumem um runtime local. - Sem pacote publicado no npm e sem demo web hospedada; o README e o código assumem um runtime local.
## Documentação ## Documentação
+7 -2
View File
@@ -1025,8 +1025,13 @@ describe('PiAgentUi Extension Server', () => {
// Trigger agent_start - should broadcast session.status and session.updated! // Trigger agent_start - should broadcast session.status and session.updated!
await listeners['agent_start']() await listeners['agent_start']()
const chunk3 = await reader?.read() // The two events are separate writes, so TCP may deliver them in different chunks.
const text3 = new TextDecoder().decode(chunk3?.value) let text3 = ''
while (!text3.includes('session.updated')) {
const chunk = await reader?.read()
if (!chunk || chunk.done) break
text3 += new TextDecoder().decode(chunk.value)
}
expect(text3).toContain('session.status') expect(text3).toContain('session.status')
expect(text3).toContain('working') expect(text3).toContain('working')
expect(text3).toContain('session.updated') expect(text3).toContain('session.updated')
+79 -137
View File
@@ -3,7 +3,7 @@ import { WebSocketServer, WebSocket } from 'ws'
import * as http from 'node:http' import * as http from 'node:http'
import * as fs from 'node:fs' import * as fs from 'node:fs'
import * as path from 'node:path' import * as path from 'node:path'
import { randomUUID } from 'node:crypto' import { randomBytes, randomUUID } from 'node:crypto'
import { import {
validateBearerToken, validateBearerToken,
preventPathTraversal, preventPathTraversal,
@@ -21,6 +21,8 @@ import {
type StoredSttSettings, type StoredSttSettings,
type StoredTtsSettings, type StoredTtsSettings,
} from './piagentui-settings-store.js' } from './piagentui-settings-store.js'
import { createStatCache } from './piagentui-stat-cache.js'
import { applySttSettingsUpdate, applyTtsSettingsUpdate, isTtsResponseFormat } from './piagentui-speech-settings.js'
import { import {
buildExtensionCatalog, buildExtensionCatalog,
executeExtensionInstallCommand, executeExtensionInstallCommand,
@@ -517,48 +519,76 @@ export default function (pi: ExtensionAPI) {
.slice(0, 120) .slice(0, 120)
} }
type ParsedSessionFile = {
id: string
title: string
cwd: string | undefined
headerTimestamp: string | undefined
messageCount: number
}
// GET /api/sessions parses every session file; re-parse only files whose mtime/size changed.
const sessionFileCache = createStatCache<ParsedSessionFile | null>()
function parseSessionFile(filePath: string): ParsedSessionFile | null {
const content = fs.readFileSync(filePath, 'utf8')
const lines = content.trim().split('\n').filter(Boolean)
if (lines.length === 0) return null
const header = JSON.parse(lines[0])
if (header.type !== 'session' || !header.id) return null
let sessionInfoTitle = ''
let firstUserMessage = ''
let messageCount = 0
for (const line of lines.slice(1)) {
try {
const entry = JSON.parse(line)
if (entry.type === 'session_info') {
sessionInfoTitle = normalizeSessionTitle(entry.name || '')
} else if (entry.type === 'message') {
messageCount++
if (!firstUserMessage && entry.message?.role === 'user') {
firstUserMessage = normalizeSessionTitle(getMessageText(entry.message))
}
}
} catch {
// ignore malformed entries
}
}
return {
id: header.id,
title: sessionInfoTitle || firstUserMessage || getFallbackSessionTitle(header.id),
cwd: header.cwd,
headerTimestamp: header.timestamp,
messageCount,
}
}
function readSessionMetadata(filePath: string): any | null { function readSessionMetadata(filePath: string): any | null {
try { try {
const content = fs.readFileSync(filePath, 'utf8') const stats = fs.statSync(filePath)
const lines = content.trim().split('\n').filter(Boolean) let cached = sessionFileCache.get(filePath, stats)
if (lines.length === 0) return null if (!cached) {
cached = { value: parseSessionFile(filePath) }
const header = JSON.parse(lines[0]) sessionFileCache.set(filePath, stats, cached.value)
if (header.type !== 'session' || !header.id) return null
let sessionInfoTitle = ''
let firstUserMessage = ''
let messageCount = 0
for (const line of lines.slice(1)) {
try {
const entry = JSON.parse(line)
if (entry.type === 'session_info') {
sessionInfoTitle = normalizeSessionTitle(entry.name || '')
} else if (entry.type === 'message') {
messageCount++
if (!firstUserMessage && entry.message?.role === 'user') {
firstUserMessage = normalizeSessionTitle(getMessageText(entry.message))
}
}
} catch {
// ignore malformed entries
}
} }
const stats = fs.statSync(filePath) const parsed = cached.value
const createdTime = header.timestamp ? new Date(header.timestamp).getTime() : stats.birthtimeMs if (!parsed) return null
const title = sessionInfoTitle || firstUserMessage || getFallbackSessionTitle(header.id)
const createdTime = parsed.headerTimestamp ? new Date(parsed.headerTimestamp).getTime() : stats.birthtimeMs
return { return {
id: header.id, id: parsed.id,
title, title: parsed.title,
directory: header.cwd || latestCtx?.cwd || process.cwd(), directory: parsed.cwd || latestCtx?.cwd || process.cwd(),
time: { time: {
created: Number.isNaN(createdTime) ? stats.birthtimeMs : createdTime, created: Number.isNaN(createdTime) ? stats.birthtimeMs : createdTime,
updated: stats.mtimeMs, updated: stats.mtimeMs,
}, },
summary: { deletions: 0, files: 0, additions: messageCount }, summary: { deletions: 0, files: 0, additions: parsed.messageCount },
} }
} catch { } catch {
return null return null
@@ -691,7 +721,8 @@ export default function (pi: ExtensionAPI) {
} }
function generateBearerToken(): string { function generateBearerToken(): string {
return Array.from({ length: 4 }, () => Math.random().toString(36).substring(2)).join('-') // CSPRNG: the token is the only credential guarding the local API.
return randomBytes(24).toString('base64url')
} }
const settingsStore = createPiAgentUiSettingsStore(resolvePiAgentUiSettingsPaths()) const settingsStore = createPiAgentUiSettingsStore(resolvePiAgentUiSettingsPaths())
@@ -774,56 +805,11 @@ export default function (pi: ExtensionAPI) {
function updateSttSettings(update: any): { ok: true; settings: SanitizedSttSettings } | { ok: false; error: string } { function updateSttSettings(update: any): { ok: true; settings: SanitizedSttSettings } | { ok: false; error: string } {
const current = getLocalSettings() const current = getLocalSettings()
const existing = current.stt || {} const result = applySttSettingsUpdate(current.stt || {}, update)
const next: StoredSttSettings = { ...existing } if (!result.ok) return result
if (update.enabled !== undefined) next.enabled = update.enabled === true saveLocalSettings({ ...current, stt: result.next })
if (update.providerKind !== undefined) { return { ok: true, settings: sanitizeSttSettings(result.next) }
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) }
} }
function getEffectiveTtsSettings(): StoredTtsSettings { function getEffectiveTtsSettings(): StoredTtsSettings {
@@ -857,62 +843,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 } { function updateTtsSettings(update: any): { ok: true; settings: SanitizedTtsSettings } | { ok: false; error: string } {
const current = getLocalSettings() const current = getLocalSettings()
const existing = current.tts || {} const result = applyTtsSettingsUpdate(current.tts || {}, update)
const next: StoredTtsSettings = { ...existing } if (!result.ok) return result
if (update.enabled !== undefined) next.enabled = update.enabled === true saveLocalSettings({ ...current, tts: result.next })
if (update.providerKind !== undefined) { return { ok: true, settings: sanitizeTtsSettings(result.next) }
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) }
} }
async function readRequestBuffer(req: http.IncomingMessage): Promise<Buffer> { async function readRequestBuffer(req: http.IncomingMessage): Promise<Buffer> {
@@ -2129,7 +2066,12 @@ export default function (pi: ExtensionAPI) {
if (!fs.existsSync(piAgentDir)) { if (!fs.existsSync(piAgentDir)) {
fs.mkdirSync(piAgentDir, { recursive: true }) fs.mkdirSync(piAgentDir, { recursive: true })
} }
fs.writeFileSync(discoveryPath, JSON.stringify({ port: localPort, token: localToken }, null, 2), 'utf8') // The file holds the API token: keep it readable by the owner only (mode is ignored on Windows).
fs.writeFileSync(discoveryPath, JSON.stringify({ port: localPort, token: localToken }, null, 2), {
encoding: 'utf8',
mode: 0o600,
})
fs.chmodSync(discoveryPath, 0o600)
console.log(`[PiAgentUi] Wrote connection info to: ${discoveryPath}`) console.log(`[PiAgentUi] Wrote connection info to: ${discoveryPath}`)
} catch (err) { } catch (err) {
console.error('[PiAgentUi] Failed to write discovery file:', err) console.error('[PiAgentUi] Failed to write discovery file:', err)
@@ -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)
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { createStatCache } from './piagentui-stat-cache.js'
const stamp = (mtimeMs: number, size = 10) => ({ mtimeMs, size })
describe('createStatCache', () => {
it('misses for unknown paths and hits for the same stamp', () => {
const cache = createStatCache<string>()
expect(cache.get('a', stamp(1))).toBeUndefined()
cache.set('a', stamp(1), 'x')
expect(cache.get('a', stamp(1))).toEqual({ value: 'x' })
})
it('treats a changed mtime or size as a miss and drops the stale entry', () => {
const cache = createStatCache<string>()
cache.set('a', stamp(1, 10), 'x')
expect(cache.get('a', stamp(2, 10))).toBeUndefined()
expect(cache.size).toBe(0)
cache.set('a', stamp(1, 10), 'x')
expect(cache.get('a', stamp(1, 11))).toBeUndefined()
})
it('can cache null values', () => {
const cache = createStatCache<string | null>()
cache.set('a', stamp(1), null)
expect(cache.get('a', stamp(1))).toEqual({ value: null })
})
it('evicts the oldest entries beyond the limit', () => {
const cache = createStatCache<number>(2)
cache.set('a', stamp(1), 1)
cache.set('b', stamp(1), 2)
cache.set('c', stamp(1), 3)
expect(cache.size).toBe(2)
expect(cache.get('a', stamp(1))).toBeUndefined()
expect(cache.get('b', stamp(1))).toEqual({ value: 2 })
expect(cache.get('c', stamp(1))).toEqual({ value: 3 })
})
it('refreshing a key moves it to the newest position', () => {
const cache = createStatCache<number>(2)
cache.set('a', stamp(1), 1)
cache.set('b', stamp(1), 2)
cache.set('a', stamp(2), 11)
cache.set('c', stamp(1), 3)
expect(cache.get('b', stamp(1))).toBeUndefined()
expect(cache.get('a', stamp(2))).toEqual({ value: 11 })
})
})
+34
View File
@@ -0,0 +1,34 @@
export type FileStamp = { mtimeMs: number; size: number }
/**
* Small bounded cache of values derived from file contents, keyed by path and
* invalidated when the file's mtime or size changes. Oldest entries are evicted first.
*/
export function createStatCache<T>(maxEntries = 500) {
const entries = new Map<string, FileStamp & { value: T }>()
return {
/** Returns `{ value }` on a hit (the value itself may be null), `undefined` on a miss or stale entry. */
get(filePath: string, stamp: FileStamp): { value: T } | undefined {
const entry = entries.get(filePath)
if (!entry) return undefined
if (entry.mtimeMs !== stamp.mtimeMs || entry.size !== stamp.size) {
entries.delete(filePath)
return undefined
}
return { value: entry.value }
},
set(filePath: string, stamp: FileStamp, value: T): void {
entries.delete(filePath)
entries.set(filePath, { mtimeMs: stamp.mtimeMs, size: stamp.size, value })
while (entries.size > maxEntries) {
const oldest = entries.keys().next().value
if (oldest === undefined) break
entries.delete(oldest)
}
},
get size(): number {
return entries.size
},
}
}
-51
View File
@@ -1,51 +0,0 @@
import type { Session, Model } from '../types/api/compat'
export interface SessionListResponse {
sessions: Session[]
}
export interface SessionCreateRequest {
title?: string
directory?: string
}
export interface SessionUpdateRequest {
title?: string
}
export interface SessionForkRequest {
messageID?: string
directory?: string
}
export interface SendMessageRequest {
sessionId: string
text: string
model?: { providerID: string; modelID: string }
directory?: string
}
export interface SendMessageResponse {
messageID: string
sessionID: string
}
export interface ModelsResponse {
models: Model[]
}
export interface PartDeltaPayload {
sessionID: string
messageID: string
partID: string
field: 'text' | 'reasoning' | 'tool'
delta: string
}
export interface PiGlobalEvent {
directory?: string
payload: {
type: string
properties: unknown
}
}
+3 -93
View File
@@ -1,6 +1,5 @@
import { memo, useState, useRef, useEffect, useLayoutEffect, useMemo, useCallback } from 'react' import { memo, useState, useRef, useEffect, useLayoutEffect, useMemo, useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { diffLines } from 'diff'
import { animate } from 'motion/mini' import { animate } from 'motion/mini'
import { ChevronDownIcon, ChevronRightIcon, SplitIcon, SpinnerIcon, UndoIcon } from '../../components/Icons' import { ChevronDownIcon, ChevronRightIcon, SplitIcon, SpinnerIcon, UndoIcon } from '../../components/Icons'
import { CopyButton, SmoothHeight } from '../../components/ui' import { CopyButton, SmoothHeight } from '../../components/ui'
@@ -25,6 +24,8 @@ import {
MessageErrorView, MessageErrorView,
} from './parts' } from './parts'
import { extractToolData } from './tools' import { extractToolData } from './tools'
import { getToolSummaryCategory, type ToolSummaryCategory } from './toolSummary'
import { computeDiffStats } from '../../utils/diffStats'
import type { import type {
Message, Message,
Part, Part,
@@ -597,7 +598,7 @@ const ToolGroup = memo(function ToolGroup({
for (const part of parts) { for (const part of parts) {
if (part.state?.status === 'error') continue if (part.state?.status === 'error') continue
const data = extractToolData(part) const data = extractToolData(part)
const stats = data.diffStats || computePartDiffStats(data) const stats = data.diffStats || computeDiffStats(data)
if (stats) { if (stats) {
additions += stats.additions additions += stats.additions
deletions += stats.deletions deletions += stats.deletions
@@ -774,21 +775,6 @@ function formatTokens(
return `${total} ${t('tokens')}` return `${total} ${t('tokens')}`
} }
type ToolSummaryCategory =
| 'execute'
| 'write'
| 'edit'
| 'read'
| 'search'
| 'list'
| 'network'
| 'task'
| 'todo'
| 'question'
| 'skill'
| 'think'
| 'other'
type ToolSummaryPhase = 'done' | 'active' | 'failed' type ToolSummaryPhase = 'done' | 'active' | 'failed'
interface SummarySegment { interface SummarySegment {
@@ -897,45 +883,6 @@ function formatToolSummarySegment(
return t(key, { count }) 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 { function isToolPartActive(part: ToolPart): boolean {
return part.state?.status === 'running' || part.state?.status === 'pending' 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 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 // Helper: Group parts for rendering
// ============================================ // ============================================
+2 -43
View File
@@ -1,6 +1,6 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react' import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { diffLines } from 'diff' import { computeDiffStats } from '../../../utils/diffStats'
import { ChevronDownIcon, ChevronRightIcon } from '../../../components/Icons' import { ChevronDownIcon, ChevronRightIcon } from '../../../components/Icons'
import type { ToolPart } from '../../../types/message' import type { ToolPart } from '../../../types/message'
import { useDelayedRender } from '../../../hooks' import { useDelayedRender } from '../../../hooks'
@@ -203,7 +203,7 @@ export const ToolPartView = memo(function ToolPartView({
if (descriptive) { if (descriptive) {
const hasDiffFiles = !!toolData.files?.length const hasDiffFiles = !!toolData.files?.length
// diffStats 可能从 metadata 来,也可能需要从 diff 数据计算 // diffStats 可能从 metadata 来,也可能需要从 diff 数据计算
const diffStats = toolData.diffStats || computeDiffStatsFromData(toolData) const diffStats = toolData.diffStats || computeDiffStats(toolData)
return ( return (
<div className="group py-0.5"> <div className="group py-0.5">
@@ -451,47 +451,6 @@ function isReadableTool(toolName: string): boolean {
return READABLE_TOOL_PATTERNS.test(toolName.toLowerCase()) 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 - 根据工具类型选择渲染器 // 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 { describe, expect, it } from 'vitest'
import type { ToolPart } from '../../../types/message' import type { ToolPart } from '../../../types/message'
import { defaultExtractData } from './registry' import { defaultExtractData, extractToolData } from './registry'
describe('defaultExtractData', () => { describe('defaultExtractData', () => {
it('extracts files and diagnostics from metadata', () => { 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 // 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 { export function defaultExtractData(part: ToolPart): ExtractedToolData {
const { state } = part const { state } = part
const inputObj = state?.input as Record<string, unknown> | undefined const inputObj = state?.input as Record<string, unknown> | undefined
@@ -78,120 +228,15 @@ export function defaultExtractData(part: ToolPart): ExtractedToolData {
result.error = String(state.error) result.error = String(state.error)
} }
// FilePath // FilePath (+ output language derived from it)
if (metadata && typeof metadata.filepath === 'string') { const filePath = extractFilePath(inputObj, metadata)
result.filePath = metadata.filepath if (filePath) {
} result.filePath = filePath
if (!result.filePath && inputObj?.filePath) { result.outputLang = detectLanguage(filePath)
result.filePath = String(inputObj.filePath)
} }
// Exit code Object.assign(result, extractMetadataFields(metadata))
if (metadata && typeof metadata.exit === 'number') { Object.assign(result, extractOutputFields(result, state, metadata))
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'
}
}
}
return result return result
} }
@@ -200,6 +245,14 @@ export function defaultExtractData(part: ToolPart): ExtractedToolData {
// Tool-Specific Data Extractors // 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 { function bashExtractData(part: ToolPart): ExtractedToolData {
const base = defaultExtractData(part) const base = defaultExtractData(part)
const inputObj = part.state?.input as Record<string, unknown> | undefined const inputObj = part.state?.input as Record<string, unknown> | undefined
@@ -210,10 +263,8 @@ function bashExtractData(part: ToolPart): ExtractedToolData {
base.inputLang = 'bash' base.inputLang = 'bash'
} }
const cwd = inputObj?.workdir ?? inputObj?.cwd ?? metadata?.workdir ?? metadata?.cwd const cwd = resolveCwd(inputObj, metadata)
if (typeof cwd === 'string' && cwd.trim()) { if (cwd) base.cwd = cwd
base.cwd = cwd.trim()
}
return base return base
} }
-21
View File
@@ -1,21 +0,0 @@
import { useEffect, useState } from 'react'
import { flushSync } from 'react-dom'
export function usePanelResizeStatus(): boolean {
const [isResizing, setIsResizing] = useState(false)
useEffect(() => {
const onStart = () => flushSync(() => setIsResizing(true))
const onEnd = () => setIsResizing(false)
window.addEventListener('panel-resize-start', onStart)
window.addEventListener('panel-resize-end', onEnd)
return () => {
window.removeEventListener('panel-resize-start', onStart)
window.removeEventListener('panel-resize-end', onEnd)
}
}, [])
return isResizing
}
+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 { function sanitizeIdList(raw: unknown): string[] {
if (!raw || typeof raw !== 'object') { return Array.isArray(raw) ? raw.filter((id): id is string => typeof id === 'string' && id.length > 0) : []
return { version: 1, directories: {} } }
}
const data = raw as Partial<PersistedTerminalLayoutMap> function sanitizeActiveTabId(raw: { bottom?: unknown; right?: unknown }): Record<PanelPosition, string | null> {
if (data.version !== 1 || !data.directories || typeof data.directories !== 'object') { 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: {} } return { version: 1, directories: {} }
} }
const directories: Record<string, PersistedTerminalDirectoryLayout> = {} const directories: Record<string, PersistedTerminalDirectoryLayout> = {}
for (const [directory, value] of Object.entries(data.directories)) { for (const [directory, value] of Object.entries(data.directories)) {
if (!directory || !value || typeof value !== 'object') continue const layout = directory ? sanitizeTerminalDirectoryLayout(value) : null
const entry = value as Partial<PersistedTerminalDirectoryLayout> if (layout) directories[directory] = layout
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,
}
} }
return { version: 1, directories } 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
}