diff --git a/apps/mobile/src/components/agents/tool-card-display.test.ts b/apps/mobile/src/components/agents/tool-card-display.test.ts index aa53c491fe..403c542292 100644 --- a/apps/mobile/src/components/agents/tool-card-display.test.ts +++ b/apps/mobile/src/components/agents/tool-card-display.test.ts @@ -356,6 +356,58 @@ describe('getToolDisplay badge rules', () => { }); }); +describe('getToolDisplay badge rules — live CLI shapes', () => { + it('omits the glob badge for No files found', () => { + expect( + getDisplay(makeToolPart('glob', completed({ pattern: '**/*.ts' }, 'No files found'))).badge + ).toBeUndefined(); + }); + + it('badges a single glob path as 1 files', () => { + expect( + getDisplay( + makeToolPart('glob', completed({ pattern: '**/*.ts' }, '/repo/apps/mobile/AGENTS.md')) + ).badge + ).toBe('1 files'); + }); + + it('does not count the opencode truncated note as a file', () => { + const output = + '/repo/a.ts\n/repo/b.ts\n\n(Results are truncated: showing first 100 results. Consider using a more specific path or pattern.)'; + expect(getDisplay(makeToolPart('glob', completed({ pattern: '**/*.ts' }, output))).badge).toBe( + '2 files' + ); + }); + + it('does not count the partial note as a file', () => { + const output = '/repo/a.ts\n/repo/b.ts\n\n(Some discovered files could not be read.)'; + expect(getDisplay(makeToolPart('glob', completed({ pattern: '**/*.ts' }, output))).badge).toBe( + '2 files' + ); + }); + + it('omits the grep badge for No files found', () => { + expect( + getDisplay(makeToolPart('grep', completed({ pattern: 'foo' }, 'No files found'))).badge + ).toBeUndefined(); + }); + + it('badges the live grep hit as 2 matches', () => { + const output = 'Found 1 matches\n/repo/src/a.ts:\n Line 1: hello'; + expect(getDisplay(makeToolPart('grep', completed({ pattern: 'foo' }, output))).badge).toBe( + '2 matches' + ); + }); + + it('does not count a parenthetical grep note as a match', () => { + const output = + 'Found 1 matches\n/repo/src/a.ts:\n Line 1: hello\n\n(Some paths were inaccessible.)'; + expect(getDisplay(makeToolPart('grep', completed({ pattern: 'foo' }, output))).badge).toBe( + '2 matches' + ); + }); +}); + describe('toolPartHasDetails', () => { it('returns false for suggest even with input', () => { expect(toolPartHasDetails(makeToolPart('suggest', completed({ prompt: 'hi' })))).toBe(false); diff --git a/apps/mobile/src/components/agents/tool-list-model.test.ts b/apps/mobile/src/components/agents/tool-list-model.test.ts index d10ddcedda..3dc166939c 100644 --- a/apps/mobile/src/components/agents/tool-list-model.test.ts +++ b/apps/mobile/src/components/agents/tool-list-model.test.ts @@ -132,6 +132,86 @@ describe('buildResultRowsModel — glob rows', () => { }); }); +describe('buildResultRowsModel — live CLI shapes', () => { + it('lifts glob No files found into the caption and keeps no rows', () => { + const model = buildResultRowsModel('No files found', 'glob'); + expect(model.caption).toBe('No files found'); + expect(model.rows).toEqual([]); + expect(model.truncated).toBe(false); + }); + + it('keeps a single glob path as one row with no caption', () => { + const model = buildResultRowsModel('/repo/apps/mobile/AGENTS.md', 'glob'); + expect(model.caption).toBeUndefined(); + expect(model.rows).toEqual([{ text: '/repo/apps/mobile/AGENTS.md', emphasis: false }]); + expect(model.truncated).toBe(false); + }); + + it('keeps every glob path as a row', () => { + const model = buildResultRowsModel('/repo/a.ts\n/repo/b.ts\n/repo/c.ts', 'glob'); + expect(model.rows).toEqual([ + { text: '/repo/a.ts', emphasis: false }, + { text: '/repo/b.ts', emphasis: false }, + { text: '/repo/c.ts', emphasis: false }, + ]); + }); + + it('drops the opencode truncated note and flags truncation', () => { + const output = + '/repo/a.ts\n/repo/b.ts\n\n(Results are truncated: showing first 100 results. Consider using a more specific path or pattern.)'; + const model = buildResultRowsModel(output, 'glob'); + expect(model.rows).toEqual([ + { text: '/repo/a.ts', emphasis: false }, + { text: '/repo/b.ts', emphasis: false }, + ]); + expect(model.truncated).toBe(true); + }); + + it('drops the core truncated note and flags truncation', () => { + const output = '/repo/a.ts\n\n(Results truncated: showing first 1 files.)'; + const model = buildResultRowsModel(output, 'glob'); + expect(model.rows).toEqual([{ text: '/repo/a.ts', emphasis: false }]); + expect(model.truncated).toBe(true); + }); + + it('drops the partial note without flagging truncation', () => { + const output = '/repo/a.ts\n\n(Some discovered files could not be read.)'; + const model = buildResultRowsModel(output, 'glob'); + expect(model.rows).toEqual([{ text: '/repo/a.ts', emphasis: false }]); + expect(model.truncated).toBe(false); + }); + + it('lifts grep No files found into the caption and keeps no rows', () => { + const model = buildResultRowsModel('No files found', 'grep'); + expect(model.caption).toBe('No files found'); + expect(model.rows).toEqual([]); + }); + + it('keeps the live grep header and match rows', () => { + const output = 'Found 1 matches\n/repo/src/a.ts:\n Line 1: hello'; + const model = buildResultRowsModel(output, 'grep'); + expect(model.caption).toBe('Found 1 matches'); + expect(model.rows).toEqual([ + { text: '/repo/src/a.ts:', emphasis: true }, + { text: 'Line 1: hello', emphasis: false }, + ]); + }); + + it('drops grep truncated and partial notes without adding rows', () => { + const output = + 'Found 2 matches\n/repo/src/a.ts:\n Line 1: hello\n\n' + + '(Results truncated. Consider using a more specific path or pattern.)\n' + + '(Some paths were inaccessible.)'; + const model = buildResultRowsModel(output, 'grep'); + expect(model.caption).toBe('Found 2 matches'); + expect(model.rows).toEqual([ + { text: '/repo/src/a.ts:', emphasis: true }, + { text: 'Line 1: hello', emphasis: false }, + ]); + expect(model.truncated).toBe(true); + }); +}); + describe('buildResultRowsModel — list rows', () => { it('renders every line as a plain row', () => { const model = buildResultRowsModel('added.ts\napp.ts\ngone.ts', 'list'); diff --git a/apps/mobile/src/components/agents/tool-list-model.ts b/apps/mobile/src/components/agents/tool-list-model.ts index 0bed4ec312..d877fb32a2 100644 --- a/apps/mobile/src/components/agents/tool-list-model.ts +++ b/apps/mobile/src/components/agents/tool-list-model.ts @@ -5,7 +5,9 @@ // trims the indented match rows; glob drops the trailing `---` separator and // folds the `[N files truncated]` marker into the model's flag; list is one // plain row per line. All kinds lift a leading `Found N ...` summary into a -// muted caption. +// muted caption. Glob and grep lift `No files found` into the caption and +// drop a whole-line parenthetical note (`^\(.*\)$`); a dropped note that +// contains `truncated` (any case) sets the flag. // // Task rows (D8): the task array comes from `state.metadata.todos`, then // `state.input.todos`, then a JSON parse of `state.output`. Unknown status @@ -47,6 +49,8 @@ export type ResultRowsKind = 'grep' | 'glob' | 'list'; const CAPTION_PATTERN = /^Found \d+/; const TRUNCATED_LINE_PATTERN = /^\[\d+ files truncated\]$/; +const EMPTY_RESULT_LINE = 'No files found'; +const STATUS_NOTE_PATTERN = /^\(.*\)$/; /** * Convert a tool output string into display rows. Every line becomes at most @@ -73,7 +77,19 @@ export function buildResultRowsModel(output: string, kind: ResultRowsKind): Resu truncated = true; } - if (!isGlobSeparator && !isGlobTruncation) { + const isLiveKind = kind === 'glob' || kind === 'grep'; + const isEmptyResult = isLiveKind && line === EMPTY_RESULT_LINE; + const isStatusNote = isLiveKind && STATUS_NOTE_PATTERN.test(line); + + if (isEmptyResult && caption === undefined) { + caption = line.slice(0, RESULT_ROW_CHARACTER_CAP); + } + + if (isStatusNote && line.toLowerCase().includes('truncated')) { + truncated = true; + } + + if (!isGlobSeparator && !isGlobTruncation && !isEmptyResult && !isStatusNote) { if (rows.length >= RESULT_ROW_CAP) { truncated = true; break;