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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,32 @@ Then one that makes changes:
Create a reference table for the error codes in src/api/errors.ts (code, meaning, trigger scenario) and save it to docs/errors.md
```

### Ultracode: multi-agent workflows

Use Ultracode to opt into multi-agent workflow orchestration for one task or for the current session. When workflows are available, enter these commands in the interactive TUI:

| Command | Effect |
| --- | --- |
| `/ultracode on` | Enable workflow orchestration for the session |
| `/ultracode off` | Turn off session mode |
| `/ultracode status` | Show the current session mode |
| `/ultracode help` | Show usage and the current session mode |
| `/ultracode` | Same as `help`; leaves the mode unchanged |

`/ultraloop` is an exact alias with the same `on`, `off`, `status`, and `help` subcommands; both names control the same mode. The editor completes these subcommands. Bare commands, `help`, and `status` leave the mode unchanged.

While enabled, the status line shows `Ultracode on`. Session mode stays on across turns until `/ultracode off` or a session boundary, when it resets to off.

For one turn, send `ultracode: <task>` (or `ultraloop: <task>`) in the TUI or as a launch prompt:

```bash
step -p "ultracode: Review the API changes and verify the tests"
```

If the commands are missing, check `STEP_DISABLE_WORKFLOW` and whether the host enables workflow support. A restricted tool profile must also expose `workflow`; the session switch does not expand tool access.

The prompt prefix opts in only that turn and leaves session mode unchanged. Workflow availability and tool discovery alone do not grant consent to run workflows. Ultracode leaves tool permissions and model thinking settings unchanged. See the [workflow lifecycle](docs/orchestration-lifecycle.md) for opt-in and budget scope.

### Resume a session

```bash
Expand Down
12 changes: 11 additions & 1 deletion apps/cli/src/ui/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,14 @@ export function applyStepKeybindingRemap(keybindings: KeybindingsManager): void
* model/effort/mode switches most often, so they lead the list instead of
* following builtin registration order (feedback issue-c6b8e3bb543482b7).
*/
const STEP_SLASH_COMMAND_PRIORITY: readonly string[] = ["model", "permissions", "effort", "thinking", "plan"];
const STEP_SLASH_COMMAND_PRIORITY: readonly string[] = [
"model",
"permissions",
"ultracode",
"effort",
"thinking",
"plan",
];

