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

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/cli/ai/output-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { DEFAULT_MODEL, type AiModelId } from '@studio/common/ai/models';
import { emitEvent, type TurnCompletedStatus } from 'cli/ai/json-events';
import { formatTosNoticeLines } from 'cli/lib/tos-notice';
import type { AgentSessionEvent } from '@earendil-works/pi-coding-agent';
import type { AiProviderId } from 'cli/ai/providers';
import type { AskUserQuestion, SiteInfo } from 'cli/ai/types';
Expand All @@ -14,6 +15,7 @@ export interface AiOutputAdapter {
start(): void;
stop(): void;
showWelcome(): void;
showTosNotice(): void;
showOnboarding(): void;
showCapabilities(): void;
showSuccess( message: string ): void;
Expand Down Expand Up @@ -78,6 +80,12 @@ export class JsonAdapter implements AiOutputAdapter {
// No-op in JSON mode
}

showTosNotice(): void {
// Plain stderr keeps the NDJSON stdout stream clean; the caller already
// gates on IPC mode, where the desktop app shows its own disclaimer.
process.stderr.write( '\n' + formatTosNoticeLines().join( '\n' ) + '\n\n' );
}

showOnboarding(): void {
// No-op in JSON mode
}
Expand Down
28 changes: 27 additions & 1 deletion apps/cli/ai/tests/output-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
import { JsonAdapter } from 'cli/ai/output-adapter';
import { PRIVACY_POLICY_URL, TOS_URL } from 'cli/lib/tos-notice';

describe( 'JsonAdapter IPC messaging', () => {
let originalSend: typeof process.send;
Expand Down Expand Up @@ -97,3 +98,28 @@ describe( 'JsonAdapter IPC messaging', () => {
expect( listeners ).toHaveLength( 0 );
} );
} );

describe( 'JsonAdapter showTosNotice', () => {
let stderrWriteSpy: MockInstance;
let stdoutWriteSpy: MockInstance;

beforeEach( () => {
stderrWriteSpy = vi.spyOn( process.stderr, 'write' ).mockImplementation( () => true );
stdoutWriteSpy = vi.spyOn( process.stdout, 'write' ).mockImplementation( () => true );
} );

afterEach( () => {
stderrWriteSpy.mockRestore();
stdoutWriteSpy.mockRestore();
} );

it( 'writes the notice with both URLs to stderr, keeping stdout clean', () => {
new JsonAdapter().showTosNotice();

expect( stderrWriteSpy ).toHaveBeenCalledTimes( 1 );
const output = String( stderrWriteSpy.mock.calls[ 0 ][ 0 ] );
expect( output ).toContain( TOS_URL );
expect( output ).toContain( PRIVACY_POLICY_URL );
expect( stdoutWriteSpy ).not.toHaveBeenCalled();
} );
} );
9 changes: 9 additions & 0 deletions apps/cli/ai/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { openBrowser } from 'cli/lib/browser';
import { readCliConfig, type SiteData } from 'cli/lib/cli-config/core';
import { getSiteUrl } from 'cli/lib/cli-config/sites';
import { getSitesRunningStatus, isSiteRunning } from 'cli/lib/site-utils';
import { formatTosNoticeLines } from 'cli/lib/tos-notice';
import type { ToolResultMessage } from '@earendil-works/pi-ai';
import type { AgentSessionEvent } from '@earendil-works/pi-coding-agent';
import type { AskUserQuestion, SiteInfo } from 'cli/ai/types';
Expand Down Expand Up @@ -1200,6 +1201,14 @@ export class AiChatUI implements AiOutputAdapter {
this.tui.requestRender();
}

showTosNotice(): void {
const lines = formatTosNoticeLines().map( ( line ) =>
line ? ' ' + chalk.dim( line ) : line
);
this.messages.addChild( new Text( lines.join( '\n' ) + '\n', 0, 0 ) );
this.tui.requestRender();
}

set onInterrupt( fn: ( () => void ) | null ) {
this.interruptCallback = fn;
this.updateHints();
Expand Down
3 changes: 3 additions & 0 deletions apps/cli/commands/ai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { AiChatUI } from 'cli/ai/ui';
import { runCommand as runLoginCommand } from 'cli/commands/auth/login';
import { readCliConfig } from 'cli/lib/cli-config/core';
import { findSiteByFolder } from 'cli/lib/cli-config/sites';
import { maybeShowTosNotice } from 'cli/lib/tos-notice';
import { Logger, LoggerError, setProgressCallback } from 'cli/logger';
import { StudioArgv } from 'cli/types';
import type { SessionManager } from '@earendil-works/pi-coding-agent';
Expand Down Expand Up @@ -123,6 +124,8 @@ export async function runCommand( options: {
ui.start();
ui.showWelcome();

await maybeShowTosNotice( () => ui.showTosNotice() );

if ( options.showLegacyCommandNotice && ! isJsonMode ) {
ui.showInfo( __( 'ⓘ The "studio ai" command is now "studio code".' ) );
}
Expand Down
3 changes: 3 additions & 0 deletions apps/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from 'cli/lib/bump-stat';
import { setupServerFiles } from 'cli/lib/dependency-management/setup';
import { loadTranslations } from 'cli/lib/i18n';
import { setupTosNotice } from 'cli/lib/tos-notice';
import { StatsGroup, StatsMetric } from 'cli/lib/types/bump-stats';
import { setupUpdateNotifier } from 'cli/lib/update-notifier';
import { untildify } from 'cli/lib/utils';
Expand Down Expand Up @@ -44,6 +45,8 @@ async function main() {
process.exit( 1 );
}

await setupTosNotice();

const studioArgv: StudioArgv = yargs( process.argv.slice( 2 ) )
.scriptName( 'studio' )
.usage( __( 'WordPress Studio CLI' ) )
Expand Down
28 changes: 28 additions & 0 deletions apps/cli/lib/banner-box.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
type ChalkColor = ( text: string ) => string;

/**
* Renders lines inside a rounded Unicode box, returning a multi-line string
* with a blank line before and after the box.
* Strips ANSI codes when measuring so colored lines pad correctly.
*/
export function renderBannerBox( lines: string[], borderColor: ChalkColor ): string {
// Calculate box width based on longest line (strip ANSI for measurement)
// eslint-disable-next-line no-control-regex
const ansiPattern = new RegExp( '\u001B\\[[0-9;]*m', 'g' );
const stripAnsi = ( str: string ) => str.replace( ansiPattern, '' );
const maxLen = Math.max( 0, ...lines.map( ( l ) => stripAnsi( l ).length ) );
const padding = 2;
const innerWidth = maxLen + padding * 2;

const top = borderColor( `╭${ '─'.repeat( innerWidth ) }╮` );
const bottom = borderColor( `╰${ '─'.repeat( innerWidth ) }╯` );
const side = borderColor( '│' );

const paddedLines = lines.map( ( line ) => {
const visibleLen = stripAnsi( line ).length;
const rightPad = Math.max( 0, innerWidth - padding - visibleLen );
return `${ side }${ ' '.repeat( padding ) }${ line }${ ' '.repeat( rightPad ) }${ side }`;
} );

return [ '', top, ...paddedLines, bottom, '' ].join( '\n' );
}
2 changes: 2 additions & 0 deletions apps/cli/lib/cli-config/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ const cliConfigSchema = z.object( {
.optional(),
lastDependencyCheckTime: z.number().optional(),
updateCheck: updateCheckSchema.optional(),
// Unix ms timestamp of when the one-time ToS/Privacy notice was displayed.
tosNoticeShownAt: z.number().optional(),
} );

type CliConfig = z.infer< typeof cliConfigSchema >;
Expand Down
42 changes: 42 additions & 0 deletions apps/cli/lib/tests/banner-box.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/* eslint-disable no-control-regex */
import chalk from '@studio/common/lib/chalk';
import { renderBannerBox } from 'cli/lib/banner-box';

const strip = ( s: string ) => s.replace( /\u001B\[[0-9;]*m/g, '' );

// Studio's chalk emits no ANSI codes when stdout is not a TTY (as in tests),
// so colored lines are built manually to exercise the ANSI-stripping path.
const green = ( s: string ) => `\u001B[32m${ s }\u001B[39m`;

describe( 'renderBannerBox', () => {
it( 'wraps lines in a rounded box', () => {
const plain = strip( renderBannerBox( [ 'hello', 'world' ], chalk.yellow ) );
expect( plain ).toContain( '╭' );
expect( plain ).toContain( '╰' );
expect( plain ).toContain( '│' );
expect( plain ).toContain( 'hello' );
expect( plain ).toContain( 'world' );
} );

it( 'pads all rows to the same visible width', () => {
const plain = strip( renderBannerBox( [ 'a', 'longer line' ], chalk.yellow ) );
const rows = plain.split( '\n' ).filter( ( l ) => l.length > 0 );
const widths = new Set( rows.map( ( r ) => [ ...r ].length ) );
expect( widths.size ).toBe( 1 );
} );

it( 'pads colored lines to the same width as plain lines', () => {
const plain = strip( renderBannerBox( [ green( 'colored' ), 'plain..' ], chalk.yellow ) );
const rows = plain.split( '\n' ).filter( ( l ) => l.length > 0 );
const widths = new Set( rows.map( ( r ) => [ ...r ].length ) );
expect( widths.size ).toBe( 1 );
} );

it( 'applies borderColor to the border but not the content', () => {
const marker = ( s: string ) => `<${ s }>`;
const out = renderBannerBox( [ 'hello' ], marker );
expect( out ).toContain( '<│>' );
expect( out ).toMatch( /<╭─+╮>/ );
expect( out ).not.toContain( '<hello>' );
} );
} );
Loading