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
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ activity between the transcript and editor.
and error reporting; double-Esc rewind with input-point selection and safe
conversation/workspace scopes; unfocused turn-completion notifications through
terminal-native OSC 9 or BEL, with optional desktop commands; `/copy`,
`/clear`, `/exit`, Ctrl+C and Ctrl+D handling with token usage and resume
`/cls`, `/exit`, Ctrl+C and Ctrl+D handling with token usage and resume
guidance on exit.

## Workspace integration
Expand Down Expand Up @@ -186,6 +186,16 @@ Use `@plugin` when the whole Plugin is relevant, including its MCP servers or
Subagents. Use `$plugin:skill` when one exact Skill must be loaded before the
task starts.

Skill and custom-command discovery also works outside the TUI through the
runtime's subcommands, with `--json` for scripts:

```bash
zcode skills list # every discovered skill, plugin-qualified
zcode skills inspect <name> # full description, source path and metadata
zcode commands list # discovered custom slash commands
zcode commands inspect <name> # argument hints and resolved body
```

### Active-turn input

While a regular agent turn is running, press `Enter` to send the current text
Expand Down Expand Up @@ -256,8 +266,13 @@ picker to return to input selection, then `Esc` again to close rewind.
/transcript latest select the latest transcript block
/transcript next|prev|close navigate or leave transcript selection
/copy copy the selected block, or the latest response
/cls clear the visible transcript only
```

`/cls` clears what the TUI displays without touching the session. The
runtime's own `/clear` is an alias of `/new` and starts a fresh session, so it
is forwarded to the runtime unchanged.

The task center keeps autonomous task output out of the foreground transcript.
The main conversation receives only compact completion, reply and failure
notices; select the task to inspect its output and task-scoped activity. Agent
Expand Down
3 changes: 3 additions & 0 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
},
"headers": {},
"models": {
"glm-5.3": {
"name": "GLM-5.3"
},
"glm-5.2": {
"name": "GLM-5.2"
},
Expand Down
3 changes: 2 additions & 1 deletion docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ model-access paths below before sending a prompt.
Three model-access paths are supported:

- **Z.AI OAuth on macOS**: run `zcode login` when no provider is configured, or
`zcode login --oauth` to force reauthorization;
`zcode login --oauth` to force reauthorization; add `--no-browser` to print
the authorization URL instead of opening a browser (useful over SSH);
- **Z.AI/BigModel Coding Plan API key**: open `/login` in the TUI and choose the
matching masked API-key option;
- **Direct API key with a custom provider**: use the
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"sync:local": "bun run build && bun scripts/sync-runtime.ts --app /Applications/ZCode.app",
"check": "bun run build && bun scripts/check-runtime.ts",
"check:oauth-callback": "bun scripts/smoke-oauth-callback.ts",
"check:tui": "bun scripts/smoke-tui.ts && bun scripts/smoke-tui-features.ts && bun scripts/smoke-tui-pressure.ts && bun scripts/smoke-tui-widths.ts",
"check:tui": "bun scripts/smoke-tui.ts && bun scripts/smoke-tui-features.ts && bun scripts/smoke-tui-clear.ts && bun scripts/smoke-tui-pressure.ts && bun scripts/smoke-tui-widths.ts",
"test": "bun test",
"typecheck": "tsc --noEmit",
"verify:tui-perf": "bun scripts/verify-tui-perf.ts",
Expand Down
14 changes: 11 additions & 3 deletions packages/zcode-tui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,14 @@ const toolLifecycleEventKinds = new Set([
"closed"
]);

const runtimeCommandSummaries = new Map([
[
"login",
"Sign in with Z.AI/BigModel OAuth or a Coding Plan API key (`/login` opens a method picker)"
],
["new", "Start a fresh session (alias: /clear)"]
]);

const terminalThemeQueryTimeoutMs = 100;
const exitUsageQueryTimeoutMs = 250;
const updateAvailableBlockId = "update_available";
Expand Down Expand Up @@ -758,12 +766,12 @@ class ZCodeTui {
if (!name) continue;
commands.push({
name,
description: command.description ?? command.summary,
description: command.description ?? runtimeCommandSummaries.get(name) ?? command.summary,
argumentHint: command.argumentHint ?? command.inputHint ?? command.usage
});
}
for (const command of [
{ name: "clear", description: "Clear the visible transcript" },
{ name: "cls", description: "Clear the visible transcript (the runtime's /clear starts a new session)" },
{ name: "copy", description: "Copy the latest assistant response" },
{ name: "paste-image", description: "Attach an image from the system clipboard" },
{ name: "attachments", description: "Manage or clear pending attachments", argumentHint: "[clear]" },
Expand Down Expand Up @@ -943,7 +951,7 @@ class ZCodeTui {
this.stop();
return;
}
if (input === "/clear") {
if (input === "/cls") {
this.clearTranscriptProjection();
this.workflowView = undefined;
this.ui.requestRender(true);
Expand Down
84 changes: 84 additions & 0 deletions scripts/smoke-tui-clear.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#!/usr/bin/env bun
// Verify /cls clears the transcript locally and /clear is forwarded to the runtime.

import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

const root = join(import.meta.dir, "..");
const fixture = join(root, "test", "fixtures", "tui-clear.ts");
const temporaryHome = await mkdtemp(join(tmpdir(), "zcode-tui-clear-"));
const decoder = new TextDecoder();
let output = "";
const terminal = new Bun.Terminal({
cols: 110,
rows: 40,
name: "xterm-256color",
data(_terminal, data) {
output += decoder.decode(data, { stream: true });
}
});

const child = Bun.spawn([process.execPath, fixture], {
cwd: root,
env: {
...process.env,
CI: "1",
HOME: temporaryHome,
USERPROFILE: temporaryHome,
TERM: "xterm-256color",
TERM_PROGRAM: "iTerm.app"
},
terminal,
stdout: "ignore",
stderr: "ignore"
});

function plainText(value: string): string {
return value
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
.replace(/\x1bP[^\x07]*(?:\x07|\x1b\\)/g, "")
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
.replace(/\r/g, "");
}

async function waitFor(label: string, pattern: RegExp, start = 0, timeoutMs = 8_000): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (pattern.test(plainText(output.slice(start)))) return;
if (child.exitCode !== null) break;
await Bun.sleep(25);
}
throw new Error(`Timed out waiting for ${label}.\n${plainText(output).slice(-6_000)}`);
}

const timeout = setTimeout(() => child.kill("SIGKILL"), 30_000);
let failure: unknown;
try {
await waitFor("welcome screen", /ZCode/i);
await waitFor("restored transcript", /Restored startup response\./i);
terminal.write("/clear\r");
await waitFor("runtime /clear response", /Runtime handled \/clear as a new session\./i);
await waitFor("restored transcript still visible", /Restored later response\./i);
terminal.write("/cls\r");
await Bun.sleep(400);
// pi-tui repaints differentially; assert on the newest frame after the
// post-/cls full repaint (requestRender(true)) rather than all output.
const tail = plainText(output).slice(-12_000);
const lastFrame = tail.slice(Math.max(tail.lastIndexOf("◈"), tail.lastIndexOf("ZCode")));
if (/Restored (startup|later) (prompt|response)\./.test(lastFrame)) {
throw new Error(`/cls did not clear the visible transcript:\n${lastFrame.slice(-2_000)}`);
}
console.log("PASS: /clear is forwarded to the runtime and /cls clears the transcript");
} catch (error) {
failure = error;
} finally {
clearTimeout(timeout);
child.kill("SIGKILL");
await rm(temporaryHome, { recursive: true, force: true });
setTimeout(() => process.exit(failure ? 1 : 0), 50).unref();
}
if (failure) {
console.error(failure instanceof Error ? failure.message : String(failure));
process.exit(1);
}
27 changes: 27 additions & 0 deletions test/fixtures/tui-clear.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env bun
// Minimal runTui fixture for the /clear and /cls smoke check.

import { runTui } from "../../packages/zcode-tui/src/index.ts";

const sessionTranscript = [
{ messageId: "message_startup", role: "user", content: "Restored startup prompt." },
{ messageId: "message_startup_reply", role: "agent", content: "Restored startup response." },
{ messageId: "message_later", role: "user", content: "Restored later prompt." },
{ messageId: "message_later_reply", role: "agent", content: "Restored later response." }
];

await runTui({
model: "alpha/model",
modelOptions: [{ alias: "main", id: "alpha/model", name: "Alpha" }],
slashCommands: [{ name: "new", summary: "Start a fresh session." }],
loadSessionTranscript: async () => sessionTranscript,
submitPrompt: async (input) => {
if (input === "/clear") {
return { response: "Runtime handled /clear as a new session.", sessionId: "sess_new" };
}
return { response: `Echo: ${String(input)}` };
},
stdout: process.stdout,
stderr: process.stderr,
stdin: process.stdin
} as Parameters<typeof runTui>[0]);
57 changes: 57 additions & 0 deletions test/launcher-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,63 @@ describe("launcher/runtime integration", () => {
]));
}, 30_000);

test("lists and inspects workspace custom commands", async () => {
const directory = await mkdtemp(join(tmpdir(), "zcode-custom-commands-"));
try {
const commandDirectory = join(directory, ".zcode", "commands");
await mkdir(commandDirectory, { recursive: true });
await writeFile(join(commandDirectory, "smoke.md"), [
"---",
"description: Smoke command description.",
"argument-hint: <topic>",
"skills: browser-use:control-browser",
"---",
"",
"Summarize $ARGUMENTS.",
""
].join("\n"));

const listed = await run(["--cwd", directory, "commands", "list", "--json"]);
expect(listed.code).toBe(0);
expect(JSON.parse(listed.stdout)).toMatchObject({
commands: expect.arrayContaining([
expect.objectContaining({
argumentHint: "<topic>",
description: "Smoke command description.",
name: "smoke",
scope: "project",
skills: ["browser-use:control-browser"],
source: "zcode"
})
]),
cwd: directory,
diagnostics: [],
totalDiscovered: 1
});

const inspected = await run(["--cwd", directory, "commands", "inspect", "smoke", "--json"]);
expect(inspected.code).toBe(0);
expect(JSON.parse(inspected.stdout)).toMatchObject({
command: {
content: "Summarize $ARGUMENTS.",
metadata: expect.objectContaining({
argumentHint: "<topic>",
description: "Smoke command description.",
name: "smoke",
scope: "project",
skills: ["browser-use:control-browser"],
source: "zcode"
}),
truncated: false
},
cwd: directory,
diagnostics: []
});
} finally {
await rm(directory, { recursive: true, force: true });
}
}, 30_000);

test("passes app-server through unchanged and exposes Plugin references", async () => {
const workspacePath = root.replace(/\/$/u, "");
const request = {
Expand Down