refactor(desktop): split tool fallback-model into a folder with leaf modules

fallback-model.ts (1,696) folded into assistant-ui/tool/fallback-model/ with
three cohesive, self-contained leaf modules extracted (verbatim moves):

- types.ts (83)   — the shared tool-view types/interfaces.
- format.ts (133) — pure value formatting/parsing (isRecord, compactPreview,
  clampForDisplay, prettyJson, parseMaybeObject, unwrapToolPayload, numberValue,
  contextValue, formatDurationSeconds).
- targets.ts (75) — url/path/preview detection + disclosure ids (looksLikeUrl,
  findFirstUrl, hostnameOf, isPreviewableTarget, toolPart/GroupDisclosureId).

index.ts (1,434) keeps the tool-specific assembler (TOOL_META, titles, the count
machinery, subtitle/detail/diff, buildToolView) and re-exports the leaf modules,
so consumers importing `./fallback-model` are unchanged (folder index resolution)
— no importer or channel edits needed. The count/result/detail helpers reach
across each other around buildToolView, so they stay together to avoid a circular
split; the three leaves are the clean cut.

Behaviour-preserving: typecheck 0 errors, eslint clean, fallback-model test 24/26
(the 2 browser_navigate title failures are pre-existing on main — `hostnameOf`
intentionally includes the pathname; verified identical on the un-split file).
This commit is contained in:
Brooklyn Nicholson 2026-06-30 15:24:33 -05:00
parent d153918f14
commit 322138df51
4 changed files with 324 additions and 295 deletions

View file

@ -0,0 +1,133 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}
export function compactPreview(value: unknown, max = 72): string {
let raw: unknown
if (typeof value === 'string') {
raw = value
} else {
raw = parseMaybeObject(value).context
}
if (typeof raw !== 'string') {
if (raw == null) {
raw = ''
} else {
try {
raw = JSON.stringify(raw)
} catch {
raw = String(raw)
}
}
}
const line = (raw as string).replace(/\s+/g, ' ').trim()
return line.length > max ? `${line.slice(0, max - 1)}` : line
}
export function contextValue(value: unknown): string {
const row = parseMaybeObject(value)
if (typeof row.context === 'string') {
return row.context
}
if (typeof row.preview === 'string') {
return row.preview
}
return typeof value === 'string' ? value : ''
}
// Each tool result is server-capped (~100KB), but a turn over a big directory
// stacks many rows; painting/serializing them all floods the renderer (freeze,
// then OOM). Clamp every inline-painted payload to a bounded slice — the row's
// Copy button still reads the uncapped `view.detail` for the full output.
export const MAX_TOOL_RENDER_CHARS = 20_000
export function clampForDisplay(value: string, max = MAX_TOOL_RENDER_CHARS): string {
if (value.length <= max) {
return value
}
const omitted = value.length - max
return `${value.slice(0, max)}\n\n… ${omitted.toLocaleString()} more characters truncated — use Copy for the full output.`
}
export function prettyJson(value: unknown): string {
const raw = typeof value === 'string' ? value : JSON.stringify(value, null, 2)
return clampForDisplay(raw ?? '')
}
export function parseMaybeObject(value: unknown): Record<string, unknown> {
if (isRecord(value)) {
return value
}
if (typeof value !== 'string' || !value.trim()) {
return {}
}
try {
const parsed = JSON.parse(value)
return isRecord(parsed) ? parsed : {}
} catch {
return {}
}
}
export function unwrapToolPayload(value: unknown): unknown {
const record = parseMaybeObject(value)
for (const key of ['data', 'result', 'output', 'response', 'payload']) {
const payload = record[key]
if (payload !== undefined && payload !== null) {
return payload
}
}
return value
}
export function numberValue(value: unknown): null | number {
const n = typeof value === 'number' ? value : Number(value)
return Number.isFinite(n) ? n : null
}
export function formatDurationSeconds(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) {
return ''
}
if (seconds < 1) {
const ms = Math.max(1, Math.round(seconds * 1000))
return `${ms}ms`
}
if (seconds < 60) {
return `${seconds.toFixed(seconds >= 10 ? 0 : 1)}s`
}
const wholeSeconds = Math.round(seconds)
const minutes = Math.floor(wholeSeconds / 60)
const remSeconds = wholeSeconds % 60
if (minutes < 60) {
return remSeconds ? `${minutes}m ${remSeconds}s` : `${minutes}m`
}
const hours = Math.floor(minutes / 60)
const remMinutes = minutes % 60
return remMinutes ? `${hours}h ${remMinutes}m` : `${hours}h`
}

