hermes-agent/apps/desktop/src/lib/tool-result-summary.ts
Brooklyn Nicholson 715aa3de85 refactor(desktop): adopt shared utils + app-wide cleanups
Route the app off its hand-rolled helpers onto lib/{text,time,format,json-format}
and the new primitives, plus assorted small tidy-ups:
- compactNumber for counts/tokens; normalize/capitalize/asText at the many
  filter/label sites; shared Intl date/time formatters; row-hover + framed
  editor adoption; scrollbar-gutter + padding parity on list surfaces.
- Messaging/Artifacts/Cron search hints + narrow-viewport tab dropdown;
  floating-pet adopts useOnProfileSwitch; number formatting in statusbar,
  command-center, agents.
- Electron: native overlay width + backend spawn tidy.
- Settings > Keys: credential fields read as plain subtext (all-unset) until
  the group is focused or expanded, then take full input chrome with no
  horizontal/vertical shift; inline Remove (trash) + Save mirror SearchField's
  trailing-clear pattern instead of a floating hint that overlapped the card;
  Esc still cancels. Drops the now-dead or/escToCancel i18n keys.
- Shared TabDropdown/ResponsiveTabs (components/ui): PageSearchShell and the
  Command Center log file/level filters reuse the one narrow-width collapse.
- OverlayNav: data-driven pane nav — persistent rail on wide, a single dropdown
  riding the titlebar strip on narrow; Settings and Command Center adopt it, and
  the mobile dropdown carries the same section icons as the rail. Fixes narrow
  vertical centering, redundant mobile section titles, gateway-status wrap, and
  Panel master/detail stacking.
- OverlayIconButton is now the titlebar ghost button, matching the close X at
  every size. Settings sub-view nav opens section + sub-view in one navigate so
  API-keys/accounts actually open on narrow.
- Settings > Model: cube icon (was the {} namespace glyph) and a DOM-shaped
  skeleton in place of the centered spinner.
- Command palette / session switcher clear the macOS traffic lights on small
  screens.
- Prettier/eslint sweep across the touched files.
2026-07-03 13:48:44 -05:00

469 lines
10 KiB
TypeScript

