Skip to content
Merged
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
26 changes: 20 additions & 6 deletions .github/workflows/release-cua-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ on:
required: true
default: "ext"
type: string
bundle_workspace_dependencies:
description: "Bundle branch builds of cua-ai and cua-agent instead of using their published versions"
required: true
default: true
type: boolean

permissions:
contents: read
Expand Down Expand Up @@ -119,7 +124,7 @@ jobs:
- run: npm run build --workspace @onkernel/cua-agent

- name: Verify published production dependencies
if: github.event_name == 'push'
if: github.event_name == 'push' || !inputs.bundle_workspace_dependencies
run: |
node -p 'require("./packages/cli/package.json").dependencies["@onkernel/cua-ai"]' \
| xargs -I{} npm view @onkernel/cua-ai@{} version
Expand All @@ -140,18 +145,18 @@ jobs:
run: npm test --workspace @onkernel/cua-cli

- name: Pack production tarball
if: github.event_name == 'push'
if: github.event_name == 'push' || !inputs.bundle_workspace_dependencies
run: npm pack --workspace @onkernel/cua-cli --pack-destination "$RUNNER_TEMP"

- name: Pack branch workspace dependencies
if: github.event_name == 'workflow_dispatch'
if: github.event_name == 'workflow_dispatch' && inputs.bundle_workspace_dependencies
run: |
mkdir -p "$RUNNER_TEMP/workspace-packages"
npm pack --workspace @onkernel/cua-ai --pack-destination "$RUNNER_TEMP/workspace-packages"
npm pack --workspace @onkernel/cua-agent --pack-destination "$RUNNER_TEMP/workspace-packages"

- name: Pack bundled CLI prerelease
if: github.event_name == 'workflow_dispatch'
if: github.event_name == 'workflow_dispatch' && inputs.bundle_workspace_dependencies
run: |
export STAGE_DIR="$RUNNER_TEMP/cua-cli-prerelease"
mkdir -p "$STAGE_DIR/node_modules/@onkernel"
Expand Down Expand Up @@ -194,12 +199,15 @@ jobs:
npm pack "$STAGE_DIR" --pack-destination "$RUNNER_TEMP"

- name: CLI bin smoke test
env:
BUNDLE_WORKSPACE_DEPENDENCIES: ${{ inputs.bundle_workspace_dependencies }}
run: |
TARBALL=$(find "$RUNNER_TEMP" -maxdepth 1 -name "onkernel-cua-cli-*.tgz" -print -quit)
SMOKE_DIR=$(mktemp -d)
cd "$SMOKE_DIR"
npm init -y > /dev/null
npm install "$RUNNER_TEMP"/onkernel-cua-cli-*.tgz
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
npm install "$TARBALL"
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" && "$BUNDLE_WORKSPACE_DEPENDENCIES" == "true" ]]; then
test -f node_modules/@onkernel/cua-cli/node_modules/@onkernel/cua-ai/dist/index.js
test -f node_modules/@onkernel/cua-cli/node_modules/@onkernel/cua-agent/dist/index.js
fi
Expand All @@ -208,6 +216,12 @@ jobs:
echo "$OUTPUT" | grep -q "Usage:"
echo "$OUTPUT" | grep -q "cua \[options\] \[prompt\.\.\.\]"

GLOBAL_DIR=$(mktemp -d)
npm install --global --prefix "$GLOBAL_DIR" "$TARBALL"
MODELS_OUTPUT=$("$GLOBAL_DIR/bin/cua" models -p openrouter)
echo "$MODELS_OUTPUT"
echo "$MODELS_OUTPUT" | grep -q "openrouter:moonshotai/kimi-k3"

- name: Publish production release
if: github.event_name == 'push'
run: npm publish --workspace @onkernel/cua-cli --access public
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 0.8.0 - 2026-08-03

- Queue messages submitted during an active turn for steering at the next agent
step. Pressing `esc` interrupts the active work and immediately starts a new
turn with any steering messages that were still queued.

## 0.7.0 - 2026-08-03

