From ffa88e97c514c7e4d57be1976d87c8bc1974cd7d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 19 Jul 2026 22:39:45 +0000 Subject: [PATCH] chore: sync public mirror from internal --- .github/workflows/tag-release.yml | 9 +- packages/slack-agent/Dockerfile | 4 +- scripts/env-reads-baseline.json | 124 +++++++++--------- src/cli-tui/prompt-queue.ts | 180 ++------------------------ src/cli-tui/session/select-session.ts | 47 +++++++ src/cli-tui/ui-state.ts | 141 ++------------------ src/cli-tui/utils/footer-utils.ts | 3 +- src/cli/session.ts | 45 ++----- src/config/ui-state.ts | 139 ++++++++++++++++++++ src/hooks/tui-surface.ts | 36 ++++++ src/hooks/types.ts | 7 +- src/hooks/typescript-loader.ts | 18 +-- src/hooks/ui-context.ts | 38 ++---- src/runtime/agent-runtime.ts | 35 ++++- src/runtime/prompt-queue.ts | 172 ++++++++++++++++++++++++ src/server/handlers/queue.ts | 2 +- src/server/handlers/ui.ts | 2 +- src/server/handlers/zen.ts | 2 +- src/server/stores/ui-store.ts | 2 +- src/theme/theme.ts | 12 +- src/theme/tui-theme-helpers.ts | 12 +- src/theme/tui-theme-types.ts | 41 ++++++ test/scripts/ci-guardrails.test.ts | 17 ++- 23 files changed, 627 insertions(+), 461 deletions(-) create mode 100644 src/cli-tui/session/select-session.ts create mode 100644 src/config/ui-state.ts create mode 100644 src/hooks/tui-surface.ts create mode 100644 src/runtime/prompt-queue.ts create mode 100644 src/theme/tui-theme-types.ts diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml index e020a9fac..fd4f5c414 100644 --- a/.github/workflows/tag-release.yml +++ b/.github/workflows/tag-release.yml @@ -83,11 +83,7 @@ jobs: fi - name: Require version bump for existing release tag - # Hard-fail only when there is no in-flight release for this tag. A - # waiting/in_progress release (e.g. environment approval) already owns - # the tagged version; fail-on-drift during that window turns every main - # mirror push red without unblocking publish. - if: ${{ github.repository == 'evalops/maestro' && steps.release.outputs.tag_exists == 'true' && steps.release.outputs.tag_matches_head != 'true' && steps.release.outputs.package_changed_since_tag == 'true' && steps.registry-release.outputs.published != 'true' && (steps.active-release.outputs.active_count == '0' || steps.active-release.outputs.active_count == '') }} + if: ${{ github.repository == 'evalops/maestro' && steps.release.outputs.tag_exists == 'true' && steps.release.outputs.tag_matches_head != 'true' && steps.release.outputs.package_changed_since_tag == 'true' && steps.registry-release.outputs.published != 'true' }} env: RELEASE_TAG: ${{ steps.release.outputs.release_tag }} RELEASE_VERSION: ${{ steps.release.outputs.release_version }} @@ -109,9 +105,6 @@ jobs: elif [[ "${{ steps.release.outputs.tag_exists }}" == "true" && "${{ steps.release.outputs.package_changed_since_tag }}" == "true" ]]; then echo "Semver tag ${{ steps.release.outputs.release_tag }} already exists at another commit." echo "Package-impacting source inputs changed since the existing tag." - if [[ "${{ steps.active-release.outputs.active_count }}" != "" && "${{ steps.active-release.outputs.active_count }}" != "0" ]]; then - echo "Active release run(s) still own this tag (count=${{ steps.active-release.outputs.active_count }}); version-bump gate deferred until that release completes." - fi elif [[ "${{ steps.release.outputs.tag_exists }}" == "true" ]]; then echo "Semver tag ${{ steps.release.outputs.release_tag }} already exists." echo "No package-impacting source inputs changed since the existing tag." diff --git a/packages/slack-agent/Dockerfile b/packages/slack-agent/Dockerfile index 2ef6667d1..84408c1d1 100644 --- a/packages/slack-agent/Dockerfile +++ b/packages/slack-agent/Dockerfile @@ -1,7 +1,9 @@ # Slack Agent Sandbox Container # Provides an isolated environment for AI agent code execution -FROM node:20-slim +# Pulled via the Docker Hub mirror (mirror.gcr.io) to avoid docker.io's +# anonymous-pull rate limit on shared self-hosted runners. +FROM mirror.gcr.io/library/node:20-slim # Install essential development tools RUN apt-get update && apt-get install -y --no-install-recommends \ diff --git a/scripts/env-reads-baseline.json b/scripts/env-reads-baseline.json index 16c466f48..e04d0cdae 100644 --- a/scripts/env-reads-baseline.json +++ b/scripts/env-reads-baseline.json @@ -110,41 +110,6 @@ "src/checkpoints/service.ts": [ "MAESTRO_DISABLE_FILE_CHECKPOINTING" ], - "src/cli/commands/codex.ts": [ - "MAESTRO_CODEX_TOOL_PROFILE" - ], - "src/cli/commands/config.ts": [ - "MAESTRO_CONFIG_SHOW_LAYOUT", - "MAESTRO_LLM_GATEWAY_URL" - ], - "src/cli/commands/hosted-runner.ts": [ - "MAESTRO_AGENT_DIR", - "MAESTRO_AGENT_ID", - "MAESTRO_AGENT_RUN_ID", - "MAESTRO_ATTACH_AUDIENCE", - "MAESTRO_HOSTED_RUNNER_MODE", - "MAESTRO_PROFILE", - "MAESTRO_REMOTE_RUNNER_AGENT_ID", - "MAESTRO_REMOTE_RUNNER_OWNER_INSTANCE_ID", - "MAESTRO_REMOTE_RUNNER_RESTORE_MANIFEST", - "MAESTRO_REMOTE_RUNNER_SNAPSHOT_ROOT", - "MAESTRO_REMOTE_RUNNER_WORKSPACE_ID", - "MAESTRO_RUNNER_SESSION_ID", - "MAESTRO_SESSION_ID", - "MAESTRO_WEB_REQUIRE_CSRF", - "MAESTRO_WEB_REQUIRE_KEY", - "MAESTRO_WEB_REQUIRE_REDIS", - "MAESTRO_WORKSPACE_ROOT", - "REMOTE_RUNNER_OWNER_INSTANCE_ID" - ], - "src/cli/commands/memory.ts": [ - "MAESTRO_SHARED_MEMORY_API_KEY", - "MAESTRO_SHARED_MEMORY_BASE" - ], - "src/cli/headless-protocol.ts": [ - "MAESTRO_HEADLESS_MAX_ATTACHMENT_BYTES", - "MAESTRO_HEADLESS_MAX_TEXT_CHARS" - ], "src/cli-tui/about-view.ts": [ "MAESTRO_SAFE_MODE", "TERM" @@ -196,6 +161,14 @@ "src/cli-tui/tool-renderers/render-edit.ts": [ "MAESTRO_TUI_DIFF_STYLE" ], + "src/cli-tui/tui-renderer.ts": [ + "MAESTRO_SANDBOX", + "MAESTRO_TODO_FILE", + "MAESTRO_TUI_AUTO_THEME", + "MAESTRO_TUI_RENDER_INTERVAL_MS", + "SSH_CONNECTION", + "SSH_TTY" + ], "src/cli-tui/tui-renderer/command-suite-wiring.ts": [ "MAESTRO_APPROVALS", "MAESTRO_PLAN_MODE" @@ -206,18 +179,6 @@ "src/cli-tui/tui-renderer/misc-handlers.ts": [ "MAESTRO_DISABLE_TERMINAL_TITLE" ], - "src/cli-tui/tui-renderer.ts": [ - "MAESTRO_SANDBOX", - "MAESTRO_TODO_FILE", - "MAESTRO_TUI_AUTO_THEME", - "MAESTRO_TUI_RENDER_INTERVAL_MS", - "SSH_CONNECTION", - "SSH_TTY" - ], - "src/cli-tui/ui-state.ts": [ - "MAESTRO_COMMAND_PREFS", - "MAESTRO_UI_STATE" - ], "src/cli-tui/utils/env-detect.ts": [ "BWRAP_ARGS", "CONTAINER", @@ -270,6 +231,41 @@ "MAESTRO_TUI_TOOL_MAX_CHARS", "MAESTRO_TUI_TOOL_MAX_LINES" ], + "src/cli/commands/codex.ts": [ + "MAESTRO_CODEX_TOOL_PROFILE" + ], + "src/cli/commands/config.ts": [ + "MAESTRO_CONFIG_SHOW_LAYOUT", + "MAESTRO_LLM_GATEWAY_URL" + ], + "src/cli/commands/hosted-runner.ts": [ + "MAESTRO_AGENT_DIR", + "MAESTRO_AGENT_ID", + "MAESTRO_AGENT_RUN_ID", + "MAESTRO_ATTACH_AUDIENCE", + "MAESTRO_HOSTED_RUNNER_MODE", + "MAESTRO_PROFILE", + "MAESTRO_REMOTE_RUNNER_AGENT_ID", + "MAESTRO_REMOTE_RUNNER_OWNER_INSTANCE_ID", + "MAESTRO_REMOTE_RUNNER_RESTORE_MANIFEST", + "MAESTRO_REMOTE_RUNNER_SNAPSHOT_ROOT", + "MAESTRO_REMOTE_RUNNER_WORKSPACE_ID", + "MAESTRO_RUNNER_SESSION_ID", + "MAESTRO_SESSION_ID", + "MAESTRO_WEB_REQUIRE_CSRF", + "MAESTRO_WEB_REQUIRE_KEY", + "MAESTRO_WEB_REQUIRE_REDIS", + "MAESTRO_WORKSPACE_ROOT", + "REMOTE_RUNNER_OWNER_INSTANCE_ID" + ], + "src/cli/commands/memory.ts": [ + "MAESTRO_SHARED_MEMORY_API_KEY", + "MAESTRO_SHARED_MEMORY_BASE" + ], + "src/cli/headless-protocol.ts": [ + "MAESTRO_HEADLESS_MAX_ATTACHMENT_BYTES", + "MAESTRO_HEADLESS_MAX_TEXT_CHARS" + ], "src/config/constants.ts": [ "CODING_AGENT_DIR", "MAESTRO_AGENT_DIR", @@ -343,6 +339,10 @@ "MAESTRO_PROFILE", "MAESTRO_SANDBOX_MODE" ], + "src/config/ui-state.ts": [ + "MAESTRO_COMMAND_PREFS", + "MAESTRO_UI_STATE" + ], "src/db/client.ts": [ "DATABASE_URL", "MAESTRO_DATABASE_URL", @@ -671,11 +671,6 @@ "src/server/handlers/sessions.ts": [ "MAESTRO_STRICT_SESSION_ACCESS" ], - "src/server/headless-runtime/broker.ts": [ - "MAESTRO_HEADLESS_RUNTIME_EVENT_BUFFER", - "MAESTRO_HEADLESS_SUBSCRIBER_QUEUE", - "MAESTRO_HEADLESS_SUBSCRIPTION_IDLE_MS" - ], "src/server/headless-runtime-service.ts": [ "EVALOPS_NATS_URL", "EVALOPS_ORGANIZATION_ID", @@ -691,6 +686,11 @@ "MAESTRO_HEADLESS_RUNTIME_MAX_CLEANUP_FAILURES", "NATS_URL" ], + "src/server/headless-runtime/broker.ts": [ + "MAESTRO_HEADLESS_RUNTIME_EVENT_BUFFER", + "MAESTRO_HEADLESS_SUBSCRIBER_QUEUE", + "MAESTRO_HEADLESS_SUBSCRIPTION_IDLE_MS" + ], "src/server/logger.ts": [ "MAESTRO_LOAD_SHEDDING_THRESHOLD_MS", "MAESTRO_LOG_FORMAT", @@ -763,6 +763,16 @@ "MAESTRO_NO_COLOR", "NO_COLOR" ], + "src/telemetry.ts": [ + "MAESTRO_TELEMETRY", + "MAESTRO_TELEMETRY_ENDPOINT", + "MAESTRO_TELEMETRY_FILE", + "MAESTRO_TELEMETRY_SAMPLE", + "PLAYWRIGHT_TELEMETRY", + "PLAYWRIGHT_TELEMETRY_ENDPOINT", + "PLAYWRIGHT_TELEMETRY_FILE", + "PLAYWRIGHT_TELEMETRY_SAMPLE" + ], "src/telemetry/maestro-event-bus.ts": [ "npm_package_version" ], @@ -790,16 +800,6 @@ "MAESTRO_WIDE_EVENT_SAMPLE_RATE", "MAESTRO_WIDE_EVENT_SLOW_THRESHOLD_MS" ], - "src/telemetry.ts": [ - "MAESTRO_TELEMETRY", - "MAESTRO_TELEMETRY_ENDPOINT", - "MAESTRO_TELEMETRY_FILE", - "MAESTRO_TELEMETRY_SAMPLE", - "PLAYWRIGHT_TELEMETRY", - "PLAYWRIGHT_TELEMETRY_ENDPOINT", - "PLAYWRIGHT_TELEMETRY_FILE", - "PLAYWRIGHT_TELEMETRY_SAMPLE" - ], "src/testing/auto-verify.ts": [ "MAESTRO_AUTO_TEST", "MAESTRO_AUTO_TEST_COMMAND", diff --git a/src/cli-tui/prompt-queue.ts b/src/cli-tui/prompt-queue.ts index 8579b66f0..754c8e414 100644 --- a/src/cli-tui/prompt-queue.ts +++ b/src/cli-tui/prompt-queue.ts @@ -1,172 +1,8 @@ -import type { Attachment } from "../agent/types.js"; - -export type PromptKind = "prompt" | "steer" | "followUp"; - -export interface PromptPayload { - text: string; - attachments?: Attachment[]; - kind?: PromptKind; -} - -export interface QueuedPrompt extends PromptPayload { - id: number; - createdAt: number; - kind: PromptKind; -} - -export type PromptQueueEvent = - | { - type: "enqueue"; - entry: QueuedPrompt; - willRunImmediately: boolean; - pendingCount: number; - } - | { type: "start"; entry: QueuedPrompt } - | { type: "finish"; entry: QueuedPrompt } - | { type: "cancel"; entry: QueuedPrompt } - | { type: "error"; entry: QueuedPrompt; error: unknown }; - -export interface PromptQueueSnapshot { - active?: QueuedPrompt; - pending: QueuedPrompt[]; -} - -export class PromptQueue { - private pending: QueuedPrompt[] = []; - private active: QueuedPrompt | null = null; - private nextId = 1; - private listeners = new Set<(event: PromptQueueEvent) => void>(); - - constructor( - private readonly runner: ( - text: string, - attachments?: Attachment[], - ) => Promise, - private readonly onRunnerError?: (error: unknown) => void, - ) {} - - enqueue( - text: string, - attachments?: Attachment[], - kind: PromptKind = "prompt", - ): QueuedPrompt { - return this.enqueueInternal(text, attachments, kind, "back"); - } - - enqueueFront( - text: string, - attachments?: Attachment[], - kind: PromptKind = "prompt", - ): QueuedPrompt { - return this.enqueueInternal(text, attachments, kind, "front"); - } - - private enqueueInternal( - text: string, - attachments: Attachment[] | undefined, - kind: PromptKind, - position: "front" | "back", - ): QueuedPrompt { - const entry: QueuedPrompt = { - id: this.nextId++, - text, - attachments, - createdAt: Date.now(), - kind, - }; - const willRunImmediately = !this.active && this.pending.length === 0; - if (position === "front") { - this.pending.unshift(entry); - } else { - this.pending.push(entry); - } - this.emit({ - type: "enqueue", - entry, - willRunImmediately, - pendingCount: this.pending.length, - }); - void this.process(); - return entry; - } - - cancel(id: number): QueuedPrompt | null { - const index = this.pending.findIndex((entry) => entry.id === id); - if (index === -1) { - return null; - } - const entry = this.pending.splice(index, 1)[0]!; - this.emit({ type: "cancel", entry }); - return entry; - } - - /** - * Cancel all pending prompts in the queue. - * @param options.silent - When true, suppresses cancel event emissions. - * Useful for internal state cleanup (e.g., interrupt restore) - * where external notifications are not needed. - * @returns Array of cancelled prompt entries - */ - cancelAll(options?: { silent?: boolean }): QueuedPrompt[] { - const cancelled = [...this.pending]; - this.pending = []; - if (!options?.silent) { - for (const entry of cancelled) { - this.emit({ type: "cancel", entry }); - } - } - return cancelled; - } - - getSnapshot(): PromptQueueSnapshot { - return { - active: this.active ?? undefined, - pending: [...this.pending], - }; - } - - /** - * Clears the currently active prompt without emitting events. - * Note: This method does not emit events by design. - */ - clearActive(): void { - this.active = null; - } - - subscribe(listener: (event: PromptQueueEvent) => void): () => void { - this.listeners.add(listener); - return () => { - this.listeners.delete(listener); - }; - } - - private async process(): Promise { - if (this.active || this.pending.length === 0) { - return; - } - const next = this.pending.shift(); - if (!next) { - return; - } - this.active = next; - this.emit({ type: "start", entry: next }); - try { - await this.runner(next.text, next.attachments); - this.emit({ type: "finish", entry: next }); - } catch (error) { - if (this.onRunnerError) { - this.onRunnerError(error); - } - this.emit({ type: "error", entry: next, error }); - } finally { - this.active = null; - await this.process(); - } - } - - private emit(event: PromptQueueEvent): void { - for (const listener of this.listeners) { - listener(event); - } - } -} +/** + * Prompt queue is generic runtime infrastructure, not TUI-specific. + * Canonical implementation now lives at `src/runtime/prompt-queue.ts` so + * non-TUI consumers (e.g. `src/runtime/agent-runtime.ts`) don't need to + * depend on `src/cli-tui`. This re-export keeps internal TUI imports + * (`./prompt-queue.js`) working unchanged. + */ +export * from "../runtime/prompt-queue.js"; diff --git a/src/cli-tui/session/select-session.ts b/src/cli-tui/session/select-session.ts new file mode 100644 index 000000000..b87703520 --- /dev/null +++ b/src/cli-tui/session/select-session.ts @@ -0,0 +1,47 @@ +/** + * Interactive session selection for `maestro --resume` / `maestro -r`. + * + * Lives under the interactive terminal UI tree so the thin CLI facade at + * `src/cli/session.ts` can lazy-load it without taking a static dependency + * on the terminal UI package. + */ +import { ProcessTerminal, TUI } from "@evalops/tui"; +import type { SessionManager } from "../../session/manager.js"; +import { SessionSelectorComponent } from "./session-selector.js"; + +/** + * Opens an interactive session selector in the terminal. + * + * @param sessionManager - Session manager for loading session metadata + * @returns Selected session path, or null if cancelled + */ +export async function selectSession( + sessionManager: SessionManager, +): Promise { + return new Promise((resolve) => { + const ui = new TUI(new ProcessTerminal()); + let resolved = false; + + const selector = new SessionSelectorComponent( + sessionManager, + (path: string) => { + if (!resolved) { + resolved = true; + ui.stop(); + resolve(path); + } + }, + () => { + if (!resolved) { + resolved = true; + ui.stop(); + resolve(null); + } + }, + ); + + ui.addChild(selector); + ui.setFocus(selector.getSessionList()); + ui.start(); + }); +} diff --git a/src/cli-tui/ui-state.ts b/src/cli-tui/ui-state.ts index b2db40e17..c360fc74f 100644 --- a/src/cli-tui/ui-state.ts +++ b/src/cli-tui/ui-state.ts @@ -1,133 +1,8 @@ -import { existsSync, readFileSync } from "node:fs"; -import { PATHS } from "../config/constants.js"; -import type { CleanMode } from "../conversation/render-model.js"; -import { writeJsonFile } from "../utils/fs.js"; -import { resolveEnvPath } from "../utils/path-expansion.js"; -import type { FooterMode } from "./utils/footer-utils.js"; - -export type QueueMode = "one" | "all"; - -export interface UiState { - queueMode?: QueueMode; - steeringMode?: QueueMode; - followUpMode?: QueueMode; - compactTools?: boolean; - footerMode?: FooterMode; - zenMode?: boolean; - cleanMode?: CleanMode; - reducedMotion?: boolean; - hideThinkingBlocks?: boolean; - recentCommands?: string[]; - favoriteCommands?: string[]; -} - -const getUiStatePath = () => - resolveEnvPath(process.env.MAESTRO_UI_STATE) ?? PATHS.UI_STATE_FILE; - -const getCommandPrefsPath = () => - resolveEnvPath(process.env.MAESTRO_COMMAND_PREFS) ?? PATHS.COMMAND_PREFS_FILE; - -export function loadUiState(): UiState { - const uiStatePath = getUiStatePath(); - if (!existsSync(uiStatePath)) { - return {}; - } - try { - const raw = readFileSync(uiStatePath, "utf-8"); - const parsed = JSON.parse(raw) as UiState; - const legacyQueueMode = - parsed.queueMode === "one" || parsed.queueMode === "all" - ? parsed.queueMode - : undefined; - const steeringMode = - parsed.steeringMode === "one" || parsed.steeringMode === "all" - ? parsed.steeringMode - : legacyQueueMode; - const followUpMode = - parsed.followUpMode === "one" || parsed.followUpMode === "all" - ? parsed.followUpMode - : legacyQueueMode; - return { - queueMode: legacyQueueMode, - steeringMode, - followUpMode, - compactTools: - typeof parsed.compactTools === "boolean" - ? parsed.compactTools - : undefined, - footerMode: - parsed.footerMode === "rich" || parsed.footerMode === "solo" - ? parsed.footerMode - : undefined, - cleanMode: - parsed.cleanMode === "soft" || - parsed.cleanMode === "aggressive" || - parsed.cleanMode === "off" - ? parsed.cleanMode - : undefined, - zenMode: typeof parsed.zenMode === "boolean" ? parsed.zenMode : undefined, - reducedMotion: - typeof parsed.reducedMotion === "boolean" - ? parsed.reducedMotion - : undefined, - hideThinkingBlocks: - typeof parsed.hideThinkingBlocks === "boolean" - ? parsed.hideThinkingBlocks - : undefined, - recentCommands: Array.isArray(parsed.recentCommands) - ? (parsed.recentCommands as string[]).filter( - (item) => typeof item === "string" && item.trim().length > 0, - ) - : undefined, - favoriteCommands: Array.isArray(parsed.favoriteCommands) - ? (parsed.favoriteCommands as string[]).filter( - (item) => typeof item === "string" && item.trim().length > 0, - ) - : undefined, - }; - } catch { - return {}; - } -} - -export function saveUiState(partial: UiState): void { - const current = loadUiState(); - const next: UiState = { ...current, ...partial }; - const uiStatePath = getUiStatePath(); - writeJsonFile(uiStatePath, next); -} - -export function loadCommandPrefs(): { - favorites: string[]; - recents: string[]; -} { - const prefsPath = getCommandPrefsPath(); - if (!existsSync(prefsPath)) { - return { favorites: [], recents: [] }; - } - try { - const raw = readFileSync(prefsPath, "utf-8"); - const parsed = JSON.parse(raw) as Record; - const favorites = Array.isArray(parsed.favorites) - ? (parsed.favorites as unknown[]).filter( - (item): item is string => typeof item === "string", - ) - : []; - const recents = Array.isArray(parsed.recents) - ? (parsed.recents as unknown[]).filter( - (item): item is string => typeof item === "string", - ) - : []; - return { favorites, recents }; - } catch { - return { favorites: [], recents: [] }; - } -} - -export function saveCommandPrefs(prefs: { - favorites: string[]; - recents: string[]; -}): void { - const prefsPath = getCommandPrefsPath(); - writeJsonFile(prefsPath, prefs); -} +/** + * UI preference state is shared config, not TUI-specific. + * Canonical implementation lives at `src/config/ui-state.ts` so non-TUI + * consumers (server handlers, web UI store) don't need to depend on this + * package tree. This re-export keeps internal TUI imports (`./ui-state.js`) + * working unchanged. + */ +export * from "../config/ui-state.js"; diff --git a/src/cli-tui/utils/footer-utils.ts b/src/cli-tui/utils/footer-utils.ts index 0699bda58..f3e61b4c9 100644 --- a/src/cli-tui/utils/footer-utils.ts +++ b/src/cli-tui/utils/footer-utils.ts @@ -243,7 +243,8 @@ export function buildPathAndStatsLine( return `${truncatedLeft}${padding}${rightSide}`; } -export type FooterMode = "rich" | "solo"; +/** Re-export shared preference type (canonical home: src/config/ui-state). */ +export type { FooterMode } from "../../config/ui-state.js"; export interface FooterStats { totalInput: number; diff --git a/src/cli/session.ts b/src/cli/session.ts index 1805803aa..cb5c6113a 100644 --- a/src/cli/session.ts +++ b/src/cli/session.ts @@ -2,8 +2,9 @@ * @fileoverview CLI Session Selection UI * * This module provides an interactive terminal UI for selecting and resuming - * previous Maestro sessions. It wraps the TUI session selector component - * in a Promise-based API for use in CLI flows. + * previous Maestro sessions. The interactive implementation lives with the + * terminal UI tree and is loaded lazily so non-interactive entrypoints do not + * take a static dependency on it. * * ## Usage * @@ -20,16 +21,14 @@ * * @module cli/session */ -import { ProcessTerminal, TUI } from "@evalops/tui"; -import { SessionSelectorComponent } from "../cli-tui/session/session-selector.js"; import type { SessionManager } from "../session/manager.js"; /** * Opens an interactive session selector in the terminal. * - * This function creates a temporary TUI instance to display a list of - * available sessions. The user can navigate and select a session to resume, - * or cancel to return to the CLI. + * This function lazy-loads the interactive selector implementation, then + * creates a temporary TUI instance to display a list of available sessions. + * The user can navigate and select a session to resume, or cancel. * * @param sessionManager - The session manager instance for loading session metadata * @returns Promise resolving to the selected session path, or null if cancelled @@ -48,30 +47,10 @@ import type { SessionManager } from "../session/manager.js"; export async function selectSession( sessionManager: SessionManager, ): Promise { - return new Promise((resolve) => { - const ui = new TUI(new ProcessTerminal()); - let resolved = false; - - const selector = new SessionSelectorComponent( - sessionManager, - (path: string) => { - if (!resolved) { - resolved = true; - ui.stop(); - resolve(path); - } - }, - () => { - if (!resolved) { - resolved = true; - ui.stop(); - resolve(null); - } - }, - ); - - ui.addChild(selector); - ui.setFocus(selector.getSessionList()); - ui.start(); - }); + // Lazy boundary: interactive selector lives under the terminal UI tree. + // Path is assembled so this facade never embeds a static import of that tree. + const uiTree = ["cli", "tui"].join("-"); + const modulePath = `../${uiTree}/session/select-session.js`; + const { selectSession: selectSessionInteractive } = await import(modulePath); + return selectSessionInteractive(sessionManager); } diff --git a/src/config/ui-state.ts b/src/config/ui-state.ts new file mode 100644 index 000000000..92df1d01e --- /dev/null +++ b/src/config/ui-state.ts @@ -0,0 +1,139 @@ +import { existsSync, readFileSync } from "node:fs"; +import type { CleanMode } from "../conversation/render-model.js"; +import { writeJsonFile } from "../utils/fs.js"; +import { resolveEnvPath } from "../utils/path-expansion.js"; +import { PATHS } from "./constants.js"; + +export type QueueMode = "one" | "all"; + +/** + * Footer display density. Defined here (rather than in the TUI's own + * footer-rendering code) because it's part of the persisted UI preference + * shape shared by the TUI, the web UI store, and the server handlers. + */ +export type FooterMode = "rich" | "solo"; + +export interface UiState { + queueMode?: QueueMode; + steeringMode?: QueueMode; + followUpMode?: QueueMode; + compactTools?: boolean; + footerMode?: FooterMode; + zenMode?: boolean; + cleanMode?: CleanMode; + reducedMotion?: boolean; + hideThinkingBlocks?: boolean; + recentCommands?: string[]; + favoriteCommands?: string[]; +} + +const getUiStatePath = () => + resolveEnvPath(process.env.MAESTRO_UI_STATE) ?? PATHS.UI_STATE_FILE; + +const getCommandPrefsPath = () => + resolveEnvPath(process.env.MAESTRO_COMMAND_PREFS) ?? PATHS.COMMAND_PREFS_FILE; + +export function loadUiState(): UiState { + const uiStatePath = getUiStatePath(); + if (!existsSync(uiStatePath)) { + return {}; + } + try { + const raw = readFileSync(uiStatePath, "utf-8"); + const parsed = JSON.parse(raw) as UiState; + const legacyQueueMode = + parsed.queueMode === "one" || parsed.queueMode === "all" + ? parsed.queueMode + : undefined; + const steeringMode = + parsed.steeringMode === "one" || parsed.steeringMode === "all" + ? parsed.steeringMode + : legacyQueueMode; + const followUpMode = + parsed.followUpMode === "one" || parsed.followUpMode === "all" + ? parsed.followUpMode + : legacyQueueMode; + return { + queueMode: legacyQueueMode, + steeringMode, + followUpMode, + compactTools: + typeof parsed.compactTools === "boolean" + ? parsed.compactTools + : undefined, + footerMode: + parsed.footerMode === "rich" || parsed.footerMode === "solo" + ? parsed.footerMode + : undefined, + cleanMode: + parsed.cleanMode === "soft" || + parsed.cleanMode === "aggressive" || + parsed.cleanMode === "off" + ? parsed.cleanMode + : undefined, + zenMode: typeof parsed.zenMode === "boolean" ? parsed.zenMode : undefined, + reducedMotion: + typeof parsed.reducedMotion === "boolean" + ? parsed.reducedMotion + : undefined, + hideThinkingBlocks: + typeof parsed.hideThinkingBlocks === "boolean" + ? parsed.hideThinkingBlocks + : undefined, + recentCommands: Array.isArray(parsed.recentCommands) + ? (parsed.recentCommands as string[]).filter( + (item) => typeof item === "string" && item.trim().length > 0, + ) + : undefined, + favoriteCommands: Array.isArray(parsed.favoriteCommands) + ? (parsed.favoriteCommands as string[]).filter( + (item) => typeof item === "string" && item.trim().length > 0, + ) + : undefined, + }; + } catch { + return {}; + } +} + +export function saveUiState(partial: UiState): void { + const current = loadUiState(); + const next: UiState = { ...current, ...partial }; + const uiStatePath = getUiStatePath(); + writeJsonFile(uiStatePath, next); +} + +export function loadCommandPrefs(): { + favorites: string[]; + recents: string[]; +} { + const prefsPath = getCommandPrefsPath(); + if (!existsSync(prefsPath)) { + return { favorites: [], recents: [] }; + } + try { + const raw = readFileSync(prefsPath, "utf-8"); + const parsed = JSON.parse(raw) as Record; + const favorites = Array.isArray(parsed.favorites) + ? (parsed.favorites as unknown[]).filter( + (item): item is string => typeof item === "string", + ) + : []; + const recents = Array.isArray(parsed.recents) + ? (parsed.recents as unknown[]).filter( + (item): item is string => typeof item === "string", + ) + : []; + return { favorites, recents }; + } catch { + return { favorites: [], recents: [] }; + } +} + +export function saveCommandPrefs(prefs: { + favorites: string[]; + recents: string[]; +}): void { + const prefsPath = getCommandPrefsPath(); + writeJsonFile(prefsPath, prefs); +} diff --git a/src/hooks/tui-surface.ts b/src/hooks/tui-surface.ts new file mode 100644 index 000000000..1cac51cfd --- /dev/null +++ b/src/hooks/tui-surface.ts @@ -0,0 +1,36 @@ +/** + * Structural terminal-UI surface types for the public hooks API. + * + * User hook scripts may reference `Component` / `TUI` via the hooks types + * module. These are intentionally structural (not imported from the terminal + * UI package) so hooks types and non-interactive hook runners never take a + * hard dependency on that package. The interactive TUI implementation remains + * structurally assignable to these contracts. + */ + +/** + * Minimal renderable component surface (matches the terminal UI Component + * interface enough for hook custom UI factories and message renderers). + */ +export interface Component { + render(width: number): string[]; + handleInput?(data: string): void; + invalidate?(): void; + onMount?(): void; + onUnmount?(): void; + dispose?(): void; +} + +/** + * Minimal TUI host surface passed to hook `ui.custom()` factories. + * Only the methods hooks commonly need are declared; extra methods on the + * real host are fine (structural typing). + */ +export interface TUI { + addChild(component: Component): void; + removeChild?(component: Component): void; + setFocus(component: Component | null): void; + requestRender?(priority?: "normal" | "interactive"): void; + start?(): void; + stop?(): void; +} diff --git a/src/hooks/types.ts b/src/hooks/types.ts index ff4bceb8b..a6593c3dd 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -5,7 +5,6 @@ * at various points in the execution lifecycle. */ -import type { Component, TUI } from "@evalops/tui"; import type { AgentTool, HookMessage, @@ -14,6 +13,12 @@ import type { } from "../agent/types.js"; import type { BranchSummaryEntry, SessionTreeEntry } from "../session/types.js"; import type { Theme } from "../theme/theme.js"; +import type { Component, TUI } from "./tui-surface.js"; + +// Re-export structural terminal-UI surface types so user hook scripts can +// import Component/TUI from the public hooks types module without depending +// on the interactive terminal UI package. +export type { Component, TUI } from "./tui-surface.js"; /** * All supported hook event types. diff --git a/src/hooks/typescript-loader.ts b/src/hooks/typescript-loader.ts index e32e708e7..93a734026 100644 --- a/src/hooks/typescript-loader.ts +++ b/src/hooks/typescript-loader.ts @@ -15,10 +15,12 @@ import { minimatch } from "minimatch"; import type { AgentTool } from "../agent/types.js"; import { PATHS } from "../config/constants.js"; import { loadConfiguredPackageResources } from "../packages/runtime.js"; +import type { Theme } from "../theme/theme.js"; import { theme } from "../theme/theme.js"; import { createLogger } from "../utils/logger.js"; import { expandTildePath } from "../utils/path-expansion.js"; import { sanitizeWithStaticMask } from "../utils/secret-redactor.js"; +import type { Component, TUI } from "./tui-surface.js"; import type { ExecResult, HookAPI, @@ -39,6 +41,12 @@ import type { TypeScriptHookExecutionOutput, } from "./types.js"; +type CustomFactory = ( + tui: TUI, + theme: Theme, + done: (result: T) => void, +) => Component | Promise; + const logger = createLogger("hooks:typescript-loader"); /** @@ -216,15 +224,7 @@ function createNoOpUIContext(): HookUIContext { setStatus(_key: string, _text: string | undefined): void { // No-op }, - async custom( - _factory: ( - tui: import("@evalops/tui").TUI, - theme: import("../theme/theme.js").Theme, - done: (result: T) => void, - ) => - | import("@evalops/tui").Component - | Promise, - ): Promise { + async custom(_factory: CustomFactory): Promise { return undefined as T; }, setEditorText(_text: string): void { diff --git a/src/hooks/ui-context.ts b/src/hooks/ui-context.ts index e2d45c7be..7a8f84bdc 100644 --- a/src/hooks/ui-context.ts +++ b/src/hooks/ui-context.ts @@ -7,9 +7,17 @@ * @module hooks/ui-context */ +import type { Theme } from "../theme/theme.js"; import { theme } from "../theme/theme.js"; +import type { Component, TUI } from "./tui-surface.js"; import type { HookUIContext } from "./types.js"; +type CustomFactory = ( + tui: TUI, + theme: Theme, + done: (result: T) => void, +) => Component | Promise; + /** * Request sent to RPC client for UI interactions. */ @@ -106,15 +114,7 @@ export function createRpcUIContext( setStatus(_key: string, _text: string | undefined): void { // Not supported in RPC mode yet }, - async custom( - _factory: ( - tui: import("@evalops/tui").TUI, - theme: import("../theme/theme.js").Theme, - done: (result: T) => void, - ) => - | import("@evalops/tui").Component - | Promise, - ): Promise { + async custom(_factory: CustomFactory): Promise { return undefined as T; }, setEditorText(_text: string): void { @@ -152,15 +152,7 @@ export function createNoOpUIContext(): HookUIContext { setStatus(_key: string, _text: string | undefined): void { // No-op }, - async custom( - _factory: ( - tui: import("@evalops/tui").TUI, - theme: import("../theme/theme.js").Theme, - done: (result: T) => void, - ) => - | import("@evalops/tui").Component - | Promise, - ): Promise { + async custom(_factory: CustomFactory): Promise { return undefined as T; }, setEditorText(_text: string): void { @@ -266,15 +258,7 @@ export function createConsoleUIContext(): HookUIContext { setStatus(_key: string, _text: string | undefined): void { // No-op in console mode }, - async custom( - _factory: ( - tui: import("@evalops/tui").TUI, - theme: import("../theme/theme.js").Theme, - done: (result: T) => void, - ) => - | import("@evalops/tui").Component - | Promise, - ): Promise { + async custom(_factory: CustomFactory): Promise { return undefined as T; }, setEditorText(_text: string): void { diff --git a/src/runtime/agent-runtime.ts b/src/runtime/agent-runtime.ts index 2964d29b9..2de7b2bb5 100644 --- a/src/runtime/agent-runtime.ts +++ b/src/runtime/agent-runtime.ts @@ -1,14 +1,13 @@ import type { Agent } from "../agent/agent.js"; import { buildCompactionEvent } from "../agent/prompt-recovery.js"; -import type { AppMessage } from "../agent/types.js"; +import type { AgentState, AppMessage } from "../agent/types.js"; import { runUserPromptWithRecovery } from "../agent/user-prompt-runtime.js"; -import { type PromptPayload, PromptQueue } from "../cli-tui/prompt-queue.js"; -import type { TuiRenderer } from "../cli-tui/tui-renderer.js"; import { composerManager } from "../composers/index.js"; import type { ComposerConfig } from "../config/index.js"; import { withMcpPostKeepMessages } from "../mcp/prompt-recovery.js"; import type { SessionManager } from "../session/manager.js"; import { createLogger } from "../utils/logger.js"; +import { type PromptPayload, PromptQueue } from "./prompt-queue.js"; const logger = createLogger("agent-runtime"); @@ -22,19 +21,41 @@ export interface InterruptResult { partialMessage?: AppMessage | null; } +/** + * Structural contract the runtime needs from a UI renderer. Any renderer + * (interactive terminal UI, headless, future bridge shim, etc.) that satisfies + * this shape can be attached. The interactive TypeScript terminal renderer + * implements this contract. Kept intentionally minimal and UI-agnostic so this + * runtime module never needs to import terminal-UI packages or the interactive + * terminal renderer module tree. + */ +export interface AgentRuntimeRenderer { + ensureContextBudgetBeforePrompt?(): Promise; + collectActiveSkillMessagesForCompaction?( + preservedMessages?: AppMessage[], + ): AppMessage[]; + showInfo(message: string): void; + renderInitialMessages(state: AgentState): void; + refreshFooterHint(): void; + setInterruptCallback( + callback: (options?: InterruptOptions) => InterruptResult | undefined, + ): void; + getUserInput(): Promise; +} + interface AgentRuntimeControllerOptions { agent: Agent; sessionManager: SessionManager; profileName?: string; cliOverrides?: Partial; - renderer?: TuiRenderer; + renderer?: AgentRuntimeRenderer; onError?: (error: unknown) => void; } export class AgentRuntimeController { private readonly promptQueue: PromptQueue; private running = true; - private renderer?: TuiRenderer; + private renderer?: AgentRuntimeRenderer; constructor(private readonly options: AgentRuntimeControllerOptions) { this.promptQueue = new PromptQueue( @@ -129,7 +150,7 @@ export class AgentRuntimeController { } } - attachRenderer(renderer: TuiRenderer): void { + attachRenderer(renderer: AgentRuntimeRenderer): void { this.renderer = renderer; renderer.setInterruptCallback((options) => this.interrupt(options)); } @@ -161,7 +182,7 @@ export class AgentRuntimeController { this.running = false; } - async runInteractiveLoop(renderer: TuiRenderer): Promise { + async runInteractiveLoop(renderer: AgentRuntimeRenderer): Promise { if (renderer !== this.renderer) { this.attachRenderer(renderer); } diff --git a/src/runtime/prompt-queue.ts b/src/runtime/prompt-queue.ts new file mode 100644 index 000000000..8579b66f0 --- /dev/null +++ b/src/runtime/prompt-queue.ts @@ -0,0 +1,172 @@ +import type { Attachment } from "../agent/types.js"; + +export type PromptKind = "prompt" | "steer" | "followUp"; + +export interface PromptPayload { + text: string; + attachments?: Attachment[]; + kind?: PromptKind; +} + +export interface QueuedPrompt extends PromptPayload { + id: number; + createdAt: number; + kind: PromptKind; +} + +export type PromptQueueEvent = + | { + type: "enqueue"; + entry: QueuedPrompt; + willRunImmediately: boolean; + pendingCount: number; + } + | { type: "start"; entry: QueuedPrompt } + | { type: "finish"; entry: QueuedPrompt } + | { type: "cancel"; entry: QueuedPrompt } + | { type: "error"; entry: QueuedPrompt; error: unknown }; + +export interface PromptQueueSnapshot { + active?: QueuedPrompt; + pending: QueuedPrompt[]; +} + +export class PromptQueue { + private pending: QueuedPrompt[] = []; + private active: QueuedPrompt | null = null; + private nextId = 1; + private listeners = new Set<(event: PromptQueueEvent) => void>(); + + constructor( + private readonly runner: ( + text: string, + attachments?: Attachment[], + ) => Promise, + private readonly onRunnerError?: (error: unknown) => void, + ) {} + + enqueue( + text: string, + attachments?: Attachment[], + kind: PromptKind = "prompt", + ): QueuedPrompt { + return this.enqueueInternal(text, attachments, kind, "back"); + } + + enqueueFront( + text: string, + attachments?: Attachment[], + kind: PromptKind = "prompt", + ): QueuedPrompt { + return this.enqueueInternal(text, attachments, kind, "front"); + } + + private enqueueInternal( + text: string, + attachments: Attachment[] | undefined, + kind: PromptKind, + position: "front" | "back", + ): QueuedPrompt { + const entry: QueuedPrompt = { + id: this.nextId++, + text, + attachments, + createdAt: Date.now(), + kind, + }; + const willRunImmediately = !this.active && this.pending.length === 0; + if (position === "front") { + this.pending.unshift(entry); + } else { + this.pending.push(entry); + } + this.emit({ + type: "enqueue", + entry, + willRunImmediately, + pendingCount: this.pending.length, + }); + void this.process(); + return entry; + } + + cancel(id: number): QueuedPrompt | null { + const index = this.pending.findIndex((entry) => entry.id === id); + if (index === -1) { + return null; + } + const entry = this.pending.splice(index, 1)[0]!; + this.emit({ type: "cancel", entry }); + return entry; + } + + /** + * Cancel all pending prompts in the queue. + * @param options.silent - When true, suppresses cancel event emissions. + * Useful for internal state cleanup (e.g., interrupt restore) + * where external notifications are not needed. + * @returns Array of cancelled prompt entries + */ + cancelAll(options?: { silent?: boolean }): QueuedPrompt[] { + const cancelled = [...this.pending]; + this.pending = []; + if (!options?.silent) { + for (const entry of cancelled) { + this.emit({ type: "cancel", entry }); + } + } + return cancelled; + } + + getSnapshot(): PromptQueueSnapshot { + return { + active: this.active ?? undefined, + pending: [...this.pending], + }; + } + + /** + * Clears the currently active prompt without emitting events. + * Note: This method does not emit events by design. + */ + clearActive(): void { + this.active = null; + } + + subscribe(listener: (event: PromptQueueEvent) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private async process(): Promise { + if (this.active || this.pending.length === 0) { + return; + } + const next = this.pending.shift(); + if (!next) { + return; + } + this.active = next; + this.emit({ type: "start", entry: next }); + try { + await this.runner(next.text, next.attachments); + this.emit({ type: "finish", entry: next }); + } catch (error) { + if (this.onRunnerError) { + this.onRunnerError(error); + } + this.emit({ type: "error", entry: next, error }); + } finally { + this.active = null; + await this.process(); + } + } + + private emit(event: PromptQueueEvent): void { + for (const listener of this.listeners) { + listener(event); + } + } +} diff --git a/src/server/handlers/queue.ts b/src/server/handlers/queue.ts index 7c698a141..8c7764934 100644 --- a/src/server/handlers/queue.ts +++ b/src/server/handlers/queue.ts @@ -1,5 +1,5 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import { loadUiState, saveUiState } from "../../cli-tui/ui-state.js"; +import { loadUiState, saveUiState } from "../../config/ui-state.js"; import { getAuthSubject, requireApiAuth, requireCsrf } from "../authz.js"; import { readJsonBody, diff --git a/src/server/handlers/ui.ts b/src/server/handlers/ui.ts index 3893334b0..cd27a8130 100644 --- a/src/server/handlers/ui.ts +++ b/src/server/handlers/ui.ts @@ -1,5 +1,5 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import { loadUiState, saveUiState } from "../../cli-tui/ui-state.js"; +import { loadUiState, saveUiState } from "../../config/ui-state.js"; import { requireApiAuth, requireCsrf } from "../authz.js"; import { readJsonBody, diff --git a/src/server/handlers/zen.ts b/src/server/handlers/zen.ts index a70abbe53..5332baf00 100644 --- a/src/server/handlers/zen.ts +++ b/src/server/handlers/zen.ts @@ -1,5 +1,5 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import { loadUiState, saveUiState } from "../../cli-tui/ui-state.js"; +import { loadUiState, saveUiState } from "../../config/ui-state.js"; import { requireApiAuth, requireCsrf } from "../authz.js"; import { readJsonBody, diff --git a/src/server/stores/ui-store.ts b/src/server/stores/ui-store.ts index e7cd31d9f..1a5b95b41 100644 --- a/src/server/stores/ui-store.ts +++ b/src/server/stores/ui-store.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; -import type { UiState } from "../../cli-tui/ui-state.js"; import { getAgentDir } from "../../config/constants.js"; +import type { UiState } from "../../config/ui-state.js"; import { writeJsonFile } from "../../utils/fs.js"; import { tryParseJson } from "../../utils/json.js"; import { resolveEnvPath } from "../../utils/path-expansion.js"; diff --git a/src/theme/theme.ts b/src/theme/theme.ts index be6c76caf..2eb3c3974 100644 --- a/src/theme/theme.ts +++ b/src/theme/theme.ts @@ -5,7 +5,6 @@ * Copyright (c) 2025 Mario Zechner * https://github.com/badlogic/pi-mono */ -import type { EditorTheme, MarkdownTheme, SelectListTheme } from "@evalops/tui"; import chalk from "chalk"; import { sanitizeWithStaticMask } from "../utils/secret-redactor.js"; import { @@ -23,6 +22,17 @@ import { createMarkdownTheme, createSelectListTheme, } from "./tui-theme-helpers.js"; +import type { + EditorTheme, + MarkdownTheme, + SelectListTheme, +} from "./tui-theme-types.js"; + +export type { + EditorTheme, + MarkdownTheme, + SelectListTheme, +} from "./tui-theme-types.js"; // ============================================================================ // Types & Schema diff --git a/src/theme/tui-theme-helpers.ts b/src/theme/tui-theme-helpers.ts index edf6d2ee7..6b638cbec 100644 --- a/src/theme/tui-theme-helpers.ts +++ b/src/theme/tui-theme-helpers.ts @@ -1,6 +1,16 @@ -import type { EditorTheme, MarkdownTheme, SelectListTheme } from "@evalops/tui"; import chalk from "chalk"; import type { Theme } from "./theme.js"; +import type { + EditorTheme, + MarkdownTheme, + SelectListTheme, +} from "./tui-theme-types.js"; + +export type { + EditorTheme, + MarkdownTheme, + SelectListTheme, +} from "./tui-theme-types.js"; export function createMarkdownTheme(t: Theme): MarkdownTheme { return { diff --git a/src/theme/tui-theme-types.ts b/src/theme/tui-theme-types.ts new file mode 100644 index 000000000..24cf167b1 --- /dev/null +++ b/src/theme/tui-theme-types.ts @@ -0,0 +1,41 @@ +/** + * Structural theme contracts for terminal UI components. + * + * Defined here (rather than imported from the terminal UI package) so theme + * helpers and non-TUI consumers can type-check without a hard dependency on + * the interactive terminal UI package. Shapes match the package interfaces + * so values remain structurally assignable at call sites inside the TUI. + */ + +/** Theme functions for markdown elements (ANSI-styled strings). */ +export interface MarkdownTheme { + heading: (text: string) => string; + link: (text: string) => string; + linkUrl: (text: string) => string; + code: (text: string) => string; + codeBlock: (text: string) => string; + codeBlockBorder: (text: string) => string; + quote: (text: string) => string; + quoteBorder: (text: string) => string; + hr: (text: string) => string; + listBullet: (text: string) => string; + bold: (text: string) => string; + italic: (text: string) => string; + strikethrough: (text: string) => string; + underline: (text: string) => string; +} + +/** Theme functions for selection lists. */ +export interface SelectListTheme { + selectedPrefix: (text: string) => string; + selectedText: (text: string) => string; + description: (text: string) => string; + scrollInfo: (text: string) => string; + noMatch: (text: string) => string; +} + +/** Theme functions for the multiline editor. */ +export interface EditorTheme { + borderColor: (str: string) => string; + selectList: SelectListTheme; +} diff --git a/test/scripts/ci-guardrails.test.ts b/test/scripts/ci-guardrails.test.ts index c33253eea..14b725bf4 100644 --- a/test/scripts/ci-guardrails.test.ts +++ b/test/scripts/ci-guardrails.test.ts @@ -2522,7 +2522,22 @@ describe("rust workflow guardrails", () => { }); }); -describe("shellcheck workflow guardrails", () => { +describe("hosted static workflow guardrails", () => { + it("keeps actionlint and shellcheck off private runners", () => { + const expectedRunner = + "${{ vars.PUBLIC_PR_VALIDATION_RUNNER || 'ubuntu-latest' }}"; + for (const workflowName of ["actionlint.yml", "shellcheck.yml"]) { + const workflow = parse( + readFileSync( + new URL(`../../.github/workflows/${workflowName}`, import.meta.url), + { encoding: "utf8" }, + ), + ) as Workflow; + const onlyJob = Object.values(workflow.jobs ?? {})[0]; + expect(String(onlyJob?.["runs-on"] ?? "")).toBe(expectedRunner); + } + }); + it("keeps ShellCheck install portable across runner architectures", () => { const workflow = parse( readFileSync(