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:
nerdielol
2025-04-21 12:39:46 -04:00
committed by GitHub
parent 5b19451770
commit 797eba4930
4 changed files with 197 additions and 6 deletions

View File

@@ -191,6 +191,12 @@ export default function TerminalChatInput({
case "/bug":
onSubmit(cmd);
break;
case "/clear":
onSubmit(cmd);
break;
case "/clearhistory":
onSubmit(cmd);
break;
default:
break;
}
@@ -396,19 +402,29 @@ export default function TerminalChatInput({
setInput("");
setSessionId("");
setLastResponseId("");
// Clear the terminal screen (including scrollback) before resetting context
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*
// it so Ink's <Static> treats it as new output and actually renders it.
setItems((prev) => {
const filteredOldItems = prev.filter((item) => {
// Remove any tokenheavy entries (user/assistant turns and function calls)
if (
item.type === "message" &&
(item.role === "user" || item.role === "assistant")
) {
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 [

View File

@@ -263,17 +263,16 @@ export default function TerminalChatInput({
setInput("");
setSessionId("");
setLastResponseId("");
// Clear the terminal screen (including scrollback) before resetting context
clearTerminal();
// 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.
setItems((prev) => [
...prev,
// Print a clear confirmation and reset conversation items.
setItems([
{
id: `clear-${Date.now()}`,
type: "message",
role: "system",
content: [{ type: "input_text", text: "Context cleared" }],
content: [{ type: "input_text", text: "Terminal cleared" }],
},
]);

View File

@@ -50,6 +50,8 @@ export function clearTerminal(): void {
if (inkRenderer) {
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 {

View 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"];
}
});
});