/** Exported for the acceptance test suite (tui-acceptance-interactions.test.ts). */
// 结构重构(代码结构方案步骤 4)时迁往 ui/runtime/input-dispatch.ts —— 斜杠命令分派属于交互编排。
Expand Down Expand Up @@ -727,6 +734,9 @@ export class InteractiveMode {
thinkingLevel: this.session.model?.reasoning ? this.session.thinkingLevel : undefined,
workspaceRoot: this.sessionManager.getCwd(),
sessionId: this.sessionManager.getSessionId(),
ultracodeAvailable:
this.session.getAllTools().some((tool) => tool.name === "workflow") &&
this.session.extensionRunner.getCommand("ultracode") !== undefined,
}),
{
requestRender: () => this.redraw.requestRender(),
Expand Down
14 changes: 9 additions & 5 deletions apps/cli/src/ui/view/chrome/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,8 @@ export class FooterComponent implements Component {
const warning = (text: string) => theme.fg("warning", text);
const error = (text: string) => theme.fg("error", text);

const permissionStatus = this.footerData.getExtensionStatuses().get("step-permission");
const extensionStatuses = this.footerData.getExtensionStatuses();
const permissionStatus = extensionStatuses.get("step-permission");
const statusPreset = permissionStatus?.match(/^Mode:\s*([^()]+?)(?:\s*\(auto-resume\))?$/u)?.[1]?.trim();
// Extension statuses use human-readable labels (for example "Read Only"),
// while embedded hosts may expose the corresponding id ("read-only" or
Expand Down Expand Up @@ -342,6 +343,11 @@ export class FooterComponent implements Component {
const segments: string[] = [
displayMode.paint(`⏵ ${displayMode.label}`) + (cycleHint ? muted(` (${cycleHint})`) : ""),
];
// Keep session mode ahead of model/path details so it survives compact-layout truncation.
if (safeWidth >= 80) {
const ultracodeStatus = sanitizeStatusText(extensionStatuses.get("ultracode") ?? "");
if (ultracodeStatus) segments.push(muted(ultracodeStatus));
}

const model = state.model?.id;
if (safeWidth >= 60 && model) {
Expand All @@ -364,12 +370,10 @@ export class FooterComponent implements Component {
}
}

const extensionStatuses = this.footerData.getExtensionStatuses();
if (safeWidth >= 100) {
for (const [key, status] of [...extensionStatuses.entries()].sort(([a], [b]) => a.localeCompare(b))) {
// Permission is already the leading segment; keeping its status here
// duplicates the mode on wide terminals.
if (key === "step-permission") continue;
// Permission and Ultracode already have leading segments.
if (key === "step-permission" || key === "ultracode") continue;
const cleaned = status
.replace(/[\r\n\t]+/gu, " ")
.replace(/ +/gu, " ")
Expand Down
15 changes: 9 additions & 6 deletions apps/cli/src/ui/view/chrome/step-welcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export interface StepWelcomeInfo {
thinkingLevel?: string;
workspaceRoot: string;
sessionId?: string;
/** Whether the workflow tool and Ultracode command are registered for this session. */
ultracodeAvailable?: boolean;
}

const WELCOME_MARK = renderStepMark();
Expand Down Expand Up @@ -59,11 +61,11 @@ const WELCOME_TIPS = [
command: "/goal",
description: "Set a goal and keep working toward it across turns.",
},
{
command: "ultracode",
description: "Include this keyword in your prompt to enable parallel subagents.",
},
] as const;
const ULTRACODE_TIP = {
command: "/ultracode on",
description: "Use parallel agents for this session. One turn: ultracode: task.",
} as const;

const graphemeSegmenter = new Intl.Segmenter(undefined, {
granularity: "grapheme",
Expand Down Expand Up @@ -348,8 +350,9 @@ export class StepWelcomeComponent implements Component {
return `${paintStepWordmarkBorder("│ ")}${row}${" ".repeat(padding)}${paintStepWordmarkBorder(" │")}`;
};
const tips = ["", muted("Tips")];
const prefixWidth = Math.max(...WELCOME_TIPS.map((tip) => visibleWidth(tip.command))) + 2;
for (const tip of WELCOME_TIPS) {
const welcomeTips = info.ultracodeAvailable ? [...WELCOME_TIPS, ULTRACODE_TIP] : WELCOME_TIPS;
const prefixWidth = Math.max(...welcomeTips.map((tip) => visibleWidth(tip.command))) + 2;
for (const tip of welcomeTips) {
const prefix = tip.command + " ".repeat(prefixWidth - visibleWidth(tip.command));
if (innerWidth - prefixWidth < 24) {
tips.push(...wrapMultiline(tip.command, innerWidth).map(brand));
Expand Down
17 changes: 5 additions & 12 deletions apps/cli/test/__snapshots__/tui-acceptance-snapshot.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,9 @@ exports[`A. 欢迎屏快照 > 中终端走方块 mark 档(A1) > welcome-mark
"│ ██ ██ │",
"│ │",
"│ Tips │",
"│ /cron View and manage scheduled tasks. │",
"│ /goal Set a goal and keep working toward │",
"│ it across turns. │",
"│ ultracode Include this keyword in your prompt │",
"│ to enable parallel subagents. │",
"│ /cron View and manage scheduled tasks. │",
"│ /goal Set a goal and keep working toward it a │",
"│ cross turns. │",
"╰────────────────────────────────────────────────╯",
"",
]
Expand All @@ -33,9 +31,8 @@ exports[`A. 欢迎屏快照 > 宽终端走小鸟档(A1/A2/A4) > welcome-bird
"│ ▀▀▀ ▀▀▀ │",
"│ │",
"│ Tips │",
"│ /cron View and manage scheduled tasks. │",
"│ /goal Set a goal and keep working toward it across turns. │",
"│ ultracode Include this keyword in your prompt to enable parallel subagents. │",
"│ /cron View and manage scheduled tasks. │",
"│ /goal Set a goal and keep working toward it across turns. │",
"╰──────────────────────────────────────────────────────────────────────────────────────────────────╯",
"",
]
Expand All @@ -57,10 +54,6 @@ exports[`A. 欢迎屏快照 > 窄终端走徽章档且不越界(A1/A4) > wel
"│ Set a goal and keep work │",
"│ ing toward it across tur │",
"│ ns. │",
"│ ultracode │",
"│ Include this keyword in │",
"│ your prompt to enable pa │",
"│ rallel subagents. │",
"╰────────────────────────────╯",
"",
]
Expand Down
63 changes: 63 additions & 0 deletions apps/cli/test/footer-width.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,69 @@ describe("FooterComponent width handling", () => {
expect(plain).toContain("plan 1/2");
});

it.each([
{ width: 80, showOtherStatuses: false },
{ width: 120, showOtherStatuses: true },
])("shows the session mode badge once at $width columns", ({ width, showOtherStatuses }) => {
const session = createSession({
sessionName: "",
modelId: "step-3.8",
reasoning: true,
thinkingLevel: "high",
});
const footer = new FooterComponent(
session,
createFooterData(
1,
new Map([
["step-permission", "Mode: Ask"],
["ultracode", "Ultracode on"],
["plan-mode", "plan 1/2"],
]),
),
{ presentation: "step", permissionCycleKey: () => "shift+tab" },
);

const [line = ""] = footer.render(width);
const plain = stripAnsi(line);
expect(plain).toContain("Ultracode on");
expect(plain.match(/Ultracode on/gu)).toHaveLength(1);
expect(plain).toContain("⏵ Ask (shift+tab)");
expect(plain).not.toContain("Mode: Ask");
expect(plain.includes("plan 1/2")).toBe(showOtherStatuses);
expect(plain).toMatch(/88% context left$/u);
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
});

it.each([80, 120])("removes the mode badge when its extension status is cleared at width %i", (width) => {
const statuses = new Map<string, string>();
const footer = new FooterComponent(createSession({ sessionName: "" }), createFooterData(1, statuses), {
presentation: "step",
});
const disabled = footer.render(width);
expect(stripAnsi(disabled.join("\n"))).not.toContain("Ultracode");

statuses.set("ultracode", "Ultracode on");
expect(stripAnsi(footer.render(width).join("\n"))).toContain("Ultracode on");
statuses.delete("ultracode");
expect(footer.render(width)).toEqual(disabled);
});

it("keeps the mode badge visible ahead of long model and workspace details at 80 columns", () => {
const session = createSession({ sessionName: "", modelId: `model-${"x".repeat(100)}` });
vi.spyOn(session.sessionManager, "getCwd").mockReturnValue(`/tmp/${"workspace-".repeat(20)}`);
const footer = new FooterComponent(session, createFooterData(1, new Map([["ultracode", "Ultracode on"]])), {
presentation: "step",
permissionCycleKey: () => "shift+tab",
});

const [line = ""] = footer.render(80);
const plain = stripAnsi(line);
expect(plain).toContain("Ultracode on");
expect(plain).toMatch(/88% context left$/u);
expect(visibleWidth(line)).toBeLessThanOrEqual(80);
});

it("keeps the Step footer within narrow widths", () => {
const footer = new FooterComponent(createSession({ sessionName: "" }), createFooterData(1), {
presentation: "step",
Expand Down
40 changes: 30 additions & 10 deletions apps/cli/test/step-welcome-tips.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,48 +6,68 @@ import { StepWelcomeComponent } from "../src/ui/view/chrome/step-welcome.ts";
afterEach(() => initTheme("dark"));

describe("welcome tip alignment", () => {
it.each([39, 40, 80, 120])("aligns descriptions and wrapped lines at width %i", (width) => {
it.each([43, 44, 80, 120])("aligns descriptions and wrapped lines at width %i", (width) => {
initTheme("step-blue");
const component = new StepWelcomeComponent(() => ({ workspaceRoot: "/tmp/project" }));
const component = new StepWelcomeComponent(() => ({
workspaceRoot: "/tmp/project",
ultracodeAvailable: true,
}));
const lines = component.render(width).map(stripTerminalSequences);
const tipsStart = lines.findIndex((line) => line.includes("Tips")) + 1;
const tipsEnd = lines.findIndex((line) => line.includes("╰"));
const tips = lines.slice(tipsStart, tipsEnd);
const descriptions = [
["/cron", "View and manage scheduled tasks."],
["/goal", "Set a goal and keep working toward it across turns."],
["ultracode", "Include this keyword in your prompt to enable parallel subagents."],
["/ultracode on", "Use parallel agents for this session. One turn: ultracode: task."],
] as const;
for (const [command, description] of descriptions) {
const start = tips.findIndex((line) => line.startsWith(`│ ${command} `));
expect(start).toBeGreaterThanOrEqual(0);
expect(tips[start]!.slice(0, 13)).toBe(`│ ${command.padEnd(11)}`);
expect(tips[start]!.slice(0, 17)).toBe(`│ ${command.padEnd(15)}`);
const rows = [tips[start]!];
for (const line of tips.slice(start + 1)) {
if (!line.startsWith(`│ ${" ".repeat(11)}`)) break;
if (!line.startsWith(`│ ${" ".repeat(15)}`)) break;
rows.push(line);
}
const descriptionWidth = width - 15;
const descriptionWidth = width - 19;
expect(rows).toHaveLength(Math.ceil(description.length / descriptionWidth));
for (const [index, line] of rows.entries()) {
expect(line.slice(13, -2).trimEnd()).toBe(
expect(line.slice(17, -2).trimEnd()).toBe(
description.slice(index * descriptionWidth, (index + 1) * descriptionWidth).trimEnd(),
);
}
}
for (const line of lines) expect(visibleWidth(line)).toBeLessThanOrEqual(width);
});

it.each([20, 30, 38])("stacks all descriptions consistently at width %i", (width) => {
it.each([20, 30, 38, 39, 40, 42])("stacks all descriptions consistently at width %i", (width) => {
initTheme("step-blue");
const component = new StepWelcomeComponent(() => ({ workspaceRoot: "/tmp/project" }));
const component = new StepWelcomeComponent(() => ({
workspaceRoot: "/tmp/project",
ultracodeAvailable: true,
}));
const lines = component.render(width).map(stripTerminalSequences);
for (const command of ["/cron", "/goal", "ultracode"]) {
for (const command of ["/cron", "/goal", "/ultracode on"]) {
const index = lines.findIndex((line) => line.startsWith(`│ ${command} `));
expect(index).toBeGreaterThanOrEqual(0);
expect(lines[index]!.slice(2, -2).trim()).toBe(command);
expect(lines[index + 1]).toMatch(/^│ {3}\S/u);
}
for (const line of lines) expect(visibleWidth(line)).toBeLessThanOrEqual(width);
});

it.each([35, 36, 80, 120])("uses only visible tips to align descriptions at width %i", (width) => {
initTheme("step-blue");
const component = new StepWelcomeComponent(() => ({
workspaceRoot: "/tmp/project",
ultracodeAvailable: false,
}));
const lines = component.render(width).map(stripTerminalSequences);

expect(lines.some((line) => line.startsWith("│ /cron View and manage"))).toBe(true);
expect(lines.some((line) => line.startsWith("│ /goal Set a goal"))).toBe(true);
expect(lines.join("\n")).not.toContain("ultracode");
for (const line of lines) expect(visibleWidth(line)).toBeLessThanOrEqual(width);
});
});
46 changes: 45 additions & 1 deletion apps/cli/test/step-welcome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,49 @@ describe("StepWelcomeComponent", () => {
expect(output).not.toContain("step-model · off");
});

it("shows the session command and a separate one-turn example when Ultracode is available", () => {
initTheme("step-blue");
const component = new StepWelcomeComponent(() => ({
workspaceRoot: "/tmp/project",
ultracodeAvailable: true,
}));

const output = component.render(120).map(stripTerminalSequences).join("\n");
expect(output).toContain("/ultracode on");
expect(output).toContain("Use parallel agents for this session.");
expect(output).toContain("One turn: ultracode: task.");
});

it.each([false, undefined])("omits the Ultracode tip when availability is %s", (ultracodeAvailable) => {
initTheme("step-blue");
const component = new StepWelcomeComponent(() => ({
workspaceRoot: "/tmp/project",
ultracodeAvailable,
}));

for (const width of [30, 120]) {
const output = component.render(width).map(stripTerminalSequences).join("\n");
expect(output).not.toMatch(/ultracode|ultraloop|parallel subagents/u);
expect(output).toContain("/cron");
expect(output).toContain("/goal");
}
});

it("reads capability availability again when rendering", () => {
initTheme("step-blue");
let ultracodeAvailable = true;
const component = new StepWelcomeComponent(() => ({
workspaceRoot: "/tmp/project",
ultracodeAvailable,
}));

expect(component.render(120).join("\n")).toContain("/ultracode on");
ultracodeAvailable = false;
expect(component.render(120).join("\n")).not.toContain("ultracode");
ultracodeAvailable = true;
expect(component.render(120).join("\n")).toContain("/ultracode on");
});

it("removes the first-session hint after a message is projected", () => {
initTheme("step-blue");
const component = new StepWelcomeComponent(() => ({
Expand Down Expand Up @@ -172,10 +215,11 @@ describe("StepWelcomeComponent", () => {
expect(renderRequests).toBe(0);
});

it("does not overflow a very narrow terminal", () => {
it.each([false, true])("does not overflow narrow terminals with Ultracode available: %s", (ultracodeAvailable) => {
initTheme("step-blue");
const component = new StepWelcomeComponent(() => ({
workspaceRoot: "/tmp/project",
ultracodeAvailable,
}));
for (const width of [1, 4, 7, 8, 11, 30, 46, 47, 58, 59, 60, 80, 100]) {
for (const line of component.render(width)) expect(visibleWidth(line)).toBeLessThanOrEqual(width);
Expand Down
Loading
Loading