- Add `openrouter:moonshotai/kimi-k3` model selection and
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@onkernel/cua-cli",
"version": "0.7.0",
"version": "0.8.0",
"description": "Kernel-cloud-browser computer-use TUI built on @onkernel/cua-agent and pi-tui",
"license": "MIT",
"type": "module",
Expand Down
116 changes: 107 additions & 9 deletions packages/cli/src/tui/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,13 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>

let assistantBuffer: AssistantBuffer | undefined;
let inflight = 0;
let promptRunning = 0;
let turnRevision = 0;
let interruptState: { queued: string[]; cancelled: boolean } | undefined;
let lastDisplayedError: string | undefined;

const isTurnRunning = (): boolean => inflight > 0 || promptRunning > 0;

Comment thread
cursor[bot] marked this conversation as resolved.
// Ref of the live model, kept in sync by switchModel so the picker can mark
// it with a ✓. Undefined when opts.modelRef is not a catalog ref.
let currentModelRef: CuaModelRef | undefined = tryResolveModelRef(opts.modelRef);
Expand Down Expand Up @@ -229,7 +234,7 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>
* protection, so it refuses up front rather than failing on apply.
*/
const refuseWhileBusy = (command: string): boolean => {
if (inflight === 0) return false;
if (!isTurnRunning() && !interruptState) return false;
messages.addError(`${command} is unavailable while a turn is running`);
requestRender("selector_busy", false, { command });
return true;
Expand Down Expand Up @@ -496,10 +501,20 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>
});
};

const promptAgent = async (text: string): Promise<void> => {
promptRunning += 1;
try {
await opts.harness.prompt(text);
} finally {
promptRunning -= 1;
}
};