// Heuristic JSON → human summary for tool results. Default view; technical
// mode still gets the raw JSON section.
import { capitalize, normalize } from '@/lib/text'
const WRAPPER_KEYS = ['data', 'result', 'output', 'response', 'payload'] as const
const PRIORITY_KEYS = [
'title',
'name',
'path',
'file',
'filepath',
'url',
'href',
'link',
'status',
'id',
'message',
'summary',
'description'
] as const
const ERROR_KEYS = ['error', 'errors', 'failure', 'exception'] as const
// 'stderr' deliberately excluded: many CLIs emit informational lines on
// stderr (npm progress, git's hint:, gcc's `In file included from`) that
// aren't errors. Treating those as error signal flipped tool cards into
// destructive styling for healthy commands.
const ERROR_MSG_KEYS = ['message', 'reason', 'detail'] as const
const NON_ERROR_TEXT = new Set(['', '0', 'false', 'none', 'null', 'nil', 'ok', 'success', 'n/a', 'na'])
type Json = Record<string, unknown>
const isRecord = (v: unknown): v is Json => Boolean(v && typeof v === 'object' && !Array.isArray(v))
function tryJson(value: string): unknown {
const t = value.trim()
if (!t) {
return ''
}
if (!/^[{[]|^"/.test(t)) {
return value
}
try {
return JSON.parse(t)
} catch {
return value
}
}
const norm = (v: unknown): unknown => (typeof v === 'string' ? tryJson(v) : v)
const titleCase = (k: string) =>
k
.split(/[_\-.]+/)
.filter(Boolean)
.map(capitalize)
.join(' ')
const pluralize = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}`
function clipInline(value: string, max = 180): string {
const c = value.replace(/\s+/g, ' ').trim()
return c.length > max ? `${c.slice(0, max - 1)}` : c
}
function clipBlock(value: string, maxChars = 1800, maxLines = 18): string {
const t = value.trim()
if (!t) {
return ''
}
const lines = t.split('\n')
let text = lines.slice(0, maxLines).join('\n')
const clipped = lines.length > maxLines || text.length > maxChars
if (text.length > maxChars) {
text = text.slice(0, maxChars - 1).trimEnd()
}
return clipped && !text.endsWith('…') ? `${text}` : text
}
function firstString(record: Json, keys: readonly string[]): string {
for (const k of keys) {
const v = record[k]
if (typeof v === 'string' && v.trim()) {
return v.trim()
}
}
return ''
}
function orderedKeys(keys: string[]): string[] {
const priority = PRIORITY_KEYS.filter(k => keys.includes(k))
const rest = keys.filter(k => !priority.includes(k as never))
return [...priority, ...rest]
}
const isWrapperKey = (k: string) => (WRAPPER_KEYS as readonly string[]).includes(k)
const skipField = (k: string, v: unknown) => isWrapperKey(k) || ((k === 'success' || k === 'ok') && v === true)
function summarizeScalar(v: unknown): string {
if (typeof v === 'string') {
return clipInline(v)
}
if (typeof v === 'number' || typeof v === 'boolean') {
return String(v)
}
return ''
}
function summarizeRecordInline(record: Json, depth: number): string {
if (depth > 3) {
return pluralize(Object.keys(record).length, 'field')
}
const title = firstString(record, ['title', 'name', 'path', 'file', 'filepath', 'url', 'href', 'link', 'id'])
const status = firstString(record, ['status', 'category', 'type'])
const message = firstString(record, ['snippet', 'summary', 'description', 'message'])
if (title && status) {
return `${clipInline(title, 110)} (${clipInline(status, 54)})`
}
if (title && message && title !== message) {
return `${clipInline(title, 90)} - ${clipInline(message, 84)}`
}
if (title) {
return clipInline(title, 150)
}
const pairs = orderedKeys(Object.keys(record))
.filter(k => !skipField(k, record[k]))
.map(k => {
const s = summarizeScalar(record[k])
return s ? `${titleCase(k)}: ${s}` : ''
})
.filter(Boolean)
.slice(0, 2)
return pairs.length ? pairs.join(' · ') : pluralize(Object.keys(record).length, 'field')
}
function summarizeListItem(item: unknown, depth: number): string {
const v = norm(item)
if (typeof v === 'string') {
return clipInline(v)
}
if (typeof v === 'number' || typeof v === 'boolean') {
return String(v)
}
if (v == null) {
return ''
}
if (Array.isArray(v)) {
return pluralize(v.length, 'item')
}
if (isRecord(v)) {
return summarizeRecordInline(v, depth + 1)
}
return clipInline(String(v))
}
function formatFieldValue(value: unknown, depth: number): string {
const v = norm(value)
const scalar = summarizeScalar(v)
if (scalar) {
return scalar
}
if (v == null) {
return ''
}
if (Array.isArray(v)) {
if (!v.length) {
return ''
}
const scalars = v.map(summarizeScalar).filter(Boolean)
if (scalars.length === v.length && v.length <= 4) {
return clipInline(scalars.join(', '))
}
const first = summarizeListItem(v[0], depth + 1)
return first ? `${pluralize(v.length, 'item')} (${first})` : pluralize(v.length, 'item')
}
if (isRecord(v)) {
return summarizeRecordInline(v, depth + 1)
}
return clipInline(String(v))
}
// "Returned N items" / "0 items" / "Returned an empty object" are all
// noise — better to render nothing and let the title carry the signal.
function formatArraySummary(value: unknown[], depth: number): string {
if (!value.length) {
return ''
}
const max = 6
const lines = value
.slice(0, max)
.map(item => summarizeListItem(item, depth + 1))
.filter(Boolean)
.map(l => `- ${l}`)
if (!lines.length) {
return ''
}
if (value.length > max) {
const remaining = value.length - max
lines.push(`- … ${remaining} more ${remaining === 1 ? 'item' : 'items'}`)
}
return lines.join('\n')
}
function formatRecordSummary(record: Json, depth: number): string {
const keys = Object.keys(record)
if (!keys.length) {
return ''
}
if (depth <= 2) {
const direct = firstString(record, ['message', 'summary', 'description', 'preview', 'text', 'content'])
const meaningful = keys.filter(k => !skipField(k, record[k]) && !isWrapperKey(k))
if (direct && meaningful.length <= 1) {
return clipBlock(direct)
}
}
const candidates = orderedKeys(keys).filter(k => !skipField(k, record[k]))
const max = 8
const lines: string[] = []
for (const k of candidates) {
const v = formatFieldValue(record[k], depth + 1)
if (!v) {
continue
}
lines.push(`- ${titleCase(k)}: ${v}`)
if (lines.length >= max) {
break
}
}
if (!lines.length) {
return ''
}
if (candidates.length > lines.length) {
const remaining = candidates.length - lines.length
lines.push(`- … ${remaining} more ${remaining === 1 ? 'field' : 'fields'}`)
}
return lines.join('\n')
}
function formatSummaryValue(value: unknown, depth: number): string {
if (depth > 4) {
return ''
}
const v = norm(value)
if (typeof v === 'string') {
return clipBlock(v)
}
if (typeof v === 'number' || typeof v === 'boolean') {
return String(v)
}
if (v == null) {
return ''
}
if (Array.isArray(v)) {
return formatArraySummary(v, depth + 1)
}
if (isRecord(v)) {
return formatRecordSummary(v, depth + 1)
}
return clipInline(String(v))
}
function unwrapPayload(value: unknown): unknown {
let cur: unknown = norm(value)
for (let i = 0; i < 4; i += 1) {
if (!isRecord(cur)) {
return cur
}
const record = cur
const key = WRAPPER_KEYS.find(k => record[k] != null)
if (!key) {
return record
}
cur = norm(record[key])
}
return cur
}
function hasMeaningfulErrorValue(value: unknown): boolean {
const v = norm(value)
if (v == null) {
return false
}
if (typeof v === 'string') {
return !NON_ERROR_TEXT.has(normalize(v))
}
if (typeof v === 'boolean') {
return v
}
if (typeof v === 'number') {
return v !== 0
}
if (Array.isArray(v)) {
return v.some(hasMeaningfulErrorValue)
}
if (isRecord(v)) {
return Object.keys(v).length > 0
}
return true
}
function hasErrorSignal(record: Json): boolean {
const status = typeof record.status === 'string' ? record.status : ''
return (
record.success === false ||
record.ok === false ||
/\b(error|failed|failure|fatal|exception)\b/i.test(status) ||
ERROR_KEYS.some(k => hasMeaningfulErrorValue(record[k]))
)
}
function valueErrorText(value: unknown): string {
const v = norm(value)
if (typeof v === 'string') {
return hasMeaningfulErrorValue(v) ? clipBlock(v, 700, 12) : ''
}
if (Array.isArray(v)) {
return clipBlock(v.map(valueErrorText).filter(Boolean).slice(0, 3).join('; '), 700, 12)
}
if (isRecord(v)) {
const direct = firstString(v, ERROR_MSG_KEYS)
if (direct) {
return clipBlock(direct, 700, 12)
}
}
return ''
}
function findNestedError(value: unknown, depth: number, seen: Set<unknown>): string {
if (depth > 5) {
return ''
}
const v = norm(value)
if (!v || typeof v !== 'object' || seen.has(v)) {
return ''
}
seen.add(v)
if (Array.isArray(v)) {
for (const item of v) {
const nested = findNestedError(item, depth + 1, seen)
if (nested) {
return nested
}
}
return ''
}
const record = v as Json
for (const k of ERROR_KEYS) {
if (!hasMeaningfulErrorValue(record[k])) {
continue
}
const text = valueErrorText(record[k])
if (text) {
return text
}
}
if (hasErrorSignal(record)) {
const direct = firstString(record, ERROR_MSG_KEYS)
if (direct) {
return clipBlock(direct, 700, 12)
}
}
for (const k of [...ERROR_KEYS, ...WRAPPER_KEYS, 'details', 'meta']) {
const nested = findNestedError(record[k], depth + 1, seen)
if (nested) {
return nested
}
}
return ''
}
export function formatToolResultSummary(value: unknown): string {
return formatSummaryValue(unwrapPayload(value), 0) || formatSummaryValue(value, 0)
}
export function extractToolErrorMessage(value: unknown): string {
return findNestedError(value, 0, new Set())
}