mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-28 11:32:22 +00:00
fix(desktop): compact tool row titles
Make completed desktop tool rows read like useful activity labels instead of raw plumbing: terminal rows use a dispatch-style shell summarizer for agent wrappers, and read_file rows keep the action plus filename and requested line range. The shell cleanup follows condensed-milk-pi's shape: split command compounds on real separators, strip pipe tails inside each segment, clean redirects/env prefixes, then classify setup/banner/status segments. Multi-command probes render as `first command + N commands`; the full command remains available in copy/detail. Read rows now render as `Read package.json` or `Read main.ts L25-34`, using requested positive offset/limit and returned line numbers only as fallback for negative/unknown offsets.
This commit is contained in:
parent
7a65800fed
commit
41f302fa73
8 changed files with 480 additions and 12 deletions
|
|
@ -140,10 +140,10 @@ describe('buildToolView title actions', () => {
|
|||
expect(read.titleAction).toEqual({ prefix: '', text: 'Reading', suffix: ' file' })
|
||||
expect(web.title).toBe('Reading example.com/docs')
|
||||
expect(web.titleAction).toEqual({ prefix: '', text: 'Reading', suffix: ' example.com/docs' })
|
||||
expect(terminal.title).toBe('Running · npm test -- --runInBand')
|
||||
expect(terminal.titleAction).toEqual({ prefix: '', text: 'Running', suffix: ' · npm test -- --runInBand' })
|
||||
expect(code.title).toBe('Scripting · print("hello")')
|
||||
expect(code.titleAction).toEqual({ prefix: '', text: 'Scripting', suffix: ' · print("hello")' })
|
||||
expect(terminal.title).toBe('Running npm test -- --runInBand')
|
||||
expect(terminal.titleAction).toEqual({ prefix: '', text: 'Running', suffix: ' npm test -- --runInBand' })
|
||||
expect(code.title).toBe('Scripting print("hello")')
|
||||
expect(code.titleAction).toEqual({ prefix: '', text: 'Scripting', suffix: ' print("hello")' })
|
||||
})
|
||||
|
||||
it('does not mark completed tool titles as pending actions', () => {
|
||||
|
|
@ -153,6 +153,92 @@ describe('buildToolView title actions', () => {
|
|||
expect(view.titleAction).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the filename for completed read_file rows', () => {
|
||||
const view = buildToolView(
|
||||
part({ args: { path: './package.json' }, result: { content: '1|{"name":"demo"}' }, toolName: 'read_file' }),
|
||||
''
|
||||
)
|
||||
|
||||
expect(view.title).toBe('Read package.json')
|
||||
expect(view.subtitle).toBe('')
|
||||
expect(view.titleAction).toBeUndefined()
|
||||
})
|
||||
|
||||
it('adds a compact line range to line-scoped read_file rows', () => {
|
||||
const view = buildToolView(
|
||||
part({
|
||||
args: { limit: 10, offset: 25, path: './src/main.ts' },
|
||||
result: { content: '25|function toggleDock() {\n26| dock.classList.toggle("hidden");\n34|}' },
|
||||
toolName: 'read_file'
|
||||
}),
|
||||
''
|
||||
)
|
||||
|
||||
expect(view.title).toBe('Read main.ts L25-34')
|
||||
expect(view.subtitle).toBe('')
|
||||
})
|
||||
|
||||
it('uses the requested positive offset/limit for read_file row line ranges', () => {
|
||||
const view = buildToolView(
|
||||
part({
|
||||
args: { limit: 5, offset: 1, path: './package.json' },
|
||||
result: { content: '1|{\n2| "name": "bb-rainbows",\n3| "private": true,\n4| "version": "0.0.1",\n5| "type": "module",\n6| "description": "extra"' },
|
||||
toolName: 'read_file'
|
||||
}),
|
||||
''
|
||||
)
|
||||
|
||||
expect(view.title).toBe('Read package.json L1-5')
|
||||
})
|
||||
|
||||
it('uses returned line numbers for negative-offset read_file rows', () => {
|
||||
const view = buildToolView(
|
||||
part({
|
||||
args: { limit: 2, offset: -2, path: './src/main.ts' },
|
||||
result: { content: '99|lastLine();\n100|done();' },
|
||||
toolName: 'read_file'
|
||||
}),
|
||||
''
|
||||
)
|
||||
|
||||
expect(view.title).toBe('Read main.ts L99-100')
|
||||
})
|
||||
|
||||
it('renders compact terminal titles for session 20260624_231846_bdbd1e commands', () => {
|
||||
const rows = [
|
||||
[
|
||||
'cd /Users/brooklyn/www/bb-rainbows && pnpm run lint 2>&1 | tail -20; echo "lint_exit=${PIPESTATUS[0]}"',
|
||||
'Ran pnpm run lint'
|
||||
],
|
||||
[
|
||||
'cd /Users/brooklyn/www/bb-rainbows && pnpm run build 2>&1 | tail -20; echo "build_exit=${PIPESTATUS[0]}"',
|
||||
'Ran pnpm run build'
|
||||
],
|
||||
[
|
||||
'which node pnpm corepack; node -v; echo "---"; corepack --version 2>&1; echo "---pnpm via corepack---"; pnpm --version 2>&1 | tail -5',
|
||||
'Ran which node pnpm corepack + 3 commands'
|
||||
],
|
||||
[
|
||||
'echo "--- proto pnpm direct ---"; ~/.proto/tools/node/24.11.0/bin/pnpm --version 2>&1 | tail -3; echo "--- proto node ---"; ls ~/.proto/tools/node/ 2>&1; echo "--- corepack cache ---"; ls ~/.cache/node/corepack/v1/pnpm/ 2>&1',
|
||||
'Ran ~/.proto/tools/node/24.11.0/bin/pnpm --version + 2 commands'
|
||||
],
|
||||
[
|
||||
'cd /Users/brooklyn/www/bb-rainbows && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack pnpm@10.20.0 --version 2>&1 | tail -3',
|
||||
'Ran COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack pnpm@10.20.0 --version'
|
||||
],
|
||||
[
|
||||
'cd /Users/brooklyn/www/bb-rainbows && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack use pnpm@10.20.0 2>&1 | tail -10; echo "exit=$?"',
|
||||
'Ran COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack use pnpm@10.20.0'
|
||||
]
|
||||
] as const
|
||||
|
||||
for (const [command, expectedTitle] of rows) {
|
||||
const view = buildToolView(part({ args: { command }, result: { output: 'ok', exit_code: 0 }, toolName: 'terminal' }), '')
|
||||
|
||||
expect(view.title).toBe(expectedTitle)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the runtime locale for title text and action placement', () => {
|
||||
setRuntimeI18nLocale('ja')
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { type ToolTitleKey, translateNow } from '@/i18n'
|
||||
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'
|
||||
|
|
@ -125,6 +126,45 @@ function fileEditBasename(path: string): string {
|
|||
return normalized.split('/').filter(Boolean).pop() || normalized
|
||||
}
|
||||
|
||||
function numericField(record: Record<string, unknown>, key: string): number | undefined {
|
||||
const value = record[key]
|
||||
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
function readFileLineLabel(args: Record<string, unknown>, result: Record<string, unknown>): string {
|
||||
if (numericField(args, 'offset') === undefined && numericField(args, 'limit') === undefined) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const content = firstStringField(result, ['content'])
|
||||
const offset = numericField(args, 'offset')
|
||||
const limit = numericField(args, 'limit')
|
||||
|
||||
if (offset !== undefined && offset > 0) {
|
||||
if (limit === undefined || limit <= 1) {
|
||||
return `L${offset}`
|
||||
}
|
||||
|
||||
return `L${offset}-${offset + limit - 1}`
|
||||
}
|
||||
|
||||
const lines = content
|
||||
.split('\n')
|
||||
.map(line => /^(\d+)\|/.exec(line)?.[1])
|
||||
.filter((line): line is string => !!line)
|
||||
.map(Number)
|
||||
|
||||
if (lines.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const start = lines[0]!
|
||||
const end = lines[lines.length - 1]!
|
||||
|
||||
return start === end ? `L${start}` : `L${start}-${end}`
|
||||
}
|
||||
|
||||
const TOOL_META: Record<ToolTitleKey, ToolMetaSpec> = {
|
||||
browser_click: {
|
||||
icon: 'globe',
|
||||
|
|
@ -1182,7 +1222,7 @@ function toolSubtitle(
|
|||
|
||||
const command = firstStringField(argsRecord, ['command', 'code']) || contextValue(argsRecord)
|
||||
|
||||
return command ? compactPreview(command, 120) : 'Executed command'
|
||||
return command ? compactPreview(summarizeShellCommand(command), 120) : 'Executed command'
|
||||
}
|
||||
|
||||
if (toolName === 'read_file' || isFileEditTool(toolName)) {
|
||||
|
|
@ -1290,7 +1330,7 @@ function toolDetailText(
|
|||
}
|
||||
}
|
||||
|
||||
if (part.toolName === 'read_file') {
|
||||
if (part.toolName === 'read_file' && part.result !== undefined) {
|
||||
const content = firstStringField(resultRecord, ['content', 'text', 'data', 'body'])
|
||||
|
||||
if (content) {
|
||||
|
|
@ -1401,7 +1441,7 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string
|
|||
}
|
||||
}
|
||||
|
||||
if (part.toolName === 'read_file') {
|
||||
if (part.toolName === 'read_file' && part.result !== undefined) {
|
||||
if (hasSubstantialOutput) {
|
||||
return { label: copy.file, text: detail }
|
||||
}
|
||||
|
|
@ -1514,11 +1554,27 @@ function dynamicTitle(
|
|||
|
||||
return titledAction(
|
||||
action,
|
||||
translateNow('assistant.tool.titleTemplates.actionCommand', action, compactPreview(command, 160))
|
||||
translateNow(
|
||||
'assistant.tool.titleTemplates.actionCommand',
|
||||
action,
|
||||
compactPreview(summarizeShellCommand(command), 160)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (part.toolName === 'read_file' && part.result !== undefined) {
|
||||
const path = firstStringField(args, ['path', 'file', 'filepath'])
|
||||
|
||||
if (path) {
|
||||
const lineLabel = readFileLineLabel(args, result)
|
||||
const target = [fileEditBasename(path), lineLabel].filter(Boolean).join(' ')
|
||||
const action = verb(translateNow('assistant.tool.actions.reading'), translateNow('assistant.tool.actions.read'))
|
||||
|
||||
return { title: translateNow('assistant.tool.titleTemplates.actionTarget', action, target) }
|
||||
}
|
||||
}
|
||||
|
||||
if (isFileEditTool(part.toolName)) {
|
||||
const path = fileEditPath(args, result)
|
||||
|
||||
|
|
|
|||
|
|
@ -1921,7 +1921,7 @@ export const en: Translations = {
|
|||
web: 'Web'
|
||||
},
|
||||
titleTemplates: {
|
||||
actionCommand: (action, command) => `${action} · ${command}`,
|
||||
actionCommand: (action, command) => `${action} ${command}`,
|
||||
actionQuoted: (action, value) => `${action} “${value}”`,
|
||||
actionTarget: (action, target) => `${action} ${target}`,
|
||||
prefixedDone: (prefix, action) => `${prefix} ${action}`,
|
||||
|
|
|
|||
|
|
@ -2049,7 +2049,7 @@ export const ja = defineLocale({
|
|||
web: 'Web'
|
||||
},
|
||||
titleTemplates: {
|
||||
actionCommand: (action, command) => `${action} · ${command}`,
|
||||
actionCommand: (action, command) => `${action} ${command}`,
|
||||
actionQuoted: (action, value) => `「${value}」を${action}`,
|
||||
actionTarget: (action, target) => `${target} を${action}`,
|
||||
prefixedDone: (prefix, action) => `${prefix} ${action}`,
|
||||
|
|
|
|||
|
|
@ -1989,7 +1989,7 @@ export const zhHant = defineLocale({
|
|||
web: '網頁'
|
||||
},
|
||||
titleTemplates: {
|
||||
actionCommand: (action, command) => `${action} · ${command}`,
|
||||
actionCommand: (action, command) => `${action} ${command}`,
|
||||
actionQuoted: (action, value) => `${action}「${value}」`,
|
||||
actionTarget: (action, target) => `${action} ${target}`,
|
||||
prefixedDone: (prefix, action) => `${prefix}${action}`,
|
||||
|
|
|
|||
|
|
@ -2097,7 +2097,7 @@ export const zh: Translations = {
|
|||
web: '网页'
|
||||
},
|
||||
titleTemplates: {
|
||||
actionCommand: (action, command) => `${action} · ${command}`,
|
||||
actionCommand: (action, command) => `${action} ${command}`,
|
||||
actionQuoted: (action, value) => `${action}“${value}”`,
|
||||
actionTarget: (action, target) => `${action} ${target}`,
|
||||
prefixedDone: (prefix, action) => `${prefix}${action}`,
|
||||
|
|
|
|||
110
apps/desktop/src/lib/summarize-command.test.ts
Normal file
110
apps/desktop/src/lib/summarize-command.test.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { summarizeShellCommand } from './summarize-command'
|
||||
|
||||
describe('summarizeShellCommand', () => {
|
||||
it('strips a leading cd and trailing tail + status echo', () => {
|
||||
expect(
|
||||
summarizeShellCommand(
|
||||
'cd /Users/me/www/bb-rainbows && pnpm run lint 2>&1 | tail -10; echo "lint_exit=${PIPESTATUS[0]}"'
|
||||
)
|
||||
).toBe('pnpm run lint')
|
||||
})
|
||||
|
||||
it('keeps flags on the surviving command', () => {
|
||||
expect(summarizeShellCommand('cd /x && pnpm run preview --port 4317 2>&1')).toBe(
|
||||
'pnpm run preview --port 4317'
|
||||
)
|
||||
})
|
||||
|
||||
it('drops a source/activate prefix', () => {
|
||||
expect(summarizeShellCommand('source .venv/bin/activate && pytest -q')).toBe('pytest -q')
|
||||
})
|
||||
|
||||
it('skips leading env assignments', () => {
|
||||
expect(summarizeShellCommand('cd /x && NODE_ENV=test FOO=bar vitest run 2>&1 | tail -5')).toBe(
|
||||
'NODE_ENV=test FOO=bar vitest run'
|
||||
)
|
||||
})
|
||||
|
||||
it('compacts a genuine multi-command compound without listing every command', () => {
|
||||
const compound = 'git add -A && git commit -m "wip"'
|
||||
expect(summarizeShellCommand(compound)).toBe('git add -A + 1 command')
|
||||
})
|
||||
|
||||
it('leaves a single bare command untouched', () => {
|
||||
expect(summarizeShellCommand('git status --short')).toBe('git status --short')
|
||||
})
|
||||
|
||||
it('does not split on operators inside quotes', () => {
|
||||
const cmd = 'git commit -m "fix: a | b && c"'
|
||||
expect(summarizeShellCommand(cmd)).toBe(cmd)
|
||||
})
|
||||
|
||||
it('does not strip a redirection-looking char inside quotes', () => {
|
||||
expect(summarizeShellCommand('cd /x && git commit -m "a > b"')).toBe('git commit -m "a > b"')
|
||||
})
|
||||
|
||||
it('handles empty / whitespace input', () => {
|
||||
expect(summarizeShellCommand('')).toBe('')
|
||||
expect(summarizeShellCommand(' ')).toBe('')
|
||||
})
|
||||
|
||||
it('returns the original when every segment is plumbing', () => {
|
||||
const allSetup = 'cd /x && export FOO=1'
|
||||
expect(summarizeShellCommand(allSetup)).toBe(allSetup)
|
||||
})
|
||||
|
||||
it('collapses 2>&1 redirection on a plain pipeline', () => {
|
||||
expect(summarizeShellCommand('cd /x && tsc --noEmit 2>&1 | tail -20')).toBe('tsc --noEmit')
|
||||
})
|
||||
|
||||
it('drops a leading echo banner around a single command', () => {
|
||||
expect(
|
||||
summarizeShellCommand('echo "--- proto pnpm direct ---"; ~/.proto/tools/node/24.11.0/bin/pnpm --version 2>&1 | tail -3')
|
||||
).toBe('~/.proto/tools/node/24.11.0/bin/pnpm --version')
|
||||
})
|
||||
|
||||
it('drops echo banners on both sides plus the trailing status echo', () => {
|
||||
expect(summarizeShellCommand('echo "--- build ---"; npm run build 2>&1 | tail -5; echo "build_exit=$?"')).toBe(
|
||||
'npm run build'
|
||||
)
|
||||
})
|
||||
|
||||
it('compacts a genuine multi-command probe from session 20260624_231846_bdbd1e', () => {
|
||||
const probe = 'which node pnpm corepack; node -v; corepack --version 2>&1'
|
||||
expect(summarizeShellCommand(probe)).toBe('which node pnpm corepack + 2 commands')
|
||||
})
|
||||
|
||||
it('compacts the corepack diagnostic command from session 20260624_231846_bdbd1e', () => {
|
||||
expect(
|
||||
summarizeShellCommand(
|
||||
'which node pnpm corepack; node -v; echo "---"; corepack --version 2>&1; echo "---pnpm via corepack---"; pnpm --version 2>&1 | tail -5'
|
||||
)
|
||||
).toBe('which node pnpm corepack + 3 commands')
|
||||
})
|
||||
|
||||
it('compacts the proto/cache probe from session 20260624_231846_bdbd1e', () => {
|
||||
expect(
|
||||
summarizeShellCommand(
|
||||
'echo "--- proto pnpm direct ---"; ~/.proto/tools/node/24.11.0/bin/pnpm --version 2>&1 | tail -3; echo "--- proto node ---"; ls ~/.proto/tools/node/ 2>&1; echo "--- corepack cache ---"; ls ~/.cache/node/corepack/v1/pnpm/ 2>&1'
|
||||
)
|
||||
).toBe('~/.proto/tools/node/24.11.0/bin/pnpm --version + 2 commands')
|
||||
})
|
||||
|
||||
it('summarizes the successful lint command from session 20260624_231846_bdbd1e', () => {
|
||||
expect(
|
||||
summarizeShellCommand(
|
||||
'cd /Users/brooklyn/www/bb-rainbows && pnpm run lint 2>&1 | tail -20; echo "lint_exit=${PIPESTATUS[0]}"'
|
||||
)
|
||||
).toBe('pnpm run lint')
|
||||
})
|
||||
|
||||
it('summarizes a background build command from session 20260624_231846_bdbd1e', () => {
|
||||
expect(
|
||||
summarizeShellCommand(
|
||||
'cd /Users/brooklyn/www/bb-rainbows && pnpm run build 2>&1 | tail -20; echo "build_exit=${PIPESTATUS[0]}"'
|
||||
)
|
||||
).toBe('pnpm run build')
|
||||
})
|
||||
})
|
||||
216
apps/desktop/src/lib/summarize-command.ts
Normal file
216
apps/desktop/src/lib/summarize-command.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
// Adapted from condensed-milk-pi's command dispatcher: split compounds first,
|
||||
// strip pipe tails (`| head`, `| tail`, ...), then clean redirects/env prefixes
|
||||
// before deciding which segment is meaningful. This is display-only; the full
|
||||
// command remains available through Copy / detail.
|
||||
const SILENT_HEADS = new Set(['cd', 'pushd', 'popd', 'export', 'set', 'unset', 'source', '.', 'true', 'false', ':'])
|
||||
const PIPE_TAIL_HEADS = new Set(['head', 'tail', 'wc', 'sort', 'uniq'])
|
||||
|
||||
const basename = (head: string): string => head.split('/').pop() || head
|
||||
|
||||
// Split on command-chain separators, but NOT pipe. A pipe usually belongs to
|
||||
// the segment's output plumbing (`cmd 2>&1 | tail -20`); condensed-milk strips
|
||||
// that after segmenting instead of treating it as a separate producer.
|
||||
function splitCompoundCommand(input: string): string[] {
|
||||
const segments: string[] = []
|
||||
let buf = ''
|
||||
let quote: '"' | "'" | null = null
|
||||
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
const ch = input[i]!
|
||||
|
||||
if (quote) {
|
||||
buf += ch
|
||||
|
||||
if (ch === quote && input[i - 1] !== '\\') {
|
||||
quote = null
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch
|
||||
buf += ch
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const op =
|
||||
input.startsWith('&&', i) || input.startsWith('||', i)
|
||||
? input.slice(i, i + 2)
|
||||
: ch === ';' || ch === '\n'
|
||||
? ch
|
||||
: ''
|
||||
|
||||
if (op) {
|
||||
segments.push(buf)
|
||||
buf = ''
|
||||
i += op.length - 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
buf += ch
|
||||
}
|
||||
|
||||
segments.push(buf)
|
||||
|
||||
return segments.map(segment => stripPipeTail(segment.trim())).filter(Boolean)
|
||||
}
|
||||
|
||||
function splitWords(segment: string): string[] {
|
||||
const words: string[] = []
|
||||
let buf = ''
|
||||
let quote: '"' | "'" | null = null
|
||||
|
||||
for (let i = 0; i < segment.length; i += 1) {
|
||||
const ch = segment[i]!
|
||||
|
||||
if (quote) {
|
||||
buf += ch
|
||||
|
||||
if (ch === quote && segment[i - 1] !== '\\') {
|
||||
quote = null
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch
|
||||
buf += ch
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (/\s/.test(ch)) {
|
||||
if (buf) {
|
||||
words.push(buf)
|
||||
buf = ''
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
buf += ch
|
||||
}
|
||||
|
||||
if (buf) {
|
||||
words.push(buf)
|
||||
}
|
||||
|
||||
return words
|
||||
}
|
||||
|
||||
// The command word of a segment, skipping any `FOO=bar` env assignments.
|
||||
function headWord(segment: string): string {
|
||||
const tokens = splitWords(segment)
|
||||
let index = 0
|
||||
|
||||
while (index < tokens.length && /^[A-Za-z_]\w*=/.test(tokens[index]!)) {
|
||||
index += 1
|
||||
}
|
||||
|
||||
return basename(tokens[index] ?? '')
|
||||
}
|
||||
|
||||
function stripPipeTail(segment: string): string {
|
||||
const words = splitWords(segment)
|
||||
const out: string[] = []
|
||||
|
||||
for (let i = 0; i < words.length; i += 1) {
|
||||
const word = words[i]!
|
||||
|
||||
if (word === '|' && PIPE_TAIL_HEADS.has(basename(words[i + 1] ?? ''))) {
|
||||
break
|
||||
}
|
||||
|
||||
out.push(word)
|
||||
}
|
||||
|
||||
return out.join(' ').trim()
|
||||
}
|
||||
|
||||
function cleanSegment(segment: string): string {
|
||||
const words = splitWords(segment)
|
||||
const out: string[] = []
|
||||
|
||||
for (let i = 0; i < words.length; i += 1) {
|
||||
const word = words[i]!
|
||||
|
||||
if (/^\d*(?:>>?|<)$/.test(word)) {
|
||||
i += 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\d*(?:>&|<&)\d+$/.test(word) || /^\d*>&\d+$/.test(word)) {
|
||||
continue
|
||||
}
|
||||
|
||||
out.push(word)
|
||||
}
|
||||
|
||||
return out.join(' ').trim()
|
||||
}
|
||||
|
||||
function isBoundaryEcho(segment: string): boolean {
|
||||
const words = splitWords(segment)
|
||||
|
||||
if (basename(words[0] ?? '') !== 'echo') {
|
||||
return false
|
||||
}
|
||||
|
||||
// Banner/status echoes are UI plumbing. Do not treat arbitrary `echo $VALUE`
|
||||
// as noise; it may be the command's actual output.
|
||||
const rest = words.slice(1).join(' ')
|
||||
|
||||
return /-{2,}|_exit=|(?:^|\s|=)\$[?{]|PIPESTATUS/.test(rest)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce a verbose shell command to the "main" command, for display only.
|
||||
*
|
||||
* Agents wrap real work in plumbing — `cd <dir> && <cmd> 2>&1 | tail -N; echo
|
||||
* "x_exit=${PIPESTATUS[0]}"` — which buries the command the user actually cares
|
||||
* about. This peels that wrapper off using small head-word allowlists instead of
|
||||
* one giant regex:
|
||||
*
|
||||
* 1. split into segments on top-level `&&` `||` `;` (quote-aware)
|
||||
* 2. strip trailing pipe tails (`| head`, `| tail`, `| wc`, ...)
|
||||
* 3. clean env var prefixes / redirects
|
||||
* 4. drop setup/banner/status segments
|
||||
*
|
||||
* If one real command survives, show it. If multiple real commands survive,
|
||||
* show a short `first command + N commands` label instead of flooding the row
|
||||
* with every probe. The full command is always still available via Copy/detail.
|
||||
*/
|
||||
export function summarizeShellCommand(raw: string): string {
|
||||
const original = (raw ?? '').trim()
|
||||
|
||||
if (!original) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const segments = splitCompoundCommand(original)
|
||||
|
||||
if (segments.length <= 1) {
|
||||
return cleanSegment(original) || original
|
||||
}
|
||||
|
||||
const core = segments.map(cleanSegment).filter(segment => {
|
||||
const head = headWord(segment)
|
||||
|
||||
return segment && !SILENT_HEADS.has(head) && !isBoundaryEcho(segment)
|
||||
})
|
||||
|
||||
if (core.length === 0) {
|
||||
return original
|
||||
}
|
||||
|
||||
if (core.length === 1) {
|
||||
return core[0]!
|
||||
}
|
||||
|
||||
return `${core[0]} + ${core.length - 1} ${core.length === 2 ? 'command' : 'commands'}`
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue