From b5eeb769d1bbca4d4be0e41338c0b61b67911fa1 Mon Sep 17 00:00:00 2001 From: Kingsword Date: Sat, 15 Aug 2026 09:25:55 +0800 Subject: [PATCH] fix(tui): split /cls local clear from runtime /clear --- README.md | 17 ++++++- config.example.json | 3 ++ docs/CONFIGURATION.md | 3 +- package.json | 2 +- packages/zcode-tui/src/index.ts | 14 ++++-- scripts/smoke-tui-clear.ts | 84 +++++++++++++++++++++++++++++++++ test/fixtures/tui-clear.ts | 27 +++++++++++ test/launcher-runtime.test.ts | 57 ++++++++++++++++++++++ 8 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 scripts/smoke-tui-clear.ts create mode 100644 test/fixtures/tui-clear.ts diff --git a/README.md b/README.md index 8aec9b3..3240ff4 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 # full description, source path and metadata +zcode commands list # discovered custom slash commands +zcode commands inspect # argument hints and resolved body +``` + ### Active-turn input While a regular agent turn is running, press `Enter` to send the current text @@ -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 diff --git a/config.example.json b/config.example.json index d544b2f..e162cbb 100644 --- a/config.example.json +++ b/config.example.json @@ -9,6 +9,9 @@ }, "headers": {}, "models": { + "glm-5.3": { + "name": "GLM-5.3" + }, "glm-5.2": { "name": "GLM-5.2" }, diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 14a9df9..3ddfaba 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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 diff --git a/package.json b/package.json index 1749cca..717caab 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index 4f70407..7f534ec 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -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"; @@ -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]" }, @@ -943,7 +951,7 @@ class ZCodeTui { this.stop(); return; } - if (input === "/clear") { + if (input === "/cls") { this.clearTranscriptProjection(); this.workflowView = undefined; this.ui.requestRender(true); diff --git a/scripts/smoke-tui-clear.ts b/scripts/smoke-tui-clear.ts new file mode 100644 index 0000000..5a6195c --- /dev/null +++ b/scripts/smoke-tui-clear.ts @@ -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 { + 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); +} diff --git a/test/fixtures/tui-clear.ts b/test/fixtures/tui-clear.ts new file mode 100644 index 0000000..49e7bbb --- /dev/null +++ b/test/fixtures/tui-clear.ts @@ -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[0]); diff --git a/test/launcher-runtime.test.ts b/test/launcher-runtime.test.ts index 7b6033e..58fe5f9 100644 --- a/test/launcher-runtime.test.ts +++ b/test/launcher-runtime.test.ts @@ -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: ", + "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: "", + 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: "", + 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 = {