diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 30345fd..cecbd94 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -28,6 +28,7 @@ import { getCliExitCode, isCittyCliError, renderCliError, + renderCliErrorDetails, renderCliErrorJson, shouldShowCommandExamplesOnError, } from "@/lib/errors.ts"; @@ -39,7 +40,7 @@ import { } from "@/lib/usage.ts"; import { findFirstPositionalToken, valueFlagsFor } from "@/lib/command-delegation.ts"; import { findEarlyBootstrapExit } from "@/lib/early-bootstrap.ts"; -import { terminalError, applyTerminalColorFromContext } from "@/ui/terminal/styles.ts"; +import { applyTerminalColorFromContext } from "@/ui/terminal/styles.ts"; import { maybeShowUpdateNotice } from "@/lib/updater.ts"; import { validateEnvironment } from "@/lib/env.ts"; @@ -140,7 +141,7 @@ function handleCliError(error: unknown): never { console.error(renderCliError(error)); if (error instanceof CliError && error.details) { - console.error(`${terminalError("ERROR")} ${error.details}`); + console.error(renderCliErrorDetails(error.details)); } } diff --git a/cli/src/commands/catalogs.ts b/cli/src/commands/catalogs.ts index 448094b..e7b4eea 100644 --- a/cli/src/commands/catalogs.ts +++ b/cli/src/commands/catalogs.ts @@ -6,7 +6,8 @@ import { defineOperationCommand } from "@/lib/operation-command.ts"; import { defineGroupCommand, defineHttpCommand } from "@/lib/operation-command-builders.ts"; import { localPlan } from "@/lib/operation-effect.ts"; import { formatCatalogsSummary, formatCatalogsTable } from "@/features/management/render.ts"; -import { terminalMetadata } from "@/ui/terminal/styles.ts"; +import { span } from "@/ui/document.ts"; +import { renderDisplayText } from "@/ui/terminal/styles.ts"; import { fetchManagementCatalogRows, managementCatalogCreateOperation, @@ -89,7 +90,8 @@ const catalogsListCommand = defineOperationCommand({ ...(summary !== null ? { summary } : {}), }, humanText: formatCatalogsTable(rows), - metadataLines: summary !== null ? ["", terminalMetadata(summary)] : undefined, + metadataLines: + summary !== null ? ["", renderDisplayText([span(summary, "subtle")])] : undefined, }; }, }); diff --git a/cli/src/commands/completion.ts b/cli/src/commands/completion.ts index 64a9f82..6f30d2e 100644 --- a/cli/src/commands/completion.ts +++ b/cli/src/commands/completion.ts @@ -14,14 +14,8 @@ import { defaultConfigurePrompts, type ConfigurePrompts, } from "@/lib/profile-configure-interactive.ts"; -import { document, rows, section, text } from "@/ui/document.ts"; +import { document, rows, section, span, text, type DisplayText } from "@/ui/document.ts"; import { renderDocumentText } from "@/ui/renderers/terminal.ts"; -import { - formatTerminalMarkdownLinks, - terminalAccent, - terminalSubtle, - terminalSuccess, -} from "@/ui/terminal/styles.ts"; import { buildCompletionSpec } from "@/lib/completion-spec.ts"; import { readEnv } from "@/lib/env.ts"; import { @@ -247,11 +241,11 @@ export async function installCompletion( } function formatInstallMessage(result: InstallResult): string { - const startup = + const startup: DisplayText = result.startupAction === "updated" - ? `updated ${terminalAccent(result.rcPath ?? "")}` + ? [span("updated "), span(result.rcPath ?? "", "accent")] : result.startupAction === "skipped" - ? `left unchanged ${terminalSubtle("(--no-rc)")}` + ? [span("left unchanged "), span("(--no-rc)", "subtle")] : "automatic"; const next = result.startupAction === "skipped" @@ -261,15 +255,15 @@ function formatInstallMessage(result: InstallResult): string { return renderDocumentText( document( section( - text([`${terminalSuccess("✓")} Shell completion installed`]), + text([[span("✓", "success"), span(" Shell completion installed")]]), rows([ { label: "Shell:", value: result.shell }, - { label: "Script:", value: terminalAccent(result.completionPath) }, + { label: "Script:", value: [span(result.completionPath, "accent")] }, { label: "Startup:", value: startup }, { label: "Next:", value: next }, { label: "Docs:", - value: formatTerminalMarkdownLinks(`[Shell completion](${COMPLETION_DOCS_URL})`), + value: [span("Shell completion", "accent", COMPLETION_DOCS_URL)], }, ]), ), @@ -282,14 +276,14 @@ function formatCompletionHelpMessage(): string { return renderDocumentText( document( section( - text([terminalAccent("Shell completion")]), + text([[span("Shell completion", "accent")]]), rows([ { label: "Install:", value: COMPLETION_GUIDANCE.install }, { label: "Install shell:", value: COMPLETION_GUIDANCE.installShell }, { label: "Manual:", value: COMPLETION_GUIDANCE.manual }, { label: "Docs:", - value: formatTerminalMarkdownLinks(`[Shell completion](${COMPLETION_GUIDANCE.docs})`), + value: [span("Shell completion", "accent", COMPLETION_GUIDANCE.docs)], }, ]), ), diff --git a/cli/src/commands/login.ts b/cli/src/commands/login.ts index 88230a2..3f42c89 100644 --- a/cli/src/commands/login.ts +++ b/cli/src/commands/login.ts @@ -22,8 +22,9 @@ import { setActiveProfile, updateProfile, } from "@/features/profile/model.ts"; -import { terminalSuccess } from "@/ui/terminal/styles.ts"; import { readEnv, setEnv } from "@/lib/env.ts"; +import { span } from "@/ui/document.ts"; +import { renderDisplayText } from "@/ui/terminal/styles.ts"; function isInteractiveTerminal(): boolean { return process.stdin.isTTY; @@ -223,7 +224,12 @@ async function runLogin(args: LoginArgs, sink: OutputSink): Promise { unchanged: `using profile "${profileName}"`, } satisfies Record; sink.writeMetadata([ - `${terminalSuccess("✓")} Logged in (${identity}) — ${profileMessages[profileAction]}; environment "${environment}".`, + renderDisplayText([ + span("✓", "success"), + span( + ` Logged in (${identity}) — ${profileMessages[profileAction]}; environment "${environment}".`, + ), + ]), ]); } diff --git a/cli/src/features/api/render.ts b/cli/src/features/api/render.ts index f91e72a..f4d870c 100644 --- a/cli/src/features/api/render.ts +++ b/cli/src/features/api/render.ts @@ -1,7 +1,6 @@ import type { ApiOperationDetails, ApiRouteRow } from "@/features/api/model.ts"; import { buildApiOperationDetailsView, buildApiRoutesView } from "@/features/api/views.ts"; import { renderDocument, renderDocumentText } from "@/ui/renderers/terminal.ts"; -import { formatTerminalSection } from "@/ui/terminal/styles.ts"; const API_DETAILS_LABEL_WIDTH = 12; @@ -10,11 +9,9 @@ type ApiRoutesRenderOptions = { }; export function formatApiOperationDetails(operation: ApiOperationDetails): string { - return formatTerminalSection( - renderDocument(buildApiOperationDetailsView(operation), { - labelWidth: API_DETAILS_LABEL_WIDTH, - }), - ); + return renderDocument(buildApiOperationDetailsView(operation), { + labelWidth: API_DETAILS_LABEL_WIDTH, + }).join("\n"); } export function formatApiRoutes(rows: readonly ApiRouteRow[]): string { @@ -38,5 +35,5 @@ export function renderApiRoutesTableSection( rows: readonly ApiRouteRow[], emptyMessage = "No operations found.", ): string { - return formatTerminalSection(renderDocument(buildApiRoutesView(rows, { emptyMessage }))); + return renderDocumentText(buildApiRoutesView(rows, { emptyMessage })); } diff --git a/cli/src/features/api/views.ts b/cli/src/features/api/views.ts index 9e50679..db1ea2b 100644 --- a/cli/src/features/api/views.ts +++ b/cli/src/features/api/views.ts @@ -3,11 +3,11 @@ import { document, rows, section, + span, table, type DisplayDocument, type DisplayRow, } from "@/ui/document.ts"; -import { terminalAccent } from "@/ui/terminal/styles.ts"; export type ApiRoutesViewOptions = { emptyMessage?: string; @@ -16,7 +16,7 @@ export type ApiRoutesViewOptions = { function apiOperationRows(operation: ApiOperationDetails): DisplayRow[] { return [ - { label: "Operation:", value: terminalAccent(operation.operationId) }, + { label: "Operation:", value: [span(operation.operationId, "accent")] }, { label: "Method:", value: operation.method }, { label: "Path:", value: operation.path }, { @@ -42,23 +42,19 @@ export function buildApiRoutesView( columns: [ { header: "METHOD", - cell: (row) => row.method, - style: "httpMethod", + cell: (row) => [span(row.method, "httpMethod")], }, { header: "PATH", cell: (row) => row.path, - style: "foreground", }, { header: "OPERATION", - cell: (row) => row.operationId, - style: "subtle", + cell: (row) => [span(row.operationId, "subtle")], }, { header: "SUMMARY", - cell: (row) => row.summary, - style: "muted", + cell: (row) => [span(row.summary, "muted")], }, ], emptyMessage: options.emptyMessage ?? "No operations found.", diff --git a/cli/src/features/lakehouse/schema/render.ts b/cli/src/features/lakehouse/schema/render.ts index 8919bda..1d11814 100644 --- a/cli/src/features/lakehouse/schema/render.ts +++ b/cli/src/features/lakehouse/schema/render.ts @@ -1,6 +1,6 @@ import { buildSchemaTreeView } from "@/features/lakehouse/schema/views.ts"; import type { LakehouseQueryResult } from "@/lib/lakehouse-ndjson.ts"; -import { renderTreeText } from "@/ui/layouts/tree.ts"; +import { renderTreeText } from "@/ui/renderers/terminal.ts"; export function formatSchemaTree(result: LakehouseQueryResult, catalog: string): string { return renderTreeText(buildSchemaTreeView(result, catalog)); diff --git a/cli/src/features/lakehouse/schema/views.ts b/cli/src/features/lakehouse/schema/views.ts index 2f2fe79..738a38f 100644 --- a/cli/src/features/lakehouse/schema/views.ts +++ b/cli/src/features/lakehouse/schema/views.ts @@ -1,14 +1,7 @@ import { getQueryColumnNames } from "@/lib/lakehouse-client.ts"; import type { LakehouseQueryResult, LakehouseRow } from "@/lib/lakehouse-ndjson.ts"; import type { TreeNode, TreeView } from "@/ui/layouts/tree.ts"; -import { - terminalAccent, - terminalBoolean, - terminalNumber, - terminalStrong, - terminalSubtle, - terminalWarning, -} from "@/ui/terminal/styles.ts"; +import { span, type DisplaySpan } from "@/ui/document.ts"; type SchemaTreeColumn = { name: string; @@ -86,21 +79,34 @@ function buildSchemaMap(result: LakehouseQueryResult): Map column.name.length)); + const label: DisplaySpan[] = [span(tableName, "strong")]; + if (table.type && table.type !== "BASE TABLE") { + label.push(span(" "), span(`(${table.type})`, "warning")); + } + if (table.comment) { + label.push(span(" "), span(`— ${table.comment}`, "subtle")); + } return { - label: `${terminalStrong(tableName)}${typeSuffix}${comment}`, + label, children: table.columns.map((column) => schemaColumnNode(column, nameWidth)), }; } @@ -112,8 +118,8 @@ function schemaNode( const tableEntries = tables ? [...tables] : []; return { - label: terminalAccent(schemaName), - emptyLabel: terminalSubtle(""), + label: [span(schemaName, "accent")], + emptyLabel: [span("", "subtle")], children: tableEntries.map(([tableName, table]) => schemaTableNode(tableName, table)), }; } @@ -122,8 +128,8 @@ export function buildSchemaTreeView(result: LakehouseQueryResult, catalog: strin const schemas = buildSchemaMap(result); return { - title: terminalStrong(`Schemas and tables for ${catalog}`), - emptyLabel: terminalSubtle(""), + title: [span(`Schemas and tables for ${catalog}`, "strong")], + emptyLabel: [span("", "subtle")], children: [...schemas.keys()] .sort() .map((schemaName) => schemaNode(schemaName, schemas.get(schemaName))), diff --git a/cli/src/features/management/render.ts b/cli/src/features/management/render.ts index 3940424..af3bedb 100644 --- a/cli/src/features/management/render.ts +++ b/cli/src/features/management/render.ts @@ -1,7 +1,6 @@ import type { CatalogRow, WhoamiResponse } from "@/features/management/model.ts"; import { buildCatalogsTableView } from "@/features/management/views.ts"; -import { renderDocument } from "@/ui/renderers/terminal.ts"; -import { formatTerminalSection } from "@/ui/terminal/styles.ts"; +import { renderDocumentText } from "@/ui/renderers/terminal.ts"; export function formatWhoamiPrincipalLine(data: WhoamiResponse): string { const principal = data.principal ?? {}; @@ -37,5 +36,5 @@ export function formatCatalogsSummary(rows: CatalogRow[]): string | null { } export function formatCatalogsTable(rows: CatalogRow[]): string { - return formatTerminalSection(renderDocument(buildCatalogsTableView(rows))); + return renderDocumentText(buildCatalogsTableView(rows)); } diff --git a/cli/src/features/management/views.ts b/cli/src/features/management/views.ts index a20ecb6..42bfdaf 100644 --- a/cli/src/features/management/views.ts +++ b/cli/src/features/management/views.ts @@ -1,5 +1,5 @@ import type { CatalogRow } from "@/features/management/model.ts"; -import { document, section, table, type DisplayDocument } from "@/ui/document.ts"; +import { document, section, span, table, type DisplayDocument } from "@/ui/document.ts"; export function buildCatalogsTableView(rows: readonly CatalogRow[]): DisplayDocument { return document( @@ -7,11 +7,11 @@ export function buildCatalogsTableView(rows: readonly CatalogRow[]): DisplayDocu table({ rows, columns: [ - { header: "SLUG", cell: (row) => row.slug, style: "accent" }, - { header: "NAME", cell: (row) => row.name, style: "strong", flex: true }, - { header: "ENGINE", cell: (row) => row.engine, style: "muted" }, - { header: "CATALOG", cell: (row) => row.catalog, style: "string", flex: true }, - { header: "TYPE", cell: (row) => row.type, style: "subtle" }, + { header: "SLUG", cell: (row) => [span(row.slug, "accent")] }, + { header: "NAME", cell: (row) => [span(row.name, "strong")], flex: true }, + { header: "ENGINE", cell: (row) => [span(row.engine, "muted")] }, + { header: "CATALOG", cell: (row) => [span(row.catalog, "string")], flex: true }, + { header: "TYPE", cell: (row) => [span(row.type, "subtle")] }, ], emptyMessage: "No catalogs found.", options: { groupBy: (row) => row.type }, diff --git a/cli/src/features/profile/render.ts b/cli/src/features/profile/render.ts index 0c8fdf3..7a4fe51 100644 --- a/cli/src/features/profile/render.ts +++ b/cli/src/features/profile/render.ts @@ -24,7 +24,6 @@ import { TERMINAL_LABEL_WIDTH, TERMINAL_NESTED_LABEL_WIDTH, } from "@/ui/terminal/spacing.ts"; -import { formatTerminalSection } from "@/ui/terminal/styles.ts"; export function formatProfileInspect(profile: ProfileInspect): string { return renderDocumentText(buildProfileInspectView(profile)); @@ -75,7 +74,7 @@ export function formatConfigureSessionSummary( export function formatActiveContextSummary(context: ActiveContext): string { const lines = renderDocument(buildActiveContextSummaryView(context)); - return `\n\n${formatTerminalSection(padLeft(lines))}`; + return `\n\n${padLeft(lines).join("\n")}`; } export function formatActiveContextDetails(context: ActiveContext): string { @@ -83,7 +82,7 @@ export function formatActiveContextDetails(context: ActiveContext): string { indent: TERMINAL_INDENT, labelWidth: TERMINAL_LABEL_WIDTH, }); - return formatTerminalSection(lines); + return lines.join("\n"); } export function tryFormatActiveContextSummary(profileName: string): string { diff --git a/cli/src/features/profile/views.ts b/cli/src/features/profile/views.ts index ccdef2d..3a64dc4 100644 --- a/cli/src/features/profile/views.ts +++ b/cli/src/features/profile/views.ts @@ -8,19 +8,15 @@ import { document, rows, section, + span, table, text, type DisplayDocument, type DisplayRow, + type DisplayText, + type DisplayTextStyle, } from "@/ui/document.ts"; import { TERMINAL_INDENT } from "@/ui/terminal/spacing.ts"; -import { - formatTerminalUrls, - terminalAccent, - terminalDescription, - terminalHighlightCommands, - terminalNotConfiguredStatus, -} from "@/ui/terminal/styles.ts"; import type { ActiveContext, ConfigureCredentialStatus, @@ -38,6 +34,14 @@ const ACTIVE_PROFILE_MARK = "✓"; const INACTIVE_PROFILE_MARK = " "; const PROFILE_NAME_HEADER = `${INACTIVE_PROFILE_MARK} NAME`; +function linkedUrl(value: string): DisplayText { + return /^https?:\/\//.test(value) ? [span(value, "accent", value)] : value; +} + +function notConfigured(): DisplayText { + return [span("not set", "muted")]; +} + function profileInspectRows(profile: ProfileInspect): DisplayRow[] { // The env pseudo-profile has no stored name/status; a heading line replaces // them (see buildProfileInspectView). @@ -61,15 +65,13 @@ function profileInspectRows(profile: ProfileInspect): DisplayRow[] { }, { label: "Data plane", - value: profile.endpoints.data_plane ?? "default", - linkifyUrls: true, + value: linkedUrl(profile.endpoints.data_plane ?? "default"), }, { label: "Control plane", - value: profile.endpoints.control_plane ?? "default", - linkifyUrls: true, + value: linkedUrl(profile.endpoints.control_plane ?? "default"), }, - { label: "Config file", value: terminalAccent(profile.config_file) }, + { label: "Config file", value: [span(profile.config_file, "accent")] }, ]; } @@ -107,49 +109,44 @@ export function buildProfileListView(profiles: readonly ProfileSummary[]): Displ columns: [ { header: PROFILE_NAME_HEADER, - cell: (profile) => - `${profile.active ? ACTIVE_PROFILE_MARK : INACTIVE_PROFILE_MARK} ${profile.name}`, - style: "strong", + cell: (profile) => [ + span( + `${profile.active ? ACTIVE_PROFILE_MARK : INACTIVE_PROFILE_MARK} ${profile.name}`, + "strong", + ), + ], }, { header: "ORG", - cell: (profile) => profile.organization ?? "", - style: "muted", + cell: (profile) => [span(profile.organization ?? "", "muted")], }, { header: "PRINCIPAL", - cell: (profile) => profile.principal ?? "", - style: "muted", + cell: (profile) => [span(profile.principal ?? "", "muted")], }, { header: "ENV", - cell: (profile) => profile.management_env ?? "", - style: "muted", + cell: (profile) => [span(profile.management_env ?? "", "muted")], }, { header: "MGMT", - cell: (profile) => profile.management_auth ?? "", - style: "string", + cell: (profile) => [span(profile.management_auth ?? "", "string")], }, { header: "LAKEHOUSE", - cell: (profile) => profile.lakehouse_auth ?? "", - style: "string", + cell: (profile) => [span(profile.lakehouse_auth ?? "", "string")], }, { header: "OAUTH EXPIRES", - cell: (profile) => profile.oauth_expires_at ?? "", - style: "muted", + cell: (profile) => [span(profile.oauth_expires_at ?? "", "muted")], }, { header: "STATUS", - cell: (profile) => profile.status ?? "", - style: "accent", + cell: (profile) => [span(profile.status ?? "", "accent")], }, { header: "DATA PLANE", - cell: (profile) => formatTerminalUrls(profile.data_plane ?? ""), - style: "muted", + cell: (profile) => linkedUrl(profile.data_plane ?? ""), }, ], emptyMessage: "No profiles configured.", @@ -346,8 +343,8 @@ function configureSummaryRows(data: ConfigureShowData): DisplayRow[] { { label: "Config dir:", value: data.config_dir }, { label: "Active profile:", value: data.profile }, { label: "Secret store:", value: data.secret_store }, - { label: "Data plane:", value: data.data_plane, linkifyUrls: true }, - { label: "Control plane:", value: data.control_plane, linkifyUrls: true }, + { label: "Data plane:", value: linkedUrl(data.data_plane) }, + { label: "Control plane:", value: linkedUrl(data.control_plane) }, ]; } @@ -382,31 +379,50 @@ export function configureAuthenticationRows( ]; } -export function configureSetupHintLines(status: ConfigureCredentialStatus): string[] { +export function configureSetupHintLines(status: ConfigureCredentialStatus): DisplayText[] { if (!status.hasManagement && !status.hasLakehouse) { return [ - terminalDescription( - `${TERMINAL_INDENT}No credentials configured. Run: altertable profile --configure`, - ), - terminalHighlightCommands( - `${TERMINAL_INDENT}Hint: run 'altertable profile --configure --scope management' (or 'altertable profile --configure --api-key atm_xxx --env ') for management commands, then 'altertable profile --configure --scope lakehouse' (or 'altertable profile --configure --user --password

') for lakehouse queries.`, - ), + [ + span( + `${TERMINAL_INDENT}No credentials configured. Run: altertable profile --configure`, + "subtle", + ), + ], + [ + span(`${TERMINAL_INDENT}Hint: run '`), + span("altertable profile --configure --scope management", "accent"), + span("' (or '"), + span("altertable profile --configure --api-key atm_xxx --env ", "accent"), + span("') for management commands, then '"), + span("altertable profile --configure --scope lakehouse", "accent"), + span("' (or '"), + span("altertable profile --configure --user --password

", "accent"), + span("') for lakehouse queries."), + ], ]; } if (status.hasManagement && !status.hasLakehouse) { return [ - terminalHighlightCommands( - `${TERMINAL_INDENT}Hint: run 'altertable profile --configure --scope lakehouse' or 'altertable profile --configure --user --password

' for lakehouse query, upload, upsert, and append commands.`, - ), + [ + span(`${TERMINAL_INDENT}Hint: run '`), + span("altertable profile --configure --scope lakehouse", "accent"), + span("' or '"), + span("altertable profile --configure --user --password

", "accent"), + span("' for lakehouse query, upload, upsert, and append commands."), + ], ]; } if (status.hasLakehouse && !status.hasManagement) { return [ - terminalHighlightCommands( - `${TERMINAL_INDENT}Hint: run 'altertable profile --configure --scope management' or 'altertable profile --configure --api-key atm_xxx --env ' for profile show, catalogs, and other management commands.`, - ), + [ + span(`${TERMINAL_INDENT}Hint: run '`), + span("altertable profile --configure --scope management", "accent"), + span("' or '"), + span("altertable profile --configure --api-key atm_xxx --env ", "accent"), + span("' for profile show, catalogs, and other management commands."), + ], ]; } @@ -441,9 +457,9 @@ type ContextSummaryRow = { lakehouse: string; }; -function formatConfiguredValue(detail: string | null | undefined): string { +function formatConfiguredValue(detail: string | null | undefined): DisplayText { if (detail === null || detail === undefined || detail.length === 0) { - return terminalNotConfiguredStatus(); + return notConfigured(); } return detail; } @@ -455,11 +471,11 @@ function plainStatus(detail: string | null | undefined): string { return detail; } -function formatStatusCell(value: string): string { +function formatStatusCell(value: string, style: DisplayTextStyle): DisplayText { if (value === "not set") { - return terminalNotConfiguredStatus(); + return notConfigured(); } - return value; + return [span(value, style)]; } function contextSummaryRow(context: ActiveContext): ContextSummaryRow { @@ -502,8 +518,8 @@ function contextDetailRows(context: ActiveContext): DisplayRow[] { { label: "Profile:", value: context.profile }, { label: "Environment:", value: formatConfiguredValue(context.environment) }, ...identityRows(context), - { label: "Data plane:", value: context.data_plane, linkifyUrls: true }, - { label: "Control plane:", value: context.control_plane, linkifyUrls: true }, + { label: "Data plane:", value: linkedUrl(context.data_plane) }, + { label: "Control plane:", value: linkedUrl(context.control_plane) }, { label: "Lakehouse:", value: formatConfiguredValue(context.lakehouse) }, ]; } @@ -515,29 +531,25 @@ export function buildActiveContextSummaryView(context: ActiveContext): DisplayDo columns: [ { header: "PROFILE", - cell: (entry) => formatStatusCell(entry.profile), - style: "strong", + cell: (entry) => formatStatusCell(entry.profile, "strong"), }, { header: "ENV", - cell: (entry) => formatStatusCell(entry.environment), - style: "accent", + cell: (entry) => formatStatusCell(entry.environment, "accent"), }, { header: "MGMT", - cell: (entry) => formatStatusCell(entry.management), - style: "muted", + cell: (entry) => formatStatusCell(entry.management, "muted"), }, { header: "LAKEHOUSE", - cell: (entry) => formatStatusCell(entry.lakehouse), - style: "string", + cell: (entry) => formatStatusCell(entry.lakehouse, "string"), flex: true, }, ], }), ...(!context.credentialStatus.hasManagement && !context.credentialStatus.hasLakehouse - ? [text([terminalHighlightCommands("Hint: run `altertable profile --configure`")])] + ? [text([[span("Hint: run `"), span("altertable profile --configure", "accent"), span("`")]])] : []), ]; diff --git a/cli/src/lib/command-output.ts b/cli/src/lib/command-output.ts index 7d0beeb..c66d274 100644 --- a/cli/src/lib/command-output.ts +++ b/cli/src/lib/command-output.ts @@ -6,7 +6,8 @@ import { renderManagementOutput } from "@/lib/management-output.ts"; import { parseApiJson } from "@/lib/parse-api-json.ts"; import { resolvePagerOptions, writePagedOutput } from "@/lib/pager.ts"; import { getOutputSink, type OutputSink } from "@/lib/runtime.ts"; -import { terminalMetadata } from "@/ui/terminal/styles.ts"; +import { span } from "@/ui/document.ts"; +import { renderDisplayText } from "@/ui/terminal/styles.ts"; export type CommandOutputMode = | { kind: "raw_api"; body: string; humanFormatter?: (data: unknown) => string } @@ -75,7 +76,7 @@ export async function writeCommandOutput( sink.writeJson(mode.data); return; } - sink.writeMetadata([terminalMetadata(mode.metadataMessage)]); + sink.writeMetadata([renderDisplayText([span(mode.metadataMessage, "subtle")])]); return; } diff --git a/cli/src/lib/errors.ts b/cli/src/lib/errors.ts index 302fe54..e2c5aec 100644 --- a/cli/src/lib/errors.ts +++ b/cli/src/lib/errors.ts @@ -15,7 +15,8 @@ * | 9 | EXIT_NETWORK | NetworkError, TimeoutError | * | 10 | EXIT_CONFIG | ConfigurationError | */ -import { terminalError } from "@/ui/terminal/styles.ts"; +import { span } from "@/ui/document.ts"; +import { renderDisplayText } from "@/ui/terminal/styles.ts"; export const EXIT_SUCCESS = 0; export const EXIT_GENERIC = 1; @@ -272,12 +273,23 @@ export function renderCliErrorJson(error: unknown): string { export function renderCliError(error: unknown): string { if (error instanceof CliError) { - return `${terminalError("ERROR")} ${error.message}`; + return renderDisplayText([span("ERROR", "error"), span(` ${error.message}`)]); } if (error instanceof Error && error.name === "CLIError") { - return `${terminalError("ERROR")} ${error.message}`; + return renderDisplayText([span("ERROR", "error"), span(` ${error.message}`)]); } - return `${terminalError("ERROR")} Unexpected error.`; + return renderDisplayText([span("ERROR", "error"), span(" Unexpected error.")]); +} + +export function renderCliErrorDetails(details: string): string { + return details + .split(/\r\n|\r|\n/) + .map((line, index) => + index === 0 + ? renderDisplayText([span("ERROR", "error"), span(` ${line}`)]) + : renderDisplayText(line), + ) + .join("\n"); } export function getCliExitCode(error: unknown): number { diff --git a/cli/src/lib/lakehouse-provision.ts b/cli/src/lib/lakehouse-provision.ts index ea5cb2c..31f51b5 100644 --- a/cli/src/lib/lakehouse-provision.ts +++ b/cli/src/lib/lakehouse-provision.ts @@ -14,7 +14,8 @@ import { encodeManagementEndpoint } from "@/lib/management-endpoint.ts"; import { ensureFreshAccessToken, hasOAuthSession } from "@/lib/oauth-profile.ts"; import { parseApiJson } from "@/lib/parse-api-json.ts"; import { secretSet } from "@/lib/secrets.ts"; -import { terminalMuted } from "@/ui/terminal/styles.ts"; +import { span } from "@/ui/document.ts"; +import { renderDisplayText } from "@/ui/terminal/styles.ts"; import { USER_AGENT } from "@/version.ts"; const CREDENTIAL_LABEL = USER_AGENT; @@ -60,7 +61,9 @@ async function sendManagementRequest( } export async function provisionLakehouseCredential(context: ExecutionContext): Promise { - context.output.writeMetadata([terminalMuted("Refreshing lakehouse credentials...")]); + context.output.writeMetadata([ + renderDisplayText([span("Refreshing lakehouse credentials...", "muted")]), + ]); const env = requireManagementEnv(context.profile); const whoami = (await sendManagementRequest( context, diff --git a/cli/src/lib/oauth-flow.ts b/cli/src/lib/oauth-flow.ts index 1bea1d8..e037161 100644 --- a/cli/src/lib/oauth-flow.ts +++ b/cli/src/lib/oauth-flow.ts @@ -3,7 +3,8 @@ import { createServer } from "node:http"; import { spawn } from "node:child_process"; import { httpSend } from "@/lib/http.ts"; import { ConfigurationError } from "@/lib/errors.ts"; -import { terminalMetadata } from "@/ui/terminal/styles.ts"; +import { span } from "@/ui/document.ts"; +import { renderDisplayText } from "@/ui/terminal/styles.ts"; import type { OutputSink } from "@/lib/runtime.ts"; import { readEnv } from "@/lib/env.ts"; @@ -258,8 +259,13 @@ export async function runLoginFlow(sink: OutputSink, oauthBase: string): Promise { redirectUri: server.redirectUri, challenge, state }, oauthBase, ); - sink.writeMetadata([terminalMetadata("Opening your browser to sign in…")]); - sink.writeMetadata([terminalMetadata(`If it doesn't open, visit: ${authorizeUrl}`)]); + sink.writeMetadata([renderDisplayText([span("Opening your browser to sign in…", "subtle")])]); + sink.writeMetadata([ + renderDisplayText([ + span("If it doesn't open, visit: ", "subtle"), + span(authorizeUrl, "accent", authorizeUrl), + ]), + ]); openBrowser(authorizeUrl); const code = await server.waitForCode(); return await exchangeCode({ code, redirectUri: server.redirectUri, verifier }, oauthBase); diff --git a/cli/src/lib/profile-configure-core.ts b/cli/src/lib/profile-configure-core.ts index f4c1437..b913493 100644 --- a/cli/src/lib/profile-configure-core.ts +++ b/cli/src/lib/profile-configure-core.ts @@ -12,7 +12,8 @@ import { getCliContext } from "@/context.ts"; import { secretDelete, secretSet } from "@/lib/secrets.ts"; import { assertAllowedApiBase } from "@/lib/url-policy.ts"; import { getCliRuntime, getOutputSink, type OutputSink } from "@/lib/runtime.ts"; -import { terminalMetadata } from "@/ui/terminal/styles.ts"; +import { span } from "@/ui/document.ts"; +import { renderDisplayText } from "@/ui/terminal/styles.ts"; const ARGV_SECRET_WARNING = "Warning: passing secrets on the command line is visible in process listings. Prefer --password-stdin / --api-key-stdin."; @@ -114,7 +115,7 @@ export async function configureRunSet( Boolean(options.password) && !options.passwordStdin && !options.interactive; const apiKeyFromArgv = Boolean(options.apiKey) && !options.apiKeyStdin && !options.interactive; if (passwordFromArgv || apiKeyFromArgv) { - sink.writeMetadata([terminalMetadata(ARGV_SECRET_WARNING)]); + sink.writeMetadata([renderDisplayText([span(ARGV_SECRET_WARNING, "subtle")])]); } const hasAnyInput = @@ -207,13 +208,19 @@ export async function configureRunSet( } if (hasApiKey) { - sink.writeMetadata([terminalMetadata(`Saved management API key for environment ${env}.`)]); + sink.writeMetadata([ + renderDisplayText([span(`Saved management API key for environment ${env}.`, "subtle")]), + ]); } else if (hasLakehouseBasicToken) { - sink.writeMetadata([terminalMetadata("Saved lakehouse Basic token.")]); + sink.writeMetadata([renderDisplayText([span("Saved lakehouse Basic token.", "subtle")])]); } else if (hasLakehouseCredentials) { - sink.writeMetadata([terminalMetadata(`Saved lakehouse credentials for user ${user}.`)]); + sink.writeMetadata([ + renderDisplayText([span(`Saved lakehouse credentials for user ${user}.`, "subtle")]), + ]); } else if (dataPlaneUrl) { - sink.writeMetadata([terminalMetadata(`Saved data plane URL ${dataPlaneUrl}.`)]); + sink.writeMetadata([ + renderDisplayText([span(`Saved data plane URL ${dataPlaneUrl}.`, "subtle")]), + ]); } markCurrentProfileUpdated(profileName); }); @@ -238,5 +245,7 @@ export function configureRunClear(sink: OutputSink = getOutputSink()): void { // already removed } getCliRuntime().session = undefined; - sink.writeMetadata([terminalMetadata("Cleared all altertable configuration.")]); + sink.writeMetadata([ + renderDisplayText([span("Cleared all altertable configuration.", "subtle")]), + ]); } diff --git a/cli/src/lib/profile-configure-interactive.ts b/cli/src/lib/profile-configure-interactive.ts index 30b6c00..38e79ba 100644 --- a/cli/src/lib/profile-configure-interactive.ts +++ b/cli/src/lib/profile-configure-interactive.ts @@ -16,15 +16,8 @@ import { managementPlaneStatusDetail, } from "@/features/profile/model.ts"; import type { OutputSink } from "@/lib/runtime.ts"; -import { - ensurePromptColorAlignment, - terminalAccent, - terminalDefaultHint, - terminalNotConfiguredStatus, - terminalStrong, - terminalSubtle, - formatTerminalUrls, -} from "@/ui/terminal/styles.ts"; +import { span } from "@/ui/document.ts"; +import { ensurePromptColorAlignment, renderDisplayText } from "@/ui/terminal/styles.ts"; export type ConfigureWizardScope = "both" | "management" | "lakehouse"; @@ -207,7 +200,7 @@ const DEFAULT_DATA_PLANE_URL = "https://api.altertable.ai"; const DEFAULT_SCOPE: ConfigureWizardScope = "both"; const AUTO_PROFILE_NAME = "auto"; -const HIDDEN_PROMPT_HINT = terminalSubtle("(hidden)"); +const HIDDEN_PROMPT_HINT = "(hidden)"; const SCOPE_SELECT_TITLE = "What to configure?"; const CONFIGURE_SCOPE_SELECT_OPTIONS = { @@ -228,9 +221,9 @@ function formatScopeSelectLabel(plane: "management" | "lakehouse", profileName: ? managementPlaneStatusDetail(profileName) : lakehousePlaneStatusDetail(profileName); if (!detail) { - return `${terminalStrong(name)} ${terminalNotConfiguredStatus()}`; + return renderDisplayText([span(name, "strong"), span(" not set", "muted")]); } - return `${terminalStrong(name)} ${terminalSubtle(detail)}`; + return renderDisplayText([span(name, "strong"), span(` ${detail}`, "subtle")]); } function toScopePromptOption( @@ -238,7 +231,7 @@ function toScopePromptOption( profileName: string, ): ConfigureSelectOption { if (value === "both") { - return { value, label: terminalStrong("Both") }; + return { value, label: renderDisplayText([span("Both", "strong")]) }; } return { value, label: formatScopeSelectLabel(value, profileName) }; } @@ -378,18 +371,26 @@ export async function collectManagementCredentials( options: ConfigureWizardOptions, profileName: string, ): Promise<{ options: ConfigureOptions; org: string }> { - prompts.writePrompt(`\n${terminalAccent("Management")}\n`); + prompts.writePrompt(`\n${renderDisplayText([span("Management", "accent")])}\n`); const currentEnv = configGet("api_key_env", profileName); const envDefault = currentEnv || "production"; - const envPrompt = `Environment ${terminalDefaultHint(envDefault)}: `; + const envPrompt = renderDisplayText([ + span("Environment "), + span(`[${envDefault}]`, "subtle"), + span(": "), + ]); const env = await readLineWithDefault(prompts, envPrompt, envDefault); let org = options.org ?? ""; if (!org && options.profile === AUTO_PROFILE_NAME) { const currentOrg = configGet("organization_slug", profileName); const orgPrompt = currentOrg - ? `Organization slug ${terminalDefaultHint(currentOrg)}: ` - : `${terminalAccent("Organization slug")}: `; + ? renderDisplayText([ + span("Organization slug "), + span(`[${currentOrg}]`, "subtle"), + span(": "), + ]) + : renderDisplayText([span("Organization slug", "accent"), span(": ")]); org = currentOrg ? await readLineWithDefault(prompts, orgPrompt, currentOrg) : await prompts.readLine(orgPrompt); @@ -402,14 +403,22 @@ export async function collectManagementCredentials( prompts, secretKey: "api-key", keepPrompt: "Keep existing API key?", - passwordPrompt: `${terminalAccent("API key")} ${HIDDEN_PROMPT_HINT}: `, + passwordPrompt: renderDisplayText([ + span("API key", "accent"), + span(` ${HIDDEN_PROMPT_HINT}`, "subtle"), + span(": "), + ]), requiredMessage: "API key is required.", }, profileName, ); const useDefaultControlPlane = await prompts.readConfirm( - `Default control plane ${formatTerminalUrls(DEFAULT_CONTROL_PLANE_URL)}?`, + renderDisplayText([ + span("Default control plane "), + span(DEFAULT_CONTROL_PLANE_URL, "accent", DEFAULT_CONTROL_PLANE_URL), + span("?"), + ]), true, ); @@ -422,7 +431,11 @@ export async function collectManagementCredentials( if (!useDefaultControlPlane) { const controlPlaneUrl = await prompts.readLine( - `Control plane ${terminalDefaultHint(formatTerminalUrls(DEFAULT_CONTROL_PLANE_URL))}: `, + renderDisplayText([ + span("Control plane [", "subtle"), + span(DEFAULT_CONTROL_PLANE_URL, "accent", DEFAULT_CONTROL_PLANE_URL), + span("]: ", "subtle"), + ]), ); configureOptions.controlPlaneUrl = controlPlaneUrl || DEFAULT_CONTROL_PLANE_URL; } @@ -436,7 +449,7 @@ export async function collectLakehouseCredentials( profileName: string, ): Promise { const method = await prompts.readSelect( - `${terminalAccent("Lakehouse")} ${terminalSubtle("auth")}`, + renderDisplayText([span("Lakehouse", "accent"), span(" auth", "subtle")]), [ { value: "user-password", label: "Username and password" }, { value: "basic-token", label: "Basic token" }, @@ -452,7 +465,11 @@ export async function collectLakehouseCredentials( if (method === "basic-token") { const basicToken = await readRequiredPassword( prompts, - `${terminalAccent("Basic token")} ${HIDDEN_PROMPT_HINT}: `, + renderDisplayText([ + span("Basic token", "accent"), + span(` ${HIDDEN_PROMPT_HINT}`, "subtle"), + span(": "), + ]), "Basic token is required.", ); configureOptions.basicToken = basicToken; @@ -461,8 +478,8 @@ export async function collectLakehouseCredentials( const currentUser = configGet("user", profileName); const userPrompt = currentUser - ? `Username ${terminalDefaultHint(currentUser)}: ` - : `${terminalAccent("Username")}: `; + ? renderDisplayText([span("Username "), span(`[${currentUser}]`, "subtle"), span(": ")]) + : renderDisplayText([span("Username", "accent"), span(": ")]); const user = currentUser ? await readLineWithDefault(prompts, userPrompt, currentUser) : await prompts.readLine(userPrompt); @@ -475,14 +492,22 @@ export async function collectLakehouseCredentials( prompts, secretKey: "lakehouse/password", keepPrompt: "Keep existing password?", - passwordPrompt: `${terminalAccent("Password")} ${HIDDEN_PROMPT_HINT}: `, + passwordPrompt: renderDisplayText([ + span("Password", "accent"), + span(` ${HIDDEN_PROMPT_HINT}`, "subtle"), + span(": "), + ]), requiredMessage: "Password is required.", }, profileName, ); const useDefaultDataPlane = await prompts.readConfirm( - `Default data plane ${formatTerminalUrls(DEFAULT_DATA_PLANE_URL)}?`, + renderDisplayText([ + span("Default data plane "), + span(DEFAULT_DATA_PLANE_URL, "accent", DEFAULT_DATA_PLANE_URL), + span("?"), + ]), true, ); @@ -491,7 +516,11 @@ export async function collectLakehouseCredentials( if (!useDefaultDataPlane) { const dataPlaneUrl = await prompts.readLine( - `Data plane ${terminalDefaultHint(formatTerminalUrls(DEFAULT_DATA_PLANE_URL))}: `, + renderDisplayText([ + span("Data plane [", "subtle"), + span(DEFAULT_DATA_PLANE_URL, "accent", DEFAULT_DATA_PLANE_URL), + span("]: ", "subtle"), + ]), ); configureOptions.dataPlaneUrl = dataPlaneUrl || DEFAULT_DATA_PLANE_URL; } diff --git a/cli/src/lib/profile-configure.ts b/cli/src/lib/profile-configure.ts index 4fb417f..18022c4 100644 --- a/cli/src/lib/profile-configure.ts +++ b/cli/src/lib/profile-configure.ts @@ -20,15 +20,9 @@ import { type ConfigureWizardOptions, type ConfigureWizardScope, } from "@/lib/profile-configure-interactive.ts"; -import { - terminalAccent, - terminalMetadata, - terminalMuted, - getTerminalWidth, - getVisibleTextWidth, -} from "@/ui/terminal/styles.ts"; +import { getTerminalWidth, getVisibleTextWidth, renderDisplayText } from "@/ui/terminal/styles.ts"; import { deriveProfileName } from "@/features/profile/model.ts"; -import { document, section, text } from "@/ui/document.ts"; +import { document, section, span, text, type DisplayText } from "@/ui/document.ts"; import { renderDocumentText } from "@/ui/renderers/terminal.ts"; const DEFAULT_SCOPE: ConfigureWizardScope = "both"; @@ -42,20 +36,30 @@ async function saveCredentials( await configureRunSet({ ...configureOptions, interactive: true }, sink, org); } -function formatNextCommandsLine(commands: string[]): string { +function formatNextCommandsLine(commands: DisplayText[]): string { if (commands.length === 0) { return ""; } const terminalWidth = getTerminalWidth(); - const inlineLines = [terminalMuted("Next:"), ...commands]; + const inlineLines: DisplayText[] = [[span("Next:", "muted")], ...commands]; const inlineLine = renderDocumentText(document(section(text(inlineLines)))); if (getVisibleTextWidth(inlineLine) <= terminalWidth) { return inlineLine; } return renderDocumentText( - document(section(text([terminalMuted("Next:"), ...commands.map((command) => ` ${command}`)]))), + document( + section( + text([ + [span("Next:", "muted")], + ...commands.map((command) => [ + span(" "), + ...(typeof command === "string" ? [span(command)] : command), + ]), + ]), + ), + ), ); } @@ -65,13 +69,13 @@ function writeOutro( profileName: string, ): void { const summaryLines = formatConfigureSessionSummary(profileName, configuredPlanes); - const nextCommands: string[] = []; + const nextCommands: DisplayText[] = []; if (configuredPlanes.includes("management")) { - nextCommands.push(terminalAccent("altertable profile show")); + nextCommands.push([span("altertable profile show", "accent")]); } if (configuredPlanes.includes("lakehouse")) { - nextCommands.push(terminalAccent('altertable query "SELECT 1"')); + nextCommands.push([span('altertable query "SELECT 1"', "accent")]); } const nextLine = formatNextCommandsLine(nextCommands); @@ -145,7 +149,7 @@ async function runConfigureWizardInCurrentProfile( writeOutro(sink, configuredPlanes, targetProfile); } catch (error) { if (error instanceof ConfigurePromptCancelled) { - sink.writeMetadata([terminalMetadata("Configuration cancelled.")]); + sink.writeMetadata([renderDisplayText([span("Configuration cancelled.", "subtle")])]); throw error; } throw error; diff --git a/cli/src/lib/progress.ts b/cli/src/lib/progress.ts index cf48fe5..77fba5f 100644 --- a/cli/src/lib/progress.ts +++ b/cli/src/lib/progress.ts @@ -1,10 +1,6 @@ import { isJsonOutput } from "@/context.ts"; -import { - terminalAccent, - terminalError, - terminalMuted, - terminalSuccess, -} from "@/ui/terminal/styles.ts"; +import { span } from "@/ui/document.ts"; +import { renderDisplayText } from "@/ui/terminal/styles.ts"; export type ProgressHandle = { done: (message?: string) => void; @@ -33,21 +29,23 @@ export function shouldShowProgress(): boolean { export function formatUploadProgress(sentBytes: number, totalBytes: number): string { if (totalBytes <= 0) { - return terminalMuted(`Uploading ${sentBytes} bytes`); + return renderDisplayText([span(`Uploading ${sentBytes} bytes`, "muted")]); } const percent = Math.min(100, Math.round((sentBytes / totalBytes) * 100)); - return terminalMuted(`Uploading ${percent}% (${sentBytes}/${totalBytes} bytes)`); + return renderDisplayText([ + span(`Uploading ${percent}% (${sentBytes}/${totalBytes} bytes)`, "muted"), + ]); } export function formatProgressStatus(status: ProgressStatus, message: string): string { if (status === "success") { - return `${terminalSuccess("✓")} ${message}`; + return renderDisplayText([span("✓", "success"), span(` ${message}`)]); } if (status === "error") { - return `${terminalError("✗")} ${message}`; + return renderDisplayText([span("✗", "error"), span(` ${message}`)]); } - return terminalMuted(message); + return renderDisplayText([span(message, "muted")]); } export type UploadProgressReporter = { @@ -96,7 +94,9 @@ export function startProgress(message: string): ProgressHandle { function writeFrame() { const frame = SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length] ?? "⠋"; frameIndex += 1; - process.stderr.write(`\r${terminalAccent(frame)} ${formatProgressStatus("active", label)}`); + process.stderr.write( + `\r${renderDisplayText([span(frame, "accent")])} ${formatProgressStatus("active", label)}`, + ); } writeFrame(); diff --git a/cli/src/lib/query-column-types.ts b/cli/src/lib/query-column-types.ts index 6089ad9..73e11e8 100644 --- a/cli/src/lib/query-column-types.ts +++ b/cli/src/lib/query-column-types.ts @@ -1,5 +1,23 @@ import type { LakehouseColumn, LakehouseQueryResult } from "@/lib/lakehouse-ndjson.ts"; -import { classifyStringDataType, type TerminalDataType } from "@/ui/terminal/styles.ts"; + +export type QueryDataType = "null" | "boolean" | "number" | "string" | "uuid" | "timestamp"; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; + +export function isTimestampValue(value: string): boolean { + return TIMESTAMP_PATTERN.test(value); +} + +function classifyStringDataType(value: string): QueryDataType { + if (UUID_PATTERN.test(value)) { + return "uuid"; + } + if (isTimestampValue(value)) { + return "timestamp"; + } + return "string"; +} export type ColumnTypeMap = Map; @@ -20,7 +38,7 @@ export function getColumnTypeMap(columns: LakehouseQueryResult["columns"]): Colu return typeMap; } -export function mapColumnSqlTypeToDataType(sqlType: string | undefined): TerminalDataType | null { +export function mapColumnSqlTypeToDataType(sqlType: string | undefined): QueryDataType | null { if (sqlType === undefined || sqlType.length === 0) { return null; } @@ -47,7 +65,7 @@ export function resolveCellDataType( value: unknown, columnName: string | undefined, columnTypeMap: ColumnTypeMap, -): TerminalDataType { +): QueryDataType { if (value === null || value === undefined) { return "null"; } diff --git a/cli/src/lib/query-format.ts b/cli/src/lib/query-format.ts index 13b9147..1936fab 100644 --- a/cli/src/lib/query-format.ts +++ b/cli/src/lib/query-format.ts @@ -6,9 +6,11 @@ import type { import { getQueryDefaultMaxColumnWidth, getQueryDefaultLayout } from "@/lib/config.ts"; import { getColumnTypeMap, + isTimestampValue, resolveCellDataType, selectDisplayColumnNames, type ColumnTypeMap, + type QueryDataType, } from "@/lib/query-column-types.ts"; import { pluralizeLabel } from "@/lib/pluralize.ts"; import { redactPasswordFieldInText } from "@/lib/redact.ts"; @@ -16,24 +18,20 @@ import { formatRelativeTimestamp, formatTimestampWithRelative } from "@/lib/rela import { getTerminalWidth, getVisibleTextWidth, - isTimestampValue, padVisibleText, - parseJsonStringValue, + renderDisplayText, shouldUseTerminalColor, - terminalAccent, - terminalDataType, - terminalMetadata, - terminalSubtle, - terminalTimestamp, + truncateTerminalText, type TerminalTextAlignment, } from "@/ui/terminal/styles.ts"; import type { QueryLayout } from "@/ui/layouts/query.ts"; -import { renderRows } from "@/ui/renderers/terminal.ts"; +import { span, type DisplayTextStyle } from "@/ui/document.ts"; const DEFAULT_MAX_COLUMN_WIDTH = 32; const TABLE_CELL_PADDING = 1; const NULL_DISPLAY = "NULL"; const ELLIPSIS = "…"; +const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); type QueryColumnAlignment = TerminalTextAlignment; @@ -78,22 +76,51 @@ function shouldColorizeQueryCells(colorize: boolean | undefined): boolean { return colorize === true && shouldUseTerminalColor(); } +function queryDataTypeStyle(dataType: QueryDataType): DisplayTextStyle { + switch (dataType) { + case "null": + return "subtle"; + case "boolean": + return "boolean"; + case "number": + return "number"; + case "uuid": + return "accent"; + case "string": + case "timestamp": + return "string"; + } +} + +function parseJsonStringValue(value: string): string | null { + const trimmed = value.trim(); + if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) { + return null; + } + try { + JSON.parse(trimmed); + return trimmed; + } catch { + return null; + } +} + function styleQueryHeader( name: string, width: number, colorize: boolean, alignment: QueryColumnAlignment, ): string { - const truncated = truncateText(name, width); + const truncated = truncateText(renderDisplayText(name), width); const padded = padVisibleText(truncated, width, alignment); if (!shouldColorizeQueryCells(colorize)) { return padded; } - return terminalAccent(padded); + return renderDisplayText([span(padded, "accent")]); } function styleQueryTableChrome(text: string, colorize: boolean): string { - return shouldColorizeQueryCells(colorize) ? terminalMetadata(text) : text; + return shouldColorizeQueryCells(colorize) ? renderDisplayText([span(text, "subtle")]) : text; } export function highlightJsonForTerminal(json: string, enabled: boolean): string { @@ -105,21 +132,21 @@ export function highlightJsonForTerminal(json: string, enabled: boolean): string /("(?:\\.|[^"\\])*")\s*:|("(?:\\.|[^"\\])*")|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g, (match, keyPart, stringPart, booleanPart) => { if (keyPart !== undefined) { - return `${terminalAccent(keyPart)}:`; + return renderDisplayText([span(keyPart, "accent"), span(":")]); } if (stringPart !== undefined) { - return terminalDataType(stringPart, "string"); + return renderDisplayText([span(stringPart, "string")]); } if (booleanPart !== undefined) { if (booleanPart === "null") { - return terminalDataType(booleanPart, "null"); + return renderDisplayText([span(booleanPart, "subtle")]); } if (booleanPart === "false") { - return terminalSubtle(booleanPart); + return renderDisplayText([span(booleanPart, "subtle")]); } - return terminalDataType(booleanPart, "boolean"); + return renderDisplayText([span(booleanPart, "boolean")]); } - return terminalDataType(match, "number"); + return renderDisplayText([span(match, "number")]); }, ); } @@ -154,10 +181,10 @@ function formatTimestampQueryCell( } if (relative === null || display === value) { - return terminalTimestamp(display, null); + return renderDisplayText([span(display, "string")]); } - return terminalTimestamp(value, relative); + return renderDisplayText([span(value, "string"), span(` ${relative}`, "subtle")]); } function formatStringQueryCell( @@ -167,7 +194,7 @@ function formatStringQueryCell( ): string { const sanitized = redactPasswordFieldInText(value); if (sanitized.length === 0) { - return colorize ? terminalSubtle('""') : '""'; + return colorize ? renderDisplayText([span('""', "subtle")]) : '""'; } const columnTypeMap = options.columnTypeMap ?? new Map(); @@ -181,46 +208,57 @@ function formatStringQueryCell( if (jsonText !== null) { const displayText = truncateDisplayText(jsonText, options.maxWidth); if (!colorize) { - return displayText; + return renderDisplayText(displayText); } return highlightJsonForTerminal(displayText, true); } + const safeValue = renderDisplayText(sanitized); const display = dataType === "uuid" - ? truncateDisplayTextMiddle(sanitized, options.maxWidth) - : truncateDisplayText(sanitized, options.maxWidth); + ? truncateDisplayTextMiddle(safeValue, options.maxWidth) + : truncateDisplayText(safeValue, options.maxWidth); if (!colorize) { return display; } - return terminalDataType(display, dataType); + return renderDisplayText([span(display, queryDataTypeStyle(dataType))]); } export function truncateText(text: string, maxWidth: number): string { - if (text.length <= maxWidth) { - return text; - } - if (maxWidth <= 1) { - return ELLIPSIS; - } - return text.slice(0, maxWidth - ELLIPSIS.length) + ELLIPSIS; + return truncateTerminalText(text, maxWidth); } export function truncateTextMiddle(text: string, maxWidth: number): string { - if (text.length <= maxWidth) { + if (getVisibleTextWidth(text) <= maxWidth) { return text; } if (maxWidth <= 1) { return ELLIPSIS; } - const visibleCharacterCount = maxWidth - ELLIPSIS.length; - const prefixLength = Math.ceil(visibleCharacterCount / 2); - const suffixLength = Math.floor(visibleCharacterCount / 2); - const suffix = suffixLength > 0 ? text.slice(text.length - suffixLength) : ""; - return `${text.slice(0, prefixLength)}${ELLIPSIS}${suffix}`; + const characters = Array.from(graphemeSegmenter.segment(text), ({ segment }) => segment); + const availableWidth = maxWidth - getVisibleTextWidth(ELLIPSIS); + const prefixWidth = Math.ceil(availableWidth / 2); + const suffixWidth = Math.floor(availableWidth / 2); + let prefix = ""; + let suffix = ""; + + for (const character of characters) { + if (getVisibleTextWidth(prefix + character) > prefixWidth) { + break; + } + prefix += character; + } + for (let index = characters.length - 1; index >= 0; index -= 1) { + const character = characters[index] ?? ""; + if (getVisibleTextWidth(character + suffix) > suffixWidth) { + break; + } + suffix = character + suffix; + } + return `${prefix}${ELLIPSIS}${suffix}`; } export function formatQueryCellRaw(value: unknown): string { @@ -243,7 +281,7 @@ export function formatQueryCell(value: unknown, options: QueryCellOptions): stri const colorize = shouldColorizeQueryCells(options.colorize); if (value === null || value === undefined) { - return colorize ? terminalDataType(NULL_DISPLAY, "null") : NULL_DISPLAY; + return colorize ? renderDisplayText([span(NULL_DISPLAY, "subtle")]) : NULL_DISPLAY; } if (typeof value === "boolean") { const text = String(value); @@ -251,13 +289,13 @@ export function formatQueryCell(value: unknown, options: QueryCellOptions): stri return text; } if (value === false) { - return terminalSubtle(text); + return renderDisplayText([span(text, "subtle")]); } - return terminalDataType(text, "boolean"); + return renderDisplayText([span(text, "boolean")]); } if (typeof value === "number" || typeof value === "bigint") { const text = String(value); - return colorize ? terminalDataType(text, "number") : text; + return colorize ? renderDisplayText([span(text, "number")]) : text; } if (typeof value === "string") { return formatStringQueryCell(value, options, colorize); @@ -335,7 +373,7 @@ function collectQueryDisplayRows( const values = getRowCellValues(row, columnNames, allColumnNames); return { values, - rawCells: values.map((value) => formatQueryCellRaw(value)), + rawCells: values.map((value) => renderDisplayText(formatQueryCellRaw(value))), }; }); } @@ -361,12 +399,15 @@ function computeColumnWidths( maxColumnWidth: number, ): number[] { const naturalWidths = columnNames.map((name, columnIndex) => { - const cellWidths = formattedCells.map((cells) => cells[columnIndex]?.length ?? 0); - const natural = Math.max(name.length, ...cellWidths); + const nameWidth = getVisibleTextWidth(renderDisplayText(name)); + const cellWidths = formattedCells.map((cells) => getVisibleTextWidth(cells[columnIndex] ?? "")); + const natural = Math.max(nameWidth, ...cellWidths); return Math.min(natural, maxColumnWidth); }); - const minimumWidths = columnNames.map((name) => Math.min(name.length, maxColumnWidth)); + const minimumWidths = columnNames.map((name) => + Math.min(getVisibleTextWidth(renderDisplayText(name)), maxColumnWidth), + ); const totalNatural = naturalWidths.reduce((sum, width) => sum + width, 0); @@ -570,20 +611,25 @@ function renderQueryExpanded(model: QueryRenderModel, options: QueryDisplayOptio } const colorize = options.colorize ?? true; - const labelWidth = columnNames.reduce((maxWidth, name) => Math.max(maxWidth, name.length), 0) + 1; + const labelWidth = + columnNames.reduce( + (maxWidth, name) => Math.max(maxWidth, getVisibleTextWidth(renderDisplayText(name))), + 0, + ) + 1; const showRowNumbers = rows.length > 1; const records = rows.map((row, rowIndex) => { - const displayRows = columnNames.map((name, columnIndex) => { + const lines = columnNames.map((name, columnIndex) => { const value = formatQueryCell(row.values[columnIndex], { colorize, includeRelative: true, columnName: name, columnTypeMap, }); - return { label: `${name}:`, value }; + const labelText = padVisibleText(`${renderDisplayText(name)}:`, labelWidth); + const label = renderDisplayText([span(labelText, "muted")]); + return `${label} ${value}`; }); - const lines = renderRows(displayRows, { indent: "", labelWidth }); const body = lines.join("\n"); if (showRowNumbers) { return `${styleQueryTableChrome(`#${rowIndex + 1}`, colorize)}\n${body}`; @@ -621,8 +667,8 @@ export function renderQueryFooter( footerParts.push(summary); } - const footer = footerParts.join("\n"); - return shouldColorizeQueryCells(options.colorize) ? terminalMetadata(footer) : footer; + const style = shouldColorizeQueryCells(options.colorize) ? "subtle" : undefined; + return footerParts.map((line) => renderDisplayText([span(line, style)])).join("\n"); } function escapeMarkdownCell(value: string): string { diff --git a/cli/src/lib/updater.ts b/cli/src/lib/updater.ts index 3740125..6450d8c 100644 --- a/cli/src/lib/updater.ts +++ b/cli/src/lib/updater.ts @@ -12,8 +12,8 @@ import { CliError, HttpError, NetworkError } from "@/lib/errors.ts"; import { hasObjectKey } from "@/lib/object.ts"; import type { OutputSink } from "@/lib/runtime.ts"; import { getOutputSink } from "@/lib/runtime.ts"; -import { terminalAccent, terminalMetadata, terminalSuccess } from "@/ui/terminal/styles.ts"; -import { document, rows, section, type DisplayRow } from "@/ui/document.ts"; +import { renderDisplayText } from "@/ui/terminal/styles.ts"; +import { document, rows, section, span, type DisplayRow } from "@/ui/document.ts"; import { renderDocumentText } from "@/ui/renderers/terminal.ts"; import { copyProcessEnv, readEnv, readEnvFrom } from "@/lib/env.ts"; import { @@ -1100,8 +1100,12 @@ function shouldShowAutomaticUpdateNotice(options: { function writeAutomaticUpdateNotice(sink: OutputSink, latestVersion: string): void { sink.writeMetadata([ "", - terminalMetadata(`Update available: altertable ${latestVersion} (current ${VERSION}).`), - terminalMetadata(`Run ${recommendedInstallCommand(latestVersion)} to install it.`), + renderDisplayText([ + span(`Update available: altertable ${latestVersion} (current ${VERSION}).`, "subtle"), + ]), + renderDisplayText([ + span(`Run ${recommendedInstallCommand(latestVersion)} to install it.`, "subtle"), + ]), ]); } @@ -1143,12 +1147,25 @@ export async function maybeShowUpdateNotice(options: AutomaticNoticeOptions): Pr export function formatUpdateResult(result: UpdateCheckResult): string { if (!result.update_available) { - return `${terminalSuccess("Congrats!")} You're already on the latest version of altertable ${terminalMetadata(`(which is v${normalizeVersion(result.current_version)})`)}`; + return renderDisplayText([ + span("Congrats!", "success"), + span(" You're already on the latest version of altertable "), + span(`(which is v${normalizeVersion(result.current_version)})`, "subtle"), + ]); } return [ - `A new version of altertable is available: v${normalizeVersion(result.latest_version)} ${terminalMetadata(`(current v${normalizeVersion(result.current_version)})`)}`, - `Run ${terminalAccent(result.install_command)} to install it.`, + renderDisplayText([ + span( + `A new version of altertable is available: v${normalizeVersion(result.latest_version)} `, + ), + span(`(current v${normalizeVersion(result.current_version)})`, "subtle"), + ]), + renderDisplayText([ + span("Run "), + span(result.install_command, "accent"), + span(" to install it."), + ]), ].join("\n"); } diff --git a/cli/src/lib/usage.ts b/cli/src/lib/usage.ts index 93210c4..3d63cc9 100644 --- a/cli/src/lib/usage.ts +++ b/cli/src/lib/usage.ts @@ -1,13 +1,7 @@ import type { ArgDef, ArgsDef, CommandDef } from "citty"; import type { AltertableCommandGroup, AltertableCommandMeta } from "@/lib/command-context.ts"; -import { - formatCommandExamplesSection, - getVisibleTextWidth, - terminalAccent, - terminalHighlightCommands, - terminalMuted, - terminalStrong, -} from "@/ui/terminal/styles.ts"; +import { span, type DisplaySpan } from "@/ui/document.ts"; +import { getVisibleTextWidth, renderDisplayText } from "@/ui/terminal/styles.ts"; import { HELP_FLAGS, VERSION_FLAGS } from "@/lib/early-bootstrap.ts"; import { readEnv } from "@/lib/env.ts"; @@ -15,12 +9,33 @@ const HELP_INDENT = " "; const HELP_COLUMN_GAP = " "; const HELP_MIN_DESCRIPTION_WIDTH = 16; const HELP_FALLBACK_TERMINAL_WIDTH = 68; +const COMMAND_PATTERN = /altertable(?:\s+[^\s'",]+)*/g; const COMMAND_GROUP_TITLES: Record = { platform: "Platform", ingest: "Ingest", query: "Query", }; +function renderHighlightedCommands(text: string): string { + const spans: DisplaySpan[] = []; + let offset = 0; + for (const match of text.matchAll(COMMAND_PATTERN)) { + const index = match.index ?? 0; + spans.push(span(text.slice(offset, index)), span(match[0], "accent")); + offset = index + match[0].length; + } + spans.push(span(text.slice(offset))); + return renderDisplayText(spans); +} + +function formatCommandExamplesSection(examples: readonly string[]): string { + if (examples.length === 0) { + return ""; + } + const heading = renderDisplayText([span("EXAMPLES", "heading")]); + return `\n\n${heading}\n\n${examples.map((example) => ` ${renderHighlightedCommands(example)}`).join("\n")}`; +} + function toArray(value: T | T[] | undefined): T[] { if (Array.isArray(value)) { return value; @@ -228,14 +243,14 @@ function formatHelpEntries(entries: readonly HelpEntry[]): string[] { if (stacked) { const wrappedLabel = wrapHelpText(label, availableWidth); return [ - ...wrappedLabel.map((line) => `${HELP_INDENT}${terminalAccent(line)}`), + ...wrappedLabel.map((line) => `${HELP_INDENT}${renderDisplayText([span(line, "accent")])}`), ...wrappedDescription.map((line) => `${HELP_INDENT}${line}`), ]; } return wrappedDescription.map((line, index) => { if (index === 0) { - return `${HELP_INDENT}${terminalAccent(label.padEnd(labelWidth))}${HELP_COLUMN_GAP}${line}`; + return `${HELP_INDENT}${renderDisplayText([span(label.padEnd(labelWidth), "accent")])}${HELP_COLUMN_GAP}${line}`; } return `${" ".repeat(descriptionColumn)}${line}`; }); @@ -255,7 +270,11 @@ function formatHelpGuidance(commandName: string): string[] { if (invocationStart === -1) { return line; } - return `${line.slice(0, invocationStart)}${terminalAccent(invocation)}${line.slice(invocationStart + invocation.length)}`; + return renderDisplayText([ + span(line.slice(0, invocationStart)), + span(invocation, "accent"), + span(line.slice(invocationStart + invocation.length)), + ]); }); } @@ -356,18 +375,30 @@ async function renderCommandUsage(command: CommandDef, parent?: CommandDef): Pro const lines = [ ...(meta.description ? wrapHelpText(meta.description, getHelpTerminalWidth()) : []), "", - ` ${terminalStrong("Usage")}`, + ` ${renderDisplayText([span("Usage", "strong")])}`, ` ${usageTokens(commandName, args, subCommands)}`, ]; if (positionalEntries.length > 0) { - lines.push("", ` ${terminalStrong("Arguments")}`, ...formatHelpEntries(positionalEntries)); + lines.push( + "", + ` ${renderDisplayText([span("Arguments", "strong")])}`, + ...formatHelpEntries(positionalEntries), + ); } if (optionEntries.length > 0) { - lines.push("", ` ${terminalStrong("Options")}`, ...formatHelpEntries(optionEntries)); + lines.push( + "", + ` ${renderDisplayText([span("Options", "strong")])}`, + ...formatHelpEntries(optionEntries), + ); } if (commandEntries.length > 0) { - lines.push("", ` ${terminalStrong("Commands")}`, ...formatHelpEntries(commandEntries)); + lines.push( + "", + ` ${renderDisplayText([span("Commands", "strong")])}`, + ...formatHelpEntries(commandEntries), + ); lines.push("", ...formatHelpGuidance(commandName)); } @@ -375,9 +406,9 @@ async function renderCommandUsage(command: CommandDef, parent?: CommandDef): Pro if (examples.length > 0) { lines.push( "", - ` ${terminalStrong("Examples")}`, + ` ${renderDisplayText([span("Examples", "strong")])}`, ...examples.flatMap((example) => - formatHelpParagraph(example, HELP_INDENT).map(terminalHighlightCommands), + formatHelpParagraph(example, HELP_INDENT).map(renderHighlightedCommands), ), ); } @@ -395,10 +426,10 @@ async function renderRootUsage(command: CommandDef, meta: AltertableCommandMeta) const lines = [ ...wrapHelpText(meta.description ?? "", getHelpTerminalWidth()), "", - ` ${terminalStrong("Usage")}`, + ` ${renderDisplayText([span("Usage", "strong")])}`, " altertable [flags]", "", - ` ${terminalStrong("Commands")}`, + ` ${renderDisplayText([span("Commands", "strong")])}`, ]; for (const { name, meta: commandMeta } of await visibleSubCommands(command)) { @@ -418,7 +449,7 @@ async function renderRootUsage(command: CommandDef, meta: AltertableCommandMeta) if (entries.length > 0) { lines.push( ...(renderedGroup ? [""] : []), - ` ${terminalMuted(COMMAND_GROUP_TITLES[group])}`, + ` ${renderDisplayText([span(COMMAND_GROUP_TITLES[group], "muted")])}`, ...formatHelpEntries(entries), ); renderedGroup = true; @@ -435,15 +466,19 @@ async function renderRootUsage(command: CommandDef, meta: AltertableCommandMeta) { label: HELP_FLAGS.join(", "), description: "Show this help" }, { label: VERSION_FLAGS.join(", "), description: "Show the Altertable CLI version" }, ); - lines.push("", ` ${terminalStrong("Global flags")}`, ...formatHelpEntries(flags)); + lines.push( + "", + ` ${renderDisplayText([span("Global flags", "strong")])}`, + ...formatHelpEntries(flags), + ); const examples = await resolveCommandExamples(command); if (examples.length > 0) { lines.push( "", - ` ${terminalStrong("Examples")}`, + ` ${renderDisplayText([span("Examples", "strong")])}`, ...examples.flatMap((example) => - formatHelpParagraph(example, HELP_INDENT).map(terminalHighlightCommands), + formatHelpParagraph(example, HELP_INDENT).map(renderHighlightedCommands), ), ); } diff --git a/cli/src/ui/document.ts b/cli/src/ui/document.ts index 249f7d3..104c5be 100644 --- a/cli/src/ui/document.ts +++ b/cli/src/ui/document.ts @@ -1,24 +1,35 @@ +export type DisplayTextStyle = + | "strong" + | "accent" + | "string" + | "boolean" + | "number" + | "muted" + | "subtle" + | "success" + | "warning" + | "error" + | "heading" + | "httpMethod"; + +export type DisplaySpan = { + text: string; + style?: DisplayTextStyle; + href?: string; +}; + +export type DisplayText = string | readonly DisplaySpan[]; + export type DisplayRow = { label: string; - value: string; + value: DisplayText; level?: 0 | 1; - linkifyUrls?: boolean; }; -export type DisplayTableColumnStyle = - | "foreground" - | "subtle" - | "muted" - | "accent" - | "string" - | "strong" - | "httpMethod"; - export type DisplayTableColumn = { header: string; - cell: (row: Row) => string; + cell: (row: Row) => DisplayText; maxWidth?: number; - style?: DisplayTableColumnStyle; flex?: boolean; }; @@ -39,7 +50,7 @@ export type DisplayBlock = } | { kind: "text"; - lines: readonly string[]; + lines: readonly DisplayText[]; }; export type DisplaySection = { @@ -65,10 +76,18 @@ export function table(displayTable: DisplayTable): DisplayBlock { return { kind: "table", table: displayTable as DisplayTable }; } -export function text(lines: readonly string[]): DisplayBlock { +export function text(lines: readonly DisplayText[]): DisplayBlock { return { kind: "text", lines }; } +export function span(text: string, style?: DisplayTextStyle, href?: string): DisplaySpan { + return { + text, + ...(style ? { style } : {}), + ...(href ? { href } : {}), + }; +} + export function section(...blocks: readonly DisplayBlock[]): DisplaySection { return { blocks }; } diff --git a/cli/src/ui/layouts/tree.ts b/cli/src/ui/layouts/tree.ts index e113d33..b778dda 100644 --- a/cli/src/ui/layouts/tree.ts +++ b/cli/src/ui/layouts/tree.ts @@ -1,50 +1,13 @@ +import type { DisplayText } from "@/ui/document.ts"; + export type TreeNode = { - label: string; + label: DisplayText; children?: readonly TreeNode[]; - emptyLabel?: string; + emptyLabel?: DisplayText; }; export type TreeView = { - title?: string; + title?: DisplayText; children: readonly TreeNode[]; - emptyLabel?: string; + emptyLabel?: DisplayText; }; - -const TREE_BRANCH = "├── "; -const TREE_LAST_BRANCH = "└── "; -const TREE_CHILD_PREFIX = "│ "; -const TREE_LAST_CHILD_PREFIX = " "; - -function renderTreeNodes(nodes: readonly TreeNode[], prefix: string): string[] { - return nodes.flatMap((node, index) => { - const isLast = index === nodes.length - 1; - const branch = isLast ? TREE_LAST_BRANCH : TREE_BRANCH; - const childPrefix = `${prefix}${isLast ? TREE_LAST_CHILD_PREFIX : TREE_CHILD_PREFIX}`; - const children = node.children ?? []; - const lines = [`${prefix}${branch}${node.label}`]; - - if (children.length > 0) { - lines.push(...renderTreeNodes(children, childPrefix)); - } else if (node.emptyLabel) { - lines.push(`${childPrefix}${TREE_LAST_BRANCH}${node.emptyLabel}`); - } - - return lines; - }); -} - -export function renderTree(view: TreeView): string[] { - const lines = view.title ? [view.title] : []; - - if (view.children.length === 0) { - lines.push(`${TREE_LAST_BRANCH}${view.emptyLabel ?? ""}`); - return lines; - } - - lines.push(...renderTreeNodes(view.children, "")); - return lines; -} - -export function renderTreeText(view: TreeView): string { - return renderTree(view).join("\n"); -} diff --git a/cli/src/ui/renderers/terminal.ts b/cli/src/ui/renderers/terminal.ts index bd4ef9e..16c23ca 100644 --- a/cli/src/ui/renderers/terminal.ts +++ b/cli/src/ui/renderers/terminal.ts @@ -1,9 +1,11 @@ import { + span, type DisplayBlock, type DisplayDocument, type DisplayRow, type DisplaySection, } from "@/ui/document.ts"; +import type { TreeNode, TreeView } from "@/ui/layouts/tree.ts"; import { nestedIndent, TERMINAL_INDENT, @@ -11,7 +13,7 @@ import { TERMINAL_NESTED_LABEL_WIDTH, } from "@/ui/terminal/spacing.ts"; import { renderFixedTable } from "@/ui/terminal/table.ts"; -import { formatTerminalLabelValue } from "@/ui/terminal/styles.ts"; +import { renderDisplayText } from "@/ui/terminal/styles.ts"; export type TerminalRenderOptions = { indent?: string; @@ -20,6 +22,14 @@ export type TerminalRenderOptions = { nestedLabelWidth?: number; }; +function renderLabelValue( + label: string, + value: string, + options: { indent: string; labelWidth: number }, +): string { + return `${options.indent}${renderDisplayText([span(label.padEnd(options.labelWidth), "muted")])} ${value}`; +} + export function renderRows( rows: readonly DisplayRow[], options: TerminalRenderOptions = {}, @@ -30,17 +40,16 @@ export function renderRows( const nestedLabelWidth = options.nestedLabelWidth ?? TERMINAL_NESTED_LABEL_WIDTH; return rows.map((row) => - formatTerminalLabelValue(row.label, row.value, { + renderLabelValue(row.label, renderDisplayText(row.value), { indent: row.level === 1 ? childIndent : indent, labelWidth: row.level === 1 ? nestedLabelWidth : labelWidth, - linkifyUrls: row.linkifyUrls, }), ); } function renderBlock(block: DisplayBlock, options: TerminalRenderOptions): string[] { if (block.kind === "text") { - return [...block.lines]; + return block.lines.map(renderDisplayText); } if (block.kind === "table") { return renderFixedTable( @@ -73,3 +82,42 @@ export function renderDocumentText( ): string { return renderDocument(document, options).join("\n"); } + +const TREE_BRANCH = "├── "; +const TREE_LAST_BRANCH = "└── "; +const TREE_CHILD_PREFIX = "│ "; +const TREE_LAST_CHILD_PREFIX = " "; + +function renderTreeNodes(nodes: readonly TreeNode[], prefix: string): string[] { + return nodes.flatMap((node, index) => { + const isLast = index === nodes.length - 1; + const branch = isLast ? TREE_LAST_BRANCH : TREE_BRANCH; + const childPrefix = `${prefix}${isLast ? TREE_LAST_CHILD_PREFIX : TREE_CHILD_PREFIX}`; + const children = node.children ?? []; + const lines = [`${prefix}${branch}${renderDisplayText(node.label)}`]; + + if (children.length > 0) { + lines.push(...renderTreeNodes(children, childPrefix)); + } else if (node.emptyLabel) { + lines.push(`${childPrefix}${TREE_LAST_BRANCH}${renderDisplayText(node.emptyLabel)}`); + } + + return lines; + }); +} + +export function renderTree(view: TreeView): string[] { + const lines = view.title ? [renderDisplayText(view.title)] : []; + + if (view.children.length === 0) { + lines.push(`${TREE_LAST_BRANCH}${renderDisplayText(view.emptyLabel ?? "")}`); + return lines; + } + + lines.push(...renderTreeNodes(view.children, "")); + return lines; +} + +export function renderTreeText(view: TreeView): string { + return renderTree(view).join("\n"); +} diff --git a/cli/src/ui/terminal/styles.ts b/cli/src/ui/terminal/styles.ts index e9e37b7..0315a73 100644 --- a/cli/src/ui/terminal/styles.ts +++ b/cli/src/ui/terminal/styles.ts @@ -1,8 +1,7 @@ import { readEnv, setEnv, unsetEnv } from "@/lib/env.ts"; -const COMMAND_PATTERN = /altertable(?:\s+[^\s'",]+)*/g; -const MARKDOWN_LINK_PATTERN = /\[([^\]]+)]\((https?:\/\/[^)\s]+)\)/g; -const URL_PATTERN = /https?:\/\/[^\s)>\]]+/g; +import type { DisplayText, DisplayTextStyle } from "@/ui/document.ts"; + export const DEFAULT_TERMINAL_WIDTH = 80; const TERMINAL_ELLIPSIS = "…"; const ANSI_ESCAPE_CHARACTER = String.fromCharCode(27); @@ -17,18 +16,7 @@ const TERMINAL_CONTROL_PATTERN = new RegExp( "g", ); -type TerminalStyle = - | "strong" - | "underline" - | "accent" - | "string" - | "boolean" - | "number" - | "muted" - | "subtle" - | "success" - | "warning" - | "error"; +type TerminalStyle = Exclude | "underline"; const STYLE_CODES: Record = { strong: { open: 1, close: 22 }, @@ -46,10 +34,9 @@ const STYLE_CODES: Record = { export type TerminalColorMode = "auto" | "always" | "never"; -export type TerminalDataType = "null" | "boolean" | "number" | "string" | "uuid" | "timestamp"; - let contextColorMode: TerminalColorMode | undefined; let noColorEnvSetByCli = false; +const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); export function setTerminalColorMode(mode: TerminalColorMode | undefined): void { contextColorMode = mode; @@ -153,7 +140,17 @@ export function shouldUseTerminalHyperlinks(): boolean { } function getCodePointDisplayWidth(codePoint: number): number { - if (codePoint === 0xfe0f) { + if ( + codePoint === 0x200d || + codePoint === 0xfe0e || + codePoint === 0xfe0f || + (codePoint >= 0x0300 && codePoint <= 0x036f) || + (codePoint >= 0x1ab0 && codePoint <= 0x1aff) || + (codePoint >= 0x1dc0 && codePoint <= 0x1dff) || + (codePoint >= 0x20d0 && codePoint <= 0x20ff) || + (codePoint >= 0xfe20 && codePoint <= 0xfe2f) || + (codePoint >= 0x1f3fb && codePoint <= 0x1f3ff) + ) { return 0; } if ( @@ -173,14 +170,28 @@ function getCodePointDisplayWidth(codePoint: number): number { return 1; } -function getPlainTextDisplayWidth(text: string): number { - let width = 0; - for (const character of text) { +function getGraphemeDisplayWidth(grapheme: string): number { + let width = grapheme.includes("\ufe0f") ? 2 : 0; + let regionalIndicatorCount = 0; + for (const character of grapheme) { const codePoint = character.codePointAt(0); - if (codePoint === undefined) { - continue; + if (codePoint !== undefined) { + width = Math.max(width, getCodePointDisplayWidth(codePoint)); + if (codePoint >= 0x1f1e6 && codePoint <= 0x1f1ff) { + regionalIndicatorCount += 1; + } } - width += getCodePointDisplayWidth(codePoint); + } + if (regionalIndicatorCount === 2) { + return 2; + } + return width; +} + +function getPlainTextDisplayWidth(text: string): number { + let width = 0; + for (const { segment } of graphemeSegmenter.segment(text)) { + width += getGraphemeDisplayWidth(segment); } return width; } @@ -248,22 +259,18 @@ export function truncateTerminalText(text: string, maxWidth?: number): string { continue; } - const codePoint = text.codePointAt(index); - if (codePoint === undefined) { + const grapheme = graphemeSegmenter.segment(text.slice(index))[Symbol.iterator]().next() + .value?.segment; + if (grapheme === undefined) { break; } - const charWidth = getCodePointDisplayWidth(codePoint); - if (visibleWidth + charWidth > targetWidth) { + const graphemeWidth = getGraphemeDisplayWidth(grapheme); + if (visibleWidth + graphemeWidth > targetWidth) { break; } - - if (codePoint > 0xffff) { - result += String.fromCodePoint(codePoint); - index += 1; - } else { - result += character; - } - visibleWidth += charWidth; + result += grapheme; + index += grapheme.length - 1; + visibleWidth += graphemeWidth; } const closeOpenStyle = ANSI_ESCAPE_PATTERN.test(result) ? ANSI_RESET : ""; @@ -282,227 +289,100 @@ function applyTerminalStyle(style: TerminalStyle, text: string): string { return `\u001b[${code.open}m${text}\u001b[${code.close}m`; } -export function terminalStrong(text: string): string { - return applyTerminalStyle("strong", text); -} - -export function terminalAccent(text: string): string { - return applyTerminalStyle("accent", text); -} - -export function terminalString(text: string): string { - return applyTerminalStyle("string", text); -} - -export function terminalBoolean(text: string): string { - return applyTerminalStyle("boolean", text); +function sanitizeTerminalText(text: string): string { + let safe = ""; + for (const character of text) { + const codePoint = character.codePointAt(0) ?? 0; + const unsafe = + codePoint <= 0x1f || + (codePoint >= 0x7f && codePoint <= 0x9f) || + (codePoint >= 0x202a && codePoint <= 0x202e) || + (codePoint >= 0x2066 && codePoint <= 0x2069); + if (!unsafe) { + safe += character; + } else if (codePoint <= 0xff) { + safe += `\\x${codePoint.toString(16).padStart(2, "0")}`; + } else { + safe += `\\u${codePoint.toString(16).padStart(4, "0")}`; + } + } + return safe; } -export function terminalNumber(text: string): string { - return applyTerminalStyle("number", text); +function safeHyperlinkTarget(href: string): string | null { + if (sanitizeTerminalText(href) !== href) { + return null; + } + try { + const protocol = new URL(href).protocol; + return protocol === "http:" || protocol === "https:" ? href : null; + } catch { + return null; + } } -export function terminalHttpMethod(method: string): string { +function renderHttpMethod(method: string): string { switch (method.toUpperCase()) { case "GET": - return terminalSuccess(method.toUpperCase()); + return applyTerminalStyle("success", method.toUpperCase()); case "POST": - return terminalAccent(method.toUpperCase()); + return applyTerminalStyle("accent", method.toUpperCase()); case "PATCH": - return terminalWarning(method.toUpperCase()); + return applyTerminalStyle("warning", method.toUpperCase()); case "PUT": - return terminalString(method.toUpperCase()); + return applyTerminalStyle("string", method.toUpperCase()); case "DELETE": - return terminalError(method.toUpperCase()); + return applyTerminalStyle("error", method.toUpperCase()); default: - return terminalSubtle(method.toUpperCase()); + return applyTerminalStyle("subtle", method.toUpperCase()); } } -export function terminalLink(label: string, url: string): string { +function renderLink(label: string, url: string): string { if (!shouldUseTerminalHyperlinks()) { return label; } return `${OSC_HYPERLINK_START}${url}${OSC_HYPERLINK_SEPARATOR}${label}${OSC_HYPERLINK_END}`; } -export function terminalUrl(url: string): string { - const styled = terminalAccent(url); - if (!shouldUseTerminalHyperlinks()) { - return styled; - } - return terminalLink(styled, url); -} - -export function formatTerminalUrls(text: string): string { - if (!shouldUseTerminalColor()) { - return text; - } - return text.replace(URL_PATTERN, (url) => terminalUrl(url)); -} - -export function formatTerminalMarkdownLinks(text: string): string { - return text.replace(MARKDOWN_LINK_PATTERN, (_match, label: string, url: string) => { - if (!shouldUseTerminalColor()) { - return `${label} (${url})`; - } - if (!shouldUseTerminalHyperlinks()) { - return `${terminalAccent(label)} ${terminalSubtle(`(${url})`)}`; - } - return terminalLink(terminalAccent(label), url); - }); -} - -export function terminalDataType(text: string, kind: TerminalDataType): string { - switch (kind) { - case "null": - return terminalSubtle(text); - case "boolean": - return terminalBoolean(text); - case "number": - return terminalNumber(text); - case "string": - return terminalString(text); - case "uuid": - return terminalAccent(text); - case "timestamp": - return terminalString(text); - } -} - -export function terminalTimestamp(absolute: string, relative: string | null): string { - const styledAbsolute = terminalString(absolute); - if (relative === null) { - return styledAbsolute; - } - return `${styledAbsolute} ${terminalSubtle(relative)}`; -} - -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -const TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; - -export function isUuidValue(value: string): boolean { - return UUID_PATTERN.test(value); -} - -export function isTimestampValue(value: string): boolean { - return TIMESTAMP_PATTERN.test(value); -} - -export function classifyStringDataType(value: string): TerminalDataType { - if (isUuidValue(value)) { - return "uuid"; - } - if (isTimestampValue(value)) { - return "timestamp"; - } - return "string"; -} - -export function parseJsonStringValue(value: string): string | null { - const trimmed = value.trim(); - if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) { - return null; - } - try { - JSON.parse(trimmed); - return trimmed; - } catch { - return null; - } -} - -export function terminalSuccess(text: string): string { - return applyTerminalStyle("success", text); -} - -export function terminalWarning(text: string): string { - return applyTerminalStyle("warning", text); -} - -export function terminalError(text: string): string { - return applyTerminalStyle("error", text); -} - -export function terminalUnderline(text: string): string { - return applyTerminalStyle("underline", text); -} - -export function terminalSectionHeader(title: string): string { - return terminalUnderline(terminalStrong(title.toUpperCase())); -} - -export function formatTerminalSection(bodyLines: string[]): string { - return bodyLines.join("\n"); -} - -export function terminalLabel(text: string): string { - return terminalMuted(text); -} - -export function terminalTableHeader(title: string): string { - return terminalSubtle(title.toUpperCase()); -} - -export function terminalDefaultHint(value: string): string { - return terminalSubtle(`[${value}]`); -} - -export function terminalNotConfiguredStatus(): string { - return terminalMuted("not set"); -} - -export function terminalDescription(text: string): string { - return terminalSubtle(text); -} - -export function terminalSubtle(text: string): string { - return applyTerminalStyle("subtle", text); -} - -export function terminalMuted(text: string): string { - return applyTerminalStyle("muted", text); -} - -export function terminalMetadata(text: string): string { - return terminalSubtle(text); -} - -export function formatTerminalLabelValue( - label: string, - value: string, - options: { indent?: string; labelWidth?: number; linkifyUrls?: boolean } = {}, -): string { - const indent = options.indent ?? ""; - const labelWidth = options.labelWidth ?? label.length; - const displayValue = options.linkifyUrls ? formatTerminalUrls(value) : value; - return `${indent}${terminalLabel(`${label.padEnd(labelWidth)}`)} ${displayValue}`; -} - -export function terminalHighlightCommands(text: string): string { - if (!shouldUseTerminalColor()) { - return text; - } - return text.replace(COMMAND_PATTERN, (match) => terminalAccent(match)); -} - -export function terminalUsageSectionHeader(title: string): string { - return applyTerminalStyle("strong", applyTerminalStyle("underline", title)); -} - -export function formatTerminalUsageSection(title: string, bodyLines: string[]): string { - if (bodyLines.length === 0) { - return ""; - } - - return `\n\n${terminalUsageSectionHeader(title)}\n\n${bodyLines.join("\n")}`; -} - -export function formatCommandExamplesSection(examples: readonly string[]): string { - if (examples.length === 0) { - return ""; - } - - const bodyLines = examples.map((example) => ` ${terminalHighlightCommands(example)}`); - return formatTerminalUsageSection("EXAMPLES", bodyLines); +function renderHeading(text: string): string { + return applyTerminalStyle("strong", applyTerminalStyle("underline", text.toUpperCase())); +} + +const DISPLAY_TEXT_STYLE_RENDERERS = { + strong: (text: string) => applyTerminalStyle("strong", text), + accent: (text: string) => applyTerminalStyle("accent", text), + string: (text: string) => applyTerminalStyle("string", text), + boolean: (text: string) => applyTerminalStyle("boolean", text), + number: (text: string) => applyTerminalStyle("number", text), + muted: (text: string) => applyTerminalStyle("muted", text), + subtle: (text: string) => applyTerminalStyle("subtle", text), + success: (text: string) => applyTerminalStyle("success", text), + warning: (text: string) => applyTerminalStyle("warning", text), + error: (text: string) => applyTerminalStyle("error", text), + heading: renderHeading, + httpMethod: renderHttpMethod, +} satisfies Record string>; + +export function renderDisplayText(text: DisplayText): string { + if (typeof text === "string") { + return sanitizeTerminalText(text); + } + return text + .map((item) => { + const safeText = sanitizeTerminalText(item.text); + const styled = item.style ? DISPLAY_TEXT_STYLE_RENDERERS[item.style](safeText) : safeText; + if (!item.href) { + return styled; + } + const safeHref = safeHyperlinkTarget(item.href); + if (safeHref !== null && shouldUseTerminalHyperlinks()) { + return renderLink(styled, safeHref); + } + const visibleHref = sanitizeTerminalText(item.href); + return safeText === visibleHref + ? styled + : `${styled} ${applyTerminalStyle("subtle", `(${visibleHref})`)}`; + }) + .join(""); } diff --git a/cli/src/ui/terminal/table.ts b/cli/src/ui/terminal/table.ts index 778bf0e..5767b28 100644 --- a/cli/src/ui/terminal/table.ts +++ b/cli/src/ui/terminal/table.ts @@ -2,25 +2,14 @@ import { getTerminalWidth, getVisibleTextWidth, padVisibleText, - terminalAccent, - terminalHttpMethod, - terminalMuted, - terminalString, - terminalStrong, - terminalSubtle, - terminalTableHeader, + renderDisplayText, truncateTerminalText, } from "@/ui/terminal/styles.ts"; -import type { - DisplayTableColumn, - DisplayTableColumnStyle, - DisplayTableOptions, -} from "@/ui/document.ts"; +import { span, type DisplayTableColumn, type DisplayTableOptions } from "@/ui/document.ts"; const COLUMN_GAP = " "; const FLEX_SHRINK_MIN_WIDTH = 4; -export type TableColumnStyle = DisplayTableColumnStyle; export type TableColumn = DisplayTableColumn; export type FixedTableRenderOptions = DisplayTableOptions; @@ -28,30 +17,8 @@ function truncateCell(text: string, maxWidth?: number): string { return truncateTerminalText(text, maxWidth); } -function applyCellStyle(text: string, style: TableColumnStyle = "foreground"): string { - if (style === "subtle") { - return terminalSubtle(text); - } - if (style === "muted") { - return terminalMuted(text); - } - if (style === "accent") { - return terminalAccent(text); - } - if (style === "string") { - return terminalString(text); - } - if (style === "strong") { - return terminalStrong(text); - } - if (style === "httpMethod") { - return padVisibleText(terminalHttpMethod(text.trimEnd()), getVisibleTextWidth(text)); - } - return text; -} - function applyHeaderStyle(text: string): string { - return terminalTableHeader(text); + return renderDisplayText([span(text, "subtle")]); } function tablePlainWidth(columnWidths: number[], columnCount: number): number { @@ -62,10 +29,14 @@ function tablePlainWidth(columnWidths: number[], columnCount: number): number { function computeNaturalColumnWidths( columns: TableColumn[], - plainCells: string[][], + renderedHeaders: string[], + renderedCells: string[][], ): number[] { - return columns.map((column, columnIndex) => { - const values = [column.header, ...plainCells.map((cells) => cells[columnIndex] ?? "")]; + return columns.map((_column, columnIndex) => { + const values = [ + renderedHeaders[columnIndex] ?? "", + ...renderedCells.map((cells) => cells[columnIndex] ?? ""), + ]; return Math.max(...values.map((value) => getVisibleTextWidth(value)), 1); }); } @@ -101,10 +72,11 @@ function shrinkFlexColumnsToFit( function computeColumnWidths( columns: TableColumn[], - plainCells: string[][], + renderedHeaders: string[], + renderedCells: string[][], options: FixedTableRenderOptions, ): number[] { - const naturalWidths = computeNaturalColumnWidths(columns, plainCells); + const naturalWidths = computeNaturalColumnWidths(columns, renderedHeaders, renderedCells); const cappedWidths = naturalWidths.map((width, columnIndex) => { const maxWidth = columns[columnIndex]?.maxWidth; return maxWidth === undefined ? width : Math.min(width, maxWidth); @@ -126,37 +98,36 @@ export function renderFixedTable( options: FixedTableRenderOptions = {}, ): string { if (rows.length === 0) { - return terminalSubtle(emptyMessage); + return renderDisplayText([span(emptyMessage, "subtle")]); } - const plainCells = rows.map((row) => columns.map((column) => column.cell(row))); - const columnWidths = computeColumnWidths(columns, plainCells, options); + const renderedHeaders = columns.map((column) => renderDisplayText(column.header.toUpperCase())); + const renderedCells = rows.map((row) => + columns.map((column) => renderDisplayText(column.cell(row))), + ); + const columnWidths = computeColumnWidths(columns, renderedHeaders, renderedCells, options); - function formatRow(cells: string[], rowIndex: number): string { - return cells + function formatRow(rowCells: readonly string[], rowIndex: number): string { + return rowCells .map((cell, columnIndex) => { - const column = columns[columnIndex]; const width = columnWidths[columnIndex] ?? getVisibleTextWidth(cell); const truncated = truncateCell(cell, width); const padded = padVisibleText(truncated, width); if (rowIndex < 0) { return applyHeaderStyle(padded); } - return applyCellStyle(padded, column?.style); + return padded; }) .join(COLUMN_GAP); } - const header = formatRow( - columns.map((column) => column.header), - -1, - ); + const header = formatRow(renderedHeaders, -1); const bodyLines: string[] = []; - for (let rowIndex = 0; rowIndex < plainCells.length; rowIndex++) { + for (let rowIndex = 0; rowIndex < renderedCells.length; rowIndex++) { const row = rows[rowIndex]; - const cells = plainCells[rowIndex]; - if (row === undefined || cells === undefined) { + const rowCells = renderedCells[rowIndex]; + if (row === undefined || rowCells === undefined) { continue; } if (rowIndex > 0 && options.groupBy) { @@ -165,7 +136,7 @@ export function renderFixedTable( bodyLines.push(""); } } - bodyLines.push(formatRow(cells, rowIndex)); + bodyLines.push(formatRow(rowCells, rowIndex)); } return [header, ...bodyLines].join("\n"); diff --git a/cli/tests/active-context.test.ts b/cli/tests/active-context.test.ts index 5bc8d1e..68a7a16 100644 --- a/cli/tests/active-context.test.ts +++ b/cli/tests/active-context.test.ts @@ -106,6 +106,13 @@ describe("active context formatters", () => { lakehouse: "not set", }, ]); + const [entry] = summaryBlock.table.rows; + expect(summaryBlock.table.columns.map((column) => column.cell(entry))).toEqual([ + [{ text: "default", style: "strong" }], + [{ text: "production", style: "accent" }], + [{ text: "production", style: "muted" }], + [{ text: "not set", style: "muted" }], + ]); } }); }); @@ -128,7 +135,16 @@ describe("active context formatters", () => { expect.arrayContaining([ { label: "User:", value: "Alex Doe " }, { label: "Organization:", value: "Acme (acme)" }, - { label: "Data plane:", value: "https://api.altertable.ai", linkifyUrls: true }, + { + label: "Data plane:", + value: [ + { + text: "https://api.altertable.ai", + style: "accent", + href: "https://api.altertable.ai", + }, + ], + }, ]), ); } diff --git a/cli/tests/configure-credential-status.test.ts b/cli/tests/configure-credential-status.test.ts index fbb41ee..47c15a1 100644 --- a/cli/tests/configure-credential-status.test.ts +++ b/cli/tests/configure-credential-status.test.ts @@ -209,7 +209,16 @@ describe("buildConfigureShowData", () => { expect(summaryRows.rows).toEqual( expect.arrayContaining([ { label: "Active profile:", value: "default" }, - { label: "Data plane:", value: "https://api.altertable.ai", linkifyUrls: true }, + { + label: "Data plane:", + value: [ + { + text: "https://api.altertable.ai", + style: "accent", + href: "https://api.altertable.ai", + }, + ], + }, ]), ); } diff --git a/cli/tests/errors.test.ts b/cli/tests/errors.test.ts index fb5769f..125b071 100644 --- a/cli/tests/errors.test.ts +++ b/cli/tests/errors.test.ts @@ -18,6 +18,7 @@ import { getCliExitCode, httpStatusMessage, renderCliError, + renderCliErrorDetails, renderCliErrorJson, serializeCliError, shouldShowCommandExamplesOnError, @@ -45,6 +46,15 @@ describe("errors", () => { expect(renderCliError(new CliError("x"))).toBe("ERROR x"); }); + test("renderCliErrorDetails preserves trusted line structure and sanitizes each line", () => { + const rendered = renderCliErrorDetails("First line\nRun this\u001b]0;spoofed\u0007"); + + expect(rendered).toBe("ERROR First line\nRun this\\x1b]0;spoofed\\x07"); + expect(rendered).not.toContain("\\x0a"); + expect(rendered).not.toContain("\u001b"); + expect(rendered).not.toContain("\u0007"); + }); + test("ConfigurationError uses EXIT_CONFIG", () => { const error = new ConfigurationError("missing config"); expect(error.exitCode).toBe(EXIT_CONFIG); diff --git a/cli/tests/presentation.test.ts b/cli/tests/presentation.test.ts new file mode 100644 index 0000000..b1f3dc4 --- /dev/null +++ b/cli/tests/presentation.test.ts @@ -0,0 +1,31 @@ +import { afterEach, expect, test } from "bun:test"; +import { buildApiOperationDetailsView } from "@/features/api/views.ts"; +import { renderDocumentText } from "@/ui/renderers/terminal.ts"; +import { setTerminalColorMode } from "@/ui/terminal/styles.ts"; + +afterEach(() => { + setTerminalColorMode(undefined); +}); + +test("presentation documents carry semantics without terminal escapes", () => { + setTerminalColorMode("always"); + const view = buildApiOperationDetailsView({ + operationId: "createDatabase", + method: "POST", + path: "/environments/{environment_id}/databases", + parameters: ["environment_id"], + summary: "Create a database", + }); + const [section] = view.sections; + const [block] = section?.blocks ?? []; + + expect(JSON.stringify(view)).not.toContain("\u001b"); + expect(block?.kind).toBe("rows"); + if (block?.kind === "rows") { + expect(block.rows[0]).toEqual({ + label: "Operation:", + value: [{ text: "createDatabase", style: "accent" }], + }); + } + expect(renderDocumentText(view)).toContain("\u001b[96mcreateDatabase\u001b[39m"); +}); diff --git a/cli/tests/profile.test.ts b/cli/tests/profile.test.ts index c0d44a0..e270251 100644 --- a/cli/tests/profile.test.ts +++ b/cli/tests/profile.test.ts @@ -223,7 +223,16 @@ describe("profile storage", () => { expect.arrayContaining([ { label: "Profile", value: "acme_prod" }, { label: "Organization", value: "acme" }, - { label: "Data plane", value: "https://api.example.com", linkifyUrls: true }, + { + label: "Data plane", + value: [ + { + text: "https://api.example.com", + style: "accent", + href: "https://api.example.com", + }, + ], + }, ]), ); } diff --git a/cli/tests/query-format.test.ts b/cli/tests/query-format.test.ts index 877f33c..7a6701f 100644 --- a/cli/tests/query-format.test.ts +++ b/cli/tests/query-format.test.ts @@ -99,6 +99,12 @@ describe("truncateText", () => { test("truncates middle of long strings with ellipsis", () => { expect(truncateTextMiddle("019ee8e4-1d79-77d9-8693-1f67732b184d", 16)).toBe("019ee8e4…32b184d"); expect(truncateTextMiddle("abcdef", 2)).toBe("a…"); + expect(truncateTextMiddle("👨‍👩‍👧‍👦abc", 4)).toBe("👨‍👩‍👧‍👦…c"); + }); + + test("truncates by terminal width without splitting wide characters", () => { + expect(truncateText("日本語", 4)).toBe("日…"); + expect(getVisibleTextWidth(truncateText("日本語", 4))).toBeLessThanOrEqual(4); }); }); @@ -143,6 +149,14 @@ describe("formatQueryCell", () => { expect(formatQueryCell(requestedBy, { colorize: false })).toBe("password: [MASKED]"); }); + test("escapes terminal controls even when color is disabled", () => { + const output = formatQueryCell("safe\u001b]0;spoofed\u0007\nnext", { colorize: false }); + + expect(output).toBe("safe\\x1b]0;spoofed\\x07\\x0anext"); + expect(output).not.toContain("\u001b"); + expect(output).not.toContain("\u0007"); + }); + test("dims false and empty string values when colorized", () => { enableTerminalColorForTests(); try { @@ -245,6 +259,27 @@ describe("renderQueryHumanOutput", () => { expect(output).toContain("│ query │ 42 │"); }); + test("keeps wide cells inside the rendered table frame", () => { + const output = renderQueryHumanOutput( + { metadata: {}, columns: ["値"], rows: [{ 値: "日本語" }] }, + { layout: "table", maxColumnWidth: 4, terminalWidth: 80, colorize: false }, + ); + + const tableLines = output.split("\n").filter((line) => /^[┌├│└]/u.test(line)); + expect(tableLines.some((line) => line.includes("日…"))).toBe(true); + expect(tableLines.every((line) => getVisibleTextWidth(line) === 8)).toBe(true); + }); + + test("pads expanded labels by terminal width", () => { + const output = renderQueryHumanOutput( + { metadata: {}, columns: ["日本語", "id"], rows: [{ 日本語: "value", id: 1 }] }, + { layout: "line", maxColumnWidth: 32, terminalWidth: 80, colorize: false }, + ); + + expect(output).toContain("日本語: value"); + expect(output).toContain("id: 1"); + }); + test("auto layout picks line output when wider than terminal", () => { const output = renderQueryHumanOutput(WIDE_RESULT, { layout: "auto", @@ -485,6 +520,26 @@ describe("renderQueryFooter", () => { expect(footer).toContain("query_id:"); expect(footer).toContain("019ee8e4-1d79-77d9-8693-1f67732b184d"); }); + + test("sanitizes query IDs without terminal color", () => { + const result = { + metadata: { query_id: "query\u001b]0;spoofed\u0007" }, + columns: [], + rows: [], + }; + + expect(renderQueryFooter(result, { colorize: false })).toContain( + "query_id: query\\x1b]0;spoofed\\x07", + ); + expect( + renderQueryMarkdown(result, [], { + layout: "table", + maxColumnWidth: 32, + terminalWidth: 80, + colorize: false, + }), + ).toContain("query_id: query\\x1b]0;spoofed\\x07"); + }); }); describe("CSV regression", () => { diff --git a/cli/tests/table-format.test.ts b/cli/tests/table-format.test.ts index 630f48e..2f960fb 100644 --- a/cli/tests/table-format.test.ts +++ b/cli/tests/table-format.test.ts @@ -2,8 +2,10 @@ import { afterEach, describe, expect, test } from "bun:test"; import { renderFixedTable } from "@/ui/terminal/table.ts"; import { renderApiRoutesTable, renderApiRoutesTableSection } from "@/features/api/render.ts"; import { setTerminalColorMode, getVisibleTextWidth } from "@/ui/terminal/styles.ts"; +import { span } from "@/ui/document.ts"; const originalAltertableColor = process.env.ALTERTABLE_COLOR; +const originalOscHyperlink = process.env.OSC_HYPERLINK; const originalStdoutIsTTY = process.stdout.isTTY; afterEach(() => { @@ -13,6 +15,11 @@ afterEach(() => { } else { process.env.ALTERTABLE_COLOR = originalAltertableColor; } + if (originalOscHyperlink === undefined) { + delete process.env.OSC_HYPERLINK; + } else { + process.env.OSC_HYPERLINK = originalOscHyperlink; + } Object.defineProperty(process.stdout, "isTTY", { value: originalStdoutIsTTY, configurable: true, @@ -40,6 +47,28 @@ describe("renderFixedTable", () => { expect(output).not.toContain("abcdefghijklmnopqrstuvwxyz"); }); + test("sizes and truncates semantic spans by visible text", () => { + setTerminalColorMode("always"); + const output = renderFixedTable( + [{ label: "abcdefghijklmnopqrstuvwxyz" }], + [{ header: "LABEL", cell: (row) => [span(row.label, "accent")], maxWidth: 10 }], + ); + + expect(output).toContain("\u001b[96mabcdefghi…"); + expect(getVisibleTextWidth(output.split("\n")[1] ?? "")).toBe(10); + }); + + test("measures the rendered fallback for labeled links", () => { + setTerminalColorMode("never"); + process.env.OSC_HYPERLINK = "0"; + const output = renderFixedTable( + [{ label: "Docs", url: "https://example.com" }], + [{ header: "LINK", cell: (row) => [span(row.label, "accent", row.url)] }], + ); + + expect(output.split("\n")[1]).toBe("Docs (https://example.com)"); + }); + test("returns empty message for no rows", () => { expect(renderFixedTable([], [{ header: "ID", cell: () => "" }], "No rows.")).toBe("No rows."); }); diff --git a/cli/tests/terminal-style.test.ts b/cli/tests/terminal-style.test.ts index 6728566..66392c0 100644 --- a/cli/tests/terminal-style.test.ts +++ b/cli/tests/terminal-style.test.ts @@ -1,42 +1,20 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { - classifyStringDataType, - formatCommandExamplesSection, - formatTerminalMarkdownLinks, - formatTerminalLabelValue, - formatTerminalSection, - formatTerminalUrls, getVisibleTextWidth, - isTimestampValue, - isUuidValue, setTerminalColorMode, shouldUseTerminalColor, - shouldUseTerminalHyperlinks, - terminalAccent, - terminalDataType, - terminalDefaultHint, - terminalError, - terminalHighlightCommands, - terminalHttpMethod, - terminalLabel, - terminalLink, - terminalSectionHeader, - terminalStrong, - terminalSubtle, - terminalSuccess, - terminalTableHeader, - terminalTimestamp, - terminalUrl, - terminalWarning, truncateTerminalText, applyTerminalColorFromContext, + renderDisplayText, } from "@/ui/terminal/styles.ts"; import { padLeft } from "@/ui/terminal/spacing.ts"; +import { span } from "@/ui/document.ts"; const originalNoColor = process.env.NO_COLOR; const originalTerm = process.env.TERM; const originalForceColor = process.env.FORCE_COLOR; const originalAltertableColor = process.env.ALTERTABLE_COLOR; +const originalOscHyperlink = process.env.OSC_HYPERLINK; const originalStdoutIsTTY = process.stdout.isTTY; const originalStderrIsTTY = process.stderr.isTTY; @@ -74,6 +52,11 @@ afterEach(() => { } else { process.env.ALTERTABLE_COLOR = originalAltertableColor; } + if (originalOscHyperlink === undefined) { + delete process.env.OSC_HYPERLINK; + } else { + process.env.OSC_HYPERLINK = originalOscHyperlink; + } Object.defineProperty(process.stdout, "isTTY", { value: originalStdoutIsTTY, configurable: true, @@ -126,21 +109,17 @@ describe("terminal-style", () => { test("returns plain text when NO_COLOR is set", () => { process.env.NO_COLOR = "1"; - expect(terminalAccent("altertable")).toBe("altertable"); - expect(terminalStrong("USAGE")).toBe("USAGE"); - expect(terminalSubtle("[default]")).toBe("[default]"); - expect(terminalSuccess("ok")).toBe("ok"); - expect(terminalWarning("careful")).toBe("careful"); - expect(terminalError("failed")).toBe("failed"); - expect(terminalSectionHeader("Options")).toBe("OPTIONS"); - expect(terminalDefaultHint("production")).toBe("[production]"); - expect(terminalTableHeader("method")).toBe("METHOD"); - expect(formatTerminalLabelValue("Path:", "/whoami")).toContain("Path:"); - expect(terminalHighlightCommands("run altertable profile show")).toContain( - "altertable profile show", - ); - expect(formatTerminalSection(["User: alice"])).toBe("User: alice"); - expect(terminalLabel("Config dir:")).toBe("Config dir:"); + expect( + renderDisplayText([ + span("altertable", "accent"), + span(" USAGE", "strong"), + span(" [default]", "subtle"), + span(" ok", "success"), + span(" careful", "warning"), + span(" failed", "error"), + ]), + ).toBe("altertable USAGE [default] ok careful failed"); + expect(renderDisplayText([span("Options", "heading")])).toBe("OPTIONS"); }); test("disables color when stdout and stderr are not TTY in auto mode", () => { @@ -151,7 +130,7 @@ describe("terminal-style", () => { Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true }); Object.defineProperty(process.stderr, "isTTY", { value: false, configurable: true }); expect(shouldUseTerminalColor()).toBe(false); - expect(terminalAccent("altertable")).toBe("altertable"); + expect(renderDisplayText([span("altertable", "accent")])).toBe("altertable"); }); test("enables color with FORCE_COLOR even when not a TTY", () => { @@ -173,53 +152,48 @@ describe("terminal-style", () => { test("wraps text with ANSI codes when color is enabled", () => { enableTerminalColorForTests(); - expect(terminalAccent("configure")).toContain("\u001b[96mconfigure\u001b[39m"); - expect(terminalSuccess("ok")).toContain("\u001b[32mok\u001b[39m"); - expect(terminalWarning("careful")).toContain("\u001b[93mcareful\u001b[39m"); - expect(terminalError("failed")).toContain("\u001b[31mfailed\u001b[39m"); - expect(terminalSectionHeader("Usage")).toContain("USAGE"); - expect(terminalLabel("Active profile:")).toContain("\u001b[2mActive profile:\u001b[22m"); + expect(renderDisplayText([span("configure", "accent")])).toContain( + "\u001b[96mconfigure\u001b[39m", + ); + expect(renderDisplayText([span("ok", "success")])).toContain("\u001b[32mok\u001b[39m"); + expect(renderDisplayText([span("careful", "warning")])).toContain( + "\u001b[93mcareful\u001b[39m", + ); + expect(renderDisplayText([span("failed", "error")])).toContain("\u001b[31mfailed\u001b[39m"); + expect(renderDisplayText([span("Usage", "heading")])).toContain("USAGE"); }); test("maps each data type to a dedicated color", () => { enableTerminalColorForTests(); - expect(terminalDataType("NULL", "null")).toContain("\u001b[90m"); - expect(terminalDataType("true", "boolean")).toContain("\u001b[35m"); - expect(terminalDataType("42", "number")).toContain("\u001b[33m"); - expect(terminalDataType("hello", "string")).toContain("\u001b[34m"); - expect(terminalDataType("019ee8e4-1d79-77d9-8693-1f67732b184d", "uuid")).toContain( - "\u001b[96m", - ); - expect(terminalDataType("2026-06-21T06:35:24.409Z", "timestamp")).toContain("\u001b[34m"); + expect(renderDisplayText([span("NULL", "subtle")])).toContain("\u001b[90m"); + expect(renderDisplayText([span("true", "boolean")])).toContain("\u001b[35m"); + expect(renderDisplayText([span("42", "number")])).toContain("\u001b[33m"); + expect(renderDisplayText([span("hello", "string")])).toContain("\u001b[34m"); + expect(renderDisplayText([span("uuid", "accent")])).toContain("\u001b[96m"); }); test("styles HTTP methods with semantic colors", () => { enableTerminalColorForTests(); - expect(terminalHttpMethod("GET")).toContain("\u001b[32m"); - expect(terminalHttpMethod("POST")).toContain("\u001b[96m"); - expect(terminalHttpMethod("PATCH")).toContain("\u001b[93m"); - expect(terminalHttpMethod("PUT")).toContain("\u001b[34m"); - expect(terminalHttpMethod("DELETE")).toContain("\u001b[31m"); + expect(renderDisplayText([span("GET", "httpMethod")])).toContain("\u001b[32m"); + expect(renderDisplayText([span("POST", "httpMethod")])).toContain("\u001b[96m"); + expect(renderDisplayText([span("PATCH", "httpMethod")])).toContain("\u001b[93m"); + expect(renderDisplayText([span("PUT", "httpMethod")])).toContain("\u001b[34m"); + expect(renderDisplayText([span("DELETE", "httpMethod")])).toContain("\u001b[31m"); }); test("styles timestamp absolute and relative parts with distinct contrast", () => { enableTerminalColorForTests(); - const output = terminalTimestamp("2026-06-21T06:35:24.409Z", "6 days ago"); + const output = renderDisplayText([ + span("2026-06-21T06:35:24.409Z", "string"), + span(" 6 days ago", "subtle"), + ]); expect(output).toContain("\u001b[34m2026-06-21T06:35:24.409Z\u001b[39m"); - expect(output).toContain("\u001b[90m6 days ago\u001b[39m"); - }); - - test("classifies string values by shape", () => { - expect(isUuidValue("019ee8e4-1d79-77d9-8693-1f67732b184d")).toBe(true); - expect(isTimestampValue("2026-06-21T06:35:24.409Z")).toBe(true); - expect(classifyStringDataType("mcp_tool_call")).toBe("string"); - expect(classifyStringDataType("019ee8e4-1d79-77d9-8693-1f67732b184d")).toBe("uuid"); - expect(classifyStringDataType("2026-06-21T06:35:24.409Z")).toBe("timestamp"); + expect(output).toContain("\u001b[90m 6 days ago\u001b[39m"); }); test("truncateTerminalText respects visible width with ANSI codes", () => { enableTerminalColorForTests(); - const styled = terminalAccent("abcdefghijklmnopqrstuvwxyz"); + const styled = renderDisplayText([span("abcdefghijklmnopqrstuvwxyz", "accent")]); const truncated = truncateTerminalText(styled, 10); expect(truncated).toContain("…"); expect(truncated).not.toContain("klmnopqrstuvwxyz"); @@ -229,7 +203,9 @@ describe("terminal-style", () => { test("truncateTerminalText keeps terminal hyperlinks non-printing", () => { enableTerminalColorForTests(); process.env.OSC_HYPERLINK = "1"; - const linked = terminalLink("abcdefghijklmnopqrstuvwxyz", "https://example.com"); + const linked = renderDisplayText([ + span("abcdefghijklmnopqrstuvwxyz", "accent", "https://example.com"), + ]); const truncated = truncateTerminalText(linked, 10); expect(truncated).toContain("\u001b]8;;https://example.com\u0007"); expect(truncated).toContain("\u001b]8;;\u0007"); @@ -239,60 +215,66 @@ describe("terminal-style", () => { test("counts wide characters as double width", () => { expect(getVisibleTextWidth("日本語")).toBe(6); expect(getVisibleTextWidth("abc")).toBe(3); + expect(getVisibleTextWidth("e\u0301")).toBe(1); + expect(getVisibleTextWidth("👨‍👩‍👧‍👦")).toBe(2); + expect(getVisibleTextWidth("🇫🇷")).toBe(2); + }); + + test("truncates without splitting joined emoji", () => { + const truncated = truncateTerminalText("👨‍👩‍👧‍👦abc", 4); + + expect(truncated).toBe("👨‍👩‍👧‍👦a…"); + expect(getVisibleTextWidth(truncated)).toBe(4); + + const flag = truncateTerminalText("🇫🇷abc", 4); + expect(flag).toBe("🇫🇷a…"); + expect(getVisibleTextWidth(flag)).toBe(4); }); test("padLeft indents multi-line terminal output", () => { expect(padLeft(["A\nB", "C"], " ")).toEqual([" A", " B", " C"]); }); - test("linkifies URLs and emits OSC 8 hyperlinks when supported", () => { + test("renders semantic links as OSC 8 hyperlinks when supported", () => { enableTerminalColorForTests(); process.env.OSC_HYPERLINK = "1"; const url = "https://api.altertable.ai"; - const linked = terminalUrl(url); + const linked = renderDisplayText([span(url, "accent", url)]); expect(linked).toContain("\u001b]8;;https://api.altertable.ai\u0007"); expect(linked).toContain("\u001b]8;;\u0007"); - expect(formatTerminalUrls(`Data plane: ${url}`)).toContain("\u001b]8;;"); }); - test("terminalLink returns plain label when hyperlinks are disabled", () => { + test("renders styled presentation spans and links", () => { enableTerminalColorForTests(); process.env.OSC_HYPERLINK = "0"; - expect(shouldUseTerminalHyperlinks()).toBe(false); - expect(terminalLink("docs", "https://example.com")).toBe("docs"); + const url = "https://api.altertable.ai"; + expect(renderDisplayText([span(url, "accent", url)])).toBe( + "\u001b[96mhttps://api.altertable.ai\u001b[39m", + ); + expect(renderDisplayText([span("Docs", "accent", "https://example.com")])).toContain( + "Docs\u001b[39m \u001b[90m(https://example.com)", + ); }); - test("formatTerminalMarkdownLinks renders clickable labeled links when supported", () => { - enableTerminalColorForTests(); - process.env.OSC_HYPERLINK = "1"; - const output = formatTerminalMarkdownLinks("[Docs](https://example.com)"); - - expect(output).toContain("\u001b]8;;https://example.com\u0007"); - expect(output).toContain("Docs"); - expect(output).not.toContain("[Docs]"); - }); + test("escapes terminal controls and bidirectional overrides in span text", () => { + setTerminalColorMode("never"); + const output = renderDisplayText("safe\u001b]0;spoofed\u0007\nnext\u202eright"); - test("formatTerminalMarkdownLinks falls back to label and URL without color", () => { - process.env.NO_COLOR = "1"; - expect(formatTerminalMarkdownLinks("[Docs](https://example.com)")).toBe( - "Docs (https://example.com)", - ); + expect(output).toBe("safe\\x1b]0;spoofed\\x07\\x0anext\\u202eright"); + expect(output).not.toContain("\u001b"); + expect(output).not.toContain("\u0007"); + expect(output).not.toContain("\u202e"); }); - test("linkifyUrls option decorates configure label values", () => { + test("never emits unsafe hyperlink targets", () => { enableTerminalColorForTests(); - process.env.OSC_HYPERLINK = "0"; - const line = formatTerminalLabelValue("Data plane:", "https://api.altertable.ai", { - linkifyUrls: true, - }); - expect(line).toContain("\u001b[96mhttps://api.altertable.ai\u001b[39m"); - }); + process.env.OSC_HYPERLINK = "1"; + const output = renderDisplayText([ + span("Docs", "accent", "https://example.com\u0007\u001b]0;spoofed"), + ]); - test("formatCommandExamplesSection renders EXAMPLES header", () => { - enableTerminalColorForTests(); - const section = formatCommandExamplesSection(['altertable query "SELECT 1"']); - expect(section).toContain("EXAMPLES"); - expect(section).toContain("altertable query"); - expect(section).toContain('"SELECT 1"'); + expect(output).not.toContain("\u001b]8;;"); + expect(output).not.toContain("\u0007"); + expect(output).toContain("https://example.com\\x07\\x1b]0;spoofed"); }); }); diff --git a/cli/tests/tree-layout.test.ts b/cli/tests/tree-layout.test.ts index daef87f..dd5c009 100644 --- a/cli/tests/tree-layout.test.ts +++ b/cli/tests/tree-layout.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { renderTree } from "@/ui/layouts/tree.ts"; +import { renderTree } from "@/ui/renderers/terminal.ts"; describe("tree layout", () => { test("renders nested tree branches", () => { diff --git a/cli/tests/usage.test.ts b/cli/tests/usage.test.ts index 7f2f306..86b6bdd 100644 --- a/cli/tests/usage.test.ts +++ b/cli/tests/usage.test.ts @@ -9,7 +9,8 @@ import { renderAltertableUsage, resolveSubCommandForUsage } from "@/lib/usage.ts import { configureClearAll, configureRunSet } from "@/lib/profile-configure-core.ts"; import { createCliRuntime, runWithCliRuntime, setCliRuntime } from "@/lib/runtime.ts"; import { VERSION } from "@/version.ts"; -import { getVisibleTextWidth, terminalAccent } from "@/ui/terminal/styles.ts"; +import { span } from "@/ui/document.ts"; +import { getVisibleTextWidth, renderDisplayText } from "@/ui/terminal/styles.ts"; import { forceTerminalColorForTests, restoreTerminalState, @@ -123,9 +124,11 @@ describe("renderAltertableUsage", () => { const usage = await renderAltertableUsage(command, parent); expect(usage).toContain( - ` Use ${terminalAccent("altertable profile --help")} for more information about a command.`, + ` Use ${renderDisplayText([span("altertable profile --help", "accent")])} for more information about a command.`, + ); + expect(usage).toContain( + ` ${renderDisplayText([span("altertable profile show", "accent")])}`, ); - expect(usage).toContain(` ${terminalAccent("altertable profile show")}`); } finally { restoreTerminalState(terminalState); Object.defineProperty(process.stdout, "columns", {