fix: /bug report command, thinking indicator (#381)

- Fix `/bug` report command
- Fix thinking indicator
This commit is contained in:
Fouad Matin
2025-04-18 18:13:34 -07:00
committed by GitHub
parent c40f4891d4
commit aa32e22d4b
12 changed files with 154 additions and 183 deletions

View File

@@ -1,20 +0,0 @@
#!/usr/bin/env sh
# resolve script path in case of symlink
SOURCE="$0"
while [ -h "$SOURCE" ]; do
DIR=$(dirname "$SOURCE")
SOURCE=$(readlink "$SOURCE")
case "$SOURCE" in
/*) ;; # absolute path
*) SOURCE="$DIR/$SOURCE" ;; # relative path
esac
done
DIR=$(cd "$(dirname "$SOURCE")" && pwd)
if command -v node >/dev/null 2>&1; then
exec node "$DIR/../dist/cli.js" "$@"
elif command -v bun >/dev/null 2>&1; then
exec bun "$DIR/../dist/cli.js" "$@"
else
echo "Error: node or bun is required to run codex" >&2
exit 1
fi

3
codex-cli/bin/codex.js Normal file → Executable file
View File

@@ -1,4 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
// Unified entry point for Codex CLI on all platforms // Unified entry point for Codex CLI on all platforms
// Dynamically loads the compiled ESM bundle in dist/cli.js // Dynamically loads the compiled ESM bundle in dist/cli.js
@@ -18,7 +19,9 @@ const cliUrl = pathToFileURL(cliPath).href;
try { try {
await import(cliUrl); await import(cliUrl);
} catch (err) { } catch (err) {
// eslint-disable-next-line no-console
console.error(err); console.error(err);
// eslint-disable-next-line no-undef
process.exit(1); process.exit(1);
} }
})(); })();

View File

@@ -47,7 +47,7 @@
"marked-terminal": "^7.3.0", "marked-terminal": "^7.3.0",
"meow": "^13.2.0", "meow": "^13.2.0",
"open": "^10.1.0", "open": "^10.1.0",
"openai": "^4.89.0", "openai": "^4.95.1",
"react": "^18.2.0", "react": "^18.2.0",
"shell-quote": "^1.8.2", "shell-quote": "^1.8.2",
"strip-ansi": "^7.1.0", "strip-ansi": "^7.1.0",

View File

@@ -1,82 +1,28 @@
import { log, isLoggingEnabled } from "../../utils/agent/log.js"; import { log, isLoggingEnabled } from "../../utils/agent/log.js";
import Spinner from "../vendor/ink-spinner.js";
import { Box, Text, useInput, useStdin } from "ink"; import { Box, Text, useInput, useStdin } from "ink";
import React, { useState } from "react"; import React, { useState } from "react";
import { useInterval } from "use-interval"; import { useInterval } from "use-interval";
const thinkingTexts = ["Thinking"]; /* [ // Retaining a single static placeholder text for potential future use. The
"Consulting the rubber duck", // more elaborate randomised thinking prompts were removed to streamline the
"Maximizing paperclips", // UI the elapsedtime counter now provides sufficient feedback.
"Reticulating splines",
"Immanentizing the Eschaton",
"Thinking",
"Thinking about thinking",
"Spinning in circles",
"Counting dust specks",
"Updating priors",
"Feeding the utility monster",
"Taking off",
"Wireheading",
"Counting to infinity",
"Staring into the Basilisk",
"Negotiationing acausal trades",
"Searching the library of babel",
"Multiplying matrices",
"Solving the halting problem",
"Counting grains of sand",
"Simulating a simulation",
"Asking the oracle",
"Detangling qubits",
"Reading tea leaves",
"Pondering universal love and transcendent joy",
"Feeling the AGI",
"Shaving the yak",
"Escaping local minima",
"Pruning the search tree",
"Descending the gradient",
"Bikeshedding",
"Securing funding",
"Rewriting in Rust",
"Engaging infinite improbability drive",
"Clapping with one hand",
"Synthesizing",
"Rebasing thesis onto antithesis",
"Transcending the loop",
"Frogeposting",
"Summoning",
"Peeking beyond the veil",
"Seeking",
"Entering deep thought",
"Meditating",
"Decomposing",
"Creating",
"Beseeching the machine spirit",
"Calibrating moral compass",
"Collapsing the wave function",
"Doodling",
"Translating whale song",
"Whispering to silicon",
"Looking for semicolons",
"Asking ChatGPT",
"Bargaining with entropy",
"Channeling",
"Cooking",
"Parroting stochastically",
]; */
export default function TerminalChatInputThinking({ export default function TerminalChatInputThinking({
onInterrupt, onInterrupt,
active, active,
thinkingSeconds,
}: { }: {
onInterrupt: () => void; onInterrupt: () => void;
active: boolean; active: boolean;
thinkingSeconds: number;
}): React.ReactElement { }): React.ReactElement {
const [dots, setDots] = useState("");
const [awaitingConfirm, setAwaitingConfirm] = useState(false); const [awaitingConfirm, setAwaitingConfirm] = useState(false);
const [dots, setDots] = useState("");
const [thinkingText, setThinkingText] = useState( // Animate the ellipsis
() => thinkingTexts[Math.floor(Math.random() * thinkingTexts.length)], useInterval(() => {
); setDots((prev) => (prev.length < 3 ? prev + "." : ""));
}, 500);
const { stdin, setRawMode } = useStdin(); const { stdin, setRawMode } = useStdin();
@@ -110,25 +56,7 @@ export default function TerminalChatInputThinking({
}; };
}, [stdin, awaitingConfirm, onInterrupt, active, setRawMode]); }, [stdin, awaitingConfirm, onInterrupt, active, setRawMode]);
useInterval(() => { // No timers required beyond tracking the elapsed seconds supplied via props.
setDots((prev) => (prev.length < 3 ? prev + "." : ""));
}, 500);
useInterval(
() => {
setThinkingText((prev) => {
let next = prev;
if (thinkingTexts.length > 1) {
while (next === prev) {
next =
thinkingTexts[Math.floor(Math.random() * thinkingTexts.length)];
}
}
return next;
});
},
active ? 30000 : null,
);
useInput( useInput(
(_input, key) => { (_input, key) => {
@@ -153,12 +81,41 @@ export default function TerminalChatInputThinking({
{ isActive: active }, { isActive: active },
); );
// Custom ball animation including the elapsed seconds
const ballFrames = [
"( ● )",
"( ● )",
"( ● )",
"( ● )",
"( ●)",
"( ● )",
"( ● )",
"( ● )",
"( ● )",
"(● )",
];
const [frame, setFrame] = useState(0);
useInterval(() => {
setFrame((idx) => (idx + 1) % ballFrames.length);
}, 80);
// Preserve the spinner (ball) animation while keeping the elapsed seconds
// text static. We achieve this by rendering the bouncing ball inside the
// parentheses and appending the seconds counter *after* the spinner rather
// than injecting it directly next to the ball (which caused the counter to
// move horizontally together with the ball).
const frameTemplate = ballFrames[frame] ?? ballFrames[0];
const frameWithSeconds = `${frameTemplate} ${thinkingSeconds}s`;
return ( return (
<Box flexDirection="column" gap={1}> <Box flexDirection="column" gap={1}>
<Box gap={2}> <Box gap={2}>
<Spinner type="ball" /> <Text>{frameWithSeconds}</Text>
<Text> <Text>
{thinkingText} Thinking
{dots} {dots}
</Text> </Text>
</Box> </Box>

View File

@@ -15,7 +15,6 @@ import {
addToHistory, addToHistory,
} from "../../utils/storage/command-history.js"; } from "../../utils/storage/command-history.js";
import { clearTerminal, onExit } from "../../utils/terminal.js"; import { clearTerminal, onExit } from "../../utils/terminal.js";
import Spinner from "../vendor/ink-spinner.js";
import TextInput from "../vendor/ink-text-input.js"; import TextInput from "../vendor/ink-text-input.js";
import { Box, Text, useApp, useInput, useStdin } from "ink"; import { Box, Text, useApp, useInput, useStdin } from "ink";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -45,6 +44,7 @@ export default function TerminalChatInput({
onCompact, onCompact,
interruptAgent, interruptAgent,
active, active,
thinkingSeconds,
items = [], items = [],
}: { }: {
isNew: boolean; isNew: boolean;
@@ -66,6 +66,7 @@ export default function TerminalChatInput({
onCompact: () => void; onCompact: () => void;
interruptAgent: () => void; interruptAgent: () => void;
active: boolean; active: boolean;
thinkingSeconds: number;
// New: current conversation items so we can include them in bug reports // New: current conversation items so we can include them in bug reports
items?: Array<ResponseItem>; items?: Array<ResponseItem>;
}): React.ReactElement { }): React.ReactElement {
@@ -265,7 +266,9 @@ export default function TerminalChatInput({
items: items ?? [], items: items ?? [],
cliVersion: CLI_VERSION, cliVersion: CLI_VERSION,
model: loadConfig().model ?? "unknown", model: loadConfig().model ?? "unknown",
platform: `${os.platform()} ${os.arch()} ${os.release()}`, platform: [os.platform(), os.arch(), os.release()]
.map((s) => `\`${s}\``)
.join(" | "),
}); });
// Open the URL in the user's default browser // Open the URL in the user's default browser
@@ -416,6 +419,7 @@ export default function TerminalChatInput({
<TerminalChatInputThinking <TerminalChatInputThinking
onInterrupt={interruptAgent} onInterrupt={interruptAgent}
active={active} active={active}
thinkingSeconds={thinkingSeconds}
/> />
) : ( ) : (
<Box paddingX={1}> <Box paddingX={1}>
@@ -491,12 +495,42 @@ export default function TerminalChatInput({
function TerminalChatInputThinking({ function TerminalChatInputThinking({
onInterrupt, onInterrupt,
active, active,
thinkingSeconds,
}: { }: {
onInterrupt: () => void; onInterrupt: () => void;
active: boolean; active: boolean;
thinkingSeconds: number;
}) { }) {
const [dots, setDots] = useState("");
const [awaitingConfirm, setAwaitingConfirm] = useState(false); const [awaitingConfirm, setAwaitingConfirm] = useState(false);
const [dots, setDots] = useState("");
// Animate ellipsis
useInterval(() => {
setDots((prev) => (prev.length < 3 ? prev + "." : ""));
}, 500);
// Spinner frames with embedded seconds
const ballFrames = [
"( ● )",
"( ● )",
"( ● )",
"( ● )",
"( ●)",
"( ● )",
"( ● )",
"( ● )",
"( ● )",
"(● )",
];
const [frame, setFrame] = useState(0);
useInterval(() => {
setFrame((idx) => (idx + 1) % ballFrames.length);
}, 80);
// Keep the elapsedseconds text fixed while the ball animation moves.
const frameTemplate = ballFrames[frame] ?? ballFrames[0];
const frameWithSeconds = `${frameTemplate} ${thinkingSeconds}s`;
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Raw stdin listener to catch the case where the terminal delivers two // Raw stdin listener to catch the case where the terminal delivers two
@@ -544,10 +578,7 @@ function TerminalChatInputThinking({
}; };
}, [stdin, awaitingConfirm, onInterrupt, active, setRawMode]); }, [stdin, awaitingConfirm, onInterrupt, active, setRawMode]);
// Cycle the "Thinking…" animation dots. // No local timer: the parent component supplies the elapsed time via props.
useInterval(() => {
setDots((prev) => (prev.length < 3 ? prev + "." : ""));
}, 500);
// Listen for the escape key to allow the user to interrupt the current // Listen for the escape key to allow the user to interrupt the current
// operation. We require two presses within a short window (1.5s) to avoid // operation. We require two presses within a short window (1.5s) to avoid
@@ -578,8 +609,11 @@ function TerminalChatInputThinking({
return ( return (
<Box flexDirection="column" gap={1}> <Box flexDirection="column" gap={1}>
<Box gap={2}> <Box gap={2}>
<Spinner type="ball" /> <Text>{frameWithSeconds}</Text>
<Text>Thinking{dots}</Text> <Text>
Thinking
{dots}
</Text>
</Box> </Box>
{awaitingConfirm && ( {awaitingConfirm && (
<Text dimColor> <Text dimColor>

View File

@@ -17,7 +17,6 @@ import {
addToHistory, addToHistory,
} from "../../utils/storage/command-history.js"; } from "../../utils/storage/command-history.js";
import { clearTerminal, onExit } from "../../utils/terminal.js"; import { clearTerminal, onExit } from "../../utils/terminal.js";
import Spinner from "../vendor/ink-spinner.js";
import { Box, Text, useApp, useInput, useStdin } from "ink"; import { Box, Text, useApp, useInput, useStdin } from "ink";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import React, { useCallback, useState, Fragment, useEffect } from "react"; import React, { useCallback, useState, Fragment, useEffect } from "react";
@@ -37,39 +36,7 @@ const typeHelpText = `ctrl+c to exit | "/clear" to reset context | "/help" for c
const DEBUG_HIST = const DEBUG_HIST =
process.env["DEBUG_TCI"] === "1" || process.env["DEBUG_TCI"] === "true"; process.env["DEBUG_TCI"] === "1" || process.env["DEBUG_TCI"] === "true";
const thinkingTexts = ["Thinking"]; /* [ // Placeholder for potential dynamic prompts currently not used.
"Consulting the rubber duck",
"Maximizing paperclips",
"Reticulating splines",
"Immanentizing the Eschaton",
"Thinking",
"Thinking about thinking",
"Spinning in circles",
"Counting dust specks",
"Updating priors",
"Feeding the utility monster",
"Taking off",
"Wireheading",
"Counting to infinity",
"Staring into the Basilisk",
"Running acausal tariff negotiations",
"Searching the library of babel",
"Multiplying matrices",
"Solving the halting problem",
"Counting grains of sand",
"Simulating a simulation",
"Asking the oracle",
"Detangling qubits",
"Reading tea leaves",
"Pondering universal love and transcendent joy",
"Feeling the AGI",
"Shaving the yak",
"Escaping local minima",
"Pruning the search tree",
"Descending the gradient",
"Painting the bikeshed",
"Securing funding",
]; */
export default function TerminalChatInput({ export default function TerminalChatInput({
isNew: _isNew, isNew: _isNew,
@@ -87,6 +54,7 @@ export default function TerminalChatInput({
openHelpOverlay, openHelpOverlay,
interruptAgent, interruptAgent,
active, active,
thinkingSeconds,
}: { }: {
isNew: boolean; isNew: boolean;
loading: boolean; loading: boolean;
@@ -106,6 +74,7 @@ export default function TerminalChatInput({
openHelpOverlay: () => void; openHelpOverlay: () => void;
interruptAgent: () => void; interruptAgent: () => void;
active: boolean; active: boolean;
thinkingSeconds: number;
}): React.ReactElement { }): React.ReactElement {
const app = useApp(); const app = useApp();
const [selectedSuggestion, setSelectedSuggestion] = useState<number>(0); const [selectedSuggestion, setSelectedSuggestion] = useState<number>(0);
@@ -389,6 +358,7 @@ export default function TerminalChatInput({
<TerminalChatInputThinking <TerminalChatInputThinking
onInterrupt={interruptAgent} onInterrupt={interruptAgent}
active={active} active={active}
thinkingSeconds={thinkingSeconds}
/> />
</Box> </Box>
) : ( ) : (
@@ -454,15 +424,43 @@ export default function TerminalChatInput({
function TerminalChatInputThinking({ function TerminalChatInputThinking({
onInterrupt, onInterrupt,
active, active,
thinkingSeconds,
}: { }: {
onInterrupt: () => void; onInterrupt: () => void;
active: boolean; active: boolean;
thinkingSeconds: number;
}) { }) {
const [dots, setDots] = useState("");
const [awaitingConfirm, setAwaitingConfirm] = useState(false); const [awaitingConfirm, setAwaitingConfirm] = useState(false);
const [dots, setDots] = useState("");
const [thinkingText] = useState( // Animate ellipsis
() => thinkingTexts[Math.floor(Math.random() * thinkingTexts.length)], useInterval(() => {
setDots((prev) => (prev.length < 3 ? prev + "." : ""));
}, 500);
// Spinner frames with seconds embedded
const ballFrames = [
"( ● )",
"( ● )",
"( ● )",
"( ● )",
"( ●)",
"( ● )",
"( ● )",
"( ● )",
"( ● )",
"(● )",
];
const [frame, setFrame] = useState(0);
useInterval(() => {
setFrame((idx) => (idx + 1) % ballFrames.length);
}, 80);
const frameTemplate = ballFrames[frame] ?? ballFrames[0];
const frameWithSeconds = (frameTemplate as string).replace(
"●",
`${thinkingSeconds}s`,
); );
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@@ -511,9 +509,7 @@ function TerminalChatInputThinking({
}; };
}, [stdin, awaitingConfirm, onInterrupt, active, setRawMode]); }, [stdin, awaitingConfirm, onInterrupt, active, setRawMode]);
useInterval(() => { // Elapsed time provided via props no local interval needed.
setDots((prev) => (prev.length < 3 ? prev + "." : ""));
}, 500);
useInput( useInput(
(_input, key) => { (_input, key) => {
@@ -541,9 +537,9 @@ function TerminalChatInputThinking({
return ( return (
<Box flexDirection="column" gap={1}> <Box flexDirection="column" gap={1}>
<Box gap={2}> <Box gap={2}>
<Spinner type="ball" /> <Text>{frameWithSeconds}</Text>
<Text> <Text>
{thinkingText} Thinking
{dots} {dots}
</Text> </Text>
</Box> </Box>

View File

@@ -517,6 +517,7 @@ export default function TerminalChat({
return {}; return {};
}} }}
items={items} items={items}
thinkingSeconds={thinkingSeconds}
/> />
)} )}
{overlayMode === "history" && ( {overlayMode === "history" && (

View File

@@ -4,7 +4,7 @@ import type { ResponseItem } from "openai/resources/responses/responses.mjs";
import TerminalChatResponseItem from "./terminal-chat-response-item.js"; import TerminalChatResponseItem from "./terminal-chat-response-item.js";
import TerminalHeader from "./terminal-header.js"; import TerminalHeader from "./terminal-header.js";
import { Box, Static, Text } from "ink"; import { Box, Static } from "ink";
import React, { useMemo } from "react"; import React, { useMemo } from "react";
// A batch entry can either be a standalone response item or a grouped set of // A batch entry can either be a standalone response item or a grouped set of
@@ -26,8 +26,9 @@ type MessageHistoryProps = {
const MessageHistory: React.FC<MessageHistoryProps> = ({ const MessageHistory: React.FC<MessageHistoryProps> = ({
batch, batch,
headerProps, headerProps,
loading, // `loading` and `thinkingSeconds` handled by input component now.
thinkingSeconds, loading: _loading,
thinkingSeconds: _thinkingSeconds,
fullStdout, fullStdout,
}) => { }) => {
// Flatten batch entries to response items. // Flatten batch entries to response items.
@@ -35,11 +36,8 @@ const MessageHistory: React.FC<MessageHistoryProps> = ({
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
{loading && ( {/* The dedicated thinking indicator in the input area now displays the
<Box marginTop={1}> elapsed time, so we no longer render a separate counter here. */}
<Text color="yellow">{`thinking for ${thinkingSeconds}s`}</Text>
</Box>
)}
<Static items={["header", ...messages]}> <Static items={["header", ...messages]}>
{(item, index) => { {(item, index) => {
if (item === "header") { if (item === "header") {

View File

@@ -1,4 +1,7 @@
import type { ResponseItem } from "openai/resources/responses/responses.mjs"; import type {
ResponseItem,
ResponseOutputItem,
} from "openai/resources/responses/responses.mjs";
/** /**
* Build a GitHub issuesnew URL that prefills the Codex 2bugreport.yml * Build a GitHub issuesnew URL that prefills the Codex 2bugreport.yml
@@ -12,7 +15,7 @@ export function buildBugReportUrl({
platform, platform,
}: { }: {
/** Chat history so we can summarise user steps */ /** Chat history so we can summarise user steps */
items: Array<ResponseItem>; items: Array<ResponseItem | ResponseOutputItem>;
/** CLI revision string (e.g. output of `codex --revision`) */ /** CLI revision string (e.g. output of `codex --revision`) */
cliVersion: string; cliVersion: string;
/** Active model name */ /** Active model name */
@@ -25,16 +28,10 @@ export function buildBugReportUrl({
labels: "bug", labels: "bug",
}); });
// Template ids -------------------------------------------------------------
params.set("version", cliVersion); params.set("version", cliVersion);
params.set("model", model); params.set("model", model);
params.set("platform", platform);
// The platform input has no explicit `id`, so GitHub falls back to a slug of
// the label text. For “What platform is your computer?” that slug is:
// what-platform-is-your-computer
params.set("what-platform-is-your-computer", platform);
// Build the steps bullet list ---------------------------------------------
const bullets: Array<string> = []; const bullets: Array<string> = [];
for (let i = 0; i < items.length; ) { for (let i = 0; i < items.length; ) {
const entry = items[i]; const entry = items[i];
@@ -50,12 +47,14 @@ export function buildBugReportUrl({
let reasoning = 0; let reasoning = 0;
let toolCalls = 0; let toolCalls = 0;
let j = i + 1; let j = i + 1;
while ( while (j < items.length) {
j < items.length &&
!(entry?.type === "message" && entry.role === "user")
) {
const it = items[j]; const it = items[j];
if (it?.type === "message" && it?.role === "assistant") { if (it?.type === "message" && it?.role === "user") {
break;
} else if (
it?.type === "reasoning" ||
(it?.type === "message" && it?.role === "assistant")
) {
reasoning += 1; reasoning += 1;
} else if (it?.type === "function_call") { } else if (it?.type === "function_call") {
toolCalls += 1; toolCalls += 1;
@@ -63,8 +62,10 @@ export function buildBugReportUrl({
j++; j++;
} }
const codeBlock = `\`\`\`\n ${messageText}\n \`\`\``;
bullets.push( bullets.push(
`- "${messageText}"\n - \`${reasoning} reasoning steps\` | \`${toolCalls} tool calls\``, `- ${codeBlock}\n - \`${reasoning} reasoning\` | \`${toolCalls} tool\``,
); );
i = j; i = j;

View File

@@ -23,6 +23,7 @@ describe("TerminalChatInput compact command", () => {
onCompact: () => {}, onCompact: () => {},
interruptAgent: () => {}, interruptAgent: () => {},
active: true, active: true,
thinkingSeconds: 0,
}; };
const { lastFrameStripped } = renderTui(<TerminalChatInput {...props} />); const { lastFrameStripped } = renderTui(<TerminalChatInput {...props} />);
const frame = lastFrameStripped(); const frame = lastFrameStripped();

View File

@@ -30,5 +30,5 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["src", "tests"] "include": ["src", "tests", "bin"]
} }

4
pnpm-lock.yaml generated
View File

@@ -65,7 +65,7 @@ importers:
specifier: ^10.1.0 specifier: ^10.1.0
version: 10.1.1 version: 10.1.1
openai: openai:
specifier: ^4.89.0 specifier: ^4.95.1
version: 4.95.1(ws@8.18.1)(zod@3.24.3) version: 4.95.1(ws@8.18.1)(zod@3.24.3)
react: react:
specifier: ^18.2.0 specifier: ^18.2.0
@@ -2739,7 +2739,7 @@ snapshots:
'@types/node-fetch@2.6.12': '@types/node-fetch@2.6.12':
dependencies: dependencies:
'@types/node': 18.19.86 '@types/node': 22.14.1
form-data: 4.0.2 form-data: 4.0.2
'@types/node@18.19.86': '@types/node@18.19.86':