From 04cb4e99fad17fdff4b33e29faf6cc4623430ea2 Mon Sep 17 00:00:00 2001 From: Chase J <54216608+chajac@users.noreply.github.com> Date: Mon, 14 Sep 2026 03:37:48 +0100 Subject: [PATCH 1/3] feat(flows): open an interactive list at a terminal --- .changeset/flows-list-interactive-default.md | 5 + skills/qawolf-cli/SKILL.md | 8 + src/commands/__snapshots__/help.test.ts.snap | 4 + src/commands/flows/index.test.ts | 9 +- src/commands/flows/index.ts | 130 +--------------- src/commands/flows/list.interactive.test.ts | 41 +++++ src/commands/flows/list.register.ts | 142 ++++++++++++++++++ src/commands/flows/terminalListView.test.ts | 40 +++++ src/commands/flows/terminalListView.ts | 51 +++++++ src/commands/qawolfCliSkill.template.md | 8 + src/core/messages/flows.ts | 86 ++--------- src/core/messages/flowsPull.ts | 74 +++++++++ src/domains/flows/filterFlows.test.ts | 103 +++++++++++++ src/domains/flows/filterFlows.ts | 55 +++++++ src/domains/flows/fitListTable.test.ts | 88 +++++++++++ src/domains/flows/fitListTable.ts | 102 +++++++++++++ src/domains/flows/list.human.test.ts | 87 ++++++++++- src/domains/flows/list.resizing.test.ts | 55 +++++++ src/domains/flows/list.ts | 9 +- .../flows/listRemote.interactive.test.ts | 66 ++++++++ src/domains/flows/listRemote.ts | 9 +- src/domains/flows/listView.test.ts | 35 +++++ src/domains/flows/listView.ts | 20 ++- src/domains/flows/renderListTable.ts | 2 +- src/shell/commandContext.testUtils.ts | 22 +++ src/shell/ui/createUi.ts | 2 + src/shell/ui/types.ts | 3 +- 27 files changed, 1050 insertions(+), 206 deletions(-) create mode 100644 .changeset/flows-list-interactive-default.md create mode 100644 src/commands/flows/list.interactive.test.ts create mode 100644 src/commands/flows/list.register.ts create mode 100644 src/commands/flows/terminalListView.test.ts create mode 100644 src/commands/flows/terminalListView.ts create mode 100644 src/core/messages/flowsPull.ts create mode 100644 src/domains/flows/filterFlows.test.ts create mode 100644 src/domains/flows/filterFlows.ts create mode 100644 src/domains/flows/fitListTable.test.ts create mode 100644 src/domains/flows/fitListTable.ts create mode 100644 src/domains/flows/list.resizing.test.ts create mode 100644 src/domains/flows/listRemote.interactive.test.ts create mode 100644 src/domains/flows/listView.test.ts diff --git a/.changeset/flows-list-interactive-default.md b/.changeset/flows-list-interactive-default.md new file mode 100644 index 000000000..88e8a421d --- /dev/null +++ b/.changeset/flows-list-interactive-default.md @@ -0,0 +1,5 @@ +--- +"@qawolf/cli": minor +--- + +Open a searchable flow table by default at an interactive terminal. Type to filter by name, path, target, environment, or tag; press Enter to print matches or Esc to leave. Use --no-interactive for printed output. Explicit --interactive is rejected before remote authentication when the terminal cannot support it. diff --git a/skills/qawolf-cli/SKILL.md b/skills/qawolf-cli/SKILL.md index 3a059b2fe..6c8ce37ee 100644 --- a/skills/qawolf-cli/SKILL.md +++ b/skills/qawolf-cli/SKILL.md @@ -118,6 +118,14 @@ commit, then read the flow's `flowId` and `url` from `qawolf --json flows list --remote --env --include-drafts`. Send that `url`; never guess a route and never send a repository link in its place. +## Flow lists + +At an interactive terminal, `qawolf flows list` opens a searchable table. +Search by flow name, path, target, environment, or tag; press Enter to print the +matches or Esc to leave. Use `--no-interactive` to print directly. Agents and +JSON output print directly by default; `-i` requires an interactive terminal. +Pulled flows include cached IDs in JSON when the last pull recorded them. + ## Commands diff --git a/src/commands/__snapshots__/help.test.ts.snap b/src/commands/__snapshots__/help.test.ts.snap index 9be8beefd..0be327be1 100644 --- a/src/commands/__snapshots__/help.test.ts.snap +++ b/src/commands/__snapshots__/help.test.ts.snap @@ -248,6 +248,9 @@ Options: --tag Only list flows carrying this tag; repeat for several. Without --remote, matches against tags cached by the last pull (default: []) + -i, --interactive Open the flow table to filter as you type; the + default at a terminal + --no-interactive Print the flow table instead of opening it --ai-task-id List the flows on this AI task's branch, including drafts, instead of the ones in the environment (requires --remote) (env: QAWOLF_AI_TASK_ID) @@ -255,6 +258,7 @@ Options: Examples: $ qawolf flows list + $ qawolf flows list --no-interactive $ qawolf flows list "flows/checkout/**" $ qawolf flows list --remote --env staging $ qawolf flows list --tag auth diff --git a/src/commands/flows/index.test.ts b/src/commands/flows/index.test.ts index 848d91c21..0a4f6021a 100644 --- a/src/commands/flows/index.test.ts +++ b/src/commands/flows/index.test.ts @@ -41,7 +41,14 @@ async function runList(args: string[]): Promise { spyOn(process.stdout, "write").mockImplementation(capture); spyOn(process.stderr, "write").mockImplementation(capture); - await makeProgram().parseAsync(["flows", "list", ...args], { from: "user" }); + // Printed, never the interactive table: this runs through a real context, + // and from a developer's terminal the default would open a prompt and wait. + await makeProgram().parseAsync( + ["flows", "list", "--no-interactive", ...args], + { + from: "user", + }, + ); return writes.join(""); } diff --git a/src/commands/flows/index.ts b/src/commands/flows/index.ts index dbee2e4a7..2b3b4cc03 100644 --- a/src/commands/flows/index.ts +++ b/src/commands/flows/index.ts @@ -1,47 +1,20 @@ -import { Option, type Command } from "commander"; +import type { Command } from "commander"; -import { declareCommandKind } from "~/commands/commandKind.js"; -import { withContext } from "~/commands/context.js"; -import { flowsMessages } from "~/core/messages/index.js"; -import { collectValue } from "~/domains/runner/runFlagParsers.js"; import type { SignalRegistry } from "~/shell/signals/createSignalRegistry.js"; -import { handleFlowsList } from "~/domains/flows/listDefaults.js"; -import { flowsListRemote } from "~/domains/flows/listRemote.js"; +import { + type ListCommandDeps, + registerFlowsListCommand, +} from "./list.register.js"; import { registerFlowsPullCommand } from "./pull.register.js"; import { registerFlowsRunCommand } from "./run.register.js"; import { registerRunWorkerCommand } from "./runWorker.register.js"; import { withResolvedEnv } from "./withResolvedEnv.js"; -const listExamples = ` -Examples: - $ qawolf flows list - $ qawolf flows list "flows/checkout/**" - $ qawolf flows list --remote --env staging - $ qawolf flows list --tag auth - $ qawolf flows list --env staging --tag auth - $ qawolf flows list --remote --env staging --tag auth --tag smoke - $ qawolf flows list "**/checkout/**" --remote --env staging --include-drafts - $ qawolf flows list --remote --env staging --ai-task-id ait_123`; - -type FlowsListOptions = { - readonly remote: boolean; - readonly env: string | undefined; - readonly includeDrafts: boolean; - readonly aiTaskId: string | undefined; - readonly tag: string[]; -}; - -type Deps = { - // The remote listing resolves its environment (and its auth) through this. - // A test stands in its own to drive the command without a platform. - readonly withResolvedEnv: typeof withResolvedEnv; -}; - export function registerFlowsCommand( program: Command, signals: SignalRegistry, - deps: Deps = { withResolvedEnv }, + deps: ListCommandDeps = { withResolvedEnv }, ): void { const flows = program .command("flows") @@ -49,95 +22,6 @@ export function registerFlowsCommand( registerFlowsRunCommand(flows, signals); registerRunWorkerCommand(flows, signals); - - declareCommandKind(flows.command("list [pattern]"), "local", { - kindNote: "read with --remote", - }) - .description( - "List flows matching [pattern] from the local project, or from a QA Wolf environment with --remote", - ) - .option( - "--remote", - "List flows from the QA Wolf platform instead of the local project", - false, - ) - .option( - "--env ", - "Environment to list flows from: a QA Wolf environment with --remote, otherwise a pulled one by slug or id", - ) - .option( - "--include-drafts", - "Include draft flows in the listing (requires --remote)", - false, - ) - .option( - "--tag ", - "Only list flows carrying this tag; repeat for several. Without --remote, matches against tags cached by the last pull", - collectValue, - [], - ) - .addOption( - new Option( - "--ai-task-id ", - "List the flows on this AI task's branch, including drafts, instead of the ones in the environment (requires --remote)", - ).env("QAWOLF_AI_TASK_ID"), - ) - .addHelpText("after", listExamples) - .action( - ( - pattern: string | undefined, - opts: FlowsListOptions, - command: Command, - ) => { - const tags = opts.tag; - if (opts.remote) { - return deps.withResolvedEnv( - signals, - { - explicit: opts.env, - requiredMessage: flowsMessages.list.remoteRequiresEnv, - }, - (ctx, env) => - flowsListRemote( - ctx, - pattern, - { - env, - includeDrafts: opts.includeDrafts, - aiTaskId: opts.aiTaskId, - tags, - }, - { columns: process.stdout.columns }, - ), - )(opts, command); - } - // Only an explicitly passed --ai-task-id is a usage error here: - // QAWOLF_AI_TASK_ID is ambient in AI task runners, and a local - // listing must not fail just because it is set. - if (command.getOptionValueSource("aiTaskId") === "cli") { - return withContext(signals, async () => ({ - error: flowsMessages.list.aiTaskIdRequiresRemote, - }))(opts, command); - } - // --include-drafts is a platform concept; --env is not, so without - // --remote it names a pulled environment and is answered from disk. - if (opts.includeDrafts) { - return withContext(signals, async () => ({ - error: flowsMessages.list.draftsRequireRemote, - }))(opts, command); - } - // Without --remote the tags come from the pull cache, so this works - // offline; it cannot validate names against the team's tag list. - return withContext(signals, (ctx) => - handleFlowsList( - ctx, - pattern, - { tags, env: opts.env }, - { columns: process.stdout.columns }, - ), - )(opts, command); - }, - ); - + registerFlowsListCommand(flows, signals, deps); registerFlowsPullCommand(flows, signals); } diff --git a/src/commands/flows/list.interactive.test.ts b/src/commands/flows/list.interactive.test.ts new file mode 100644 index 000000000..6bd944e4c --- /dev/null +++ b/src/commands/flows/list.interactive.test.ts @@ -0,0 +1,41 @@ +import { afterEach, expect, it, mock, spyOn } from "bun:test"; +import { Command } from "commander"; + +import { flowsMessages } from "~/core/messages/index.js"; +import { makeNoopSignals } from "~/shell/signals/createSignalRegistry.fixtures.js"; + +import { registerFlowsListCommand } from "./list.register.js"; +import type { withResolvedEnv } from "./withResolvedEnv.js"; + +afterEach(() => { + process.exitCode = 0; + mock.restore(); +}); + +for (const mode of ["--json", "--agent"]) { + it(`rejects -i ${mode} before remote authentication or environment resolution`, async () => { + const output: string[] = []; + const capture = (chunk: unknown): boolean => { + output.push(String(chunk)); + return true; + }; + spyOn(process.stdout, "write").mockImplementation(capture); + spyOn(process.stderr, "write").mockImplementation(capture); + const resolve = mock(() => async () => {}); + const program = new Command().option("--json").option("--agent"); + registerFlowsListCommand(program.command("flows"), makeNoopSignals(), { + withResolvedEnv: resolve, + }); + + await program.parseAsync( + ["flows", "list", "--remote", "--env", "staging", "-i", mode], + { from: "user" }, + ); + + expect(resolve).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(2); + expect(output.join("")).toContain( + flowsMessages.list.interactiveRequiresTerminal, + ); + }); +} diff --git a/src/commands/flows/list.register.ts b/src/commands/flows/list.register.ts new file mode 100644 index 000000000..769bcdeee --- /dev/null +++ b/src/commands/flows/list.register.ts @@ -0,0 +1,142 @@ +import { Option, type Command } from "commander"; + +import { declareCommandKind } from "~/commands/commandKind.js"; +import { withContext } from "~/commands/context.js"; +import { flowsMessages } from "~/core/messages/index.js"; +import { handleFlowsList } from "~/domains/flows/listDefaults.js"; +import { flowsListRemote } from "~/domains/flows/listRemote.js"; +import { collectValue } from "~/domains/runner/runFlagParsers.js"; +import type { SignalRegistry } from "~/shell/signals/createSignalRegistry.js"; + +import { + terminalListView, + unavailableTerminalList, +} from "./terminalListView.js"; +import type { withResolvedEnv } from "./withResolvedEnv.js"; + +const listExamples = ` +Examples: + $ qawolf flows list + $ qawolf flows list --no-interactive + $ qawolf flows list "flows/checkout/**" + $ qawolf flows list --remote --env staging + $ qawolf flows list --tag auth + $ qawolf flows list --env staging --tag auth + $ qawolf flows list --remote --env staging --tag auth --tag smoke + $ qawolf flows list "**/checkout/**" --remote --env staging --include-drafts + $ qawolf flows list --remote --env staging --ai-task-id ait_123`; + +type FlowsListOptions = { + readonly remote: boolean; + readonly env: string | undefined; + readonly includeDrafts: boolean; + readonly aiTaskId: string | undefined; + readonly tag: string[]; + // Undefined unless -i or --no-interactive was passed; the terminal decides. + readonly interactive: boolean | undefined; +}; + +export type ListCommandDeps = { + readonly withResolvedEnv: typeof withResolvedEnv; +}; + +export function registerFlowsListCommand( + flows: Command, + signals: SignalRegistry, + deps: ListCommandDeps, +): void { + declareCommandKind(flows.command("list [pattern]"), "local", { + kindNote: "read with --remote", + }) + .description( + "List flows matching [pattern] from the local project, or from a QA Wolf environment with --remote", + ) + .option( + "--remote", + "List flows from the QA Wolf platform instead of the local project", + false, + ) + .option( + "--env ", + "Environment to list flows from: a QA Wolf environment with --remote, otherwise a pulled one by slug or id", + ) + .option( + "--include-drafts", + "Include draft flows in the listing (requires --remote)", + false, + ) + .option( + "--tag ", + "Only list flows carrying this tag; repeat for several. Without --remote, matches against tags cached by the last pull", + collectValue, + [], + ) + // Declared before --no-interactive, so neither passed leaves it undefined. + .option( + "-i, --interactive", + "Open the flow table to filter as you type; the default at a terminal", + ) + .option("--no-interactive", "Print the flow table instead of opening it") + .addOption( + new Option( + "--ai-task-id ", + "List the flows on this AI task's branch, including drafts, instead of the ones in the environment (requires --remote)", + ).env("QAWOLF_AI_TASK_ID"), + ) + .addHelpText("after", listExamples) + .action( + ( + pattern: string | undefined, + opts: FlowsListOptions, + command: Command, + ) => { + const unavailable = unavailableTerminalList(opts.interactive, command); + if (unavailable !== undefined) { + return withContext(signals, async () => unavailable)(opts, command); + } + const tags = opts.tag; + if (opts.remote) { + return deps.withResolvedEnv( + signals, + { + explicit: opts.env, + requiredMessage: flowsMessages.list.remoteRequiresEnv, + }, + (ctx, env) => + flowsListRemote( + ctx, + pattern, + { + env, + includeDrafts: opts.includeDrafts, + aiTaskId: opts.aiTaskId, + tags, + }, + terminalListView(opts.interactive, ctx), + ), + )(opts, command); + } + // An inherited env-var default must not prevent a local listing. + if (command.getOptionValueSource("aiTaskId") === "cli") { + return withContext(signals, async () => ({ + error: flowsMessages.list.aiTaskIdRequiresRemote, + }))(opts, command); + } + if (opts.includeDrafts) { + return withContext(signals, async () => ({ + error: flowsMessages.list.draftsRequireRemote, + }))(opts, command); + } + // Without --remote the tags come from the pull cache, so this works + // offline; it cannot validate names against the team's tag list. + return withContext(signals, (ctx) => + handleFlowsList( + ctx, + pattern, + { tags, env: opts.env }, + terminalListView(opts.interactive, ctx), + ), + )(opts, command); + }, + ); +} diff --git a/src/commands/flows/terminalListView.test.ts b/src/commands/flows/terminalListView.test.ts new file mode 100644 index 000000000..5d714f4b9 --- /dev/null +++ b/src/commands/flows/terminalListView.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "bun:test"; + +import { makeCtx } from "~/shell/commandContext.testUtils.js"; + +import { terminalListView } from "./terminalListView.js"; + +const atTerminal = makeCtx("human", { isInteractive: true }); + +describe("terminalListView", () => { + it("opens the table by default for someone at a terminal", () => { + expect(terminalListView(undefined, atTerminal).interactive).toBe(true); + }); + + // `docker run -t` without -i, or a script feeding stdin: nobody can answer. + it("prints by default when stdin is not a terminal", () => { + const ctx = makeCtx("human", { isInteractive: false }); + + expect(terminalListView(undefined, ctx).interactive).toBe(false); + }); + + it("prints by default for an agent or a JSON reader", () => { + for (const mode of ["agent", "json"] as const) { + const ctx = makeCtx(mode, { isInteractive: true }); + + expect(terminalListView(undefined, ctx).interactive).toBe(false); + } + }); + + it("prints with --no-interactive, even at a terminal", () => { + expect(terminalListView(false, atTerminal).interactive).toBe(false); + }); + + // Asked for outright, it is refused later with a reason rather than + // silently printed. + it("keeps -i where it cannot work, so it can be refused", () => { + const ctx = makeCtx("agent", { isInteractive: false }); + + expect(terminalListView(true, ctx).interactive).toBe(true); + }); +}); diff --git a/src/commands/flows/terminalListView.ts b/src/commands/flows/terminalListView.ts new file mode 100644 index 000000000..cf583b34b --- /dev/null +++ b/src/commands/flows/terminalListView.ts @@ -0,0 +1,51 @@ +import type { Command } from "commander"; + +import { unavailableView, type ListView } from "~/domains/flows/listView.js"; +import type { CommandContext, CommandResult } from "~/shell/commandContext.js"; +import { + detectOutputMode, + isInteractive, + type OutputFlags, +} from "~/shell/ui/env.js"; + +/** + * Terminal access belongs in the command layer. Domains receive the chosen + * view and the context's input-interactivity check. + */ +export function terminalListView( + interactive: boolean | undefined, + ctx: Pick, +): ListView { + return { + get columns() { + return process.stdout.columns; + }, + interactive: interactive ?? (ctx.isInteractive && ctx.ui.mode === "human"), + }; +} + +export function unavailableTerminalList( + interactive: boolean | undefined, + command: Command, +): CommandResult | undefined { + if (interactive !== true) return undefined; + // Run before withResolvedEnv: invalid terminal flags must not trigger auth + // or an environment lookup. These are the same checks buildBaseContext uses. + const env = process.env; + return unavailableView( + { + ui: { + mode: detectOutputMode({ + flags: command.optsWithGlobals(), + env, + stdoutIsTTY: Boolean(process.stdout.isTTY), + }), + }, + isInteractive: isInteractive({ + stdinIsTTY: Boolean(process.stdin.isTTY), + env, + }), + }, + { columns: process.stdout.columns, interactive: true }, + ); +} diff --git a/src/commands/qawolfCliSkill.template.md b/src/commands/qawolfCliSkill.template.md index 467047f7f..05ec9ae4a 100644 --- a/src/commands/qawolfCliSkill.template.md +++ b/src/commands/qawolfCliSkill.template.md @@ -118,6 +118,14 @@ commit, then read the flow's `flowId` and `url` from `qawolf --json flows list --remote --env --include-drafts`. Send that `url`; never guess a route and never send a repository link in its place. +## Flow lists + +At an interactive terminal, `qawolf flows list` opens a searchable table. +Search by flow name, path, target, environment, or tag; press Enter to print the +matches or Esc to leave. Use `--no-interactive` to print directly. Agents and +JSON output print directly by default; `-i` requires an interactive terminal. +Pulled flows include cached IDs in JSON when the last pull recorded them. + ## Commands diff --git a/src/core/messages/flows.ts b/src/core/messages/flows.ts index 3069578d7..020587a65 100644 --- a/src/core/messages/flows.ts +++ b/src/core/messages/flows.ts @@ -1,14 +1,6 @@ import { pluralize } from "~/core/pluralize.js"; -type PullSummaryInput = { - readonly envDir: string; - readonly flowCount: number; - readonly envVarCount: number; - readonly flowsWithTeamStorageRefs: readonly string[]; - readonly assetDownloadedCount?: number | undefined; - readonly assetReusedCount?: number | undefined; - readonly assetSkippedCount?: number | undefined; -}; +import { flowsPullMessages } from "./flowsPull.js"; export const flowsMessages = { title: "Flows", @@ -19,6 +11,15 @@ export const flowsMessages = { "--remote requires an environment. Pass --env or set QAWOLF_ENVIRONMENT.", aiTaskIdRequiresRemote: "--ai-task-id requires --remote", draftsRequireRemote: "--include-drafts requires --remote", + filterFlows: (prefix: string | undefined) => + prefix === undefined + ? "Filter flows by name, path or tag" + : `Filter the flows in ${prefix} by name, path or tag`, + filterCount: (matched: number, total: number) => + `${String(matched)} of ${pluralize(total, "flow")}`, + interactiveRequiresTerminal: + "--interactive needs a terminal. Run it in a terminal, without --json or --agent, and without piping its output.", + noFlowIdShort: "no id yet", }, selectors: { tagsNotCached: @@ -61,72 +62,7 @@ export const flowsMessages = { requiresEnv: "An environment is required. Pass --env or set QAWOLF_ENVIRONMENT.", }, - pull: { - requiresEnv: - "An environment is required. Pass --env or set QAWOLF_ENVIRONMENT.", - downloadingBundle: "Downloading flows bundle", - fetchingEnvVars: "Fetching environment variables", - fetchingTags: "Fetching flow tags", - downloadComplete: "Downloaded flows bundle and environment variables", - needsYesError: "Re-run with --yes to overwrite locally-modified files", - aborted: "Aborted; no changes.", - extractingBundle: "Extracting bundle", - downloadingTeamStorageAssets: "Downloading team-storage assets", - downloadingTeamStorageAssetsProgress: (current: number, total: number) => - `Downloading team-storage assets (${String(current)}/${String(total)})`, - teamStorageRequiresTeam: - "Team storage needs a team. Pull an environment to name its team, choose a workspace with 'qawolf auth switch', or use a team API key.", - summary: (result: PullSummaryInput, assetsAbs: string) => { - const flows = pluralize(result.flowCount, "flow"); - const envVars = - result.envVarCount === 0 - ? "" - : ` and ${pluralize(result.envVarCount, "environment variable")}`; - const lines = [`Pulled ${flows}${envVars} into ${result.envDir}`]; - if (result.flowsWithTeamStorageRefs.length > 0) { - const refs = pluralize(result.flowsWithTeamStorageRefs.length, "flow"); - lines.push(`Team-storage assets referenced by ${refs}:`); - for (const path of result.flowsWithTeamStorageRefs) { - lines.push(` - ${path}`); - } - } - const downloaded = result.assetDownloadedCount ?? 0; - const reused = result.assetReusedCount ?? 0; - const skipped = result.assetSkippedCount ?? 0; - if (downloaded > 0 || reused > 0 || skipped > 0) { - let assetSummary = `Downloaded ${pluralize( - downloaded, - "team-storage asset", - )}`; - if (reused > 0) { - assetSummary += ` and reused ${pluralize( - reused, - "team-storage asset", - )}`; - } - assetSummary += ` into ${assetsAbs}`; - if (skipped > 0) { - assetSummary += ` (${pluralize( - skipped, - "unsafe or unsupported asset", - )} skipped)`; - } - lines.push(assetSummary); - } - return lines.join("\n"); - }, - symlinkRejected: (path: string) => `symlink entry rejected: ${path}`, - unknownEntrySize: (path: string) => - `entry with unknown size rejected: ${path}`, - entryTooLarge: (path: string, size: number, maxBytes: number) => - `entry exceeds max size (${path}): ${String(size)} > ${String(maxBytes)}`, - localModsWouldOverwrite: ( - count: number, - envDir: string, - fileList: string, - ) => - `${count} locally-modified file(s) under ${envDir} would be overwritten:\n${fileList}`, - }, + pull: flowsPullMessages, ensureDeps: { multiPackagePattern: (count: number, listed: string) => `Pattern matches flows from ${count} packages — narrow it to a single package:\n${listed}\n\nHint: pass a pattern scoped to one package, e.g \`qawolf flows run '.qawolf//**'\`.`, diff --git a/src/core/messages/flowsPull.ts b/src/core/messages/flowsPull.ts new file mode 100644 index 000000000..2bab4db3a --- /dev/null +++ b/src/core/messages/flowsPull.ts @@ -0,0 +1,74 @@ +import { pluralize } from "~/core/pluralize.js"; + +type PullSummaryInput = { + readonly envDir: string; + readonly flowCount: number; + readonly envVarCount: number; + readonly flowsWithTeamStorageRefs: readonly string[]; + readonly assetDownloadedCount?: number | undefined; + readonly assetReusedCount?: number | undefined; + readonly assetSkippedCount?: number | undefined; +}; + +export const flowsPullMessages = { + requiresEnv: + "An environment is required. Pass --env or set QAWOLF_ENVIRONMENT.", + downloadingBundle: "Downloading flows bundle", + fetchingEnvVars: "Fetching environment variables", + fetchingTags: "Fetching flow tags", + downloadComplete: "Downloaded flows bundle and environment variables", + needsYesError: "Re-run with --yes to overwrite locally-modified files", + aborted: "Aborted; no changes.", + extractingBundle: "Extracting bundle", + downloadingTeamStorageAssets: "Downloading team-storage assets", + downloadingTeamStorageAssetsProgress: (current: number, total: number) => + `Downloading team-storage assets (${String(current)}/${String(total)})`, + teamStorageRequiresTeam: + "Team storage needs a team. Pull an environment to name its team, choose a workspace with 'qawolf auth switch', or use a team API key.", + summary: (result: PullSummaryInput, assetsAbs: string) => { + const flows = pluralize(result.flowCount, "flow"); + const envVars = + result.envVarCount === 0 + ? "" + : ` and ${pluralize(result.envVarCount, "environment variable")}`; + const lines = [`Pulled ${flows}${envVars} into ${result.envDir}`]; + if (result.flowsWithTeamStorageRefs.length > 0) { + const refs = pluralize(result.flowsWithTeamStorageRefs.length, "flow"); + lines.push(`Team-storage assets referenced by ${refs}:`); + for (const path of result.flowsWithTeamStorageRefs) { + lines.push(` - ${path}`); + } + } + const downloaded = result.assetDownloadedCount ?? 0; + const reused = result.assetReusedCount ?? 0; + const skipped = result.assetSkippedCount ?? 0; + if (downloaded > 0 || reused > 0 || skipped > 0) { + let assetSummary = `Downloaded ${pluralize( + downloaded, + "team-storage asset", + )}`; + if (reused > 0) { + assetSummary += ` and reused ${pluralize( + reused, + "team-storage asset", + )}`; + } + assetSummary += ` into ${assetsAbs}`; + if (skipped > 0) { + assetSummary += ` (${pluralize( + skipped, + "unsafe or unsupported asset", + )} skipped)`; + } + lines.push(assetSummary); + } + return lines.join("\n"); + }, + symlinkRejected: (path: string) => `symlink entry rejected: ${path}`, + unknownEntrySize: (path: string) => + `entry with unknown size rejected: ${path}`, + entryTooLarge: (path: string, size: number, maxBytes: number) => + `entry exceeds max size (${path}): ${String(size)} > ${String(maxBytes)}`, + localModsWouldOverwrite: (count: number, envDir: string, fileList: string) => + `${count} locally-modified file(s) under ${envDir} would be overwritten:\n${fileList}`, +} as const; diff --git a/src/domains/flows/filterFlows.test.ts b/src/domains/flows/filterFlows.test.ts new file mode 100644 index 000000000..96638932f --- /dev/null +++ b/src/domains/flows/filterFlows.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "bun:test"; + +import { flowsMessages } from "~/core/messages/index.js"; +import { + callsOf, + fakeFilterList, + makeFakeUI, +} from "~/shell/commandContext.testUtils.js"; +import type { UI } from "~/shell/ui/index.js"; + +import { filterFlows } from "./filterFlows.js"; +import type { FlowsListRow } from "./renderListTable.js"; + +const file = ".qawolf/env-a/src/flows/view-order-items.flow.ts"; + +const row = (over: Partial = {}): FlowsListRow => ({ + name: "View Order Items", + target: "Web - Chrome", + file, + env: undefined, + tags: ["Smoke Tests"], + flowId: undefined, + ...over, +}); + +/** A terminal UI whose filter keeps `kept`, or is cancelled when undefined. */ +function uiKeeping(kept: readonly FlowsListRow[] | undefined) { + const fake = fakeFilterList(() => + kept === undefined ? { ok: false } : { ok: true, value: kept }, + ); + const ui: UI = { ...makeFakeUI("human"), filterList: fake.filterList }; + return { ui, offered: () => fake.calls[0] }; +} + +const written = (ui: UI): string => + callsOf(ui.write) + .map((call) => String(call[0])) + .join(""); + +describe("filterFlows", () => { + it("offers every flow, searchable by its tags too", async () => { + const { ui, offered } = uiKeeping(undefined); + + await filterFlows(ui, [row()], { columns: 100 }); + + expect(offered()?.items).toEqual([row()]); + expect(offered()?.message).toBe( + flowsMessages.list.filterFlows(".qawolf/env-a/"), + ); + const searchable = offered()?.searchText(row()) ?? []; + expect(searchable).toContain("Smoke Tests"); + }); + + it("describes the highlighted flow by id and full path", async () => { + const { ui, offered } = uiKeeping(undefined); + + await filterFlows(ui, [row()], { columns: 100 }); + + expect(offered()?.detail(row({ flowId: "flow-123" }))).toBe( + `flow-123 · ${file}`, + ); + }); + + it("prints every flow the filter kept, in full", async () => { + const { ui } = uiKeeping([ + row(), + row({ name: "Other", file: ".qawolf/env-a/src/flows/other.flow.ts" }), + ]); + + await filterFlows(ui, [row()], { columns: 100 }); + + const out = written(ui); + expect(out).toContain("View Order Items"); + expect(out).toContain("Other"); + expect(ui.outro).toHaveBeenCalledWith("2 flows"); + }); + + it("prints nothing more when the filter is cancelled", async () => { + const { ui } = uiKeeping(undefined); + + await filterFlows(ui, [row()], { columns: 100 }); + + expect(written(ui)).toBe(""); + expect(ui.outro).not.toHaveBeenCalled(); + }); + + it("says no flows matched when the filter kept none", async () => { + const { ui } = uiKeeping([]); + + await filterFlows(ui, [row()], { columns: 100 }); + + expect(ui.info).toHaveBeenCalledWith("No flows matched."); + }); + + it("says no flows matched, without prompting, when there are none", async () => { + const { ui, offered } = uiKeeping([]); + + await filterFlows(ui, [], { columns: 100 }); + + expect(ui.info).toHaveBeenCalledWith("No flows matched."); + expect(offered()).toBeUndefined(); + }); +}); diff --git a/src/domains/flows/filterFlows.ts b/src/domains/flows/filterFlows.ts new file mode 100644 index 000000000..d22a1b122 --- /dev/null +++ b/src/domains/flows/filterFlows.ts @@ -0,0 +1,55 @@ +import { flowsMessages, runnerMessages } from "~/core/messages/index.js"; +import type { CommandResult } from "~/shell/commandContext.js"; +import type { UI } from "~/shell/ui/index.js"; + +import { fitListTable } from "./fitListTable.js"; +import type { ListView } from "./listView.js"; +import { renderFlowsList } from "./renderFlowsList.js"; +import { sharedPulledPrefix } from "./pulledPrefix.js"; +import type { FlowsListRow } from "./renderListTable.js"; + +/** + * The flow table, narrowing as the user types; the flows kept are printed. + * Human mode only: callers check with `unavailableView` before doing any work. + */ +export async function filterFlows( + ui: UI, + rows: readonly FlowsListRow[], + view: Pick, +): Promise { + if (rows.length === 0) { + ui.info(runnerMessages.noFlowsMatched); + return; + } + + ui.gap(); + ui.intro(flowsMessages.title); + const result = await ui.filterList({ + message: flowsMessages.list.filterFlows(sharedPulledPrefix(rows)), + items: rows, + // Search includes tags hidden by column truncation. + searchText: (row) => [ + row.name, + row.file, + row.target, + row.env, + ...(row.tags ?? []), + ], + table: fitListTable, + describeCount: flowsMessages.list.filterCount, + // The highlighted flow in full: the table may have cut its path. + detail: (row) => + `${row.flowId ?? flowsMessages.list.noFlowIdShort} · ${row.file}`, + }); + if (!result.ok) return; + if (result.value.length === 0) { + ui.info(runnerMessages.noFlowsMatched); + return; + } + + // Printed in full: the live table cuts cells to keep each flow on one line. + ui.write( + renderFlowsList(result.value, { styled: true, columns: view.columns }), + ); + ui.outro(flowsMessages.flowCount(result.value.length)); +} diff --git a/src/domains/flows/fitListTable.test.ts b/src/domains/flows/fitListTable.test.ts new file mode 100644 index 000000000..2ce334125 --- /dev/null +++ b/src/domains/flows/fitListTable.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "bun:test"; + +import { fitListTable } from "./fitListTable.js"; +import type { FlowsListRow } from "./renderListTable.js"; + +// oxlint-disable-next-line no-control-regex +const strip = (s: string): string => s.replace(/\x1b\[[\d;]*m/g, ""); + +const path = "src/flows/account/view-order-items.flow.ts"; + +const row = (over: Partial = {}): FlowsListRow => ({ + name: "View Order Items", + target: "Web - Chrome", + file: `.qawolf/env-a/${path}`, + env: undefined, + tags: ["Smoke Tests", "CI"], + flowId: undefined, + ...over, +}); + +const plainLine = (rows: FlowsListRow[], width: number, of = row()): string => + strip(fitListTable(rows, width).line(of)); + +describe("fitListTable", () => { + it("keeps every cell whole when the table fits", () => { + const line = plainLine([row()], 300); + expect(line).toContain("View Order Items"); + expect(line).toContain(path); + expect(line).not.toContain("…"); + }); + + it("drops the shared pulled prefix from the file column", () => { + expect(plainLine([row()], 300)).not.toContain(".qawolf/env-a/"); + }); + + it("fits every line within the width, cutting cells with an ellipsis", () => { + const table = fitListTable([row()], 60); + expect(table.header.length).toBeLessThanOrEqual(60); + expect(strip(table.line(row())).length).toBeLessThanOrEqual(60); + expect(strip(table.line(row()))).toContain("…"); + }); + + // The path is the widest cell here, so it gives way first. + it("cuts the widest column before any other", () => { + const line = plainLine([row()], 80); + expect(line).toContain("View Order Items"); + expect(line).toContain("Web - Chrome"); + expect(line).toContain("…"); + }); + + it.each([0, 1, 5, 10, 20, 40])( + "fits headers and rows within %i columns", + (width) => { + const table = fitListTable([row()], width); + expect(Bun.stringWidth(table.header)).toBeLessThanOrEqual(width); + expect(Bun.stringWidth(table.line(row()))).toBeLessThanOrEqual(width); + }, + ); + + it("fits CJK, emoji and combining characters by display columns", () => { + const item = row({ name: "登录".repeat(10), file: "src/👩🏽‍💻/café.flow.ts" }); + const table = fitListTable([item], 77); + expect(Bun.stringWidth(table.line(item))).toBeLessThanOrEqual(77); + }); + + it("draws rows other than the ones it was laid out from", () => { + expect( + plainLine([row(), row({ name: "Other" })], 300, row({ name: "Other" })), + ).toContain("Other"); + }); + + // Cut from the end, a path keeps `src/flows/…` — the part every flow shares. + it("keeps the file name when a path has to be cut", () => { + expect(plainLine([row()], 75)).toMatch(/…\S*view-order-items\.flow\.ts$/); + }); + + it("dims the folder and leaves the file name bright", () => { + expect(fitListTable([row()], 300).line(row())).toContain( + "\x1b[2msrc/flows/account/\x1b[22mview-order-items.flow.ts", + ); + }); + + it("dims the secondary columns, not the name", () => { + const line = fitListTable([row()], 300).line(row()); + expect(line).toContain("\x1b[2mWeb - Chrome"); + expect(line).toStartWith("View Order Items"); + }); +}); diff --git a/src/domains/flows/fitListTable.ts b/src/domains/flows/fitListTable.ts new file mode 100644 index 000000000..246802870 --- /dev/null +++ b/src/domains/flows/fitListTable.ts @@ -0,0 +1,102 @@ +import { dim } from "~/core/ansi.js"; +import { clipColumns, displayWidth, padColumns } from "~/core/displayWidth.js"; + +import { sharedPulledPrefix, withoutPulledPrefix } from "./pulledPrefix.js"; +import { listTableColumns, type FlowsListRow } from "./renderListTable.js"; + +const gap = " "; + +const quietColumns = new Set(["target", "tags"]); + +function styleCell(header: string, text: string): string { + if (header === "file") { + // The folder is shared context; the file name is what tells rows apart. + const cut = Math.max(text.lastIndexOf("/"), text.lastIndexOf("\\")) + 1; + return `${dim(text.slice(0, cut))}${text.slice(cut)}`; + } + return quietColumns.has(header) ? dim(text) : text; +} + +// Takes a character at a time from whichever column is widest, so a long path +// or tag list gives way before a short name does. +function shrinkToFit( + natural: readonly number[], + minimums: readonly number[], + budget: number, +): number[] { + const widths = [...natural]; + let total = widths.reduce((sum, width) => sum + width, 0); + while (total > budget) { + let widest = -1; + widths.forEach((width, index) => { + if (width <= (minimums[index] ?? 0)) return; + if (widest === -1 || width > (widths[widest] ?? 0)) widest = index; + }); + if (widest === -1) break; + widths[widest] = (widths[widest] ?? 0) - 1; + total -= 1; + } + return widths; +} + +/** + * The flow list as exactly one line per flow within `width`. Cells that do not + * fit are cut and end in "…"; lines carry styling, the header does not. + */ +export function fitListTable( + rows: readonly FlowsListRow[], + width: number, +): { header: string; line: (row: FlowsListRow) => string } { + // The shared `.qawolf//` prefix says the same thing on every row. + const prefix = sharedPulledPrefix(rows); + const shorten = (row: FlowsListRow): FlowsListRow => ({ + ...row, + file: withoutPulledPrefix(row.file, prefix), + }); + + const columns = listTableColumns(rows); + // Keep the leftmost identifying columns when full headings no longer fit. + while ( + columns.length > 1 && + columns.reduce((sum, column) => sum + displayWidth(column.header), 0) + + gap.length * (columns.length - 1) > + width + ) + columns.pop(); + const natural = columns.map((column) => + Math.max( + displayWidth(column.header), + ...rows.map((row) => displayWidth(column.value(shorten(row)))), + ), + ); + const widths = shrinkToFit( + natural, + columns.map((column) => displayWidth(column.header)), + width - gap.length * (columns.length - 1), + ); + + const join = (cells: string[]): string => + clipColumns(cells.join(gap).trimEnd(), width); + const cell = (text: string, room: number, keepEnd: boolean): string => + padColumns(clipColumns(text, room, keepEnd), room); + return { + header: join( + columns.map((column, index) => + cell(column.header, widths[index] ?? 0, false), + ), + ), + line: (row) => + join( + columns.map((column, index) => + styleCell( + column.header, + cell( + column.value(shorten(row)), + widths[index] ?? 0, + column.header === "file", + ), + ), + ), + ), + }; +} diff --git a/src/domains/flows/list.human.test.ts b/src/domains/flows/list.human.test.ts index 577a35828..81fa552d5 100644 --- a/src/domains/flows/list.human.test.ts +++ b/src/domains/flows/list.human.test.ts @@ -7,7 +7,14 @@ import { makeNoopLogger } from "~/shell/logger.testUtils.js"; import { makeMemoryFs } from "~/shell/fs.testUtils.js"; import { type FlowsListDeps, flowsList } from "./list.js"; -import { callsOf, makeFakeUI } from "~/shell/commandContext.testUtils.js"; +import { + callsOf, + fakeFilterList, + makeFakeUI, +} from "~/shell/commandContext.testUtils.js"; +import { flowsMessages } from "~/core/messages/index.js"; +import { exitCodes } from "~/shell/exit.js"; +import type { FlowsListRow } from "./renderListTable.js"; const noopSignals = makeNoopSignals(); @@ -82,7 +89,7 @@ describe("flowsList human mode on a narrow terminal", () => { undefined, deps, { tags: [] }, - { columns: 20 }, + { columns: 20, interactive: false }, ); const output = callsOf(ui.write) @@ -97,3 +104,79 @@ describe("flowsList human mode on a narrow terminal", () => { expect(ui.outro).toHaveBeenCalledWith("1 flow"); }); }); + +describe("flowsList --interactive", () => { + it("refuses unusable stdin before reading flows", async () => { + const deps = makeDeps({ files: ["/proj/src/flows/login.flow.ts"] }); + + const result = await flowsList( + makeCtx(), + undefined, + deps, + { tags: [] }, + { columns: 80, interactive: true }, + ); + + expect(result).toEqual({ + error: flowsMessages.list.interactiveRequiresTerminal, + exitCode: exitCodes.invalidArgs, + }); + expect(deps.expandPatterns).not.toHaveBeenCalled(); + }); + + it("lets the table be filtered, then prints what is left", async () => { + // Keeps everything it is offered, as Enter with nothing typed would. + const fake = fakeFilterList((args) => ({ + ok: true, + value: args.items, + })); + const ui = { ...makeFakeUI(), filterList: fake.filterList }; + const deps = makeDeps({ + files: ["/proj/src/flows/login.flow.ts"], + metaByFile: { + "/proj/src/flows/login.flow.ts": { + name: "Login", + target: "Web - Chrome", + }, + }, + }); + + await flowsList( + { ...makeCtx(ui, "human"), isInteractive: true }, + undefined, + deps, + { tags: [] }, + { columns: undefined, interactive: true }, + ); + + expect(fake.calls).toHaveLength(1); + const output = callsOf(ui.write) + .map((c) => String(c[0])) + .join(""); + expect(output).toContain("Login"); + expect(ui.outro).toHaveBeenCalledWith("1 flow"); + }); + + // Checked first, so a list that cannot be shown does no work at all. + it("refuses outside a terminal before reading any flow", async () => { + const ui = makeFakeUI("agent"); + const deps = makeDeps({ + files: ["/proj/src/flows/login.flow.ts"], + metaByFile: {}, + }); + + const result = await flowsList( + makeCtx(ui, "agent"), + undefined, + deps, + { tags: [] }, + { columns: undefined, interactive: true }, + ); + + expect(result).toEqual({ + error: flowsMessages.list.interactiveRequiresTerminal, + exitCode: exitCodes.invalidArgs, + }); + expect(deps.expandPatterns).not.toHaveBeenCalled(); + }); +}); diff --git a/src/domains/flows/list.resizing.test.ts b/src/domains/flows/list.resizing.test.ts new file mode 100644 index 000000000..10d5bebd6 --- /dev/null +++ b/src/domains/flows/list.resizing.test.ts @@ -0,0 +1,55 @@ +import { expect, it } from "bun:test"; +import { stripVTControlCharacters } from "node:util"; + +import { + callsOf, + fakeFilterList, + makeCtx, +} from "~/shell/commandContext.testUtils.js"; + +import { flowsList } from "./list.js"; +import type { FlowsListRow } from "./renderListTable.js"; + +for (const [initial, resized] of [ + [20, 200], + [200, 20], +] as const) { + it(`prints for the current width after resizing from ${initial} to ${resized}`, async () => { + let columns: number = initial; + const fake = fakeFilterList((args) => { + columns = resized; + return { ok: true, value: args.items }; + }); + const ctx = makeCtx("human", { isInteractive: true }); + ctx.ui.filterList = fake.filterList; + await flowsList( + ctx, + undefined, + { + cwd: "/proj", + expandPatterns: async () => ["/proj/src/flows/login.flow.ts"], + peekFlowMeta: async () => ({ name: "Login", target: "Web - Chrome" }), + readCachedFlows: async () => new Map(), + readEnvLabel: async () => "staging", + findPulledEnv: async () => undefined, + listPulledEnvDirs: async () => [], + }, + { tags: [] }, + { + interactive: true, + get columns() { + return columns; + }, + }, + ); + + const text = stripVTControlCharacters( + callsOf(ctx.ui.write) + .map(([value]) => String(value)) + .join(""), + ); + if (resized === 200) expect(text).toMatch(/^name\s+target\s+file/m); + else expect(text).toMatch(/^Login {2}· {2}Web - Chrome$/m); + expect(text).toContain("login.flow.ts"); + }); +} diff --git a/src/domains/flows/list.ts b/src/domains/flows/list.ts index c100512de..570cf023f 100644 --- a/src/domains/flows/list.ts +++ b/src/domains/flows/list.ts @@ -14,8 +14,9 @@ import { envLabelFor, readEnvLabels } from "./envLabels.js"; import { selectPulledEnv } from "./selectPulledEnv.js"; import { emptySelectionResult, tagsNotCachedResult } from "./selectorGuards.js"; import { renderFlowsList } from "./renderFlowsList.js"; +import { filterFlows } from "./filterFlows.js"; import { type FlowsListItem, toListRow } from "./listItem.js"; -import { type ListView, printedView } from "./listView.js"; +import { type ListView, printedView, unavailableView } from "./listView.js"; import type { CachedFlow } from "./readCachedFlows.js"; import { renderListTable } from "./renderListTable.js"; @@ -47,6 +48,9 @@ export async function flowsList( selectors: FlowSelectors & { env?: string | undefined } = { tags: [] }, view: ListView = printedView, ): Promise { + const unavailable = unavailableView(ctx, view); + if (unavailable !== undefined) return unavailable; + const patterns = pattern ? [pattern] : []; let files = await deps.expandPatterns(patterns, deps.cwd); @@ -95,6 +99,9 @@ export async function flowsList( const empty = await emptySelectionResult(selectors, items.length, undefined); if (empty !== undefined) return empty; + if (view.interactive) { + return filterFlows(ctx.ui, items.map(toListRow), view); + } if (ctx.ui.mode === "json") { ctx.ui.json(items); return; diff --git a/src/domains/flows/listRemote.interactive.test.ts b/src/domains/flows/listRemote.interactive.test.ts new file mode 100644 index 000000000..1da5443f8 --- /dev/null +++ b/src/domains/flows/listRemote.interactive.test.ts @@ -0,0 +1,66 @@ +import { expect, it } from "bun:test"; +import { stripVTControlCharacters } from "node:util"; + +import { + callsOf, + fakeFilterList, + makeCtx, +} from "~/shell/commandContext.testUtils.js"; +import { + makeCallPublicApiMock, + makeMockPlatformClient, +} from "~/shell/platform/createPlatformClient.testUtils.js"; + +import { flowsListRemote } from "./listRemote.js"; +import type { FlowsListRow } from "./renderListTable.js"; + +it("filters remote flows and prints matches at the resized terminal width", async () => { + let columns = 200; + const fake = fakeFilterList((args) => { + columns = 20; + return { ok: true, value: args.items }; + }); + const ctx = makeCtx("human", { isInteractive: true }); + ctx.ui.filterList = fake.filterList; + const platformClient = makeMockPlatformClient({ + callPublicApi: makeCallPublicApiMock().mockResolvedValue({ + ok: true, + value: { + flows: [ + { + flowId: "flow-a", + name: "Example", + path: "src/flows/example.flow.ts", + executionTarget: "Web - Chrome", + tags: ["smoke"], + url: "https://example.invalid/flow-a", + }, + ], + }, + }), + }); + + await flowsListRemote( + { ...ctx, platformClient, apiKeySource: "env" }, + undefined, + { env: "env-a", includeDrafts: false, aiTaskId: undefined, tags: [] }, + { + interactive: true, + get columns() { + return columns; + }, + }, + ); + + expect(fake.calls[0]?.items).toEqual([ + expect.objectContaining({ flowId: "flow-a", tags: ["smoke"] }), + ]); + const output = stripVTControlCharacters( + callsOf(ctx.ui.write) + .map(([value]) => String(value)) + .join(""), + ); + expect(output).toMatch(/^Example {2}· {2}Web - Chrome$/m); + expect(output).toContain("flow-a"); + expect(output).toContain("example.flow.ts"); +}); diff --git a/src/domains/flows/listRemote.ts b/src/domains/flows/listRemote.ts index 6314adcfa..1c4bb0743 100644 --- a/src/domains/flows/listRemote.ts +++ b/src/domains/flows/listRemote.ts @@ -11,8 +11,9 @@ import { matchesSelectors } from "~/core/flowSelectors.js"; import { fetchKnownTags } from "./fetchKnownTags.js"; import { renderFlowsList } from "./renderFlowsList.js"; +import { filterFlows } from "./filterFlows.js"; import { renderListTable, type FlowsListRow } from "./renderListTable.js"; -import { type ListView, printedView } from "./listView.js"; +import { type ListView, printedView, unavailableView } from "./listView.js"; import { emptySelectionResult } from "./selectorGuards.js"; type RemoteListItem = { @@ -48,6 +49,9 @@ export async function flowsListRemote( options: FlowsListRemoteOptions, view: ListView = printedView, ): Promise { + const unavailable = unavailableView(ctx, view); + if (unavailable !== undefined) return unavailable; + const result = await ctx.platformClient.callPublicApi( publicContractsV1.flow.list, { @@ -83,6 +87,9 @@ export async function flowsListRemote( ); if (empty !== undefined) return empty; + if (view.interactive) { + return filterFlows(ctx.ui, items.map(toListRow), view); + } if (ctx.ui.mode === "json") { ctx.ui.json(items); return; diff --git a/src/domains/flows/listView.test.ts b/src/domains/flows/listView.test.ts new file mode 100644 index 000000000..2f94e367e --- /dev/null +++ b/src/domains/flows/listView.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "bun:test"; + +import { flowsMessages } from "~/core/messages/index.js"; +import { makeCtx } from "~/shell/commandContext.testUtils.js"; +import { exitCodes } from "~/shell/exit.js"; + +import { unavailableView } from "./listView.js"; + +describe("unavailableView", () => { + // A script or an agent cannot answer a prompt; waiting would hang it. + it("refuses --interactive outside a terminal", () => { + const ctx = makeCtx("agent"); + + expect(unavailableView(ctx, { columns: 80, interactive: true })).toEqual({ + error: flowsMessages.list.interactiveRequiresTerminal, + exitCode: exitCodes.invalidArgs, + }); + }); + + it("allows --interactive at a terminal", () => { + const ctx = makeCtx("human", { isInteractive: true }); + + expect( + unavailableView(ctx, { columns: 80, interactive: true }), + ).toBeUndefined(); + }); + + it("has nothing to say about a printed list, in any mode", () => { + const ctx = makeCtx("json"); + + expect( + unavailableView(ctx, { columns: undefined, interactive: false }), + ).toBeUndefined(); + }); +}); diff --git a/src/domains/flows/listView.ts b/src/domains/flows/listView.ts index 5c32cd18e..cba5c8f18 100644 --- a/src/domains/flows/listView.ts +++ b/src/domains/flows/listView.ts @@ -1,5 +1,23 @@ +import { flowsMessages } from "~/core/messages/index.js"; +import type { CommandResult } from "~/shell/commandContext.js"; +import { exitCodes } from "~/shell/exit.js"; +import type { UI } from "~/shell/ui/index.js"; + export type ListView = { readonly columns: number | undefined; + readonly interactive: boolean; }; -export const printedView: ListView = { columns: undefined }; +export const printedView: ListView = { columns: undefined, interactive: false }; + +export function unavailableView( + ctx: { readonly ui: Pick; readonly isInteractive: boolean }, + view: ListView, +): CommandResult | undefined { + if (!view.interactive || (ctx.ui.mode === "human" && ctx.isInteractive)) + return undefined; + return { + error: flowsMessages.list.interactiveRequiresTerminal, + exitCode: exitCodes.invalidArgs, + }; +} diff --git a/src/domains/flows/renderListTable.ts b/src/domains/flows/renderListTable.ts index 50619ca1b..000c99655 100644 --- a/src/domains/flows/renderListTable.ts +++ b/src/domains/flows/renderListTable.ts @@ -35,7 +35,7 @@ const fileColumn: TableColumn = { value: (row) => row.file, }; -function listTableColumns( +export function listTableColumns( rows: readonly FlowsListRow[], detail: "compact" | "full" = "compact", ): TableColumn[] { diff --git a/src/shell/commandContext.testUtils.ts b/src/shell/commandContext.testUtils.ts index b9726c138..61a1b5693 100644 --- a/src/shell/commandContext.testUtils.ts +++ b/src/shell/commandContext.testUtils.ts @@ -13,6 +13,11 @@ import { makeNoopSignals } from "~/shell/signals/createSignalRegistry.fixtures.j import type { OutputMode } from "~/shell/ui/env.js"; import type { UI } from "~/shell/ui/index.js"; import { makeMemoryFs } from "~/shell/fs.testUtils.js"; +import type { + FilterListArgs, + FilterListFn, + PromptResult, +} from "~/shell/ui/renderers/types.js"; const noopSignals = makeNoopSignals(); @@ -27,6 +32,7 @@ export function makeFakeUI(mode: OutputMode = "human"): UI { password: mock(() => Promise.resolve({ ok: false } as const)), select: mock(() => Promise.resolve({ ok: false } as const)), text: mock(() => Promise.resolve({ ok: false } as const)), + filterList: mock(() => Promise.resolve({ ok: false } as const)), withProgress: mock( async (steps: { message: string; task: () => Promise }[]) => { const results: unknown[] = []; @@ -137,3 +143,19 @@ export function makeAuthCtx( export const callsOf = unknown>( fn: T, ): unknown[][] => (fn as unknown as ReturnType).mock.calls; + +/** + * A `filterList` that answers with `answer`, recording what each call offered. + * `FilterListFn` is generic over the item type, so the one cast it needs lives + * here rather than in every test. + */ +export function fakeFilterList( + answer: (args: FilterListArgs) => PromptResult, +): { filterList: FilterListFn; calls: FilterListArgs[] } { + const calls: FilterListArgs[] = []; + const filterList = (args: FilterListArgs) => { + calls.push(args); + return Promise.resolve(answer(args)); + }; + return { filterList: filterList as unknown as FilterListFn, calls }; +} diff --git a/src/shell/ui/createUi.ts b/src/shell/ui/createUi.ts index ad95cb98c..defc0ee00 100644 --- a/src/shell/ui/createUi.ts +++ b/src/shell/ui/createUi.ts @@ -5,6 +5,7 @@ import { createConfirm } from "./renderers/confirm.js"; import { createJson } from "./renderers/json.js"; import { pickRenderers } from "./renderers/modes/index.js"; import { createPassword } from "./renderers/password.js"; +import { createFilterList } from "./renderers/filterList.js"; import { createSelect } from "./renderers/select.js"; import { createText } from "./renderers/text.js"; import type { UI } from "./types.js"; @@ -25,6 +26,7 @@ export function createUI( password: createPassword({ mode, clack }), select: createSelect({ mode, clack }), text: createText({ mode, clack }), + filterList: createFilterList({ mode }), json: createJson(), }; } diff --git a/src/shell/ui/types.ts b/src/shell/ui/types.ts index 6c1d927d7..69e17cf91 100644 --- a/src/shell/ui/types.ts +++ b/src/shell/ui/types.ts @@ -1,5 +1,5 @@ import type { OutputMode } from "./env.js"; -import type { PromptResult } from "./renderers/types.js"; +import type { FilterListFn, PromptResult } from "./renderers/types.js"; import type { SelectFn } from "./renderers/select.js"; import type { TextFn } from "./renderers/text.js"; import type { WithProgressFn } from "./renderers/modes/progress.js"; @@ -27,6 +27,7 @@ export type UI = { password(message: string, hint?: string): Promise>; select: SelectFn; text: TextFn; + filterList: FilterListFn; withProgress: WithProgressFn; step(message: string, progress?: { current: number; total: number }): void; success(message: string): void; From 5a70e94d42bc5e67226519ae0be924c7a9163a79 Mon Sep 17 00:00:00 2001 From: Chase J <54216608+chajac@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:04:13 +0100 Subject: [PATCH 2/3] refactor(cli): order prompt declarations consistently --- src/shell/commandContext.testUtils.ts | 2 +- src/shell/ui/createUi.ts | 2 +- src/shell/ui/types.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/shell/commandContext.testUtils.ts b/src/shell/commandContext.testUtils.ts index 61a1b5693..f36be5f89 100644 --- a/src/shell/commandContext.testUtils.ts +++ b/src/shell/commandContext.testUtils.ts @@ -29,10 +29,10 @@ export function makeFakeUI(mode: OutputMode = "human"): UI { note: mock(() => {}), outro: mock(() => {}), confirm: mock(() => Promise.resolve({ ok: false } as const)), + filterList: mock(() => Promise.resolve({ ok: false } as const)), password: mock(() => Promise.resolve({ ok: false } as const)), select: mock(() => Promise.resolve({ ok: false } as const)), text: mock(() => Promise.resolve({ ok: false } as const)), - filterList: mock(() => Promise.resolve({ ok: false } as const)), withProgress: mock( async (steps: { message: string; task: () => Promise }[]) => { const results: unknown[] = []; diff --git a/src/shell/ui/createUi.ts b/src/shell/ui/createUi.ts index defc0ee00..99396c32b 100644 --- a/src/shell/ui/createUi.ts +++ b/src/shell/ui/createUi.ts @@ -23,10 +23,10 @@ export function createUI( mode, ...pickRenderers(mode, clack, opts.verboseTarget), confirm: createConfirm({ mode, clack }), + filterList: createFilterList({ mode }), password: createPassword({ mode, clack }), select: createSelect({ mode, clack }), text: createText({ mode, clack }), - filterList: createFilterList({ mode }), json: createJson(), }; } diff --git a/src/shell/ui/types.ts b/src/shell/ui/types.ts index 69e17cf91..f825ab642 100644 --- a/src/shell/ui/types.ts +++ b/src/shell/ui/types.ts @@ -24,10 +24,10 @@ export type UI = { destructive?: boolean; }, ): Promise>; + filterList: FilterListFn; password(message: string, hint?: string): Promise>; select: SelectFn; text: TextFn; - filterList: FilterListFn; withProgress: WithProgressFn; step(message: string, progress?: { current: number; total: number }): void; success(message: string): void; From 1be441c20dc545cc9055b8b015a4f19059c1eee7 Mon Sep 17 00:00:00 2001 From: Chase J <54216608+chajac@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:51:44 +0100 Subject: [PATCH 3/3] fix(flows): preserve the remote list title --- src/domains/flows/filterFlows.test.ts | 1 + src/domains/flows/filterFlows.ts | 3 ++- src/domains/flows/listRemote.interactive.test.ts | 1 + src/domains/flows/listRemote.ts | 4 +++- 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/domains/flows/filterFlows.test.ts b/src/domains/flows/filterFlows.test.ts index 96638932f..d347c9504 100644 --- a/src/domains/flows/filterFlows.test.ts +++ b/src/domains/flows/filterFlows.test.ts @@ -44,6 +44,7 @@ describe("filterFlows", () => { await filterFlows(ui, [row()], { columns: 100 }); expect(offered()?.items).toEqual([row()]); + expect(ui.intro).toHaveBeenCalledWith("Flows"); expect(offered()?.message).toBe( flowsMessages.list.filterFlows(".qawolf/env-a/"), ); diff --git a/src/domains/flows/filterFlows.ts b/src/domains/flows/filterFlows.ts index d22a1b122..30271a956 100644 --- a/src/domains/flows/filterFlows.ts +++ b/src/domains/flows/filterFlows.ts @@ -16,6 +16,7 @@ export async function filterFlows( ui: UI, rows: readonly FlowsListRow[], view: Pick, + options: { readonly title?: string } = {}, ): Promise { if (rows.length === 0) { ui.info(runnerMessages.noFlowsMatched); @@ -23,7 +24,7 @@ export async function filterFlows( } ui.gap(); - ui.intro(flowsMessages.title); + ui.intro(options.title ?? flowsMessages.title); const result = await ui.filterList({ message: flowsMessages.list.filterFlows(sharedPulledPrefix(rows)), items: rows, diff --git a/src/domains/flows/listRemote.interactive.test.ts b/src/domains/flows/listRemote.interactive.test.ts index 1da5443f8..6433c9595 100644 --- a/src/domains/flows/listRemote.interactive.test.ts +++ b/src/domains/flows/listRemote.interactive.test.ts @@ -55,6 +55,7 @@ it("filters remote flows and prints matches at the resized terminal width", asyn expect(fake.calls[0]?.items).toEqual([ expect.objectContaining({ flowId: "flow-a", tags: ["smoke"] }), ]); + expect(ctx.ui.intro).toHaveBeenCalledWith("Remote Flows"); const output = stripVTControlCharacters( callsOf(ctx.ui.write) .map(([value]) => String(value)) diff --git a/src/domains/flows/listRemote.ts b/src/domains/flows/listRemote.ts index 1c4bb0743..5cf7c6497 100644 --- a/src/domains/flows/listRemote.ts +++ b/src/domains/flows/listRemote.ts @@ -88,7 +88,9 @@ export async function flowsListRemote( if (empty !== undefined) return empty; if (view.interactive) { - return filterFlows(ctx.ui, items.map(toListRow), view); + return filterFlows(ctx.ui, items.map(toListRow), view, { + title: flowsMessages.remoteTitle, + }); } if (ctx.ui.mode === "json") { ctx.ui.json(items);