@compilr-dev/cli 0.6.2 → 0.6.4

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.
@@ -171,8 +171,8 @@ export const menuCommand = {
171
171
  console.log(s.muted(' Run: npm update -g @compilr-dev/cli'));
172
172
  console.log('');
173
173
  }
174
- // Dashboard loop - returns to dashboard after sub-commands until user exits or continues
175
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
174
+ // Dashboard loop returns to dashboard after sub-commands until user exits or continues
175
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentional infinite loop; exit happens via internal break/return
176
176
  while (true) {
177
177
  const dashboard = new DashboardOverlayV2();
178
178
  const result = await ctx.ui.showOverlay(dashboard);
@@ -22,4 +22,5 @@ export * from './terminals.js';
22
22
  export * from './notifications.js';
23
23
  export * from './mcp.js';
24
24
  export * from './delegations.js';
25
+ export * from './perf.js';
25
26
  export declare const allCommands: CommandHandlerV2[];
@@ -21,6 +21,7 @@ import { terminalsCommands } from './terminals.js';
21
21
  import { notificationsCommands } from './notifications.js';
22
22
  import { mcpCommands } from './mcp.js';
23
23
  import { delegationsCommands } from './delegations.js';
24
+ import { perfCommands } from './perf.js';
24
25
  // Re-export individual modules
25
26
  export * from './core.js';
26
27
  export * from './settings.js';
@@ -40,6 +41,7 @@ export * from './terminals.js';
40
41
  export * from './notifications.js';
41
42
  export * from './mcp.js';
42
43
  export * from './delegations.js';
44
+ export * from './perf.js';
43
45
  // All commands combined
44
46
  export const allCommands = [
45
47
  ...coreCommands,
@@ -60,4 +62,5 @@ export const allCommands = [
60
62
  ...notificationsCommands,
61
63
  ...mcpCommands,
62
64
  ...delegationsCommands,
65
+ ...perfCommands,
63
66
  ];
@@ -0,0 +1,9 @@
1
+ /**
2
+ * /perf Command
3
+ *
4
+ * Display the most recent CLI startup-performance profile.
5
+ * The profile is captured automatically and written to
6
+ * ~/.compilr-dev/startup-perf.log on every launch.
7
+ */
8
+ import type { CommandHandlerV2 } from '../types.js';
9
+ export declare const perfCommands: CommandHandlerV2[];
@@ -0,0 +1,66 @@
1
+ /**
2
+ * /perf Command
3
+ *
4
+ * Display the most recent CLI startup-performance profile.
5
+ * The profile is captured automatically and written to
6
+ * ~/.compilr-dev/startup-perf.log on every launch.
7
+ */
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import pc from 'picocolors';
11
+ import { getDataPath } from '../../settings/paths.js';
12
+ const perfCommand = {
13
+ name: 'perf',
14
+ description: 'Show the latest startup-performance profile',
15
+ details: 'Display the timing breakdown captured during this CLI launch. ' +
16
+ 'Useful for diagnosing slow startups (cold cache, heavy MCP servers, ' +
17
+ 'large project DBs). The profile is overwritten on every launch.',
18
+ examples: [{ code: '/perf', description: 'Show the most recent startup profile' }],
19
+ execute(_args, ctx) {
20
+ const logPath = path.join(getDataPath(), 'startup-perf.log');
21
+ if (!fs.existsSync(logPath)) {
22
+ ctx.ui.print({
23
+ type: 'info',
24
+ message: 'No startup profile found. (Profile is written on the next launch.)',
25
+ });
26
+ return Promise.resolve(true);
27
+ }
28
+ let content;
29
+ try {
30
+ content = fs.readFileSync(logPath, 'utf-8');
31
+ }
32
+ catch (err) {
33
+ ctx.ui.print({
34
+ type: 'warning',
35
+ message: `Could not read startup profile: ${String(err)}`,
36
+ });
37
+ return Promise.resolve(true);
38
+ }
39
+ if (!content.trim()) {
40
+ ctx.ui.print({ type: 'info', message: 'Startup profile is empty.' });
41
+ return Promise.resolve(true);
42
+ }
43
+ // Print the profile, dimming the path footer.
44
+ const lines = content.split('\n');
45
+ const out = [];
46
+ for (const line of lines) {
47
+ if (line.startsWith('===')) {
48
+ out.push(pc.bold(line));
49
+ }
50
+ else if (line.startsWith('Slow steps')) {
51
+ out.push(pc.yellow(line));
52
+ }
53
+ else if (line.startsWith('Total:')) {
54
+ out.push(pc.cyan(line));
55
+ }
56
+ else {
57
+ out.push(line);
58
+ }
59
+ }
60
+ out.push('');
61
+ out.push(pc.dim(` Profile: ${logPath}`));
62
+ ctx.ui.print({ type: 'raw-lines', lines: out });
63
+ return Promise.resolve(true);
64
+ },
65
+ };
66
+ export const perfCommands = [perfCommand];
@@ -178,9 +178,8 @@ export const initCommand = {
178
178
  'Type to enter values in input steps',
179
179
  ],
180
180
  async execute(_args, ctx) {
181
- // Show onboarding wizard (same as first-run experience)
182
- // Loop if user chooses to configure keys
183
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
181
+ // Show onboarding wizard (same as first-run experience).
182
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentional infinite loop; exit happens via internal break/return
184
183
  while (true) {
185
184
  const wizard = new OnboardingWizardOverlayV2();
186
185
  const result = await ctx.ui.showOverlay(wizard);
Binary file
package/dist/index.js CHANGED
@@ -88,7 +88,7 @@ function showHelp() {
88
88
  console.log(`
89
89
  @compilr-dev/cli v${VERSION}
90
90
 
91
- Usage: npm run dev [options]
91
+ Usage: compilr [options]
92
92
 
93
93
  Options:
94
94
  --provider, -p <provider> LLM provider (claude, openai, gemini, ollama)
@@ -103,13 +103,16 @@ Environment Variables:
103
103
  ANTHROPIC_API_KEY Required for Claude provider
104
104
  OPENAI_API_KEY Required for OpenAI provider
105
105
  GOOGLE_AI_API_KEY Required for Gemini provider
106
+ NO_COLOR Disable ANSI colors
107
+ COMPILR_NO_UPDATE_CHECK Skip the npm registry update check at startup
108
+ CI Auto-skips the update check
106
109
 
107
110
  Examples:
108
- npm run dev
109
- npm run dev -- --model claude-sonnet-4-6
110
- npm run dev -- --provider gemini --model gemini-2.0-flash
111
- npm run dev -- --minimal --show-filtering
112
- npm run dev -- --update
111
+ compilr
112
+ compilr --model claude-sonnet-4-6
113
+ compilr --provider gemini --model gemini-2.0-flash
114
+ compilr --minimal --show-filtering
115
+ compilr --update
113
116
  `);
114
117
  }
115
118
  // =============================================================================
package/dist/repl-v2.js CHANGED
@@ -3306,28 +3306,34 @@ export class ReplV2 {
3306
3306
  tokenDisplay += ` (${extras.join(', ')})`;
3307
3307
  }
3308
3308
  parts.push(tokenDisplay);
3309
- // Add token breakdown
3309
+ // Detailed breakdown is logged (not printed) to keep the per-turn
3310
+ // footer focused on duration + tokens. Set COMPILR_VERBOSE_FOOTER=1
3311
+ // to bring back the inline breakdown for debugging.
3310
3312
  const cacheInfo = formatCacheInfo();
3311
- if (cacheInfo) {
3312
- // Show cache info if available
3313
- parts.push(cacheInfo);
3314
- }
3315
- else {
3316
- // Show component breakdown
3317
- const breakdown = formatBreakdownCompact();
3318
- if (breakdown) {
3313
+ const breakdown = cacheInfo ?? formatBreakdownCompact();
3314
+ log.debug({
3315
+ component: 'turn-summary',
3316
+ durationMs: metrics.durationMs,
3317
+ inputTokens: metrics.inputTokens,
3318
+ outputTokens: metrics.outputTokens,
3319
+ thinkingTokens: metrics.thinkingTokens,
3320
+ cacheReadTokens: metrics.cacheReadTokens,
3321
+ toolCalls: metrics.toolCalls,
3322
+ apiCalls: metrics.apiCalls,
3323
+ breakdown,
3324
+ debugPayload: metrics.debugPayload,
3325
+ }, 'Turn summary');
3326
+ if (process.env.COMPILR_VERBOSE_FOOTER === '1') {
3327
+ if (breakdown)
3319
3328
  parts.push(breakdown);
3329
+ if (metrics.toolCalls > 0) {
3330
+ parts.push(`${String(metrics.toolCalls)} tool${metrics.toolCalls > 1 ? 's' : ''}`);
3331
+ }
3332
+ if (metrics.debugPayload) {
3333
+ const { systemTokens, contentsTokens, toolsTokens } = metrics.debugPayload;
3334
+ const totalTokens = systemTokens + contentsTokens + toolsTokens;
3335
+ parts.push(`[dbg ${String(metrics.apiCalls)}calls sent:${formatTokens(totalTokens)} sys:${formatTokens(systemTokens)} cont:${formatTokens(contentsTokens)} tools:${formatTokens(toolsTokens)}]`);
3320
3336
  }
3321
- }
3322
- if (metrics.toolCalls > 0) {
3323
- parts.push(`${String(metrics.toolCalls)} tool${metrics.toolCalls > 1 ? 's' : ''}`);
3324
- }
3325
- // DEBUG: Show payload debug info (token estimates sent to provider)
3326
- if (metrics.debugPayload) {
3327
- const { systemTokens, contentsTokens, toolsTokens } = metrics.debugPayload;
3328
- const totalTokens = systemTokens + contentsTokens + toolsTokens;
3329
- // Format: [dbg 2calls sent:31k sys:10k cont:8k tools:13k]
3330
- parts.push(`[dbg ${String(metrics.apiCalls)}calls sent:${formatTokens(totalTokens)} sys:${formatTokens(systemTokens)} cont:${formatTokens(contentsTokens)} tools:${formatTokens(toolsTokens)}]`);
3331
3337
  }
3332
3338
  // Print as muted info line
3333
3339
  this.ui.print({ type: 'turn-summary', parts });
@@ -3358,8 +3364,12 @@ export class ReplV2 {
3358
3364
  // Only run main() when executed directly (not when imported)
3359
3365
  if (import.meta.url === `file://${process.argv[1]}`) {
3360
3366
  const repl = new ReplV2();
3361
- // Handle SIGINT
3367
+ // Handle SIGINT — restore raw mode before exit so the parent shell
3368
+ // doesn't inherit a broken terminal state.
3362
3369
  process.on('SIGINT', () => {
3370
+ if (process.stdin.isTTY) {
3371
+ process.stdin.setRawMode(false);
3372
+ }
3363
3373
  console.log('\n\nInterrupted\n');
3364
3374
  process.exit(0);
3365
3375
  });
@@ -387,7 +387,7 @@ export class ProjectSessionManager {
387
387
  else if (Array.isArray(lastUserMsg.content)) {
388
388
  const textBlock = lastUserMsg.content.find((b) => b.type === 'text');
389
389
  if (textBlock?.type === 'text') {
390
- preview = textBlock.text;
390
+ preview = (textBlock).text;
391
391
  }
392
392
  }
393
393
  }
@@ -9,6 +9,7 @@
9
9
  * - Proper wrapping for long input
10
10
  */
11
11
  import pc from 'picocolors';
12
+ import { ttyWrite } from './ui/terminal.js';
12
13
  // ANSI escape codes for terminal control
13
14
  const ANSI = {
14
15
  HIDE_CURSOR: '\x1B[?25l',
@@ -83,7 +84,7 @@ function renderDropdown(state, promptLen, currentLine, totalLines, hasSeparators
83
84
  if (hasSeparators)
84
85
  linesToMoveDown += 1;
85
86
  if (linesToMoveDown > 0) {
86
- process.stdout.write(ANSI.MOVE_DOWN(linesToMoveDown));
87
+ ttyWrite(ANSI.MOVE_DOWN(linesToMoveDown));
87
88
  }
88
89
  process.stdout.write('\n');
89
90
  const visible = state.matches.slice(0, MAX_VISIBLE);
@@ -98,8 +99,8 @@ function renderDropdown(state, promptLen, currentLine, totalLines, hasSeparators
98
99
  // Move back up to cursor position
99
100
  const linesRendered = visible.length;
100
101
  const linesToMoveUp = linesRendered + linesToMoveDown + 1;
101
- process.stdout.write(ANSI.MOVE_UP(linesToMoveUp));
102
- process.stdout.write(ANSI.MOVE_TO_COLUMN(promptLen + state.cursorPos + 1));
102
+ ttyWrite(ANSI.MOVE_UP(linesToMoveUp));
103
+ ttyWrite(ANSI.MOVE_TO_COLUMN(promptLen + state.cursorPos + 1));
103
104
  return linesRendered;
104
105
  }
105
106
  /**
@@ -113,16 +114,17 @@ function clearDropdown(dropdownLines, currentLine, totalLines, hasSeparators) {
113
114
  if (hasSeparators)
114
115
  linesToMoveDown += 1;
115
116
  if (linesToMoveDown > 0) {
116
- process.stdout.write(ANSI.MOVE_DOWN(linesToMoveDown));
117
+ ttyWrite(ANSI.MOVE_DOWN(linesToMoveDown));
117
118
  }
118
119
  process.stdout.write('\n');
119
120
  // Clear dropdown lines
120
121
  for (let i = 0; i < dropdownLines; i++) {
121
- process.stdout.write(ANSI.CLEAR_LINE + '\n');
122
+ ttyWrite(ANSI.CLEAR_LINE);
123
+ process.stdout.write('\n');
122
124
  }
123
125
  // Move back up
124
126
  const linesToMoveUp = dropdownLines + linesToMoveDown + 1;
125
- process.stdout.write(ANSI.MOVE_UP(linesToMoveUp));
127
+ ttyWrite(ANSI.MOVE_UP(linesToMoveUp));
126
128
  }
127
129
  /**
128
130
  * Create interactive input with autocomplete
@@ -198,13 +200,13 @@ export function createInteractiveInput(prompt, onSubmit, showSeparator = true, g
198
200
  const termWidth = getTerminalWidth();
199
201
  // Clear previous render
200
202
  if (linesAboveCursor > 0) {
201
- process.stdout.write('\r');
202
- process.stdout.write(ANSI.MOVE_UP(linesAboveCursor));
203
+ ttyWrite('\r');
204
+ ttyWrite(ANSI.MOVE_UP(linesAboveCursor));
203
205
  }
204
206
  else {
205
- process.stdout.write('\r');
207
+ ttyWrite('\r');
206
208
  }
207
- process.stdout.write(ANSI.CLEAR_TO_END_OF_SCREEN);
209
+ ttyWrite(ANSI.CLEAR_TO_END_OF_SCREEN);
208
210
  // Render todos
209
211
  renderedTodoLines = renderTodoSection();
210
212
  // Render top separator
@@ -246,10 +248,10 @@ export function createInteractiveInput(prompt, onSubmit, showSeparator = true, g
246
248
  }
247
249
  linesToMoveUp += (currentLinePhysical - 1 - cursorPhysicalRow);
248
250
  if (linesToMoveUp > 0) {
249
- process.stdout.write(ANSI.MOVE_UP(linesToMoveUp));
251
+ ttyWrite(ANSI.MOVE_UP(linesToMoveUp));
250
252
  }
251
253
  const cursorCol = (cursorAbsPos % termWidth) + 1;
252
- process.stdout.write(ANSI.MOVE_TO_COLUMN(cursorCol));
254
+ ttyWrite(ANSI.MOVE_TO_COLUMN(cursorCol));
253
255
  // Track cursor position for next render
254
256
  linesAboveCursor = physicalLinesRendered + physicalLinesBeforeCursor + cursorPhysicalRow;
255
257
  }
@@ -339,9 +341,9 @@ export function createInteractiveInput(prompt, onSubmit, showSeparator = true, g
339
341
  dropdownLines = 0;
340
342
  // Clear display
341
343
  if (linesAboveCursor > 0) {
342
- process.stdout.write('\r' + ANSI.MOVE_UP(linesAboveCursor));
344
+ ttyWrite('\r' + ANSI.MOVE_UP(linesAboveCursor));
343
345
  }
344
- process.stdout.write('\r' + ANSI.CLEAR_TO_END_OF_SCREEN);
346
+ ttyWrite('\r' + ANSI.CLEAR_TO_END_OF_SCREEN);
345
347
  // Print clean input
346
348
  for (let i = 0; i < state.lines.length; i++) {
347
349
  const linePrompt = i === 0 ? prompt : pc.dim(' \\ ');
@@ -494,7 +496,7 @@ export function createInteractiveInput(prompt, onSubmit, showSeparator = true, g
494
496
  if (isLeftArrow) {
495
497
  if (state.cursorPos > 0) {
496
498
  state.cursorPos--;
497
- process.stdout.write('\x1B[D');
499
+ ttyWrite('\x1B[D');
498
500
  }
499
501
  else if (state.currentLine > 0) {
500
502
  state.currentLine--;
@@ -507,7 +509,7 @@ export function createInteractiveInput(prompt, onSubmit, showSeparator = true, g
507
509
  if (isRightArrow) {
508
510
  if (state.cursorPos < state.lines[state.currentLine].length) {
509
511
  state.cursorPos++;
510
- process.stdout.write('\x1B[C');
512
+ ttyWrite('\x1B[C');
511
513
  }
512
514
  else if (state.currentLine < state.lines.length - 1) {
513
515
  state.currentLine++;
@@ -7,6 +7,7 @@
7
7
  import pc from 'picocolors';
8
8
  import { getCustomCommandRegistry } from './commands/index.js';
9
9
  import { truncate } from './ui/base/index.js';
10
+ import { ttyWrite } from './ui/terminal.js';
10
11
  // ANSI escape codes
11
12
  const ANSI = {
12
13
  HIDE_CURSOR: '\x1B[?25l',
@@ -201,11 +202,11 @@ function render(state, prevLineCount) {
201
202
  // Clear previous render
202
203
  if (prevLineCount > 0) {
203
204
  // Move to column 1, move up to first line, clear everything below
204
- process.stdout.write('\r');
205
+ ttyWrite('\r');
205
206
  if (prevLineCount > 1) {
206
- process.stdout.write(ANSI.MOVE_UP(prevLineCount - 1));
207
+ ttyWrite(ANSI.MOVE_UP(prevLineCount - 1));
207
208
  }
208
- process.stdout.write(ANSI.CLEAR_TO_END);
209
+ ttyWrite(ANSI.CLEAR_TO_END);
209
210
  }
210
211
  // Render new content
211
212
  process.stdout.write(lines.join('\n'));
@@ -283,7 +284,7 @@ export function showHelpMenu() {
283
284
  // Start on a new line (menu appears below the prompt)
284
285
  process.stdout.write('\n');
285
286
  // Hide cursor during menu display
286
- process.stdout.write(ANSI.HIDE_CURSOR);
287
+ ttyWrite(ANSI.HIDE_CURSOR);
287
288
  // Store original raw mode state
288
289
  const wasRawMode = process.stdin.isRaw;
289
290
  // Enable raw mode
@@ -298,13 +299,13 @@ export function showHelpMenu() {
298
299
  // lineCount lines with lineCount-1 newlines between them, plus the initial \n we added
299
300
  // So cursor is (lineCount-1)+1 = lineCount lines below where "You: /help" ended
300
301
  if (lineCount > 0) {
301
- process.stdout.write(ANSI.MOVE_UP(lineCount));
302
- process.stdout.write(ANSI.CLEAR_TO_END);
302
+ ttyWrite(ANSI.MOVE_UP(lineCount));
303
+ ttyWrite(ANSI.CLEAR_TO_END);
303
304
  }
304
305
  // Print newline so next prompt appears on fresh line
305
306
  process.stdout.write('\n');
306
307
  // Show cursor
307
- process.stdout.write(ANSI.SHOW_CURSOR);
308
+ ttyWrite(ANSI.SHOW_CURSOR);
308
309
  // Restore raw mode to original state
309
310
  if (process.stdin.isTTY && !wasRawMode) {
310
311
  process.stdin.setRawMode(false);
@@ -42,6 +42,7 @@ import * as terminal from '../terminal.js';
42
42
  import { getStyles } from '../../themes/index.js';
43
43
  import { OverlayLifecycle } from './overlay-lifecycle.js';
44
44
  import { debugLog } from '../../utils/debug-log.js';
45
+ import { ttyWrite } from '../terminal.js';
45
46
  // =============================================================================
46
47
  // BaseOverlay Abstract Class
47
48
  // =============================================================================
@@ -150,7 +151,7 @@ export class BaseOverlay {
150
151
  this.state.maxLineCount = 0;
151
152
  // Explicitly move cursor to home and save position
152
153
  // This ensures we're at (0,0) regardless of what clearScreen() did
153
- process.stdout.write('\x1b[H');
154
+ ttyWrite('\x1b[H');
154
155
  terminal.saveCursor();
155
156
  }
156
157
  // ===========================================================================
@@ -14,6 +14,7 @@ import { markedTerminal } from 'marked-terminal';
14
14
  import { BaseOverlayV2 } from '../../base/overlay-base-v2.js';
15
15
  import { getCurrentTheme } from '../../../themes/index.js';
16
16
  import * as terminal from '../../terminal.js';
17
+ import { ttyWrite } from '../../terminal.js';
17
18
  import { ARTIFACT_TYPE_LABELS } from '../../constants/labels.js';
18
19
  // =============================================================================
19
20
  // Alternate Screen Buffer Management
@@ -22,14 +23,14 @@ let inAlternateScreen = false;
22
23
  function enterAlternateScreen() {
23
24
  if (inAlternateScreen)
24
25
  return;
25
- process.stdout.write('\x1b[?1049h'); // Switch to alternate screen
26
- process.stdout.write('\x1b[H'); // Move cursor to home
26
+ ttyWrite('\x1b[?1049h'); // Switch to alternate screen
27
+ ttyWrite('\x1b[H'); // Move cursor to home
27
28
  inAlternateScreen = true;
28
29
  }
29
30
  function exitAlternateScreen() {
30
31
  if (!inAlternateScreen)
31
32
  return;
32
- process.stdout.write('\x1b[?1049l'); // Switch back to main screen
33
+ ttyWrite('\x1b[?1049l'); // Switch back to main screen
33
34
  inAlternateScreen = false;
34
35
  }
35
36
  // Ensure we exit alternate screen on process termination
@@ -42,10 +43,14 @@ function setupAlternateScreenCleanup() {
42
43
  cleanup();
43
44
  process.exit(130);
44
45
  });
45
- process.on('SIGTERM', () => {
46
- cleanup();
47
- process.exit(143);
48
- });
46
+ // Windows doesn't deliver SIGTERM Node silently ignores the listener
47
+ // there, but we add the platform guard for clarity.
48
+ if (process.platform !== 'win32') {
49
+ process.on('SIGTERM', () => {
50
+ cleanup();
51
+ process.exit(143);
52
+ });
53
+ }
49
54
  }
50
55
  setupAlternateScreenCleanup();
51
56
  // =============================================================================
@@ -611,9 +611,9 @@ export class ConfigOverlayV2 extends BaseOverlayV2 {
611
611
  */
612
612
  toggleOrCycleItem() {
613
613
  const item = this.state.configItems[this.state.selectedItem];
614
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
614
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- selectedItem can briefly point past array end during async re-render
615
615
  if (!item)
616
- return null; // Defensive check
616
+ return null;
617
617
  if (item.type === 'boolean') {
618
618
  const newValue = !item.value;
619
619
  item.value = newValue;
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import chalk from 'chalk';
15
15
  import hljs from 'highlight.js';
16
+ import { ttyWrite } from '../../terminal.js';
16
17
  // =============================================================================
17
18
  // Alternate Screen Buffer Management
18
19
  // =============================================================================
@@ -20,14 +21,14 @@ let inAlternateScreen = false;
20
21
  function enterAlternateScreen() {
21
22
  if (inAlternateScreen)
22
23
  return;
23
- process.stdout.write('\x1b[?1049h'); // Switch to alternate screen
24
- process.stdout.write('\x1b[H'); // Move cursor to home
24
+ ttyWrite('\x1b[?1049h'); // Switch to alternate screen
25
+ ttyWrite('\x1b[H'); // Move cursor to home
25
26
  inAlternateScreen = true;
26
27
  }
27
28
  function exitAlternateScreen() {
28
29
  if (!inAlternateScreen)
29
30
  return;
30
- process.stdout.write('\x1b[?1049l'); // Switch back to main screen
31
+ ttyWrite('\x1b[?1049l'); // Switch back to main screen
31
32
  inAlternateScreen = false;
32
33
  }
33
34
  // Ensure we exit alternate screen on process termination
@@ -42,11 +43,14 @@ function setupAlternateScreenCleanup() {
42
43
  cleanup();
43
44
  process.exit(130);
44
45
  });
45
- // Handle termination
46
- process.on('SIGTERM', () => {
47
- cleanup();
48
- process.exit(143);
49
- });
46
+ // Handle termination (Windows doesn't deliver SIGTERM — Node silently
47
+ // ignores the listener there, but we add the platform guard for clarity)
48
+ if (process.platform !== 'win32') {
49
+ process.on('SIGTERM', () => {
50
+ cleanup();
51
+ process.exit(143);
52
+ });
53
+ }
50
54
  // Handle uncaught exceptions
51
55
  process.on('uncaughtException', (err) => {
52
56
  cleanup();
@@ -203,7 +203,10 @@ export class PendingOverlayV2 extends BaseOverlayV2 {
203
203
  const agentLabel = `$${req.agentId}`;
204
204
  const ctx = req.context;
205
205
  const filePath = ctx?.input
206
- ? (ctx.input.filePath ?? ctx.input.file_path ?? ctx.input.path ?? '')
206
+ ? (() => {
207
+ const input = ctx.input;
208
+ return (input.filePath ?? input.file_path ?? input.path ?? '');
209
+ })()
207
210
  : '';
208
211
  const fileName = filePath.split('/').pop() || filePath;
209
212
  // Header
@@ -6,6 +6,7 @@
6
6
  * clear/render and console output.
7
7
  */
8
8
  import * as terminal from './terminal.js';
9
+ import { ttyWrite } from './terminal.js';
9
10
  import { getVisibleLength } from './line-utils.js';
10
11
  import { getStyles } from '../themes/index.js';
11
12
  export class OverlayManager {
@@ -89,7 +90,7 @@ export class OverlayManager {
89
90
  // ===========================================================================
90
91
  enterFullscreenOverlayMode() {
91
92
  this.host.clearFooter();
92
- process.stdout.write('\x1b[2J\x1b[H');
93
+ ttyWrite('\x1b[2J\x1b[H');
93
94
  this.renderState = { lineCount: 0, maxLineCount: 0 };
94
95
  }
95
96
  enterInlineOverlayMode() {
@@ -127,9 +128,9 @@ export class OverlayManager {
127
128
  clearOverlayRender() {
128
129
  const linesToClear = this.renderState.maxLineCount;
129
130
  if (linesToClear > 0) {
130
- process.stdout.write(`\x1b[${String(linesToClear)}A`);
131
- process.stdout.write('\r');
132
- process.stdout.write('\x1b[J');
131
+ ttyWrite(`\x1b[${String(linesToClear)}A`);
132
+ ttyWrite('\r');
133
+ ttyWrite('\x1b[J');
133
134
  }
134
135
  }
135
136
  renderOverlay() {
@@ -179,9 +180,9 @@ export class OverlayManager {
179
180
  const { line, column } = content.cursorPosition;
180
181
  const linesFromEnd = paddedLines.length - 1 - line;
181
182
  if (linesFromEnd > 0) {
182
- process.stdout.write(`\x1b[${String(linesFromEnd)}A`);
183
+ ttyWrite(`\x1b[${String(linesFromEnd)}A`);
183
184
  }
184
- process.stdout.write(`\x1b[${String(column)}G`);
185
+ ttyWrite(`\x1b[${String(column)}G`);
185
186
  }
186
187
  terminal.showCursor();
187
188
  }
@@ -229,7 +229,8 @@ export function renderItem(item, config) {
229
229
  // If we add items that don't add trailing blanks, or if the rendering
230
230
  // order changes, this could cause visual artifacts (overwriting content).
231
231
  // If issues arise, consider tracking the last printed item type instead.
232
- process.stdout.write('\x1b[1A'); // Move up one line
232
+ if (process.stdout.isTTY)
233
+ process.stdout.write('\x1b[1A'); // Move up one line
233
234
  }
234
235
  // Show interrupted line with mascot
235
236
  const suggestion = item.suggestion ?? 'What should I do instead?';
@@ -407,8 +407,7 @@ export class TerminalRenderer extends EventEmitter {
407
407
  finally {
408
408
  this.isRendering = false;
409
409
  // Check if another render was requested during this one
410
- // (renderPending can be set by callbacks during render)
411
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
410
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- renderPending can be flipped by callbacks during render
412
411
  if (this.renderPending) {
413
412
  this.requestRender();
414
413
  }
@@ -542,8 +542,9 @@ export class TerminalUI extends EventEmitter {
542
542
  */
543
543
  reRenderConversationWithVerbosity(verbosity) {
544
544
  const s = getStyles();
545
- // Clear screen and scrollback buffer
546
- process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
545
+ // Clear screen and scrollback buffer (TTY-only — keep piped output clean)
546
+ if (process.stdout.isTTY)
547
+ process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
547
548
  // Reset footer state
548
549
  this.footer.resetRenderState();
549
550
  // Show mode indicator at top for temp modes
@@ -641,8 +642,9 @@ export class TerminalUI extends EventEmitter {
641
642
  * Called when verbose mode changes.
642
643
  */
643
644
  reRenderConversation() {
644
- // Clear screen AND scrollback buffer, then move to home
645
- process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
645
+ // Clear screen AND scrollback buffer, then move to home (TTY-only — keep piped output clean)
646
+ if (process.stdout.isTTY)
647
+ process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
646
648
  // Reset footer state since we cleared everything
647
649
  this.footer.resetRenderState();
648
650
  // Show filter indicator if active
@@ -3,7 +3,16 @@
3
3
  *
4
4
  * Low-level terminal operations using ANSI escape codes.
5
5
  * Pure functions with no state.
6
+ *
7
+ * All output is gated through `ttyWrite()` so that pipes and redirected
8
+ * streams don't get raw escape sequences. `compilr | tee output.log`
9
+ * should produce a clean log, not a binary mess.
10
+ */
11
+ /**
12
+ * Write a control sequence only when stdout is an interactive TTY.
13
+ * No-op when output is piped or redirected — keeps log files clean.
6
14
  */
15
+ export declare function ttyWrite(seq: string): void;
7
16
  /**
8
17
  * Set terminal window title
9
18
  */
@@ -3,7 +3,20 @@
3
3
  *
4
4
  * Low-level terminal operations using ANSI escape codes.
5
5
  * Pure functions with no state.
6
+ *
7
+ * All output is gated through `ttyWrite()` so that pipes and redirected
8
+ * streams don't get raw escape sequences. `compilr | tee output.log`
9
+ * should produce a clean log, not a binary mess.
10
+ */
11
+ /**
12
+ * Write a control sequence only when stdout is an interactive TTY.
13
+ * No-op when output is piped or redirected — keeps log files clean.
6
14
  */
15
+ export function ttyWrite(seq) {
16
+ if (process.stdout.isTTY) {
17
+ process.stdout.write(seq);
18
+ }
19
+ }
7
20
  // =============================================================================
8
21
  // Terminal Title
9
22
  // =============================================================================
@@ -11,7 +24,7 @@
11
24
  * Set terminal window title
12
25
  */
13
26
  export function setTitle(title) {
14
- process.stdout.write(`\x1b]0;${title}\x07`);
27
+ ttyWrite(`\x1b]0;${title}\x07`);
15
28
  }
16
29
  // =============================================================================
17
30
  // Terminal Dimensions
@@ -36,7 +49,7 @@ export function getTerminalHeight() {
36
49
  */
37
50
  export function moveCursorUp(n) {
38
51
  if (n > 0) {
39
- process.stdout.write(`\x1b[${String(n)}A`);
52
+ ttyWrite(`\x1b[${String(n)}A`);
40
53
  }
41
54
  }
42
55
  /**
@@ -44,32 +57,32 @@ export function moveCursorUp(n) {
44
57
  */
45
58
  export function moveCursorDown(n) {
46
59
  if (n > 0) {
47
- process.stdout.write(`\x1b[${String(n)}B`);
60
+ ttyWrite(`\x1b[${String(n)}B`);
48
61
  }
49
62
  }
50
63
  /**
51
64
  * Move cursor to column (1-indexed)
52
65
  */
53
66
  export function moveCursorToColumn(col) {
54
- process.stdout.write(`\x1b[${String(col)}G`);
67
+ ttyWrite(`\x1b[${String(col)}G`);
55
68
  }
56
69
  /**
57
70
  * Move cursor to beginning of line
58
71
  */
59
72
  export function moveCursorToLineStart() {
60
- process.stdout.write('\r');
73
+ ttyWrite('\r');
61
74
  }
62
75
  /**
63
76
  * Save cursor position
64
77
  */
65
78
  export function saveCursor() {
66
- process.stdout.write('\x1b[s');
79
+ ttyWrite('\x1b[s');
67
80
  }
68
81
  /**
69
82
  * Restore cursor position
70
83
  */
71
84
  export function restoreCursor() {
72
- process.stdout.write('\x1b[u');
85
+ ttyWrite('\x1b[u');
73
86
  }
74
87
  // =============================================================================
75
88
  // Cursor Visibility
@@ -78,13 +91,13 @@ export function restoreCursor() {
78
91
  * Hide cursor
79
92
  */
80
93
  export function hideCursor() {
81
- process.stdout.write('\x1b[?25l');
94
+ ttyWrite('\x1b[?25l');
82
95
  }
83
96
  /**
84
97
  * Show cursor
85
98
  */
86
99
  export function showCursor() {
87
- process.stdout.write('\x1b[?25h');
100
+ ttyWrite('\x1b[?25h');
88
101
  }
89
102
  // =============================================================================
90
103
  // Clearing
@@ -93,13 +106,13 @@ export function showCursor() {
93
106
  * Clear current line
94
107
  */
95
108
  export function clearLine() {
96
- process.stdout.write('\r\x1b[K');
109
+ ttyWrite('\r\x1b[K');
97
110
  }
98
111
  /**
99
112
  * Clear from cursor to end of screen
100
113
  */
101
114
  export function clearToEndOfScreen() {
102
- process.stdout.write('\x1b[J');
115
+ ttyWrite('\x1b[J');
103
116
  }
104
117
  /**
105
118
  * Clear N lines above cursor (including current line)
@@ -118,7 +131,7 @@ export function clearLinesAbove(count) {
118
131
  * Clear entire screen
119
132
  */
120
133
  export function clearScreen() {
121
- process.stdout.write('\x1b[2J\x1b[H');
134
+ ttyWrite('\x1b[2J\x1b[H');
122
135
  }
123
136
  /**
124
137
  * Enter alternate screen buffer.
@@ -131,14 +144,14 @@ export function enterAlternateScreen() {
131
144
  // \x1b[?1000l - disable mouse click tracking
132
145
  // \x1b[?1002l - disable mouse drag tracking
133
146
  // \x1b[?1003l - disable all mouse tracking
134
- process.stdout.write('\x1b[?1049h\x1b[?1000l\x1b[?1002l\x1b[?1003l');
147
+ ttyWrite('\x1b[?1049h\x1b[?1000l\x1b[?1002l\x1b[?1003l');
135
148
  }
136
149
  /**
137
150
  * Leave alternate screen buffer.
138
151
  * Returns to main screen (previous content restored).
139
152
  */
140
153
  export function leaveAlternateScreen() {
141
- process.stdout.write('\x1b[?1049l');
154
+ ttyWrite('\x1b[?1049l');
142
155
  }
143
156
  // =============================================================================
144
157
  // Line Calculations
@@ -305,5 +318,5 @@ export function writeLine(text = '') {
305
318
  * Ring terminal bell
306
319
  */
307
320
  export function bell() {
308
- process.stdout.write('\x07');
321
+ ttyWrite('\x07');
309
322
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compilr-dev/cli",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "AI-powered coding assistant CLI using @compilr-dev/agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",