Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions apps/mobile/src/components/agents/tool-card-display.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
80 changes: 80 additions & 0 deletions apps/mobile/src/components/agents/tool-list-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
20 changes: 18 additions & 2 deletions apps/mobile/src/components/agents/tool-list-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down