mirror of
https://github.com/openai/codex.git
synced 2026-02-08 18:03:37 +00:00
Compare commits
2 Commits
sec
...
codex/stre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89cee48442 | ||
|
|
afcb2f4f82 |
@@ -1,9 +0,0 @@
|
||||
# npm releases
|
||||
|
||||
Run the following:
|
||||
|
||||
To build the 0.2.x or later version of the npm module, which runs the Rust version of the CLI, build it as follows:
|
||||
|
||||
```bash
|
||||
./codex-cli/scripts/stage_rust_release.py --release-version 0.6.0
|
||||
```
|
||||
@@ -4,7 +4,10 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stages an npm release for @openai/codex.
|
||||
#
|
||||
# Usage:
|
||||
# The script used to accept a single optional positional argument that indicated
|
||||
# the temporary directory in which to stage the package. We now support a
|
||||
# flag-based interface so that we can extend the command with further options
|
||||
# without breaking the call-site contract.
|
||||
#
|
||||
# --tmp <dir> : Use <dir> instead of a freshly created temp directory.
|
||||
# --native : Bundle the pre-built Rust CLI binaries for Linux alongside
|
||||
@@ -138,8 +141,7 @@ popd >/dev/null
|
||||
echo "Staged version $VERSION for release in $TMPDIR"
|
||||
|
||||
if [[ "$INCLUDE_NATIVE" -eq 1 ]]; then
|
||||
echo "Verify the CLI:"
|
||||
echo " node ${TMPDIR}/bin/codex.js --version"
|
||||
echo "Test Rust:"
|
||||
echo " node ${TMPDIR}/bin/codex.js --help"
|
||||
else
|
||||
echo "Test Node:"
|
||||
|
||||
@@ -10,25 +10,19 @@ import type {
|
||||
import MultilineTextEditor from "./multiline-editor";
|
||||
import { TerminalChatCommandReview } from "./terminal-chat-command-review.js";
|
||||
import TextCompletions from "./terminal-chat-completions.js";
|
||||
import { loadConfig, type AppConfig } from "../../utils/config.js";
|
||||
import { loadConfig } from "../../utils/config.js";
|
||||
import { getFileSystemSuggestions } from "../../utils/file-system-suggestions.js";
|
||||
import { expandFileTags } from "../../utils/file-tag-utils";
|
||||
import { createInputItem } from "../../utils/input-utils.js";
|
||||
import { log } from "../../utils/logger/log.js";
|
||||
import { setSessionId } from "../../utils/session.js";
|
||||
import { SLASH_COMMANDS, type SlashCommand } from "../../utils/slash-commands";
|
||||
import {
|
||||
runSecurityReview,
|
||||
SecurityReviewError,
|
||||
} from "../../utils/security-review.js";
|
||||
import type { SecurityReviewMode } from "../../utils/security-review.js";
|
||||
import {
|
||||
loadCommandHistory,
|
||||
addToHistory,
|
||||
} from "../../utils/storage/command-history.js";
|
||||
import { clearTerminal, onExit } from "../../utils/terminal.js";
|
||||
import { Box, Text, useApp, useInput, useStdin } from "ink";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import React, {
|
||||
useCallback,
|
||||
@@ -45,130 +39,6 @@ const suggestions = [
|
||||
"are there any bugs in my code?",
|
||||
];
|
||||
|
||||
const SEC_REVIEW_COMMAND = "/secreview";
|
||||
|
||||
type SecReviewCommandOptions = {
|
||||
mode: SecurityReviewMode;
|
||||
includePaths: Array<string>;
|
||||
outputPath?: string;
|
||||
repoPath?: string;
|
||||
modelName?: string;
|
||||
};
|
||||
|
||||
function tokenizeCommand(input: string): Array<string> {
|
||||
const tokens: Array<string> = [];
|
||||
const regex = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(input)) !== null) {
|
||||
if (match[1] != null) {
|
||||
tokens.push(match[1]);
|
||||
} else if (match[2] != null) {
|
||||
tokens.push(match[2]);
|
||||
} else if (match[3] != null) {
|
||||
tokens.push(match[3]);
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function parseSecReviewCommand(input: string): SecReviewCommandOptions {
|
||||
const tokens = tokenizeCommand(input).slice(1); // drop the command itself
|
||||
let mode: SecurityReviewMode = "full";
|
||||
const includePaths: Array<string> = [];
|
||||
let outputPath: string | undefined;
|
||||
let repoPath: string | undefined;
|
||||
let modelName: string | undefined;
|
||||
|
||||
const parseMode = (value: string, option: string) => {
|
||||
if (value === "bugs") {
|
||||
mode = "bugs";
|
||||
} else if (value === "full") {
|
||||
mode = "full";
|
||||
} else {
|
||||
throw new Error(`Unknown ${option} value "${value}". Use "full" or "bugs".`);
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < tokens.length; i += 1) {
|
||||
const token = tokens[i];
|
||||
|
||||
const expectValue = (label: string): string => {
|
||||
if (i + 1 >= tokens.length) {
|
||||
throw new Error(`Expected value after ${label}`);
|
||||
}
|
||||
i += 1;
|
||||
return tokens[i];
|
||||
};
|
||||
|
||||
if (token === "--") {
|
||||
break;
|
||||
} else if (token === "bugs" || token === "--bugs" || token === "--mode=bugs") {
|
||||
mode = "bugs";
|
||||
} else if (token === "full" || token === "--full" || token === "--mode=full") {
|
||||
mode = "full";
|
||||
} else if (token === "--mode") {
|
||||
parseMode(expectValue("--mode"), "--mode");
|
||||
} else if (token.startsWith("--mode=")) {
|
||||
parseMode(token.slice("--mode=".length), "--mode");
|
||||
} else if (token === "--path" || token === "-p") {
|
||||
includePaths.push(expectValue(token));
|
||||
} else if (token.startsWith("--path=")) {
|
||||
includePaths.push(token.slice("--path=".length));
|
||||
} else if (token.startsWith("-p=")) {
|
||||
includePaths.push(token.slice("-p=".length));
|
||||
} else if (
|
||||
token === "--output" ||
|
||||
token === "-o" ||
|
||||
token === "--output-location"
|
||||
) {
|
||||
outputPath = expectValue(token);
|
||||
} else if (token.startsWith("--output=")) {
|
||||
outputPath = token.slice("--output=".length);
|
||||
} else if (token.startsWith("-o=")) {
|
||||
outputPath = token.slice("-o=".length);
|
||||
} else if (
|
||||
token === "--repo" ||
|
||||
token === "--repo-location" ||
|
||||
token === "--repository"
|
||||
) {
|
||||
repoPath = expectValue(token);
|
||||
} else if (token.startsWith("--repo=")) {
|
||||
repoPath = token.slice("--repo=".length);
|
||||
} else if (token.startsWith("--repo-location=")) {
|
||||
repoPath = token.slice("--repo-location=".length);
|
||||
} else if (token === "--model" || token === "--model-name") {
|
||||
modelName = expectValue(token);
|
||||
} else if (token.startsWith("--model=")) {
|
||||
modelName = token.slice("--model=".length);
|
||||
} else if (token.startsWith("--model-name=")) {
|
||||
modelName = token.slice("--model-name=".length);
|
||||
} else if (token.length > 0) {
|
||||
includePaths.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
includePaths: includePaths.filter((p) => p.length > 0),
|
||||
outputPath,
|
||||
repoPath,
|
||||
modelName,
|
||||
};
|
||||
}
|
||||
|
||||
function trimLogOutput(logText: string, maxLines: number = 40): string {
|
||||
const normalised = logText.replace(/\r\n/g, "\n").trimEnd();
|
||||
if (normalised === "") {
|
||||
return "(empty)";
|
||||
}
|
||||
const lines = normalised.split("\n");
|
||||
if (lines.length <= maxLines) {
|
||||
return normalised;
|
||||
}
|
||||
const tail = lines.slice(-maxLines);
|
||||
return ["… (showing last " + maxLines + " lines)", ...tail].join("\n");
|
||||
}
|
||||
|
||||
export default function TerminalChatInput({
|
||||
isNew,
|
||||
loading,
|
||||
@@ -190,7 +60,6 @@ export default function TerminalChatInput({
|
||||
active,
|
||||
thinkingSeconds,
|
||||
items = [],
|
||||
config,
|
||||
}: {
|
||||
isNew: boolean;
|
||||
loading: boolean;
|
||||
@@ -216,7 +85,6 @@ export default function TerminalChatInput({
|
||||
thinkingSeconds: number;
|
||||
// New: current conversation items so we can include them in bug reports
|
||||
items?: Array<ResponseItem>;
|
||||
config: AppConfig;
|
||||
}): React.ReactElement {
|
||||
// Slash command suggestion index
|
||||
const [selectedSlashSuggestion, setSelectedSlashSuggestion] =
|
||||
@@ -644,230 +512,6 @@ export default function TerminalChatInput({
|
||||
} else if (inputValue.startsWith("/approval")) {
|
||||
setInput("");
|
||||
openApprovalOverlay();
|
||||
return;
|
||||
} else if (inputValue.startsWith(SEC_REVIEW_COMMAND)) {
|
||||
setInput("");
|
||||
const commandId = `secreview-${Date.now()}`;
|
||||
|
||||
let parsed: SecReviewCommandOptions;
|
||||
try {
|
||||
parsed = parseSecReviewCommand(inputValue);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `${commandId}-parse-error`,
|
||||
type: "message",
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: `⚠️ Unable to parse ${SEC_REVIEW_COMMAND} arguments: ${message}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const repoPath = parsed.repoPath
|
||||
? path.isAbsolute(parsed.repoPath)
|
||||
? parsed.repoPath
|
||||
: path.resolve(process.cwd(), parsed.repoPath)
|
||||
: process.cwd();
|
||||
|
||||
const resolvedOutputPath =
|
||||
parsed.outputPath != null
|
||||
? path.isAbsolute(parsed.outputPath)
|
||||
? parsed.outputPath
|
||||
: path.resolve(repoPath, parsed.outputPath)
|
||||
: undefined;
|
||||
|
||||
const scopeDescription =
|
||||
parsed.includePaths.length > 0
|
||||
? parsed.includePaths.join(", ")
|
||||
: "entire repository";
|
||||
|
||||
const introLines = [
|
||||
`🔐 Running AppSec security review (mode: ${parsed.mode}).`,
|
||||
`Repository: ${repoPath}`,
|
||||
`Scope: ${scopeDescription}`,
|
||||
];
|
||||
|
||||
if (resolvedOutputPath) {
|
||||
introLines.push(`Output: ${resolvedOutputPath}`);
|
||||
}
|
||||
|
||||
if (parsed.modelName) {
|
||||
introLines.push(`Model override: ${parsed.modelName}`);
|
||||
}
|
||||
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `${commandId}-start`,
|
||||
type: "message",
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: introLines.join("\n"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
const result = await runSecurityReview({
|
||||
repoPath,
|
||||
includePaths: parsed.includePaths,
|
||||
outputPath: resolvedOutputPath,
|
||||
modelName: parsed.modelName,
|
||||
mode: parsed.mode,
|
||||
config,
|
||||
});
|
||||
|
||||
const summaryLines = [
|
||||
"✅ AppSec review complete.",
|
||||
`Artifacts: ${result.outputRoot}`,
|
||||
];
|
||||
if (!result.reportContent) {
|
||||
summaryLines.push("ℹ️ report.md not found in output.");
|
||||
}
|
||||
if (!result.bugsContent) {
|
||||
summaryLines.push("ℹ️ context/bugs.md not found in output.");
|
||||
}
|
||||
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `${commandId}-complete`,
|
||||
type: "message",
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: summaryLines.join("\n"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
if (parsed.mode === "full" && result.reportContent) {
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `${commandId}-report`,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "output_text",
|
||||
text: `# AppSec Security Review Report\n\n${result.reportContent}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if (result.bugsContent) {
|
||||
const heading =
|
||||
parsed.mode === "full"
|
||||
? "## Bugs Summary"
|
||||
: "# AppSec Bugs Summary";
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `${commandId}-bugs`,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "output_text",
|
||||
text: `${heading}\n\n${result.bugsContent}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `${commandId}-no-bugs`,
|
||||
type: "message",
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text:
|
||||
"No bug summary produced. Check the output directory for details.",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if (parsed.mode === "bugs" && result.reportContent) {
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `${commandId}-report-location`,
|
||||
type: "message",
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: `Full report available at ${result.reportPath}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (result.stdout.trim()) {
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `${commandId}-logs`,
|
||||
type: "message",
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: `Logs:\n${trimLogOutput(result.stdout)}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const stderr =
|
||||
error instanceof SecurityReviewError && error.stderr
|
||||
? `\n\nstderr last lines:\n${trimLogOutput(error.stderr)}`
|
||||
: "";
|
||||
const stdout =
|
||||
error instanceof SecurityReviewError && error.stdout
|
||||
? `\n\nstdout last lines:\n${trimLogOutput(error.stdout)}`
|
||||
: "";
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `${commandId}-error`,
|
||||
type: "message",
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: `❌ AppSec review failed: ${message}${stderr}${stdout}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return;
|
||||
} else if (["exit", "q", ":q"].includes(inputValue)) {
|
||||
setInput("");
|
||||
@@ -1063,13 +707,13 @@ export default function TerminalChatInput({
|
||||
submitInput([inputItem]);
|
||||
|
||||
// Get config for history persistence.
|
||||
const historyConfig = loadConfig();
|
||||
const config = loadConfig();
|
||||
|
||||
// Add to history and update state.
|
||||
const updatedHistory = await addToHistory(value, history, {
|
||||
maxSize: historyConfig.history?.maxSize ?? 1000,
|
||||
saveHistory: historyConfig.history?.saveHistory ?? true,
|
||||
sensitivePatterns: historyConfig.history?.sensitivePatterns ?? [],
|
||||
maxSize: config.history?.maxSize ?? 1000,
|
||||
saveHistory: config.history?.saveHistory ?? true,
|
||||
sensitivePatterns: config.history?.sensitivePatterns ?? [],
|
||||
});
|
||||
|
||||
setHistory(updatedHistory);
|
||||
@@ -1098,7 +742,6 @@ export default function TerminalChatInput({
|
||||
onCompact,
|
||||
skipNextSubmit,
|
||||
items,
|
||||
config,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -255,7 +255,18 @@ export default function TerminalChat({
|
||||
onItem: (item) => {
|
||||
log(`onItem: ${JSON.stringify(item)}`);
|
||||
setItems((prev) => {
|
||||
const updated = uniqueById([...prev, item as ResponseItem]);
|
||||
let updated = prev;
|
||||
if (item.id) {
|
||||
const idx = prev.findIndex((i) => i.id === item.id);
|
||||
if (idx !== -1) {
|
||||
updated = [...prev];
|
||||
updated[idx] = item as ResponseItem;
|
||||
} else {
|
||||
updated = uniqueById([...prev, item as ResponseItem]);
|
||||
}
|
||||
} else {
|
||||
updated = uniqueById([...prev, item as ResponseItem]);
|
||||
}
|
||||
saveRollout(sessionId, updated);
|
||||
return updated;
|
||||
});
|
||||
@@ -580,7 +591,6 @@ export default function TerminalChat({
|
||||
}}
|
||||
items={items}
|
||||
thinkingSeconds={thinkingSeconds}
|
||||
config={config}
|
||||
/>
|
||||
)}
|
||||
{overlayMode === "history" && (
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { FileOpenerScheme } from "src/utils/config.js";
|
||||
|
||||
import TerminalChatResponseItem from "./terminal-chat-response-item.js";
|
||||
import TerminalHeader from "./terminal-header.js";
|
||||
import { Box, Static } from "ink";
|
||||
import { Box } from "ink";
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
// A batch entry can either be a standalone response item or a grouped set of
|
||||
@@ -42,50 +42,41 @@ const TerminalMessageHistory: React.FC<TerminalMessageHistoryProps> = ({
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* The dedicated thinking indicator in the input area now displays the
|
||||
elapsed time, so we no longer render a separate counter here. */}
|
||||
<Static items={["header", ...messages]}>
|
||||
{(item, index) => {
|
||||
if (item === "header") {
|
||||
return <TerminalHeader key="header" {...headerProps} />;
|
||||
}
|
||||
{/* Render header first so subsequent updates do not cause it to reappear */}
|
||||
<TerminalHeader {...headerProps} />
|
||||
{messages.map((message, index) => {
|
||||
// Suppress empty reasoning updates (i.e. items with an empty summary).
|
||||
const msg = message as unknown as { summary?: Array<unknown> };
|
||||
if (msg.summary?.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// After the guard above, item is a ResponseItem
|
||||
const message = item as ResponseItem;
|
||||
// Suppress empty reasoning updates (i.e. items with an empty summary).
|
||||
const msg = message as unknown as { summary?: Array<unknown> };
|
||||
if (msg.summary?.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
key={`${message.id}-${index}`}
|
||||
flexDirection="column"
|
||||
marginLeft={
|
||||
message.type === "message" &&
|
||||
(message.role === "user" || message.role === "assistant")
|
||||
? 0
|
||||
: 4
|
||||
}
|
||||
marginTop={
|
||||
message.type === "message" && message.role === "user" ? 0 : 1
|
||||
}
|
||||
marginBottom={
|
||||
message.type === "message" && message.role === "assistant"
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
>
|
||||
<TerminalChatResponseItem
|
||||
item={message}
|
||||
fullStdout={fullStdout}
|
||||
setOverlayMode={setOverlayMode}
|
||||
fileOpener={fileOpener}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
</Static>
|
||||
return (
|
||||
<Box
|
||||
key={`${message.id}-${index}`}
|
||||
flexDirection="column"
|
||||
marginLeft={
|
||||
message.type === "message" &&
|
||||
(message.role === "user" || message.role === "assistant")
|
||||
? 0
|
||||
: 4
|
||||
}
|
||||
marginTop={
|
||||
message.type === "message" && message.role === "user" ? 0 : 1
|
||||
}
|
||||
marginBottom={
|
||||
message.type === "message" && message.role === "assistant" ? 1 : 0
|
||||
}
|
||||
>
|
||||
<TerminalChatResponseItem
|
||||
item={message}
|
||||
fullStdout={fullStdout}
|
||||
setOverlayMode={setOverlayMode}
|
||||
fileOpener={fileOpener}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -56,10 +56,6 @@ export default function HelpOverlay({
|
||||
<Text color="cyan">/bug</Text> – generate a prefilled GitHub issue URL
|
||||
with session log
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color="cyan">/secreview</Text> – run AppSec security review and
|
||||
show the results
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color="cyan">/diff</Text> – view working tree git diff
|
||||
</Text>
|
||||
|
||||
@@ -669,10 +669,16 @@ export class AgentLoop {
|
||||
}
|
||||
|
||||
// Skip items we've already processed to avoid staging duplicates
|
||||
if (item.id && alreadyStagedItemIds.has(item.id)) {
|
||||
if (
|
||||
item.id &&
|
||||
alreadyStagedItemIds.has(item.id) &&
|
||||
item.status !== "in_progress"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
alreadyStagedItemIds.add(item.id);
|
||||
if (item.id && item.status !== "in_progress") {
|
||||
alreadyStagedItemIds.add(item.id);
|
||||
}
|
||||
|
||||
// Store the item so the final flush can still operate on a complete list.
|
||||
// We'll nil out entries once they're delivered.
|
||||
@@ -1035,11 +1041,42 @@ export class AgentLoop {
|
||||
try {
|
||||
let newTurnInput: Array<ResponseInputItem> = [];
|
||||
|
||||
const partials = new Map<string, string>();
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
for await (const event of stream as AsyncIterable<ResponseEvent>) {
|
||||
log(`AgentLoop.run(): response event ${event.type}`);
|
||||
|
||||
// process and surface each item (no-op until we can depend on streaming events)
|
||||
if (event.type === "response.output_text.delta") {
|
||||
const id = event.item_id;
|
||||
const soFar = partials.get(id) ?? "";
|
||||
const text = soFar + event.delta;
|
||||
partials.set(id, text);
|
||||
stageItem({
|
||||
id,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
status: "in_progress",
|
||||
content: [{ type: "output_text", text }],
|
||||
} as ResponseItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === "response.output_text.done") {
|
||||
const id = event.item_id;
|
||||
const text = event.text;
|
||||
partials.set(id, text);
|
||||
stageItem({
|
||||
id,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text }],
|
||||
} as ResponseItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
// process and surface each item when completed
|
||||
if (event.type === "response.output_item.done") {
|
||||
const item = event.item;
|
||||
// 1) if it's a reasoning item, annotate it
|
||||
@@ -1062,6 +1099,12 @@ export class AgentLoop {
|
||||
if (callId) {
|
||||
this.pendingAborts.add(callId);
|
||||
}
|
||||
} else if (
|
||||
item.type === "message" &&
|
||||
(item as { role?: string }).role === "assistant"
|
||||
) {
|
||||
// Final message already emitted via output_text.done
|
||||
continue;
|
||||
} else {
|
||||
stageItem(item as ResponseItem);
|
||||
}
|
||||
|
||||
@@ -24,10 +24,6 @@ export const SLASH_COMMANDS: Array<SlashCommand> = [
|
||||
{ command: "/help", description: "Show list of commands" },
|
||||
{ command: "/model", description: "Open model selection panel" },
|
||||
{ command: "/approval", description: "Open approval mode selection panel" },
|
||||
{
|
||||
command: "/secreview",
|
||||
description: "Run AppSec security review and display the generated reports",
|
||||
},
|
||||
{
|
||||
command: "/bug",
|
||||
description: "Generate a prefilled GitHub issue URL with session log",
|
||||
|
||||
@@ -61,11 +61,6 @@ describe("/clear command", () => {
|
||||
active: true,
|
||||
thinkingSeconds: 0,
|
||||
items: existingItems,
|
||||
config: {
|
||||
model: "codex-mini-latest",
|
||||
instructions: "",
|
||||
provider: "openai",
|
||||
},
|
||||
};
|
||||
|
||||
const { stdin, flush, cleanup } = renderTui(
|
||||
|
||||
@@ -66,27 +66,17 @@ function stubProps(): any {
|
||||
loading: false,
|
||||
submitInput: vi.fn(),
|
||||
confirmationPrompt: null,
|
||||
explanation: undefined,
|
||||
submitConfirmation: vi.fn(),
|
||||
setLastResponseId: vi.fn(),
|
||||
setItems: vi.fn(),
|
||||
// Cast to any to satisfy the generic React.Dispatch signature without
|
||||
// pulling the ResponseItem type into the test bundle.
|
||||
setItems: (() => {}) as any,
|
||||
contextLeftPercent: 100,
|
||||
openOverlay: vi.fn(),
|
||||
openModelOverlay: vi.fn(),
|
||||
openHelpOverlay: vi.fn(),
|
||||
openApprovalOverlay: vi.fn(),
|
||||
openSessionsOverlay: vi.fn(),
|
||||
openDiffOverlay: vi.fn(),
|
||||
onCompact: vi.fn(),
|
||||
interruptAgent: vi.fn(),
|
||||
active: true,
|
||||
thinkingSeconds: 0,
|
||||
items: [],
|
||||
config: {
|
||||
model: "codex-mini-latest",
|
||||
instructions: "",
|
||||
provider: "openai",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ test("SLASH_COMMANDS includes expected commands", () => {
|
||||
expect(commands).toContain("/help");
|
||||
expect(commands).toContain("/model");
|
||||
expect(commands).toContain("/approval");
|
||||
expect(commands).toContain("/secreview");
|
||||
expect(commands).toContain("/clearhistory");
|
||||
expect(commands).toContain("/diff");
|
||||
});
|
||||
|
||||
@@ -26,11 +26,6 @@ describe("TerminalChatInput compact command", () => {
|
||||
interruptAgent: () => {},
|
||||
active: true,
|
||||
thinkingSeconds: 0,
|
||||
config: {
|
||||
model: "codex-mini-latest",
|
||||
instructions: "",
|
||||
provider: "openai",
|
||||
},
|
||||
};
|
||||
const { lastFrameStripped } = renderTui(<TerminalChatInput {...props} />);
|
||||
const frame = lastFrameStripped();
|
||||
|
||||
@@ -81,11 +81,6 @@ describe("TerminalChatInput file tag suggestions", () => {
|
||||
interruptAgent: vi.fn(),
|
||||
active: true,
|
||||
thinkingSeconds: 0,
|
||||
config: {
|
||||
model: "codex-mini-latest",
|
||||
instructions: "",
|
||||
provider: "openai",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -47,11 +47,6 @@ describe("TerminalChatInput multiline functionality", () => {
|
||||
interruptAgent: () => {},
|
||||
active: true,
|
||||
thinkingSeconds: 0,
|
||||
config: {
|
||||
model: "codex-mini-latest",
|
||||
instructions: "",
|
||||
provider: "openai",
|
||||
},
|
||||
};
|
||||
|
||||
const { stdin, lastFrameStripped, flush, cleanup } = renderTui(
|
||||
@@ -104,11 +99,6 @@ describe("TerminalChatInput multiline functionality", () => {
|
||||
interruptAgent: () => {},
|
||||
active: true,
|
||||
thinkingSeconds: 0,
|
||||
config: {
|
||||
model: "codex-mini-latest",
|
||||
instructions: "",
|
||||
provider: "openai",
|
||||
},
|
||||
};
|
||||
|
||||
const { stdin, lastFrameStripped, flush, cleanup } = renderTui(
|
||||
|
||||
49
codex-rs/Cargo.lock
generated
49
codex-rs/Cargo.lock
generated
@@ -250,28 +250,6 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-stream"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
|
||||
dependencies = [
|
||||
"async-stream-impl",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-stream-impl"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.88"
|
||||
@@ -676,7 +654,6 @@ dependencies = [
|
||||
"thiserror 2.0.12",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
"tokio-util",
|
||||
"toml 0.9.1",
|
||||
"tracing",
|
||||
@@ -818,12 +795,10 @@ dependencies = [
|
||||
"ratatui",
|
||||
"ratatui-image",
|
||||
"regex-lite",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
"shlex",
|
||||
"strum 0.27.1",
|
||||
"strum_macros 0.27.1",
|
||||
"time",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
@@ -4541,30 +4516,6 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-test"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.15"
|
||||
|
||||
@@ -64,5 +64,4 @@ maplit = "1.0.2"
|
||||
predicates = "3"
|
||||
pretty_assertions = "1.4.1"
|
||||
tempfile = "3"
|
||||
tokio-test = "0.4"
|
||||
wiremock = "0.6"
|
||||
|
||||
@@ -395,39 +395,9 @@ async fn stream_from_fixture(path: impl AsRef<Path>) -> Result<ResponseStream> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_test::io::Builder as IoBuilder;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
// ────────────────────────────
|
||||
// Helpers
|
||||
// ────────────────────────────
|
||||
|
||||
/// Runs the SSE parser on pre-chunked byte slices and returns every event
|
||||
/// (including any final `Err` from a stream-closure check).
|
||||
async fn collect_events(chunks: &[&[u8]]) -> Vec<Result<ResponseEvent>> {
|
||||
let mut builder = IoBuilder::new();
|
||||
for chunk in chunks {
|
||||
builder.read(chunk);
|
||||
}
|
||||
|
||||
let reader = builder.build();
|
||||
let stream = ReaderStream::new(reader).map_err(CodexErr::Io);
|
||||
let (tx, mut rx) = mpsc::channel::<Result<ResponseEvent>>(16);
|
||||
tokio::spawn(process_sse(stream, tx));
|
||||
|
||||
let mut events = Vec::new();
|
||||
while let Some(ev) = rx.recv().await {
|
||||
events.push(ev);
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
/// Builds an in-memory SSE stream from JSON fixtures and returns only the
|
||||
/// successfully parsed events (panics on internal channel errors).
|
||||
async fn run_sse(events: Vec<serde_json::Value>) -> Vec<ResponseEvent> {
|
||||
let mut body = String::new();
|
||||
for e in events {
|
||||
@@ -441,11 +411,9 @@ mod tests {
|
||||
body.push_str(&format!("event: {kind}\ndata: {e}\n\n"));
|
||||
}
|
||||
}
|
||||
|
||||
let (tx, mut rx) = mpsc::channel::<Result<ResponseEvent>>(8);
|
||||
let stream = ReaderStream::new(std::io::Cursor::new(body)).map_err(CodexErr::Io);
|
||||
tokio::spawn(process_sse(stream, tx));
|
||||
|
||||
let mut out = Vec::new();
|
||||
while let Some(ev) = rx.recv().await {
|
||||
out.push(ev.expect("channel closed"));
|
||||
@@ -453,104 +421,14 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
// ────────────────────────────
|
||||
// Tests from `implement-test-for-responses-api-sse-parser`
|
||||
// ────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn parses_items_and_completed() {
|
||||
let item1 = json!({
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "Hello"}]
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let item2 = json!({
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "World"}]
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let completed = json!({
|
||||
"type": "response.completed",
|
||||
"response": { "id": "resp1" }
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let sse1 = format!("event: response.output_item.done\ndata: {item1}\n\n");
|
||||
let sse2 = format!("event: response.output_item.done\ndata: {item2}\n\n");
|
||||
let sse3 = format!("event: response.completed\ndata: {completed}\n\n");
|
||||
|
||||
let events = collect_events(&[sse1.as_bytes(), sse2.as_bytes(), sse3.as_bytes()]).await;
|
||||
|
||||
assert_eq!(events.len(), 3);
|
||||
|
||||
matches!(
|
||||
&events[0],
|
||||
Ok(ResponseEvent::OutputItemDone(ResponseItem::Message { role, .. }))
|
||||
if role == "assistant"
|
||||
);
|
||||
|
||||
matches!(
|
||||
&events[1],
|
||||
Ok(ResponseEvent::OutputItemDone(ResponseItem::Message { role, .. }))
|
||||
if role == "assistant"
|
||||
);
|
||||
|
||||
match &events[2] {
|
||||
Ok(ResponseEvent::Completed {
|
||||
response_id,
|
||||
token_usage,
|
||||
}) => {
|
||||
assert_eq!(response_id, "resp1");
|
||||
assert!(token_usage.is_none());
|
||||
}
|
||||
other => panic!("unexpected third event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn error_when_missing_completed() {
|
||||
let item1 = json!({
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "Hello"}]
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let sse1 = format!("event: response.output_item.done\ndata: {item1}\n\n");
|
||||
|
||||
let events = collect_events(&[sse1.as_bytes()]).await;
|
||||
|
||||
assert_eq!(events.len(), 2);
|
||||
|
||||
matches!(events[0], Ok(ResponseEvent::OutputItemDone(_)));
|
||||
|
||||
match &events[1] {
|
||||
Err(CodexErr::Stream(msg)) => {
|
||||
assert_eq!(msg, "stream closed before response.completed")
|
||||
}
|
||||
other => panic!("unexpected second event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────
|
||||
// Table-driven test from `main`
|
||||
// ────────────────────────────
|
||||
|
||||
/// Verifies that the adapter produces the right `ResponseEvent` for a
|
||||
/// variety of incoming `type` values.
|
||||
/// Verifies that the SSE adapter emits the expected [`ResponseEvent`] for
|
||||
/// a variety of `type` values from the Responses API. The test is written
|
||||
/// table-driven style to keep additions for new event kinds trivial.
|
||||
///
|
||||
/// Each `Case` supplies an input event, a predicate that must match the
|
||||
/// *first* `ResponseEvent` produced by the adapter, and the total number
|
||||
/// of events expected after appending a synthetic `response.completed`
|
||||
/// marker that terminates the stream.
|
||||
#[tokio::test]
|
||||
async fn table_driven_event_kinds() {
|
||||
struct TestCase {
|
||||
@@ -563,9 +441,11 @@ mod tests {
|
||||
fn is_created(ev: &ResponseEvent) -> bool {
|
||||
matches!(ev, ResponseEvent::Created)
|
||||
}
|
||||
|
||||
fn is_output(ev: &ResponseEvent) -> bool {
|
||||
matches!(ev, ResponseEvent::OutputItemDone(_))
|
||||
}
|
||||
|
||||
fn is_completed(ev: &ResponseEvent) -> bool {
|
||||
matches!(ev, ResponseEvent::Completed { .. })
|
||||
}
|
||||
@@ -618,14 +498,9 @@ mod tests {
|
||||
for case in cases {
|
||||
let mut evs = vec![case.event];
|
||||
evs.push(completed.clone());
|
||||
|
||||
let out = run_sse(evs).await;
|
||||
assert_eq!(out.len(), case.expected_len, "case {}", case.name);
|
||||
assert!(
|
||||
(case.expect_first)(&out[0]),
|
||||
"first event mismatch in case {}",
|
||||
case.name
|
||||
);
|
||||
assert!((case.expect_first)(&out[0]), "case {}", case.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -722,7 +722,6 @@ disable_response_storage = true
|
||||
query_params: None,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
supports_temperature: true,
|
||||
};
|
||||
let model_provider_map = {
|
||||
let mut model_provider_map = built_in_model_providers();
|
||||
|
||||
@@ -64,14 +64,6 @@ pub struct ModelProviderInfo {
|
||||
/// value should be used. If the environment variable is not set, or the
|
||||
/// value is empty, the header will not be included in the request.
|
||||
pub env_http_headers: Option<HashMap<String, String>>,
|
||||
|
||||
/// Whether the provider accepts an explicit `temperature` parameter.
|
||||
#[serde(default = "default_supports_temperature")]
|
||||
pub supports_temperature: bool,
|
||||
}
|
||||
|
||||
const fn default_supports_temperature() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl ModelProviderInfo {
|
||||
@@ -213,7 +205,6 @@ pub fn built_in_model_providers() -> HashMap<String, ModelProviderInfo> {
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
supports_temperature: false,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -243,7 +234,6 @@ base_url = "http://localhost:11434/v1"
|
||||
query_params: None,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
supports_temperature: true,
|
||||
};
|
||||
|
||||
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
|
||||
@@ -269,7 +259,6 @@ query_params = { api-version = "2025-04-01-preview" }
|
||||
}),
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
supports_temperature: true,
|
||||
};
|
||||
|
||||
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
|
||||
@@ -298,7 +287,6 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" }
|
||||
env_http_headers: Some(maplit::hashmap! {
|
||||
"X-Example-Env-Header".to_string() => "EXAMPLE_ENV_VAR".to_string(),
|
||||
}),
|
||||
supports_temperature: true,
|
||||
};
|
||||
|
||||
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
event: response.created
|
||||
data: {"type":"response.created","response":{"id":"resp1"}}
|
||||
|
||||
event: response.output_item.done
|
||||
data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"fixture hello"}]}}
|
||||
|
||||
event: response.completed
|
||||
data: {"type":"response.completed","response":{"id":"resp1","output":[]}}
|
||||
@@ -1,119 +0,0 @@
|
||||
#![expect(clippy::unwrap_used)]
|
||||
|
||||
use assert_cmd::Command as AssertCommand;
|
||||
use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
/// Tests streaming chat completions through the CLI using a mock server.
|
||||
/// This test:
|
||||
/// 1. Sets up a mock server that simulates OpenAI's chat completions API
|
||||
/// 2. Configures codex to use this mock server via a custom provider
|
||||
/// 3. Sends a simple "hello?" prompt and verifies the streamed response
|
||||
/// 4. Ensures the response is received exactly once and contains "hi"
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn chat_mode_stream_cli() {
|
||||
if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
println!(
|
||||
"Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let sse = concat!(
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n",
|
||||
"data: {\"choices\":[{\"delta\":{}}]}\n\n",
|
||||
"data: [DONE]\n\n"
|
||||
);
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat/completions"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.set_body_raw(sse, "text/event-stream"),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let home = TempDir::new().unwrap();
|
||||
let provider_override = format!(
|
||||
"model_providers.mock={{ name = \"mock\", base_url = \"{}/v1\", env_key = \"PATH\", wire_api = \"chat\" }}",
|
||||
server.uri()
|
||||
);
|
||||
let mut cmd = AssertCommand::new("cargo");
|
||||
cmd.arg("run")
|
||||
.arg("-p")
|
||||
.arg("codex-cli")
|
||||
.arg("--quiet")
|
||||
.arg("--")
|
||||
.arg("exec")
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("-c")
|
||||
.arg(&provider_override)
|
||||
.arg("-c")
|
||||
.arg("model_provider=\"mock\"")
|
||||
.arg("-C")
|
||||
.arg(env!("CARGO_MANIFEST_DIR"))
|
||||
.arg("hello?");
|
||||
cmd.env("CODEX_HOME", home.path())
|
||||
.env("OPENAI_API_KEY", "dummy")
|
||||
.env("OPENAI_BASE_URL", format!("{}/v1", server.uri()));
|
||||
|
||||
let output = cmd.output().unwrap();
|
||||
println!("Status: {}", output.status);
|
||||
println!("Stdout:\n{}", String::from_utf8_lossy(&output.stdout));
|
||||
println!("Stderr:\n{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(output.status.success());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("hi"));
|
||||
assert_eq!(stdout.matches("hi").count(), 1);
|
||||
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
/// Tests streaming responses through the CLI using a local SSE fixture file.
|
||||
/// This test:
|
||||
/// 1. Uses a pre-recorded SSE response fixture instead of a live server
|
||||
/// 2. Configures codex to read from this fixture via CODEX_RS_SSE_FIXTURE env var
|
||||
/// 3. Sends a "hello?" prompt and verifies the response
|
||||
/// 4. Ensures the fixture content is correctly streamed through the CLI
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn responses_api_stream_cli() {
|
||||
if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
println!(
|
||||
"Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let fixture =
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cli_responses_fixture.sse");
|
||||
|
||||
let home = TempDir::new().unwrap();
|
||||
let mut cmd = AssertCommand::new("cargo");
|
||||
cmd.arg("run")
|
||||
.arg("-p")
|
||||
.arg("codex-cli")
|
||||
.arg("--quiet")
|
||||
.arg("--")
|
||||
.arg("exec")
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("-C")
|
||||
.arg(env!("CARGO_MANIFEST_DIR"))
|
||||
.arg("hello?");
|
||||
cmd.env("CODEX_HOME", home.path())
|
||||
.env("OPENAI_API_KEY", "dummy")
|
||||
.env("CODEX_RS_SSE_FIXTURE", fixture)
|
||||
.env("OPENAI_BASE_URL", "http://unused.local");
|
||||
|
||||
let output = cmd.output().unwrap();
|
||||
assert!(output.status.success());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("fixture hello"));
|
||||
}
|
||||
@@ -107,7 +107,6 @@ async fn keeps_previous_response_id_between_tasks() {
|
||||
query_params: None,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
supports_temperature: true,
|
||||
};
|
||||
|
||||
// Init session
|
||||
|
||||
@@ -96,7 +96,6 @@ async fn retries_on_early_close() {
|
||||
query_params: None,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
supports_temperature: true,
|
||||
};
|
||||
|
||||
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||
|
||||
@@ -30,7 +30,6 @@ pub fn load_default_config_for_test(codex_home: &TempDir) -> Config {
|
||||
/// with only a `type` field results in an event with no `data:` section. This
|
||||
/// makes it trivial to extend the fixtures as OpenAI adds new event kinds or
|
||||
/// fields.
|
||||
#[allow(dead_code)]
|
||||
pub fn load_sse_fixture(path: impl AsRef<std::path::Path>) -> String {
|
||||
let events: Vec<serde_json::Value> =
|
||||
serde_json::from_reader(std::fs::File::open(path).expect("read fixture"))
|
||||
@@ -55,7 +54,6 @@ pub fn load_sse_fixture(path: impl AsRef<std::path::Path>) -> String {
|
||||
/// fixture template with the supplied identifier before parsing. This lets a
|
||||
/// single JSON template be reused by multiple tests that each need a unique
|
||||
/// `response_id`.
|
||||
#[allow(dead_code)]
|
||||
pub fn load_sse_fixture_with_id(path: impl AsRef<std::path::Path>, id: &str) -> String {
|
||||
let raw = std::fs::read_to_string(path).expect("read fixture template");
|
||||
let replaced = raw.replace("__ID__", id);
|
||||
|
||||
@@ -40,7 +40,6 @@ ratatui = { version = "0.29.0", features = [
|
||||
] }
|
||||
ratatui-image = "8.0.0"
|
||||
regex-lite = "0.1"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
serde_json = { version = "1", features = ["preserve_order"] }
|
||||
shlex = "1.3.0"
|
||||
strum = "0.27.1"
|
||||
@@ -60,7 +59,6 @@ tui-markdown = "0.3.3"
|
||||
tui-textarea = "0.7.0"
|
||||
unicode-segmentation = "1.12.0"
|
||||
uuid = "1"
|
||||
time = { version = "0.3", features = ["formatting"] }
|
||||
|
||||
[dev-dependencies]
|
||||
insta = "1.43.1"
|
||||
|
||||
@@ -266,11 +266,6 @@ impl<'a> App<'a> {
|
||||
widget.add_diff_output(text);
|
||||
}
|
||||
}
|
||||
SlashCommand::SecurityReview => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.start_security_review_with_defaults();
|
||||
}
|
||||
}
|
||||
},
|
||||
AppEvent::StartFileSearch(query) => {
|
||||
self.file_search.on_user_query(query);
|
||||
@@ -280,11 +275,6 @@ impl<'a> App<'a> {
|
||||
widget.apply_file_search_result(query, matches);
|
||||
}
|
||||
}
|
||||
AppEvent::SecurityReviewFinished { mode, outcome } => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.handle_security_review_finished(mode, outcome);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
terminal.clear()?;
|
||||
|
||||
@@ -2,7 +2,6 @@ use codex_core::protocol::Event;
|
||||
use codex_file_search::FileMatch;
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
use crate::security_review::{SecurityReviewFailure, SecurityReviewMode, SecurityReviewResult};
|
||||
use crate::slash_command::SlashCommand;
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
@@ -46,10 +45,4 @@ pub(crate) enum AppEvent {
|
||||
query: String,
|
||||
matches: Vec<FileMatch>,
|
||||
},
|
||||
|
||||
/// Completion event for a `/secreview` invocation.
|
||||
SecurityReviewFinished {
|
||||
mode: SecurityReviewMode,
|
||||
outcome: Result<SecurityReviewResult, SecurityReviewFailure>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_core::codex_wrapper::init_codex;
|
||||
@@ -29,7 +29,6 @@ use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
@@ -39,10 +38,7 @@ use crate::bottom_pane::InputResult;
|
||||
use crate::conversation_history_widget::ConversationHistoryWidget;
|
||||
use crate::history_cell::PatchEventType;
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
use crate::security_review::{run_security_review, SecurityReviewFailure, SecurityReviewMode, SecurityReviewRequest, SecurityReviewResult};
|
||||
use codex_file_search::FileMatch;
|
||||
use path_clean::PathClean;
|
||||
use shlex;
|
||||
|
||||
pub(crate) struct ChatWidget<'a> {
|
||||
app_event_tx: AppEventSender,
|
||||
@@ -53,8 +49,6 @@ pub(crate) struct ChatWidget<'a> {
|
||||
config: Config,
|
||||
initial_user_message: Option<UserMessage>,
|
||||
token_usage: TokenUsage,
|
||||
security_review_handle: Option<JoinHandle<()>>,
|
||||
active_security_review_mode: Option<SecurityReviewMode>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
@@ -85,15 +79,6 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ParsedSecReviewCommand {
|
||||
mode: SecurityReviewMode,
|
||||
include_paths: Vec<String>,
|
||||
output_path: Option<String>,
|
||||
repo_path: Option<String>,
|
||||
model_name: Option<String>,
|
||||
}
|
||||
|
||||
impl ChatWidget<'_> {
|
||||
pub(crate) fn new(
|
||||
config: Config,
|
||||
@@ -150,8 +135,6 @@ impl ChatWidget<'_> {
|
||||
initial_images,
|
||||
),
|
||||
token_usage: TokenUsage::default(),
|
||||
security_review_handle: None,
|
||||
active_security_review_mode: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,11 +182,6 @@ impl ChatWidget<'_> {
|
||||
|
||||
fn submit_user_message(&mut self, user_message: UserMessage) {
|
||||
let UserMessage { text, image_paths } = user_message;
|
||||
|
||||
if self.try_handle_slash_command(&text) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut items: Vec<InputItem> = Vec::new();
|
||||
|
||||
if !text.is_empty() {
|
||||
@@ -240,240 +218,6 @@ impl ChatWidget<'_> {
|
||||
self.conversation_history.scroll_to_bottom();
|
||||
}
|
||||
|
||||
fn try_handle_slash_command(&mut self, text: &str) -> bool {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.starts_with("/secreview") {
|
||||
match parse_security_review_command(trimmed) {
|
||||
Ok(command) => {
|
||||
if let Err(err) = self.launch_security_review(command) {
|
||||
self.report_security_review_error(err);
|
||||
}
|
||||
}
|
||||
Err(err) => self.report_security_review_error(err),
|
||||
}
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn start_security_review_with_defaults(&mut self) {
|
||||
let command = ParsedSecReviewCommand::default();
|
||||
if let Err(err) = self.launch_security_review(command) {
|
||||
self.report_security_review_error(err);
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_security_review(&mut self, command: ParsedSecReviewCommand) -> Result<(), String> {
|
||||
let repo_candidate = if let Some(repo_override) = command.repo_path.as_ref() {
|
||||
let candidate = Path::new(repo_override);
|
||||
if candidate.is_absolute() {
|
||||
candidate.to_path_buf()
|
||||
} else {
|
||||
self.config.cwd.join(candidate)
|
||||
}
|
||||
} else {
|
||||
self.config.cwd.clone()
|
||||
}
|
||||
.clean();
|
||||
|
||||
let repo_path = match repo_candidate.canonicalize() {
|
||||
Ok(path) => path,
|
||||
Err(_) => repo_candidate.clone(),
|
||||
};
|
||||
|
||||
if !repo_path.exists() {
|
||||
return Err(format!(
|
||||
"Repository path '{}' does not exist.",
|
||||
repo_path.display()
|
||||
));
|
||||
}
|
||||
if !repo_path.is_dir() {
|
||||
return Err(format!(
|
||||
"Repository path '{}' is not a directory.",
|
||||
repo_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let mut resolved_paths: Vec<PathBuf> = Vec::new();
|
||||
let mut display_paths: Vec<String> = Vec::new();
|
||||
|
||||
for include in &command.include_paths {
|
||||
let candidate = resolve_path(&repo_path, include);
|
||||
let canonical = match candidate.canonicalize() {
|
||||
Ok(path) => path,
|
||||
Err(_) => candidate.clone(),
|
||||
};
|
||||
|
||||
if !canonical.exists() {
|
||||
return Err(format!("Path '{}' does not exist.", canonical.display()));
|
||||
}
|
||||
if !canonical.starts_with(&repo_path) {
|
||||
return Err(format!(
|
||||
"Path '{}' is outside the repository root '{}'.",
|
||||
canonical.display(),
|
||||
repo_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let relative = canonical
|
||||
.strip_prefix(&repo_path)
|
||||
.unwrap_or(&canonical)
|
||||
.display()
|
||||
.to_string();
|
||||
display_paths.push(relative);
|
||||
resolved_paths.push(canonical);
|
||||
}
|
||||
|
||||
let output_root = if let Some(output_override) = command.output_path.as_ref() {
|
||||
let candidate = Path::new(output_override);
|
||||
if candidate.is_absolute() {
|
||||
candidate.to_path_buf()
|
||||
} else {
|
||||
repo_path.join(candidate)
|
||||
}
|
||||
} else {
|
||||
repo_path.join("appsec_review")
|
||||
}
|
||||
.clean();
|
||||
|
||||
let model_name = command
|
||||
.model_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.config.model.clone());
|
||||
|
||||
if self.security_review_handle.is_some() {
|
||||
return Err("A security review is already running. Please wait for it to finish or abort it before starting another.".to_string());
|
||||
}
|
||||
|
||||
let scope_description = if resolved_paths.is_empty() {
|
||||
"entire repository".to_string()
|
||||
} else {
|
||||
display_paths.join(", ")
|
||||
};
|
||||
|
||||
let summary = format!(
|
||||
"🔐 Running AppSec security review (mode: {}).\nRepository: {}\nScope: {}\nOutput: {}\nModel: {}",
|
||||
command.mode.as_str(),
|
||||
repo_path.display(),
|
||||
scope_description,
|
||||
output_root.display(),
|
||||
model_name
|
||||
);
|
||||
self.conversation_history.add_background_event(summary);
|
||||
self.conversation_history.scroll_to_bottom();
|
||||
self.bottom_pane.set_task_running(true);
|
||||
self.request_redraw();
|
||||
|
||||
let provider = self.config.model_provider.clone();
|
||||
let request = SecurityReviewRequest {
|
||||
repo_path: repo_path.clone(),
|
||||
include_paths: resolved_paths,
|
||||
output_root: output_root.clone(),
|
||||
mode: command.mode,
|
||||
model: model_name,
|
||||
provider,
|
||||
progress_sender: Some(self.app_event_tx.clone()),
|
||||
};
|
||||
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
let mode = command.mode;
|
||||
let handle = tokio::spawn(async move {
|
||||
let outcome = run_security_review(request).await;
|
||||
app_event_tx.send(AppEvent::SecurityReviewFinished { mode, outcome });
|
||||
});
|
||||
self.security_review_handle = Some(handle);
|
||||
self.active_security_review_mode = Some(mode);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn report_security_review_error(&mut self, message: String) {
|
||||
self.security_review_handle = None;
|
||||
self.active_security_review_mode = None;
|
||||
self.bottom_pane.set_task_running(false);
|
||||
self.conversation_history
|
||||
.add_background_event(format!("❌ {message}"));
|
||||
self.conversation_history.scroll_to_bottom();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn handle_security_review_finished(
|
||||
&mut self,
|
||||
mode: SecurityReviewMode,
|
||||
outcome: Result<SecurityReviewResult, SecurityReviewFailure>,
|
||||
) {
|
||||
self.security_review_handle = None;
|
||||
self.active_security_review_mode = None;
|
||||
self.bottom_pane.set_task_running(false);
|
||||
match outcome {
|
||||
Ok(result) => {
|
||||
let SecurityReviewResult {
|
||||
bugs_markdown,
|
||||
report_markdown,
|
||||
bugs_path,
|
||||
report_path,
|
||||
logs,
|
||||
} = result;
|
||||
|
||||
let mut summary = format!(
|
||||
"✅ AppSec security review complete (mode: {}).\nBugs saved to {}.",
|
||||
mode.as_str(),
|
||||
bugs_path.display()
|
||||
);
|
||||
if let Some(report_path) = report_path.as_ref() {
|
||||
summary.push_str(&format!(
|
||||
"\nReport saved to {}.",
|
||||
report_path.display()
|
||||
));
|
||||
}
|
||||
self.conversation_history.add_background_event(summary);
|
||||
|
||||
if matches!(mode, SecurityReviewMode::Full) {
|
||||
if let Some(markdown) = report_markdown.and_then(|m| {
|
||||
if m.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(m)
|
||||
}
|
||||
}) {
|
||||
self.conversation_history.add_agent_message(
|
||||
&self.config,
|
||||
format!("# AppSec Security Review Report\n\n{markdown}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if !bugs_markdown.trim().is_empty() {
|
||||
let heading = if matches!(mode, SecurityReviewMode::Full) {
|
||||
"## Bugs Summary"
|
||||
} else {
|
||||
"# AppSec Bugs Summary"
|
||||
};
|
||||
self.conversation_history.add_agent_message(
|
||||
&self.config,
|
||||
format!("{heading}\n\n{bugs_markdown}"),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(log_text) = format_security_review_logs(&logs) {
|
||||
self.conversation_history
|
||||
.add_background_event(format!("Logs:\n{log_text}"));
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let SecurityReviewFailure { message, logs } = error;
|
||||
let mut summary = format!("❌ AppSec security review failed: {message}");
|
||||
if let Some(log_text) = format_security_review_logs(&logs) {
|
||||
summary.push_str(&format!("\n\nLogs:\n{log_text}"));
|
||||
}
|
||||
self.conversation_history.add_background_event(summary);
|
||||
}
|
||||
}
|
||||
|
||||
self.conversation_history.scroll_to_bottom();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn handle_codex_event(&mut self, event: Event) {
|
||||
let Event { id, msg } = event;
|
||||
match msg {
|
||||
@@ -676,20 +420,6 @@ impl ChatWidget<'_> {
|
||||
/// Returns true if the key press was handled, false if it was not.
|
||||
/// If the key press was not handled, the caller should handle it (likely by exiting the process).
|
||||
pub(crate) fn on_ctrl_c(&mut self) -> bool {
|
||||
if let Some(handle) = self.security_review_handle.take() {
|
||||
handle.abort();
|
||||
let mode = self
|
||||
.active_security_review_mode
|
||||
.take()
|
||||
.unwrap_or_else(SecurityReviewMode::default);
|
||||
let failure = SecurityReviewFailure {
|
||||
message: "AppSec security review aborted by user.".to_string(),
|
||||
logs: vec!["AppSec security review aborted by user.".to_string()],
|
||||
};
|
||||
self.handle_security_review_finished(mode, Err(failure));
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.bottom_pane.is_task_running() {
|
||||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
self.submit_op(Op::Interrupt);
|
||||
@@ -751,137 +481,3 @@ fn add_token_usage(current_usage: &TokenUsage, new_usage: &TokenUsage) -> TokenU
|
||||
total_tokens: current_usage.total_tokens + new_usage.total_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_security_review_command(input: &str) -> Result<ParsedSecReviewCommand, String> {
|
||||
let tokens = shlex::split(input).ok_or_else(|| "Unable to parse command arguments.".to_string())?;
|
||||
if tokens.is_empty() {
|
||||
return Err("Empty command.".to_string());
|
||||
}
|
||||
|
||||
if tokens[0] != "/secreview" {
|
||||
return Err("Unrecognized command.".to_string());
|
||||
}
|
||||
|
||||
let mut command = ParsedSecReviewCommand::default();
|
||||
let mut idx = 1;
|
||||
while idx < tokens.len() {
|
||||
let token = &tokens[idx];
|
||||
|
||||
if token == "--" {
|
||||
for extra in tokens.iter().skip(idx + 1) {
|
||||
if !extra.is_empty() {
|
||||
command.include_paths.push(extra.to_string());
|
||||
}
|
||||
}
|
||||
break;
|
||||
} else if matches!(
|
||||
token.as_str(),
|
||||
"bugs" | "--bugs" | "--mode=bugs"
|
||||
) {
|
||||
command.mode = SecurityReviewMode::Bugs;
|
||||
} else if matches!(
|
||||
token.as_str(),
|
||||
"full" | "--full" | "--mode=full"
|
||||
) {
|
||||
command.mode = SecurityReviewMode::Full;
|
||||
} else if token == "--mode" {
|
||||
idx += 1;
|
||||
if idx >= tokens.len() {
|
||||
return Err("Expected value after --mode.".to_string());
|
||||
}
|
||||
command.mode = parse_mode(&tokens[idx])?;
|
||||
} else if let Some(value) = token.strip_prefix("--mode=") {
|
||||
command.mode = parse_mode(value)?;
|
||||
} else if token == "--path" || token == "-p" {
|
||||
idx += 1;
|
||||
if idx >= tokens.len() {
|
||||
return Err(format!("Expected value after {token}."));
|
||||
}
|
||||
command.include_paths.push(tokens[idx].clone());
|
||||
} else if let Some(value) = token.strip_prefix("--path=") {
|
||||
command.include_paths.push(value.to_string());
|
||||
} else if let Some(value) = token.strip_prefix("-p=") {
|
||||
command.include_paths.push(value.to_string());
|
||||
} else if matches!(
|
||||
token.as_str(),
|
||||
"--output" | "-o" | "--output-location"
|
||||
) {
|
||||
idx += 1;
|
||||
if idx >= tokens.len() {
|
||||
return Err(format!("Expected value after {token}."));
|
||||
}
|
||||
command.output_path = Some(tokens[idx].clone());
|
||||
} else if let Some(value) = token.strip_prefix("--output=") {
|
||||
command.output_path = Some(value.to_string());
|
||||
} else if let Some(value) = token.strip_prefix("-o=") {
|
||||
command.output_path = Some(value.to_string());
|
||||
} else if matches!(
|
||||
token.as_str(),
|
||||
"--repo" | "--repo-location" | "--repository"
|
||||
) {
|
||||
idx += 1;
|
||||
if idx >= tokens.len() {
|
||||
return Err(format!("Expected value after {token}."));
|
||||
}
|
||||
command.repo_path = Some(tokens[idx].clone());
|
||||
} else if let Some(value) = token.strip_prefix("--repo=") {
|
||||
command.repo_path = Some(value.to_string());
|
||||
} else if let Some(value) = token.strip_prefix("--repo-location=") {
|
||||
command.repo_path = Some(value.to_string());
|
||||
} else if token == "--model" || token == "--model-name" {
|
||||
idx += 1;
|
||||
if idx >= tokens.len() {
|
||||
return Err(format!("Expected value after {token}."));
|
||||
}
|
||||
command.model_name = Some(tokens[idx].clone());
|
||||
} else if let Some(value) = token.strip_prefix("--model=") {
|
||||
command.model_name = Some(value.to_string());
|
||||
} else if let Some(value) = token.strip_prefix("--model-name=") {
|
||||
command.model_name = Some(value.to_string());
|
||||
} else if !token.is_empty() {
|
||||
command.include_paths.push(token.clone());
|
||||
}
|
||||
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
fn parse_mode(value: &str) -> Result<SecurityReviewMode, String> {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"full" => Ok(SecurityReviewMode::Full),
|
||||
"bugs" | "bugs-only" | "bugsonly" => Ok(SecurityReviewMode::Bugs),
|
||||
other => Err(format!("Unknown mode '{other}'. Use 'full' or 'bugs'.")),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_path(base: &Path, candidate: &str) -> PathBuf {
|
||||
let path = Path::new(candidate);
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
base.join(path)
|
||||
}
|
||||
.clean()
|
||||
}
|
||||
|
||||
fn format_security_review_logs(logs: &[String]) -> Option<String> {
|
||||
if logs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let joined = logs.join("\n");
|
||||
if joined.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let lines: Vec<&str> = joined.lines().collect();
|
||||
const MAX_LINES: usize = 40;
|
||||
if lines.len() <= MAX_LINES {
|
||||
Some(joined)
|
||||
} else {
|
||||
let tail = lines[lines.len().saturating_sub(MAX_LINES)..].join("\n");
|
||||
Some(format!("… (showing last {MAX_LINES} lines)\n{tail}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
mod app;
|
||||
mod security_review;
|
||||
mod app_event;
|
||||
mod app_event_sender;
|
||||
mod bottom_pane;
|
||||
|
||||
@@ -14,8 +14,6 @@ pub enum SlashCommand {
|
||||
// more frequently used commands should be listed first.
|
||||
New,
|
||||
Diff,
|
||||
#[strum(serialize = "secreview")]
|
||||
SecurityReview,
|
||||
Quit,
|
||||
ToggleMouseMode,
|
||||
}
|
||||
@@ -32,9 +30,6 @@ impl SlashCommand {
|
||||
SlashCommand::Diff => {
|
||||
"Show git diff of the working directory (including untracked files)"
|
||||
}
|
||||
SlashCommand::SecurityReview => {
|
||||
"Run AppSec security review and display the generated reports"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user