fix: /clear now clears terminal screen and resets context left indicator (#425)
## What does this PR do?
* Implements the full `/clear` command in **codex‑cli**:
* Resets chat history **and** wipes the terminal screen.
* Shows a single system message: `Context cleared`.
* Adds comprehensive unit tests for the new behaviour.
## Why is it needed?
* Fixes user‑reported bugs:
* **#395**
* **#405**
## How is it implemented?
* **Code** – Adds `process.stdout.write('\x1b[3J\x1b[H\x1b[2J')` in
`terminal.tsx`. Removed reference to `prev` in `
setItems((prev) => [
...prev,
` in `terminal-chat-new-input.tsx` & `terminal-chat-input.tsx`.
## CI / QA
All commands pass locally:
```bash
pnpm test # green
pnpm run lint # green
pnpm run typecheck # zero TS errors
```
## Results
https://github.com/user-attachments/assets/11dcf05c-e054-495a-8ecb-ac6ef21a9da4
---------
Co-authored-by: Thibault Sottiaux <tibo@openai.com>
This commit is contained in:
@@ -191,6 +191,12 @@ export default function TerminalChatInput({
|
|||||||
case "/bug":
|
case "/bug":
|
||||||
onSubmit(cmd);
|
onSubmit(cmd);
|
||||||
break;
|
break;
|
||||||
|
case "/clear":
|
||||||
|
onSubmit(cmd);
|
||||||
|
break;
|
||||||
|
case "/clearhistory":
|
||||||
|
onSubmit(cmd);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -396,19 +402,29 @@ export default function TerminalChatInput({
|
|||||||
setInput("");
|
setInput("");
|
||||||
setSessionId("");
|
setSessionId("");
|
||||||
setLastResponseId("");
|
setLastResponseId("");
|
||||||
|
// Clear the terminal screen (including scrollback) before resetting context
|
||||||
clearTerminal();
|
clearTerminal();
|
||||||
|
|
||||||
|
// Emit a system notice in the chat; no raw console writes so Ink keeps control.
|
||||||
|
|
||||||
// Emit a system message to confirm the clear action. We *append*
|
// Emit a system message to confirm the clear action. We *append*
|
||||||
// it so Ink's <Static> treats it as new output and actually renders it.
|
// it so Ink's <Static> treats it as new output and actually renders it.
|
||||||
setItems((prev) => {
|
setItems((prev) => {
|
||||||
const filteredOldItems = prev.filter((item) => {
|
const filteredOldItems = prev.filter((item) => {
|
||||||
|
// Remove any token‑heavy entries (user/assistant turns and function calls)
|
||||||
if (
|
if (
|
||||||
item.type === "message" &&
|
item.type === "message" &&
|
||||||
(item.role === "user" || item.role === "assistant")
|
(item.role === "user" || item.role === "assistant")
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
if (
|
||||||
|
item.type === "function_call" ||
|
||||||
|
item.type === "function_call_output"
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true; // keep developer/system and other meta entries
|
||||||
});
|
});
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -263,17 +263,16 @@ export default function TerminalChatInput({
|
|||||||
setInput("");
|
setInput("");
|
||||||
setSessionId("");
|
setSessionId("");
|
||||||
setLastResponseId("");
|
setLastResponseId("");
|
||||||
|
// Clear the terminal screen (including scrollback) before resetting context
|
||||||
clearTerminal();
|
clearTerminal();
|
||||||
|
|
||||||
// Emit a system message to confirm the clear action. We *append*
|
// Print a clear confirmation and reset conversation items.
|
||||||
// it so Ink's <Static> treats it as new output and actually renders it.
|
setItems([
|
||||||
setItems((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
{
|
||||||
id: `clear-${Date.now()}`,
|
id: `clear-${Date.now()}`,
|
||||||
type: "message",
|
type: "message",
|
||||||
role: "system",
|
role: "system",
|
||||||
content: [{ type: "input_text", text: "Context cleared" }],
|
content: [{ type: "input_text", text: "Terminal cleared" }],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export function clearTerminal(): void {
|
|||||||
if (inkRenderer) {
|
if (inkRenderer) {
|
||||||
inkRenderer.clear();
|
inkRenderer.clear();
|
||||||
}
|
}
|
||||||
|
// Also clear scrollback and primary buffer to ensure a truly blank slate
|
||||||
|
process.stdout.write("\x1b[3J\x1b[H\x1b[2J");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onExit(): void {
|
export function onExit(): void {
|
||||||
|
|||||||
174
codex-cli/tests/clear-command.test.tsx
Normal file
174
codex-cli/tests/clear-command.test.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import React from "react";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { renderTui } from "./ui-test-helpers.js";
|
||||||
|
import TerminalChatInput from "../src/components/chat/terminal-chat-input.js";
|
||||||
|
import TerminalChatNewInput from "../src/components/chat/terminal-chat-new-input.js";
|
||||||
|
import * as TermUtils from "../src/utils/terminal.js";
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function type(
|
||||||
|
stdin: NodeJS.WritableStream,
|
||||||
|
text: string,
|
||||||
|
flush: () => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
stdin.write(text);
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("/clear command", () => {
|
||||||
|
it("invokes clearTerminal and resets context in TerminalChatInput", async () => {
|
||||||
|
const clearSpy = vi
|
||||||
|
.spyOn(TermUtils, "clearTerminal")
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
|
const setItems = vi.fn();
|
||||||
|
|
||||||
|
// Minimal stub of a ResponseItem – cast to bypass exhaustive type checks in this test context
|
||||||
|
const existingItems = [
|
||||||
|
{
|
||||||
|
id: "dummy-1",
|
||||||
|
type: "message",
|
||||||
|
role: "system",
|
||||||
|
content: [{ type: "input_text", text: "Old item" }],
|
||||||
|
},
|
||||||
|
] as Array<any>;
|
||||||
|
|
||||||
|
const props: ComponentProps<typeof TerminalChatInput> = {
|
||||||
|
isNew: false,
|
||||||
|
loading: false,
|
||||||
|
submitInput: () => {},
|
||||||
|
confirmationPrompt: null,
|
||||||
|
explanation: undefined,
|
||||||
|
submitConfirmation: () => {},
|
||||||
|
setLastResponseId: () => {},
|
||||||
|
setItems,
|
||||||
|
contextLeftPercent: 100,
|
||||||
|
openOverlay: () => {},
|
||||||
|
openModelOverlay: () => {},
|
||||||
|
openApprovalOverlay: () => {},
|
||||||
|
openHelpOverlay: () => {},
|
||||||
|
openDiffOverlay: () => {},
|
||||||
|
onCompact: () => {},
|
||||||
|
interruptAgent: () => {},
|
||||||
|
active: true,
|
||||||
|
thinkingSeconds: 0,
|
||||||
|
items: existingItems,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { stdin, flush, cleanup } = renderTui(
|
||||||
|
<TerminalChatInput {...props} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
await type(stdin, "/clear", flush);
|
||||||
|
await type(stdin, "\r", flush); // press Enter
|
||||||
|
|
||||||
|
// Allow any asynchronous state updates to propagate
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(clearSpy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(setItems).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
const stateUpdater = setItems.mock.calls[0]![0];
|
||||||
|
expect(typeof stateUpdater).toBe("function");
|
||||||
|
const newItems = stateUpdater(existingItems);
|
||||||
|
expect(Array.isArray(newItems)).toBe(true);
|
||||||
|
expect(newItems).toHaveLength(2);
|
||||||
|
expect(newItems.at(-1)).toMatchObject({
|
||||||
|
role: "system",
|
||||||
|
type: "message",
|
||||||
|
content: [{ type: "input_text", text: "Terminal cleared" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
clearSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invokes clearTerminal and resets context in TerminalChatNewInput", async () => {
|
||||||
|
const clearSpy = vi
|
||||||
|
.spyOn(TermUtils, "clearTerminal")
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
|
const setItems = vi.fn();
|
||||||
|
|
||||||
|
const props: ComponentProps<typeof TerminalChatNewInput> = {
|
||||||
|
isNew: false,
|
||||||
|
loading: false,
|
||||||
|
submitInput: () => {},
|
||||||
|
confirmationPrompt: null,
|
||||||
|
explanation: undefined,
|
||||||
|
submitConfirmation: () => {},
|
||||||
|
setLastResponseId: () => {},
|
||||||
|
setItems,
|
||||||
|
contextLeftPercent: 100,
|
||||||
|
openOverlay: () => {},
|
||||||
|
openModelOverlay: () => {},
|
||||||
|
openApprovalOverlay: () => {},
|
||||||
|
openHelpOverlay: () => {},
|
||||||
|
openDiffOverlay: () => {},
|
||||||
|
interruptAgent: () => {},
|
||||||
|
active: true,
|
||||||
|
thinkingSeconds: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { stdin, flush, cleanup } = renderTui(
|
||||||
|
<TerminalChatNewInput {...props} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
await type(stdin, "/clear", flush);
|
||||||
|
await type(stdin, "\r", flush); // press Enter
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(clearSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(setItems).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const firstArg = setItems.mock.calls[0]![0];
|
||||||
|
expect(Array.isArray(firstArg)).toBe(true);
|
||||||
|
expect(firstArg).toHaveLength(1);
|
||||||
|
expect(firstArg[0]).toMatchObject({
|
||||||
|
role: "system",
|
||||||
|
type: "message",
|
||||||
|
content: [{ type: "input_text", text: "Terminal cleared" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
clearSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearTerminal", () => {
|
||||||
|
it("writes escape sequence to stdout", () => {
|
||||||
|
const originalQuiet = process.env["CODEX_QUIET_MODE"];
|
||||||
|
delete process.env["CODEX_QUIET_MODE"];
|
||||||
|
|
||||||
|
process.env["CODEX_QUIET_MODE"] = "0";
|
||||||
|
|
||||||
|
const writeSpy = vi
|
||||||
|
.spyOn(process.stdout, "write")
|
||||||
|
.mockImplementation(() => true);
|
||||||
|
|
||||||
|
TermUtils.clearTerminal();
|
||||||
|
|
||||||
|
expect(writeSpy).toHaveBeenCalledWith("\x1b[3J\x1b[H\x1b[2J");
|
||||||
|
|
||||||
|
writeSpy.mockRestore();
|
||||||
|
|
||||||
|
if (originalQuiet !== undefined) {
|
||||||
|
process.env["CODEX_QUIET_MODE"] = originalQuiet;
|
||||||
|
} else {
|
||||||
|
delete process.env["CODEX_QUIET_MODE"];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user