Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
445 changes: 445 additions & 0 deletions src/common/bash-tooling.ts

Large diffs are not rendered by default.

114 changes: 114 additions & 0 deletions src/tests/bashTooling.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
buildInstallSpawn,
buildBashToolInstallPlan,
formatBashToolInstallResult,
formatBashToolingStatus,
type BashToolingStatus,
} from "../common/bash-tooling";

test("formatBashToolingStatus reports ready and missing tools", () => {
assert.equal(formatBashToolingStatus(status([])), "rg+jq ready");
assert.equal(formatBashToolingStatus(status(["rg"])), "missing rg (/install)");
assert.equal(formatBashToolingStatus(status(["rg", "jq"])), "missing rg,jq (/install)");
});

test("buildBashToolInstallPlan uses winget on Windows", () => {
const plan = buildBashToolInstallPlan(["rg", "jq"], {
platform: "win32",
hasCommand: (command) => command === "winget",
});

assert.equal(plan?.manager, "winget");
assert.deepEqual(
plan?.commands.map((command) => command.display),
[
"winget install -e --id BurntSushi.ripgrep.MSVC --accept-package-agreements --accept-source-agreements",
"winget install -e --id jqlang.jq --accept-package-agreements --accept-source-agreements",
]
);
});

test("buildBashToolInstallPlan uses one brew install for both tools", () => {
const plan = buildBashToolInstallPlan(["rg", "jq"], {
platform: "darwin",
hasCommand: (command) => command === "brew",
});

assert.equal(plan?.manager, "brew");
assert.deepEqual(
plan?.commands.map((command) => command.display),
["brew install ripgrep jq"]
);
});

test("buildBashToolInstallPlan uses sudo for apt-get when not root", () => {
const plan = buildBashToolInstallPlan(["jq"], {
platform: "linux",
hasCommand: (command) => command === "apt-get" || command === "sudo",
isRoot: false,
});

assert.equal(plan?.manager, "apt-get");
assert.deepEqual(
plan?.commands.map((command) => command.display),
["sudo apt-get update", "sudo apt-get install -y jq"]
);
});

test("formatBashToolInstallResult includes fallback instructions without a package manager", () => {
const result = formatBashToolInstallResult({
before: status(["rg", "jq"]),
after: status(["rg", "jq"]),
plan: null,
exitCode: null,
signal: null,
});

assert.equal(result.includes("No supported package manager found"), true);
});

test("formatBashToolInstallResult separates PATH refresh from installer failure", () => {
const result = formatBashToolInstallResult({
before: status(["jq"]),
after: status(["jq"]),
plan: {
manager: "winget",
commands: [{ command: "winget", args: ["install", "jq"], display: "winget install jq" }],
},
exitCode: 0,
signal: null,
});

assert.equal(result.includes("Bash still cannot find"), true);
assert.equal(result.includes("Restart the terminal"), true);
});

test("buildInstallSpawn does not use a shell for native commands", () => {
const result = buildInstallSpawn({ command: "winget", args: ["install", "jq"], display: "" }, "win32");

assert.notEqual(result.command, "cmd.exe");
assert.equal(result.command.endsWith("winget.exe") || result.command === "winget", true);
assert.deepEqual(result.args, ["install", "jq"]);
});

test("buildInstallSpawn runs Windows cmd shims through cmd.exe without node shell option", () => {
assert.deepEqual(
buildInstallSpawn({ command: "C:\\Tools\\scoop.cmd", args: ["install", "ripgrep", "jq"], display: "" }, "win32"),
{
command: "cmd.exe",
args: ["/d", "/s", "/c", '"C:\\Tools\\scoop.cmd" install ripgrep jq'],
}
);
});

