explorbot 0.1.25 → 0.1.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/package.json +1 -1
  2. package/dist/src/ai/historian/codeceptjs.js +1 -1
  3. package/dist/src/ai/historian/experience.js +1 -1
  4. package/dist/src/ai/historian/playwright.js +1 -1
  5. package/dist/src/ai/historian/screencast.js +3 -3
  6. package/dist/src/ai/historian.js +1 -1
  7. package/dist/src/ai/navigator.js +4 -4
  8. package/dist/src/ai/pilot.js +8 -5
  9. package/dist/src/ai/planner.js +2 -4
  10. package/dist/src/ai/provider.js +5 -3
  11. package/dist/src/ai/researcher/cache.js +8 -0
  12. package/dist/src/ai/researcher/deep-analysis.js +146 -61
  13. package/dist/src/ai/researcher/locators.js +1 -1
  14. package/dist/src/ai/researcher.js +4 -4
  15. package/dist/src/ai/session-analyst.js +4 -1
  16. package/dist/src/ai/task-agent.js +2 -2
  17. package/dist/src/commands/context-aria-command.js +1 -1
  18. package/dist/src/commands/explore-command.js +67 -25
  19. package/dist/src/commands/test-command.js +2 -1
  20. package/dist/src/components/App.js +1 -33
  21. package/dist/src/components/LogPane.js +9 -3
  22. package/dist/src/experience-tracker.js +2 -2
  23. package/dist/src/explorbot.js +1 -1
  24. package/dist/src/reporter.js +24 -5
  25. package/dist/src/utils/log-filters.js +27 -0
  26. package/dist/src/utils/logger.js +28 -1
  27. package/dist/src/utils/next-steps.js +1 -7
  28. package/package.json +1 -1
  29. package/src/ai/historian/codeceptjs.ts +1 -1
  30. package/src/ai/historian/experience.ts +1 -1
  31. package/src/ai/historian/playwright.ts +1 -1
  32. package/src/ai/historian/screencast.ts +3 -3
  33. package/src/ai/historian.ts +1 -1
  34. package/src/ai/navigator.ts +4 -4
  35. package/src/ai/pilot.ts +8 -5
  36. package/src/ai/planner.ts +1 -3
  37. package/src/ai/provider.ts +5 -3
  38. package/src/ai/researcher/cache.ts +7 -0
  39. package/src/ai/researcher/deep-analysis.ts +166 -68
  40. package/src/ai/researcher/locators.ts +1 -1
  41. package/src/ai/researcher.ts +4 -4
  42. package/src/ai/session-analyst.ts +4 -1
  43. package/src/ai/task-agent.ts +2 -2
  44. package/src/commands/context-aria-command.ts +1 -1
  45. package/src/commands/explore-command.ts +68 -23
  46. package/src/commands/test-command.ts +2 -1
  47. package/src/components/App.tsx +0 -33
  48. package/src/components/LogPane.tsx +18 -3
  49. package/src/experience-tracker.ts +2 -2
  50. package/src/explorbot.ts +1 -1
  51. package/src/reporter.ts +24 -6
  52. package/src/utils/log-filters.ts +26 -0
  53. package/src/utils/logger.ts +24 -2
  54. package/src/utils/next-steps.ts +1 -6
@@ -7,7 +7,6 @@ import { type Plan, type Test, TestResult } from '../test-plan.js';
7
7
  import { getCliName } from '../utils/cli-name.ts';
8
8
  import { ErrorPageError } from '../utils/error-page.ts';
9
9
  import { tag } from '../utils/logger.js';
10
- import { jsonToTable } from '../utils/markdown-parser.js';
11
10
  import { type NextStepSection, printNextSteps, relativeToCwd } from '../utils/next-steps.ts';
12
11
  import { safeFilename } from '../utils/strings.ts';
13
12
  import { BaseCommand, type Suggestion } from './base-command.js';
@@ -88,14 +87,14 @@ export class ExploreCommand extends BaseCommand {
88
87
  const t = tests[i];
89
88
  lines.push(` ${String(i + 1).padStart(2)}. [${this.originLabel(t)}] [${t.priority.padEnd(9)}] ${t.scenario}`);
90
89
  }
91
- tag('multiline').log(lines.join('\n'));
90
+ tag('multiline').log(lines.join('\n'), { maxLines: 24 });
92
91
  }
93
92
 
