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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/flows-list-mark-and-copy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@qawolf/cli": minor
---

Mark flows with Tab in the interactive list, keeping marks across searches. Ctrl-Y copies paths and Ctrl-O copies IDs for marked flows, or the highlighted flow when none are marked. Enter prints marked flows, or all matches when none are marked. Missing IDs produce a notice.

Copied paths are separate literal shell arguments using POSIX syntax on macOS/Linux and PowerShell syntax on Windows. The CLI uses system clipboard tools and falls back to requesting the terminal clipboard when tools are unavailable.
2 changes: 2 additions & 0 deletions knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const config: KnipConfig = {
],
project: ["src/**/*.ts"],
ignoreBinaries: [
// Optional system shell used by the clipboard quoting round-trip test.
"pwsh",
// the built bundle, invoked as `node dist/cli.js` in the runtime-smoke CI
// job; not present when knip runs (it runs before the build step)
"dist/cli.js",
Expand Down
8 changes: 6 additions & 2 deletions skills/qawolf-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,14 @@ 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
Search by flow name, path, target, environment, or tag. Tab marks flows across
searches. Ctrl-Y copies paths and Ctrl-O copies IDs for marked flows, or for the
highlighted flow when none are marked. Enter prints marked flows, or all matches
when none are marked; Esc leaves. 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.
Copied paths use POSIX shell quoting on macOS/Linux and PowerShell quoting on
Windows. Missing IDs produce a notice; pull again to populate older caches.

## Commands

Expand Down
5 changes: 3 additions & 2 deletions src/commands/__snapshots__/help.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,9 @@ Options:
--tag <name> 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
-i, --interactive Open the flow table to filter as you type, mark flows
and copy their paths or ids; the default at a
terminal
--no-interactive Print the flow table instead of opening it
--ai-task-id <aiTaskId> List the flows on this AI task's branch, including
drafts, instead of the ones in the environment
Expand Down
2 changes: 1 addition & 1 deletion src/commands/flows/list.register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export function registerFlowsListCommand(
// 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",
"Open the flow table to filter as you type, mark flows and copy their paths or ids; the default at a terminal",
)
.option("--no-interactive", "Print the flow table instead of opening it")
.addOption(
Expand Down
8 changes: 6 additions & 2 deletions src/commands/qawolfCliSkill.template.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,14 @@ 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
Search by flow name, path, target, environment, or tag. Tab marks flows across
searches. Ctrl-Y copies paths and Ctrl-O copies IDs for marked flows, or for the
highlighted flow when none are marked. Enter prints marked flows, or all matches
when none are marked; Esc leaves. 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.
Copied paths use POSIX shell quoting on macOS/Linux and PowerShell quoting on
Windows. Missing IDs produce a notice; pull again to populate older caches.

## Commands

Expand Down
2 changes: 2 additions & 0 deletions src/core/ansi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,7 @@ export const dim = (text: string): string => `\x1b[2m${text}\x1b[22m`;
export const strike = (text: string): string => `\x1b[9m${text}\x1b[29m`;
export const inverse = (text: string): string => `\x1b[7m${text}\x1b[27m`;
export const cyan = (text: string): string => `\x1b[36m${text}\x1b[39m`;
export const green = (text: string): string => `\x1b[32m${text}\x1b[39m`;
export const yellow = (text: string): string => `\x1b[33m${text}\x1b[39m`;

export const visibleLength = displayWidth;
16 changes: 16 additions & 0 deletions src/core/messages/flows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ import { pluralize } from "~/core/pluralize.js";

import { flowsPullMessages } from "./flowsPull.js";

function copiedValues(values: readonly string[], noun: string): string {
const [only] = values;
return values.length === 1 && only !== undefined
? only
: pluralize(values.length, noun);
}

export const flowsMessages = {
title: "Flows",
remoteTitle: "Remote Flows",
Expand All @@ -19,6 +26,15 @@ export const flowsMessages = {
`${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.",
copyPath: "copy path",
copyId: "copy id",
copied: (values: readonly string[], noun: string) =>
`Copied ${copiedValues(values, noun)}`,
copiedViaTerminal: (values: readonly string[], noun: string) =>
`Sent ${copiedValues(values, noun)} to your terminal's clipboard`,
idsLeftOut: (missing: number) =>
`${pluralize(missing, "flow")} had no id yet and ${missing === 1 ? "was" : "were"} left out`,
noFlowId: "No flow id yet. Pull this environment again to fetch it.",
noFlowIdShort: "no id yet",
},
selectors: {
Expand Down
14 changes: 14 additions & 0 deletions src/core/shellArguments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/** Quotes one literal argument; PowerShell syntax is not cmd.exe syntax. */
export function quoteShellArgument(
value: string,
dialect: "posix" | "powershell",
): string {
const bare =
dialect === "powershell" ? /^[a-zA-Z0-9_./\\:-]+$/ : /^[a-zA-Z0-9_./-]+$/;
if (bare.test(value)) return value;
if (dialect === "powershell") {
// PowerShell treats smart apostrophes as quote delimiters too.
return `'${value.replace(/['\u2018-\u201b]/g, "$&$&")}'`;
}
return `'${value.replaceAll("'", "'\\''")}'`;
}
172 changes: 172 additions & 0 deletions src/domains/flows/copyFlowActions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { describe, expect, it, mock } from "bun:test";
import { spawnSync } from "node:child_process";

import { flowsMessages } from "~/core/messages/index.js";
import type { CopyToClipboard } from "~/shell/clipboard.js";
import { formatClipboardPaths } from "~/shell/clipboardPaths.js";

import { copyFlowActions } from "./copyFlowActions.js";
import type { FlowsListRow } from "./renderListTable.js";

const row = (name: string, flowId: string | undefined): FlowsListRow => ({
name,
target: "Web - Chrome",
file: `src/flows/${name}.flow.ts`,
env: undefined,
tags: undefined,
flowId,
});

/** The actions, over a clipboard that answers `outcome`. */
function setup(
outcome: "copied" | "terminal" = "copied",
platform: NodeJS.Platform = "linux",
) {
const copy = mock<CopyToClipboard>(() => Promise.resolve(outcome));
const actions = copyFlowActions(copy, (paths) =>
formatClipboardPaths(paths, platform),
);
const run = (key: string, rows: FlowsListRow[]) =>
actions.find((action) => action.key === key)?.run(rows);
return { copy, run };
}

describe("copyFlowActions", () => {
it("copies one flow's path with Ctrl-Y, naming it", async () => {
const { copy, run } = setup();

const notice = await run("y", [row("a", undefined)]);

expect(copy).toHaveBeenCalledWith("src/flows/a.flow.ts");
expect(notice).toEqual({
tone: "success",
text: "Copied src/flows/a.flow.ts",
});
});

// So they paste straight into one command.
it("separates several paths with spaces, and counts them", async () => {
const { copy, run } = setup();

const notice = await run("y", [row("a", undefined), row("b", undefined)]);

expect(copy).toHaveBeenCalledWith(
"src/flows/a.flow.ts src/flows/b.flow.ts",
);
expect(notice).toEqual({ tone: "success", text: "Copied 2 paths" });
});

it("copies the flow ids with Ctrl-O", async () => {
const { copy, run } = setup();

const notice = await run("o", [row("a", "id-1"), row("b", "id-2")]);

expect(copy).toHaveBeenCalledWith("id-1 id-2");
expect(notice).toEqual({ tone: "success", text: "Copied 2 ids" });
});

for (const [kind, key] of [
["path", "y"],
["id", "o"],
] as const) {
for (const shell of ["sh", "bash", "zsh"]) {
// This corpus includes POSIX filenames with control characters. Windows
// copies PowerShell syntax, covered by clipboardPaths.test.ts.
it.skipIf(
process.platform === "win32" ||
spawnSync(shell, ["-c", "exit 0"]).error !== undefined,
)(
`preserves each copied ${kind} as one literal argument in ${shell}`,
async () => {
const { copy, run } = setup();
const files = [
"src/flows/checkout cart.flow.ts",
"src/flows/reader's note.flow.ts",
"src/flows/payments[1].flow.ts",
"src/flows/$USER.flow.ts",
"src/flows/$(printf injected).flow.ts",
"src/flows/`printf injected`.flow.ts",
"src/flows/semicolon; printf injected",
"src/flows/first\nsecond.flow.ts",
"src/flows/trailing.flow.ts\n",
"src/flows/carriage.flow.ts\r",
"src/flows/back\\slash.flow.ts",
'src/flows/"quoted".flow.ts',
"#comment.flow.ts",
"~/literal.flow.ts",
"src/flows/{a,b}?.flow.ts",
"src/flows/!history.flow.ts",
];

await run(
key,
files.map((file) => ({ ...row("a", file), file })),
);
const copied = copy.mock.calls[0]?.[0];
expect(copied).toBeDefined();
const result = spawnSync(shell, ["-c", `printf '%s\\0' ${copied}`], {
encoding: "utf8",
});

expect(result.status).toBe(0);
expect(result.stderr).toBe("");
expect(result.stdout.split("\0").slice(0, -1)).toEqual(files);
},
);
}

for (const outcome of ["copied", "terminal"] as const) {
it(`names the Windows ${kind} quoting format when ${outcome}`, async () => {
const { copy, run } = setup(outcome, "win32");

const notice = await run(key, [row("reader's note", "reader's note")]);

expect(copy).toHaveBeenCalledWith(
key === "y"
? "'src/flows/reader''s note.flow.ts'"
: "'reader''s note'",
);
expect(notice?.text).toEndWith(" · PowerShell syntax");
});
}
}

it("says a flow has no id yet rather than copying nothing", async () => {
const { copy, run } = setup();

const notice = await run("o", [row("a", undefined)]);

expect(notice).toEqual({
tone: "warning",
text: flowsMessages.list.noFlowId,
});
expect(copy).not.toHaveBeenCalled();
});

it("copies the ids that are known, and warns about the rest", async () => {
const { copy, run } = setup();

const notice = await run("o", [
row("a", "id-1"),
row("b", undefined),
row("c", undefined),
]);

expect(copy).toHaveBeenCalledWith("id-1");
expect(notice).toEqual({
tone: "warning",
text: "Copied id-1 · 2 flows had no id yet and were left out",
});
});

it("says when the terminal was asked to copy instead", async () => {
const { run } = setup("terminal");

const notice = await run("y", [row("a", undefined), row("b", undefined)]);

expect(notice).toEqual({
tone: "success",
text: "Sent 2 paths to your terminal's clipboard",
});
});
});
60 changes: 60 additions & 0 deletions src/domains/flows/copyFlowActions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { flowsMessages } from "~/core/messages/index.js";
import type { CopyToClipboard } from "~/shell/clipboard.js";
import { formatClipboardPaths } from "~/shell/clipboardPaths.js";
import type { FilterAction, FilterNotice } from "~/shell/ui/renderers/types.js";

import type { FlowsListRow } from "./renderListTable.js";

export function copyFlowActions(
copy: CopyToClipboard,
formatPaths = formatClipboardPaths,
): FilterAction<FlowsListRow>[] {
// Quote individual values before joining, so shell syntax stays literal.
const copied = async (
values: readonly string[],
noun: "path" | "id",
): Promise<FilterNotice> => {
const formatted = formatPaths(values);
const message =
(await copy(formatted.text)) === "copied"
? flowsMessages.list.copied(values, noun)
: flowsMessages.list.copiedViaTerminal(values, noun);
return {
tone: "success",
text: formatted.syntax ? `${message} · ${formatted.syntax}` : message,
};
};

return [
{
key: "y",
label: flowsMessages.list.copyPath,
run: (rows) =>
copied(
rows.map((row) => row.file),
"path",
),
},
{
key: "o",
label: flowsMessages.list.copyId,
run: async (rows) => {
const ids = rows.flatMap((row) =>
row.flowId === undefined ? [] : [row.flowId],
);
if (ids.length === 0) {
return { tone: "warning", text: flowsMessages.list.noFlowId };
}
const notice = await copied(ids, "id");
const missing = rows.length - ids.length;
// The ids that are known still help, but the gap must not go unseen.
return missing === 0
? notice
: {
tone: "warning",
text: `${notice.text} · ${flowsMessages.list.idsLeftOut(missing)}`,
};
},
},
];
}
Loading
Loading