View file

@ -3,88 +3,38 @@ import { normalizeExternalUrl } from '@/lib/external-link'
import { summarizeShellCommand } from '@/lib/summarize-command'
import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary'
export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web'
export type ToolStatus = 'error' | 'running' | 'success' | 'warning'
import {
compactPreview,
contextValue,
formatDurationSeconds,
isRecord,
numberValue,
parseMaybeObject,
prettyJson,
unwrapToolPayload
} from './format'
import {
findFirstUrl,
hostnameOf,
looksLikePath,
looksLikeUrl
} from './targets'
import type {
CountMetric,
MessageRunningStateSlice,
SearchResultRow,
ToolMeta,
ToolMetaSpec,
ToolPart,
ToolStatus,
ToolTitleAction,
ToolTone,
ToolView
} from './types'
export interface ToolPart {
args?: unknown
isError?: boolean
result?: unknown
toolCallId?: string
toolName: string
type: 'tool-call'
}
export interface SearchResultRow {
snippet: string
title: string
url: string
}
export interface ToolTitleAction {
prefix: string
suffix: string
text: string
}
interface CountMetric {
count: number
noun: string
}
export interface ToolView {
countLabel?: string
detail: string
detailLabel: string
durationLabel?: string
icon?: string
imageUrl?: string
inlineDiff: string
previewTarget?: string
rawArgs: string
rawResult: string
/** Set for tools whose output naturally contains ANSI escape codes
* (terminal/execute_code) so the renderer knows to run them through
* the ANSI parser instead of printing them as literals. */
rendersAnsi?: boolean
searchHits?: SearchResultRow[]
/** When the backend reports stderr as a separate stream (terminal /
* execute_code), the renderer shows it as its own labeled, neutrally
* tinted block under stdout distinct from an error tone. */
stderr?: string
/** When set, the renderer uses stdout+stderr as separate sections and
* ignores the merged `detail`. */
stdout?: string
status: ToolStatus
subtitle: string
title: string
titleAction?: ToolTitleAction
tone: ToolTone
}
interface ToolMeta {
done: string
icon?: string
pending: string
pendingAction: string
tone: ToolTone
}
interface ToolMetaSpec {
icon?: string
tone: ToolTone
}
export interface MessageRunningStateSlice {
message: {
status?: {
type?: string
}
}
thread: {
isRunning: boolean
}
}
export * from './format'
export * from './targets'
export * from './types'
const FILE_EDIT_TOOL_NAMES = new Set(['edit_file', 'patch', 'write_file'])
@ -317,139 +267,6 @@ function toolMeta(name: string): ToolMeta {
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}
export function compactPreview(value: unknown, max = 72): string {
let raw: unknown
if (typeof value === 'string') {
raw = value
} else {
raw = parseMaybeObject(value).context
}
if (typeof raw !== 'string') {
if (raw == null) {
raw = ''
} else {
try {
raw = JSON.stringify(raw)
} catch {
raw = String(raw)
}
}
}
const line = (raw as string).replace(/\s+/g, ' ').trim()
return line.length > max ? `${line.slice(0, max - 1)}` : line
}
function contextValue(value: unknown): string {
const row = parseMaybeObject(value)
if (typeof row.context === 'string') {
return row.context
}
if (typeof row.preview === 'string') {
return row.preview
}
return typeof value === 'string' ? value : ''
}
// Each tool result is server-capped (~100KB), but a turn over a big directory
// stacks many rows; painting/serializing them all floods the renderer (freeze,
// then OOM). Clamp every inline-painted payload to a bounded slice — the row's
// Copy button still reads the uncapped `view.detail` for the full output.
export const MAX_TOOL_RENDER_CHARS = 20_000
export function clampForDisplay(value: string, max = MAX_TOOL_RENDER_CHARS): string {
if (value.length <= max) {
return value
}
const omitted = value.length - max
return `${value.slice(0, max)}\n\n… ${omitted.toLocaleString()} more characters truncated — use Copy for the full output.`
}
function prettyJson(value: unknown): string {
const raw = typeof value === 'string' ? value : JSON.stringify(value, null, 2)
return clampForDisplay(raw ?? '')
}
function parseMaybeObject(value: unknown): Record<string, unknown> {
if (isRecord(value)) {
return value
}
if (typeof value !== 'string' || !value.trim()) {
return {}
}
try {
const parsed = JSON.parse(value)
return isRecord(parsed) ? parsed : {}
} catch {
return {}
}
}
function unwrapToolPayload(value: unknown): unknown {
const record = parseMaybeObject(value)
for (const key of ['data', 'result', 'output', 'response', 'payload']) {
const payload = record[key]
if (payload !== undefined && payload !== null) {
return payload
}
}
return value
}
function numberValue(value: unknown): null | number {
const n = typeof value === 'number' ? value : Number(value)
return Number.isFinite(n) ? n : null
}
function formatDurationSeconds(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) {
return ''
}
if (seconds < 1) {
const ms = Math.max(1, Math.round(seconds * 1000))
return `${ms}ms`
}
if (seconds < 60) {
return `${seconds.toFixed(seconds >= 10 ? 0 : 1)}s`
}
const wholeSeconds = Math.round(seconds)
const minutes = Math.floor(wholeSeconds / 60)
const remSeconds = wholeSeconds % 60
if (minutes < 60) {
return remSeconds ? `${minutes}m ${remSeconds}s` : `${minutes}m`
}
const hours = Math.floor(minutes / 60)
const remMinutes = minutes % 60
return remMinutes ? `${hours}h ${remMinutes}m` : `${hours}h`
}
const COUNT_FIELD_KEYS = [
'count',
'total',
@ -719,79 +536,6 @@ function toolResultCount(
return textMetric ? normalizeMetricForTool(part.toolName, textMetric) : null
}
function looksLikeUrl(value: string): boolean {
return /^https?:\/\//i.test(value)
}
function looksLikePath(value: string): boolean {
return /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value)
}
export function isPreviewableTarget(target: string): boolean {
return Boolean(
target &&
(/^file:\/\//i.test(target) ||
/^(?:\/|\.{1,2}\/|~\/).+\.html?$/i.test(target) ||
/^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(target))
)
}
function stableHash(value: string): string {
let hash = 0
for (let index = 0; index < value.length; index += 1) {
hash = Math.imul(31, hash) + value.charCodeAt(index)
}
return Math.abs(hash).toString(36)
}
export function toolPartDisclosureId(part: ToolPart): string {
if (part.toolCallId) {
return `tool:${part.toolCallId}`
}
return `tool:${part.toolName}:${stableHash(JSON.stringify(part.args ?? ''))}`
}
export function toolGroupDisclosureId(parts: ToolPart[]): string {
return `tool-group:${parts.map(toolPartDisclosureId).join('|')}`
}
const URL_PATTERN = /https?:\/\/[^\s'"<>)\]]+/i
function findFirstUrl(...sources: unknown[]): string {
for (const src of sources) {
if (typeof src === 'string') {
const m = src.match(URL_PATTERN)
if (m) {
return m[0]
}
} else if (src && typeof src === 'object') {
for (const v of Object.values(src as Record<string, unknown>)) {
const found = findFirstUrl(v)
if (found) {
return found
}
}
}
}
return ''
}
function hostnameOf(value: string): string {
try {
const url = new URL(value)
return `${url.hostname}${url.pathname && url.pathname !== '/' ? url.pathname : ''}`
} catch {
return value
}
}
export function looksRedundant(title: string, detail: string): boolean {
if (!detail) {
return true
@ -1540,10 +1284,7 @@ function dynamicTitle(
}
const failed =
part.isError ||
result.success === false ||
result.ok === false ||
Boolean(firstStringField(result, ['error']))
part.isError || result.success === false || result.ok === false || Boolean(firstStringField(result, ['error']))
if (failed) {
const failAction = translateNow('assistant.tool.actions.failedToOpen')
@ -1556,10 +1297,7 @@ function dynamicTitle(
const action = verb(translateNow('assistant.tool.actions.opening'), translateNow('assistant.tool.actions.opened'))
return titledAction(
action,
translateNow('assistant.tool.titleTemplates.actionTarget', action, hostnameOf(url))
)
return titledAction(action, translateNow('assistant.tool.titleTemplates.actionTarget', action, hostnameOf(url)))
}
if (part.toolName === 'web_search') {

View file

@ -0,0 +1,75 @@
import type { ToolPart } from './types'
export function looksLikeUrl(value: string): boolean {
return /^https?:\/\//i.test(value)
}
export function looksLikePath(value: string): boolean {
return /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value)
}
export function isPreviewableTarget(target: string): boolean {
return Boolean(
target &&
(/^file:\/\//i.test(target) ||
/^(?:\/|\.{1,2}\/|~\/).+\.html?$/i.test(target) ||
/^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(target))
)
}
export function stableHash(value: string): string {
let hash = 0
for (let index = 0; index < value.length; index += 1) {
hash = Math.imul(31, hash) + value.charCodeAt(index)
}
return Math.abs(hash).toString(36)
}
export function toolPartDisclosureId(part: ToolPart): string {
if (part.toolCallId) {
return `tool:${part.toolCallId}`
}
return `tool:${part.toolName}:${stableHash(JSON.stringify(part.args ?? ''))}`
}
export function toolGroupDisclosureId(parts: ToolPart[]): string {
return `tool-group:${parts.map(toolPartDisclosureId).join('|')}`
}
export const URL_PATTERN = /https?:\/\/[^\s'"<>)\]]+/i
export function findFirstUrl(...sources: unknown[]): string {
for (const src of sources) {
if (typeof src === 'string') {
const m = src.match(URL_PATTERN)
if (m) {
return m[0]
}
} else if (src && typeof src === 'object') {
for (const v of Object.values(src as Record<string, unknown>)) {
const found = findFirstUrl(v)
if (found) {
return found
}
}
}
}
return ''
}
export function hostnameOf(value: string): string {
try {
const url = new URL(value)
return `${url.hostname}${url.pathname && url.pathname !== '/' ? url.pathname : ''}`
} catch {
return value
}
}

View file

@ -0,0 +1,83 @@
export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web'
export type ToolStatus = 'error' | 'running' | 'success' | 'warning'
export interface ToolPart {
args?: unknown
isError?: boolean
result?: unknown
toolCallId?: string
toolName: string
type: 'tool-call'
}
export interface SearchResultRow {
snippet: string
title: string
url: string
}
export interface ToolTitleAction {
prefix: string
suffix: string
text: string
}
export interface CountMetric {
count: number
noun: string
}
export interface ToolView {
countLabel?: string
detail: string
detailLabel: string
durationLabel?: string
icon?: string
imageUrl?: string
inlineDiff: string
previewTarget?: string
rawArgs: string
rawResult: string
/** Set for tools whose output naturally contains ANSI escape codes
* (terminal/execute_code) so the renderer knows to run them through
* the ANSI parser instead of printing them as literals. */
rendersAnsi?: boolean
searchHits?: SearchResultRow[]
/** When the backend reports stderr as a separate stream (terminal /
* execute_code), the renderer shows it as its own labeled, neutrally
* tinted block under stdout distinct from an error tone. */
stderr?: string
/** When set, the renderer uses stdout+stderr as separate sections and
* ignores the merged `detail`. */
stdout?: string
status: ToolStatus
subtitle: string
title: string
titleAction?: ToolTitleAction
tone: ToolTone
}
export interface ToolMeta {
done: string
icon?: string
pending: string
pendingAction: string
tone: ToolTone
}
export interface ToolMetaSpec {
icon?: string
tone: ToolTone
}
export interface MessageRunningStateSlice {
message: {
status?: {
type?: string
}
}
thread: {
isRunning: boolean
}
}