const runPrompt = async (text: string): Promise<void> => {
debug?.log("run_prompt_start", { length: text.length });
try {
const parsed = parseSlashCommand(text);
if (parsed && refuseWhileBusy(`/${parsed.command}`)) return;
if (parsed?.command === "model") {
const argument = parsed.argument.trim();
if (!argument) {
Expand Down Expand Up @@ -548,7 +563,21 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>
await opts.harness.skill(skill.name, skillRemainder);
return;
}
await opts.harness.prompt(text);
if (interruptState) {
interruptState.queued.push(text);
messages.addNotice(interruptState.cancelled ? "queued for after abort" : "queued for the interrupted turn");
requestRender("prompt_queued_during_interrupt");
return;
Comment thread
cursor[bot] marked this conversation as resolved.
}
if (isTurnRunning()) {
const revision = turnRevision;
await opts.harness.steer(text);
if (revision !== turnRevision) return;
messages.addNotice("queued for the next available turn");
requestRender("prompt_queued_for_steer");
return;
Comment thread
cursor[bot] marked this conversation as resolved.
}
await promptAgent(text);
} catch (err) {
messages.addError((err as Error).message);
debug?.log("run_prompt_error", { message: (err as Error).message });
Expand All @@ -568,13 +597,74 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>
void runPrompt(trimmed);
};

const startQueuedPrompt = (queued: string[], notice: string): void => {
messages.addNotice(`${notice}; sending ${queued.length} queued message${queued.length === 1 ? "" : "s"}`);
requestRender("queued_prompt_start", false, { queued: queued.length });
void promptAgent(queued.join("\n\n")).catch((err: unknown) => {
messages.addError((err as Error).message);
debug?.log("queued_prompt_error", { message: (err as Error).message });
requestRender("queued_prompt_error");
});
};

const interruptTurn = async (): Promise<void> => {
if (interruptState) return;
const state: { queued: string[]; cancelled: boolean } = { queued: [], cancelled: false };
interruptState = state;
turnRevision += 1;
messages.addNotice("interrupting…");
requestRender("input_interrupt_start", false, { key: "escape" });
try {
const { clearedSteer, clearedFollowUp } = await opts.harness.abort();
if (state.cancelled) {
const queued = state.queued;
state.queued = [];
if (queued.length > 0) {
interruptState = undefined;
startQueuedPrompt(queued, "abort complete");
}
return;
}
const queued = [
...clearedSteer.map(userMessageText).filter((text): text is string => !!text),
...clearedFollowUp.map(userMessageText).filter((text): text is string => !!text),
...state.queued,
];
state.queued = [];
if (queued.length === 0) {
messages.addNotice("turn aborted");
requestRender("input_abort_stream", false, { key: "escape" });
return;
}

interruptState = undefined;
startQueuedPrompt(queued, "turn interrupted");
} catch (err) {
state.queued = [];
messages.addError((err as Error).message);
debug?.log("input_interrupt_error", { message: (err as Error).message });
requestRender("input_interrupt_error");
} finally {
if (interruptState === state) interruptState = undefined;
}
};

const removeListener = tui.addInputListener((data) => {
// Input listeners run before the focused component, so an open picker has
// to own every key: otherwise ctrl+c / ctrl+d here would quit the app
// instead of cancelling the picker.
if (activeSelector) return undefined;
if (matchesKey(data, "ctrl+c")) {
if (inflight > 0) {
if (interruptState) {
interruptState.cancelled = true;
interruptState.queued = [];
messages.addNotice("aborted");
debug?.log("input_cancel_interrupt_replay", { key: "ctrl+c" });
requestRender("input_cancel_interrupt_replay", false, { key: "ctrl+c" });
return { consume: true };
}
if (isTurnRunning()) {
turnRevision += 1;
void opts.harness.abort();
messages.addNotice("aborted");
debug?.log("input_abort_stream", { key: "ctrl+c" });
Expand All @@ -591,11 +681,9 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>
debug?.log("input_exit_request", { key: "ctrl+d" });
return { consume: true };
}
if (matchesKey(data, "escape") && inflight > 0) {
void opts.harness.abort();
messages.addNotice("turn aborted");
debug?.log("input_abort_stream", { key: "escape" });
requestRender("input_abort_stream", false, { key: "escape" });
if (matchesKey(data, "escape") && (isTurnRunning() || interruptState)) {
void interruptTurn();
debug?.log("input_interrupt_stream", { key: "escape" });
return { consume: true };
}
return undefined;
Expand All @@ -616,7 +704,7 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>

await waitForExit(
() => exitRequested,
() => inflight > 0,
() => isTurnRunning() || !!interruptState,
);

return 0;
Expand Down Expand Up @@ -644,6 +732,16 @@ function modelLabel(model: Model<any> | undefined): string {
return model.id;
}

function userMessageText(message: AgentMessage): string | undefined {
if (message.role !== "user") return undefined;
if (typeof message.content === "string") return message.content.trim() || undefined;
const text = message.content
.filter((content) => content.type === "text")
.map((content) => content.text)
.join("");
return text.trim() || undefined;
}

function lastErrorMessage(messages: AgentMessage[]): string | undefined {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const m = messages[i];
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/test/fixtures/scripted-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
export type ScriptedStep =
| { type: "text"; text: string; chunkSize?: number; chunkMs?: number }
| { type: "tool_call"; toolName: string; args: Record<string, unknown>; id?: string }
| { type: "wait_abort" }
| { type: "wait_abort"; settleMs?: number }
| { type: "error"; message: string };

export interface ScriptedTurn {
Expand Down Expand Up @@ -154,6 +154,7 @@ function buildStream(model: Model<Api>, turn: ScriptedTurn | undefined, signal?:
contentIndex += 1;
} else if (step.type === "wait_abort") {
await waitForAbort(signal);
if (step.settleMs) await new Promise((resolve) => setTimeout(resolve, step.settleMs));
aborted = true;
break;
} else if (step.type === "error") {
Expand Down
25 changes: 25 additions & 0 deletions packages/cli/test/fixtures/tui-fixtures/interrupt-cancel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"modelRef": "openai:gpt-5.5",
"turns": [
{
"steps": [
{
"type": "text",
"text": "working..."
},
{
"type": "wait_abort",
"settleMs": 3000
}
]
},
{
"steps": [
{
"type": "text",
"text": "fixture response"
}
]
}
]
}
23 changes: 23 additions & 0 deletions packages/cli/test/fixtures/tui-fixtures/steer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"modelRef": "openai:gpt-5.5",
"turns": [
{
"steps": [
{
"type": "text",
"text": "working...",
"chunkSize": 5,
"chunkMs": 500
}
]
},
{
"steps": [
{
"type": "text",
"text": "queued response"
}
]
}
]
}
Loading
Loading