94
93
  private async runFreshMode(mainUrl: string | undefined, feature: string | undefined, styles?: string[]): Promise<void> {
95
94
  await this.runAllStyles(mainUrl, feature, undefined, undefined, styles);
95
+ this.rememberCurrentPlan();
96
96
  const mainPlan = this.explorBot.getCurrentPlan();
97
97
  if (!mainPlan) return;
98
- this.completedPlans.push(mainPlan);
99
98
 
100
99
  if (feature || this.isLimitReached()) return;
101
100
 
@@ -270,6 +269,7 @@ export class ExploreCommand extends BaseCommand {
270
269
  const styleList = styles ?? Object.keys(getStyles());
271
270
  let fresh = true;
272
271
  for (const style of styleList) {
272
+ if (this.isLimitReached()) break;
273
273
  if (!fresh && pageUrl && !this.dryRun) {
274
274
  await this.explorBot.visit(pageUrl);
275
275
  }
@@ -278,10 +278,19 @@ export class ExploreCommand extends BaseCommand {
278
278
  if (this.dryRun) opts.noSave = true;
279
279
  await this.planWithRetry(feature, opts, pageUrl);
280
280
  await this.runPendingTests();
281
+ this.rememberCurrentPlan();
281
282
  fresh = false;
282
283
  }
283
284
  }
284
285
 
286
+ private rememberCurrentPlan(): void {
287
+ const plan = this.explorBot.getCurrentPlan();
288
+ if (!plan) return;
289
+ if (this.completedPlans.includes(plan)) return;
290
+ if (plan.tests.every((test) => test.startTime == null)) return;
291
+ this.completedPlans.push(plan);
292
+ }
293
+
285
294
  private async planWithRetry(feature: string | undefined, opts: { fresh: boolean; style: string; extend?: Plan; completedPlans?: Plan[]; noSave?: boolean }, pageUrl?: string): Promise<void> {
286
295
  const before = new Set(this.explorBot.getCurrentPlan()?.tests ?? []);
287
296
 
@@ -401,34 +410,63 @@ export class ExploreCommand extends BaseCommand {
401
410
 
402
411
  if (allTests.length === 0) return;
403
412
 
404
- const hasSubPages = this.completedPlans.length > 1;
413
+ const hasSubPages = new Set(this.completedPlans.map((plan) => plan.title)).size > 1;
405
414
  const hasOrigin = this.oldTestRefs.size > 0;
406
- const rows = allTests.map(({ test, planTitle }, index) => {
415
+ const completed = allTests.map(({ test, planTitle }, index) => {
407
416
  const durationMs = test.getDurationMs();
408
417
  const duration = durationMs != null ? `${(durationMs / 1000).toFixed(1)}s` : '-';
409
418
  let status = 'failed';
410
419
  if (test.isSuccessful) status = 'passed';
411
420
  else if (test.isSkipped) status = 'skipped';
412
- const row: Record<string, string> = {
413
- '#': String(index + 1),
414
- Status: status,
415
- Title: test.scenario.replace(/\|/g, '-'),
416
- Priority: test.priority,
417
- Time: duration,
418
- Steps: String(Object.keys(test.notes).length),
421
+ return {
422
+ index: index + 1,
423
+ status,
424
+ title: test.scenario.replace(/\s+/g, ' ').trim(),
425
+ priority: test.priority,
426
+ duration,
427
+ durationMs: durationMs ?? 0,
428
+ steps: Object.keys(test.notes).length,
429
+ origin: hasOrigin ? this.originLabel(test) : '',
430
+ planTitle: hasSubPages ? planTitle : '',
419
431
  };
420
- if (hasOrigin) {
421
- row.Origin = this.originLabel(test);
432
+ });
433
+ const passed = completed.filter((t) => t.status === 'passed').length;
434
+ const failed = completed.filter((t) => t.status === 'failed').length;
435
+ const skipped = completed.filter((t) => t.status === 'skipped').length;
436
+ const totalSeconds = completed.reduce((sum, t) => sum + t.durationMs, 0) / 1000;
437
+ const lines = [`Results: ${passed} passed, ${failed} failed, ${skipped} skipped - ${formatDuration(totalSeconds)}`];
438
+
439
+ const failedTests = completed.filter((t) => t.status === 'failed');
440
+ if (failedTests.length > 0) {
441
+ lines.push('', 'Failed tests:');
442
+ for (const test of failedTests) {
443
+ lines.push(` #${test.index} [${test.priority}] ${test.title} (${test.duration}, ${test.steps} steps)`);
422
444
  }
423
- if (hasSubPages) {
424
- row.Plan = planTitle;
445
+ }
446
+
447
+ const slowTests = completed
448
+ .filter((t) => t.durationMs >= 1000)
449
+ .sort((a, b) => b.durationMs - a.durationMs)
450
+ .slice(0, 3);
451
+ if (slowTests.length > 0) {
452
+ lines.push('', 'Slowest tests:');
453
+ for (const test of slowTests) {
454
+ lines.push(` #${test.index} ${test.duration} - ${test.title}`);
425
455
  }
426
- return row;
427
- });
428
- const columns = ['#', 'Status', 'Title', 'Priority', 'Time', 'Steps'];
429
- if (hasOrigin) columns.push('Origin');
430
- if (hasSubPages) columns.push('Plan');
431
- tag('multiline').log(jsonToTable(rows, columns));
456
+ }
457
+
458
+ const detailLines = completed
459
+ .map((test) => {
460
+ const details = [test.origin, test.planTitle].filter(Boolean).join(' - ');
461
+ return details ? ` #${test.index} ${details}` : '';
462
+ })
463
+ .filter(Boolean);
464
+ if (detailLines.length > 0) {
465
+ lines.push('', 'Details:');
466
+ lines.push(...detailLines);
467
+ }
468
+
469
+ tag('multiline').log(lines.join('\n'));
432
470
  tag('info').log(`${figureSet.tick} ${allTests.length} tests completed`);
433
471
  }
434
472
 
@@ -463,8 +501,8 @@ export class ExploreCommand extends BaseCommand {
463
501
  }
464
502
 
465
503
  if (screencasts.length > 0) {
466
- const commands = screencasts.map((f) => ({ label: '', command: relativeToCwd(f) }));
467
504
  const screencastDir = relativeToCwd(outputPath('screencasts'));
505
+ const commands = [{ label: 'Folder', command: screencastDir }];
468
506
  const planSlugs = [...new Set(this.completedPlans.map((p) => safeFilename(p.title)).filter(Boolean))];
469
507
  for (const slug of planSlugs) {
470
508
  commands.push({ label: 'Browse plan', command: `ls ${screencastDir}/${slug}-*` });
@@ -529,3 +567,10 @@ function parseRatio(s: string): number | null {
529
567
  if (Number.isNaN(n) || n < 0 || n > 1) return null;
530
568
  return n;
531
569
  }
570
+
571
+ function formatDuration(seconds: number): string {
572
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
573
+ const minutes = Math.floor(seconds / 60);
574
+ const remainingSeconds = Math.round(seconds % 60);
575
+ return `${minutes}m ${remainingSeconds}s`;
576
+ }
@@ -69,7 +69,8 @@ export class TestCommand extends BaseCommand {
69
69
 
70
70
  tag('info').log(`Launching ${toExecute.length} test scenario(s).`);
71
71
  const tester = this.explorBot.agentTester();
72
- for (const test of toExecute) {
72
+ for (const [index, test] of toExecute.entries()) {
73
+ tag('info').log(`Starting test ${index + 1}/${toExecute.length}: ${test.scenario}`);
73
74
  await tester.test(test);
74
75
  }
75
76
  tag('success').log('Test execution finished');
@@ -14,7 +14,6 @@ import InputPane from './InputPane.js';
14
14
  import InputReadline from './InputReadline.js';
15
15
  import LogPane from './LogPane.js';
16
16
  import PlanEditor from './PlanEditor.js';
17
- import PlanPane, { type PlanSummary } from './PlanPane.js';
18
17
  import SessionTimer from './SessionTimer.js';
19
18
  import StateTransitionPane from './StateTransitionPane.js';
20
19
  import TaskPane, { WINDOW_SIZE } from './TaskPane.js';
@@ -158,42 +157,20 @@ export function App({ explorBot, initialShowInput = false, exitOnEmptyInput = fa
158
157
 
159
158
  const planRef = useRef<ReturnType<typeof explorBot.getCurrentPlan>>(undefined);
160
159
  const unsubscribeRef = useRef<(() => void) | undefined>(undefined);
161
- const [completedPlans, setCompletedPlans] = useState<PlanSummary[]>([]);
162
- const [activePlanInfo, setActivePlanInfo] = useState<PlanSummary | null>(null);
163
160
 
164
161
  useEffect(() => {
165
- const makeSummary = (plan: NonNullable<ReturnType<typeof explorBot.getCurrentPlan>>): PlanSummary => {
166
- const enabled = plan.tests.filter((t) => t.enabled);
167
- return {
168
- title: plan.title,
169
- testCount: enabled.length,
170
- passed: enabled.filter((t) => t.isSuccessful).length,
171
- failed: enabled.filter((t) => t.hasFailed).length,
172
- };
173
- };
174
-
175
162
  const subscribeToPlan = (plan: NonNullable<ReturnType<typeof explorBot.getCurrentPlan>>) => {
176
163
  if (unsubscribeRef.current) unsubscribeRef.current();
177
164
 
178
- if (planRef.current && planRef.current !== plan && planRef.current.tests.length > 0) {
179
- const summary = makeSummary(planRef.current);
180
- setCompletedPlans((prev) => {
181
- if (prev.some((p) => p.title === summary.title)) return prev;
182
- return [...prev, summary];
183
- });
184
- }
185
-
186
165
  planRef.current = plan;
187
166
  tasksRef.current = [...plan.tests];
188
167
  setTasks(tasksRef.current);
189
168
  setTaskScrollOffset(0);
190
- setActivePlanInfo(makeSummary(plan));
191
169
 
192
170
  let lastInProgressIdx = -1;
193
171
  unsubscribeRef.current = plan.onTestsChange((updatedTests) => {
194
172
  tasksRef.current = [...updatedTests];
195
173
  setTasks(tasksRef.current);
196
- setActivePlanInfo(makeSummary(plan));
197
174
  const inProgressIdx = updatedTests.findIndex((t) => t.status === 'in_progress' && t.enabled);
198
175
  if (inProgressIdx >= 0 && inProgressIdx !== lastInProgressIdx) {
199
176
  lastInProgressIdx = inProgressIdx;
@@ -211,17 +188,9 @@ export function App({ explorBot, initialShowInput = false, exitOnEmptyInput = fa
211
188
  subscribeToPlan(currentPlan);
212
189
  } else if (!currentPlan && planRef.current) {
213
190
  if (unsubscribeRef.current) unsubscribeRef.current();
214
- if (planRef.current.tests.length > 0) {
215
- const summary = makeSummary(planRef.current);
216
- setCompletedPlans((prev) => {
217
- if (prev.some((p) => p.title === summary.title)) return prev;
218
- return [...prev, summary];
219
- });
220
- }
221
191
  planRef.current = undefined;
222
192
  tasksRef.current = [];
223
193
  setTasks([]);
224
- setActivePlanInfo(null);
225
194
  }
226
195
  }, 2000);
227
196
 
@@ -389,8 +358,6 @@ export function App({ explorBot, initialShowInput = false, exitOnEmptyInput = fa
389
358
  )}
390
359
  <Autocomplete />
391
360
  </Box>
392
-
393
- <PlanPane completedPlans={completedPlans} activePlan={activePlanInfo} />
394
361
  </Box>
395
362
  );
396
363
  }
@@ -22,7 +22,6 @@ const LogPane: React.FC<LogPaneProps> = React.memo(({ verboseMode }) => {
22
22
  const pendingLogsRef = React.useRef<LogEntry[]>([]);
23
23
  const flushTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
24
24
 
25
- const MAX_MULTILINE_LINES = 16;
26
25
  const MAX_STEP_LINES = 8;
27
26
  const MAX_SUBSTEP_LINES = 6;
28
27
 
@@ -115,6 +114,8 @@ const LogPane: React.FC<LogPaneProps> = React.memo(({ verboseMode }) => {
115
114
  return { color: 'yellow' as const };
116
115
  case 'debug':
117
116
  return { color: 'gray' as const, dimColor: true };
117
+ case 'operation':
118
+ return { color: 'gray' as const, dimColor: true };
118
119
  case 'substep':
119
120
  return { color: 'gray' as const, dimColor: true };
120
121
  case 'step':
@@ -146,7 +147,8 @@ const LogPane: React.FC<LogPaneProps> = React.memo(({ verboseMode }) => {
146
147
  const cleaned = stripAnsi(dedent(log.content));
147
148
  const parsed = parseMarkdownToTerminal(cleaned);
148
149
  const lines = parsed.split('\n');
149
- const truncated = lines.length > MAX_MULTILINE_LINES ? `${lines.slice(0, MAX_MULTILINE_LINES).join('\n')}\n... (${lines.length - MAX_MULTILINE_LINES} more lines)` : parsed;
150
+ const maxLines = log.maxLines || 16;
151
+ const truncated = lines.length > maxLines ? `${lines.slice(0, maxLines).join('\n')}\n... (${lines.length - maxLines} more lines)` : parsed;
150
152
  return (
151
153
  <Box key={index} borderStyle="classic" borderLeft={false} borderRight={false} marginY={1} padding={1} borderColor="dim" overflow="hidden">
152
154
  <Text color="gray" dimColor>
@@ -163,6 +165,7 @@ const LogPane: React.FC<LogPaneProps> = React.memo(({ verboseMode }) => {
163
165
  type: 'multiline',
164
166
  content: `HTML Content:\n\n${markdown}`,
165
167
  timestamp: log.timestamp,
168
+ maxLines: 10,
166
169
  };
167
170
 
168
171
  return renderLogEntry(multilineLog, index);
@@ -182,6 +185,18 @@ const LogPane: React.FC<LogPaneProps> = React.memo(({ verboseMode }) => {
182
185
  );
183
186
  }
184
187
 
188
+ if (log.type === 'operation') {
189
+ return (
190
+ <Box key={index} marginLeft={2} flexDirection="column">
191
+ {lines.map((line, lineIndex) => (
192
+ <Text key={`${index}-${lineIndex}`} {...styles}>
193
+ {lineIndex === 0 ? `· ${line}` : ` ${line}`}
194
+ </Text>
195
+ ))}
196
+ </Box>
197
+ );
198
+ }
199
+
185
200
  if (log.type === 'step') {
186
201
  return (
187
202
  <Box key={index} flexDirection="column" paddingLeft={2}>
@@ -212,7 +227,7 @@ const LogPane: React.FC<LogPaneProps> = React.memo(({ verboseMode }) => {
212
227
  );
213
228
  };
214
229
 
215
- const maxLogs = 100;
230
+ const maxLogs = 80;
216
231
  const visibleLogs = logs.length > maxLogs ? logs.slice(-maxLogs) : logs;
217
232
  return <Box flexDirection="column">{visibleLogs.map((log, index) => renderLogEntry(log, index)).filter(Boolean)}</Box>;
218
233
  });
@@ -188,7 +188,7 @@ export class ExperienceTracker {
188
188
  const updatedContent = `${newEntry}\n\n${content}`;
189
189
  this.writeExperienceFile(stateHash, updatedContent, data);
190
190
 
191
- tag('substep').log(` Added ACTION to: ${stateHash}.md`);
191
+ tag('operation').log(`Added ACTION to: ${stateHash}.md`);
192
192
  }
193
193
 
194
194
  writeFlow(state: ActionResult, body: string, relatedUrls?: string[]): void {
@@ -218,7 +218,7 @@ export class ExperienceTracker {
218
218
  const updatedContent = `${body}\n${content}`;
219
219
  this.writeExperienceFile(stateHash, updatedContent, data);
220
220
 
221
- tag('substep').log(`Added FLOW to: ${stateHash}.md`);
221
+ tag('operation').log(`Added FLOW to: ${stateHash}.md`);
222
222
  }
223
223
 
224
224
  getAllExperience(): ExperienceFile[] {
package/src/explorbot.ts CHANGED
@@ -487,7 +487,7 @@ export class ExplorBot {
487
487
  return;
488
488
  }
489
489
 
490
- tag('multiline').log(markdown);
490
+ tag('multiline').log(markdown, { maxLines: 22 });
491
491
 
492
492
  const filePath = this.agentSessionAnalyst().writeReport(markdown);
493
493
  tag('info').log(`Session report saved: ${relativeToCwd(filePath)}`);
package/src/reporter.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { join } from 'node:path';
2
2
  import { Client } from '@testomatio/reporter';
3
3
  import type { Step } from '@testomatio/reporter/types/types.js';
4
- import { ConfigParser, outputPath } from './config.js';
4
+ import { outputPath } from './config.js';
5
5
  import type { ReporterConfig } from './config.js';
6
6
  import type { StateManager } from './state-manager.js';
7
7
  import { Stats } from './stats.js';
@@ -103,11 +103,13 @@ export class Reporter {
103
103
  }
104
104
 
105
105
  try {
106
- this.client = new Client({ apiKey: process.env.TESTOMATIO || '', title: this.buildTitle() });
107
- const timeoutMs = Number(process.env.TESTOMATIO_TIMEOUT_MS || '15000');
108
- const timeoutPromise = new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), timeoutMs));
106
+ const result = await withQuietReporterLogs(async () => {
107
+ this.client = new Client({ apiKey: process.env.TESTOMATIO || '', title: this.buildTitle() });
108
+ const timeoutMs = Number(process.env.TESTOMATIO_TIMEOUT_MS || '15000');
109
+ const timeoutPromise = new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), timeoutMs));
109
110
 
110
- const result = await Promise.race([this.client.createRun({ configuration: { exploratory: true } }).then(() => 'success' as const), timeoutPromise]);
111
+ return await Promise.race([this.client.createRun({ configuration: { exploratory: true } }).then(() => 'success' as const), timeoutPromise]);
112
+ });
111
113
 
112
114
  if (result === 'timeout') {
113
115
  debugLog('Reporter run creation timed out');
@@ -294,7 +296,9 @@ export class Reporter {
294
296
  }
295
297
 
296
298
  try {
297
- await this.client.updateRunStatus('finished');
299
+ await withQuietReporterLogs(async () => {
300
+ await this.client.updateRunStatus('finished');
301
+ });
298
302
  this.isRunStarted = false;
299
303
  debugLog('Testomat.io run finished');
300
304
  } catch (error) {
@@ -340,3 +344,17 @@ export class Reporter {
340
344
  return;
341
345
  }
342
346
  }
347
+
348
+ async function withQuietReporterLogs<T>(fn: () => Promise<T>): Promise<T> {
349
+ const previousLevel = process.env.TESTOMATIO_LOG_LEVEL;
350
+ process.env.TESTOMATIO_LOG_LEVEL = 'ERROR';
351
+ try {
352
+ return await fn();
353
+ } finally {
354
+ if (previousLevel === undefined) {
355
+ process.env.TESTOMATIO_LOG_LEVEL = undefined;
356
+ } else {
357
+ process.env.TESTOMATIO_LOG_LEVEL = previousLevel;
358
+ }
359
+ }
360
+ }
@@ -0,0 +1,26 @@
1
+ export class RecentStepFilter {
2
+ private recentStepKeys = new Map<string, number>();
3
+
4
+ constructor(private ttlMs = 15000) {}
5
+
6
+ shouldSuppress(content: string, now = Date.now()): boolean {
7
+ const key = normalizeStepCommand(content);
8
+ if (!key) return false;
9
+
10
+ for (const [existingKey, timestamp] of this.recentStepKeys) {
11
+ if (now - timestamp > this.ttlMs) {
12
+ this.recentStepKeys.delete(existingKey);
13
+ }
14
+ }
15
+
16
+ if (this.recentStepKeys.has(key)) return true;
17
+ this.recentStepKeys.set(key, now);
18
+ return false;
19
+ }
20
+ }
21
+
22
+ function normalizeStepCommand(content: string): string | null {
23
+ const normalized = content.replace(/\s+/g, ' ').trim();
24
+ if (!normalized.startsWith('I.')) return null;
25
+ return normalized.toLowerCase();
26
+ }
@@ -8,9 +8,10 @@ import { marked } from 'marked';
8
8
  import stripAnsi from 'strip-ansi';
9
9
  import { ConfigParser } from '../config.js';
10
10
  import { Observability } from '../observability.ts';
11
+ import { RecentStepFilter } from './log-filters.ts';
11
12
  import { parseMarkdownToTerminal } from './markdown-terminal.ts';
12
13
 
13
- export type LogType = 'info' | 'success' | 'error' | 'warning' | 'debug' | 'substep' | 'step' | 'multiline' | 'html' | 'input';
14
+ export type LogType = 'info' | 'success' | 'error' | 'warning' | 'debug' | 'substep' | 'operation' | 'step' | 'multiline' | 'html' | 'input';
14
15
 
15
16
  export interface TaggedLogEntry {
16
17
  type: LogType;
@@ -18,6 +19,7 @@ export interface TaggedLogEntry {
18
19
  timestamp?: Date;
19
20
  originalArgs?: any[];
20
21
  namespace?: string;
22
+ maxLines?: number;
21
23
  }
22
24
 
23
25
  type LogEntry = TaggedLogEntry;
@@ -77,6 +79,7 @@ const debugFilter = new DebugFilter();
77
79
  class ConsoleDestination implements LogDestination {
78
80
  private verboseMode = false;
79
81
  private forceEnabled = false;
82
+ private recentSteps = new RecentStepFilter();
80
83
 
81
84
  isEnabled(): boolean {
82
85
  return this.forceEnabled || !process.env.INK_RUNNING;
@@ -93,6 +96,8 @@ class ConsoleDestination implements LogDestination {
93
96
  write(entry: TaggedLogEntry): void {
94
97
  if (entry.type === 'debug') return;
95
98
  if (entry.type === 'html') return;
99
+ if (entry.type === 'operation' && !this.verboseMode) return;
100
+ if (entry.type === 'step' && !this.verboseMode && this.recentSteps.shouldSuppress(entry.content)) return;
96
101
  let content = entry.content;
97
102
  if (entry.type === 'multiline') {
98
103
  const cleaned = stripAnsi(dedent(entry.content));
@@ -107,6 +112,8 @@ class ConsoleDestination implements LogDestination {
107
112
  content = chalk.yellow(content);
108
113
  } else if (entry.type === 'step') {
109
114
  content = chalk.gray(` ${content}`);
115
+ } else if (entry.type === 'operation') {
116
+ content = chalk.gray(` · ${content}`);
110
117
  } else if (entry.type === 'substep') {
111
118
  content = chalk.gray(` > ${content}`);
112
119
  }
@@ -247,6 +254,7 @@ class ReactDestination implements LogDestination {
247
254
  }
248
255
 
249
256
  private shouldWrite(entry: TaggedLogEntry): boolean {
257
+ if (entry.type === 'operation' && !this.debugMode) return false;
250
258
  if (entry.type !== 'debug') return true;
251
259
  if (this.debugMode) return true;
252
260
  if (!entry.namespace) return true;
@@ -306,7 +314,7 @@ class CaptainDestination implements LogDestination {
306
314
 
307
315
  stopCapture(): string[] {
308
316
  this.capturing = false;
309
- const logs = this.entries.filter((e) => e.type !== 'debug' && e.type !== 'html' && e.type !== 'multiline').map((e) => `[${e.type}] ${e.content}`);
317
+ const logs = this.entries.filter((e) => e.type !== 'debug' && e.type !== 'html' && e.type !== 'multiline' && e.type !== 'operation').map((e) => `[${e.type}] ${e.content}`);
310
318
  this.entries = [];
311
319
  return logs;
312
320
  }
@@ -432,6 +440,7 @@ class Logger {
432
440
  return;
433
441
  }
434
442
 
443
+ const options = this.extractLogOptions(type, args);
435
444
  let content = this.processArgs(args);
436
445
  if (type === 'step' && args[0]?.toCode) {
437
446
  content = args[0].toCode();
@@ -441,6 +450,7 @@ class Logger {
441
450
  content,
442
451
  timestamp: new Date(),
443
452
  originalArgs: args,
453
+ maxLines: options?.maxLines,
444
454
  };
445
455
 
446
456
  if (this.file.isEnabled()) this.file.write(entry);
@@ -480,6 +490,18 @@ class Logger {
480
490
  multiline(...args: any[]): void {
481
491
  this.log('multiline', ...args);
482
492
  }
493
+
494
+ private extractLogOptions(type: LogType, args: any[]): { maxLines?: number } | null {
495
+ if (type !== 'multiline') return null;
496
+ const last = args[args.length - 1];
497
+ if (!last || typeof last !== 'object' || Array.isArray(last)) return null;
498
+ if (!('maxLines' in last)) return null;
499
+
500
+ args.pop();
501
+ const maxLines = Number(last.maxLines);
502
+ if (!Number.isFinite(maxLines) || maxLines <= 0) return null;
503
+ return { maxLines };
504
+ }
483
505
  }
484
506
 
485
507
  const logger = Logger.getInstance();
@@ -42,10 +42,5 @@ export function printNextSteps(sections: NextStepSection[]): void {
42
42
  blocks.push(lines.join('\n'));
43
43
  }
44
44
 
45
- for (let i = 0; i < blocks.length; i++) {
46
- if (i > 0) tag('info').log('');
47
- for (const line of blocks[i].split('\n')) {
48
- tag('info').log(line);
49
- }
50
- }
45
+ tag('multiline').log(blocks.join('\n\n'), { maxLines: 18 });
51
46
  }