function status(missing: Array<"rg" | "jq">): BashToolingStatus {
return {
tools: [
{ name: "rg", available: !missing.includes("rg") },
{ name: "jq", available: !missing.includes("jq") },
],
missing,
};
}
9 changes: 9 additions & 0 deletions src/tests/slashCommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ test("buildSlashCommands prefixes skills before built-ins", () => {
"init",
"resume",
"continue",
"mcp",
"install",
"undo",
"mcp",
"raw",
Expand Down Expand Up @@ -91,6 +93,13 @@ test("findExactSlashCommand returns built-in /skills", () => {
assert.equal(item?.kind, "skills");
});

test("findExactSlashCommand returns built-in /install", () => {
const items = buildSlashCommands(skills);
const item = findExactSlashCommand(items, "/install");
assert.ok(item);
assert.equal(item?.kind, "install");
});

test("findExactSlashCommand returns built-in /model", () => {
const items = buildSlashCommands(skills);
const item = findExactSlashCommand(items, "/model");
Expand Down
1 change: 1 addition & 0 deletions src/tests/welcomeScreen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,6 @@ test("buildWelcomeTips includes built-in slash commands and loaded skills", () =
const labels = tips.map((tip) => tip.label);
assert.ok(labels.includes("/new"));
assert.ok(labels.includes("/loaded"));
assert.ok(labels.includes("rg + jq"));
assert.equal(labels.includes("/fresh"), false);
});
46 changes: 46 additions & 0 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ import {
import { buildExitSummaryText } from "./exitSummary";
import { RawMode, useRawModeContext } from "./contexts";
import { renderMessageToStdout } from "./components/MessageView/utils";
import {
formatBashToolInstallResult,
getBashToolingStatus,
installMissingBashTools,
type BashToolingStatus,
} from "../common/bash-tooling";

const DEFAULT_MODEL = "deepseek-v4-pro";
const DEFAULT_BASE_URL = "https://api.deepseek.com";
Expand Down Expand Up @@ -81,6 +87,7 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.
const [showWelcome, setShowWelcome] = useState(true);
const [welcomeNonce, setWelcomeNonce] = useState(0);
const [resolvedSettings, setResolvedSettings] = useState(() => resolveCurrentSettings(projectRoot));
const [bashToolingStatus, setBashToolingStatus] = useState<BashToolingStatus>(() => getBashToolingStatus());
const [nowTick, setNowTick] = useState(0);
const [mcpStatuses, setMcpStatuses] = useState<ReturnType<typeof sessionManager.getMcpStatus>>([]);
const [showProcessStdout, setShowProcessStdout] = useState(false);
Expand Down Expand Up @@ -244,6 +251,28 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.
setView("mcp-status");
return;
}
if (submission.command === "install") {
setBusy(true);
setErrorLine(null);
setStatusLine("Installing missing Bash tools...");
try {
const result = await installMissingBashTools();
setBashToolingStatus(result.after);
setWelcomeNonce((n) => n + 1);
setMessages((prev) => [...prev, buildSyntheticSystemMessage(formatBashToolInstallResult(result))]);
if (result.after.missing.length === 0) {
setStatusLine("Bash tools ready: rg+jq");
} else {
setStatusLine(`Bash tools still missing: ${result.after.missing.join(",")}`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorLine(message);
} finally {
setBusy(false);
}
return;
}

const prompt: UserPromptContent = {
text: submission.text,
Expand Down Expand Up @@ -613,6 +642,7 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.
projectRoot={projectRoot}
settings={resolvedSettings}
skills={skills}
bashToolingStatus={bashToolingStatus}
width={screenWidth}
/>
);
Expand Down Expand Up @@ -731,6 +761,22 @@ function buildSyntheticUserMessage(content: string, imageCount: number): Session
};
}

function buildSyntheticSystemMessage(content: string): SessionMessage {
const now = new Date().toISOString();
return {
id: `local-system-${Math.random().toString(36).slice(2)}`,
sessionId: "local",
role: "system",
content,
contentParams: null,
messageParams: null,
compacted: false,
visible: true,
createTime: now,
updateTime: now,
};
}

export function buildPromptDraftFromSessionMessage(message: SessionMessage, nonce: number): PromptDraft {
return {
nonce,
Expand Down
9 changes: 9 additions & 0 deletions src/ui/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,15 @@ export const PromptInput = React.memo(function PromptInput({
resetPromptInput();
return;
}
if (item.kind === "install") {
onSubmit({ text: "/install", imageUrls: [], command: "install" });
setBuffer(EMPTY_BUFFER);
clearUndoRedoStacks();
setImageUrls([]);
setSelectedSkills([]);
setShowSkillsDropdown(false);
return;
}
if (item.kind === "exit") {
onSubmit({ text: "/exit", imageUrls: [], command: "exit" });
setBuffer(EMPTY_BUFFER);
Expand Down
15 changes: 13 additions & 2 deletions src/ui/WelcomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,22 @@ import { buildSlashCommands, BUILTIN_SLASH_COMMANDS, formatSlashCommandDescripti
import { ThemedGradient } from "./ThemedGradient";
import { AsciiLogo } from "../AsciiArt";
import { useAppContext } from "./contexts";
import type { BashToolingStatus } from "../common/bash-tooling";
import { formatBashToolingStatus } from "../common/bash-tooling";

type WelcomeScreenProps = {
projectRoot: string;
settings: ResolvedDeepcodingSettings;
skills: SkillInfo[];
bashToolingStatus: BashToolingStatus;
width: number;
};

const TITLE_PANEL_WIDTH = 70;
const PANEL_CONTENT_HEIGHT = 8;
const PANEL_CONTENT_HEIGHT = 9;

const SHORTCUT_TIPS = [
{ label: "rg + jq", description: "Install ripgrep and jq to make Bash project exploration faster and denser" },
{ label: "Enter", description: "Send the prompt" },
{ label: "Shift+Enter", description: "Insert a newline" },
{ label: "Ctrl+V", description: "Paste an image from the clipboard" },
Expand All @@ -28,7 +32,13 @@ const SHORTCUT_TIPS = [
{ label: "Ctrl+D twice", description: "Quit Deep Code CLI" },
];

export function WelcomeScreen({ projectRoot, settings, skills, width }: WelcomeScreenProps): React.ReactElement {
export function WelcomeScreen({
projectRoot,
settings,
skills,
bashToolingStatus,
width,
}: WelcomeScreenProps): React.ReactElement {
const { version } = useAppContext();
const tips = useMemo(() => buildWelcomeTips(skills), [skills]);
const [tipIndex] = useState(() => randomTipIndex(tips.length));
Expand Down Expand Up @@ -64,6 +74,7 @@ export function WelcomeScreen({ projectRoot, settings, skills, width }: WelcomeS
<SettingRow label="Model" value={settings.model} />
<SettingRow label="Thinking Enabled" value={String(settings.thinkingEnabled)} />
<SettingRow label="Reasoning Effort" value={settings.thinkingEnabled ? settings.reasoningEffort : "-"} />
<SettingRow label="Bash Tools" value={formatBashToolingStatus(bashToolingStatus)} />
<SettingRow label="CWD" value={cwd} />
</Box>
</Box>
Expand Down
7 changes: 7 additions & 0 deletions src/ui/slashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type SlashCommandKind =
| "continue"
| "undo"
| "mcp"
| "install"
| "raw"
| "exit";

Expand Down Expand Up @@ -71,6 +72,12 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [
label: "/mcp",
description: "Show MCP server status and available tools",
},
{
kind: "install",
name: "install",
label: "/install",
description: "Install missing Bash exploration tools: rg and jq",
},
{
kind: "raw",
name: "raw",
Expand Down